Linux's __ptrace_may_access() is the gate that decides whether one process can read another's open file descriptors through pidfd_getfd(). Until May 14, 2026, the function's deny clause read: if the target has a memory image, and the image is not user-dumpable, and the caller lacks CAP_SYS_PTRACE, deny. The kernel's do_exit() calls exit_mm() before exit_files(). For the few microseconds between those two calls, the target has no memory image and every file descriptor it ever opened is still sitting in its files_struct. The deny clause's predicate is mm && .... When mm is null, the predicate is false and the deny never fires. The patch Linus committed on May 14, 2026 is the patch Jann Horn posted to LKML in October 2020.
CVE-2026-46333: The Dumpability Check Needs an mm. exit_mm() Runs Before exit_files().
pattern
cve
proof of concept
The deny clause needs the property it denies on.
The pre-patch logic in kernel/ptrace.c reads, with surrounding context omitted:
static int __ptrace_may_access(struct task_struct *task, unsigned int mode)
{
const struct cred *cred = current_cred(), *tcred;
struct mm_struct *mm;
/* ... uid and capability checks ... */
smp_rmb();
mm = task->mm;
if (mm &&
((get_dumpable(mm) != SUID_DUMP_USER) &&
!ptrace_has_cap(mm->user_ns, mode)))
return -EPERM;
return security_ptrace_access_check(task, mode);
}The deny clause is one statement. Read it as a conjunction: deny when mm is non-null, AND get_dumpable(mm) is not SUID_DUMP_USER, AND the caller does not hold CAP_SYS_PTRACE in the target's user namespace. Every term has to be true for the deny to fire.
The function exists to protect SUID binaries. When a process execs a SUID-root binary, the kernel sets the new mm's dumpable flag to SUID_DUMP_DISABLE. That value is not SUID_DUMP_USER. So during the SUID binary's normal lifetime, the second conjunct is true, the third is usually true (the caller is the unprivileged user who spawned it), and mm is non-null. The deny fires. pidfd_getfd() returns -EPERM. The unprivileged caller cannot snatch the SUID's fds. This is the property the function was written to enforce, and during the SUID's lifetime it does.
The first conjunct is the one that fails open. mm && is written as a guard: the next two terms read fields of mm, so the deny clause needs mm to be non-null to evaluate them safely. The guard reads, in English, as "if there is a memory image, decide based on it." There is a second English reading: "if there is no memory image, fall through and let the LSM decide." The two readings produce identical behavior every moment a task has a memory image. They diverge when a task does not.
do_exit() frees the memory before it closes the files.
The kernel's process teardown is kernel/exit.c's do_exit(). The relevant ordering, abbreviated to the two calls that matter:
void __noreturn do_exit(long code)
{
/* ... pre-teardown work ... */
exit_mm();
/* ... many other exit_* calls ... */
exit_files(tsk);
/* ... */
}exit_mm() releases the task's mm_struct. Before this patch, the last meaningful thing it did to the task was set task->mm = NULL. After exit_mm() returns, the task has no memory image. exit_files() is the call, several functions later in do_exit()'s sequence, that walks the task's files_struct and closes every open file descriptor.
The window between those two calls is short. On a kernel running a real workload, it is a few microseconds. During that window:
task->mmis null.task->filesis intact. Every file descriptor the task opened during its lifetime is still pointed at a realstruct file.
The two public PoCs target this window. They are not racing against a moving deadline. They are racing against a window that exists, deterministically, every time a SUID binary exits.
pidfd_getfd is the API that walks through the open window.
pidfd_getfd(pidfd, target_fd, 0) was added in Linux 5.6 as a structured way for a process to dup a file descriptor out of another process. The intended use case is debuggers and process supervisors that already have a controlling relationship with the target. The kernel guards it with __ptrace_may_access(target, PTRACE_MODE_ATTACH_REALCREDS).
PTRACE_MODE_ATTACH_REALCREDS compares the caller's real uid to the target's real uid. For a running SUID binary, the real uid is the unprivileged user who spawned it; the euid is root. The uid check passes. The protective gate is the dumpability conjunction. While the SUID binary still has its mm, the conjunction's three terms are all true and the gate denies.
The 0xBlackash PoC, CVE-2026-46333.c, runs this loop:
for (int round = 0; round < MAX_ROUNDS && !success; round++) {
pid_t child = fork();
if (child == 0) {
/* redirect stdio to /dev/null, exec ssh-keysign */
execl(binary, "ssh-keysign", NULL);
_exit(127);
}
int pidfd = pidfd_open(child, 0);
/* ... */
for (int attempt = 0; attempt < MAX_TRIES && !success; attempt++) {
for (int fd = 3; fd < MAX_FDS_SCAN; fd++) {
int stolen_fd = pidfd_getfd(pidfd, fd, 0);
if (stolen_fd < 0) continue;
/* readlink, check for ssh_host_*_key, read contents */
}
}
}ssh-keysign is a short-lived SUID helper that exists to sign host keys for outbound SSH host-based authentication. It opens /etc/ssh/ssh_host_*_key early in its execution and exits within milliseconds when its stdin closes. Every invocation produces one race window. The PoC runs up to 800 rounds, twenty-five thousand probes per round.
The sibling exploit, CVE-2026-46333-shadow.c, swaps chage -l root in for ssh-keysign. chage opens /etc/shadow to read root's password aging fields and exits. Same shape. The PoC runs up to twelve hundred rounds.
The studiogangster repo's main.go is the generalized version: pass any SUID binary as argv[1] and any path of interest as the trailing argument, and the harness captures every file descriptor the target had open at the moment the race landed. The repo's README describes it as a "safe test PoC" with a "responsible use" section. The capability is unbounded; the framing is defensive.
The output of both flavors is the file contents. There is no second stage. The window in do_exit() is the primitive, and the read is what the primitive returns.
The patch caches what the teardown removes.
Linus's commit on May 14, 2026, 31e62c2ebbfd, rewrites the dumpability check. The diff in kernel/ptrace.c:
+static bool task_still_dumpable(struct task_struct *task, unsigned int mode)
+{
+ struct mm_struct *mm = task->mm;
+ if (mm) {
+ if (get_dumpable(mm) == SUID_DUMP_USER)
+ return true;
+ return ptrace_has_cap(mm->user_ns, mode);
+ }
+
+ if (task->user_dumpable)
+ return true;
+ return ptrace_has_cap(&init_user_ns, mode);
+}
+
static int __ptrace_may_access(struct task_struct *task, unsigned int mode)
{
const struct cred *cred = current_cred(), *tcred;
- struct mm_struct *mm;
kuid_t caller_uid;
/* ... */
smp_rmb();
- mm = task->mm;
- if (mm &&
- ((get_dumpable(mm) != SUID_DUMP_USER) &&
- !ptrace_has_cap(mm->user_ns, mode)))
- return -EPERM;
+ if (!task_still_dumpable(task, mode))
+ return -EPERM;
return security_ptrace_access_check(task, mode);
}The new helper inverts the polarity of the question. The old code's deny clause asked "should we deny?" with mm as a precondition that, when false, skipped the deny. The new code's task_still_dumpable() asks "is this task still dumpable?" and structures the answer so that the absence of an mm is a path to "no, require ptrace cap" rather than a path past the entire check. When mm is non-null, the function consults get_dumpable() as before. When mm is null, the function consults a new cached field, task->user_dumpable, set elsewhere in the patch from the same get_dumpable() value during exit_mm(). If even that cached value cannot return SUID_DUMP_USER, the function falls back to requiring CAP_SYS_PTRACE in the init namespace.
Linus's commit message reads: "The 'dumpability' of a task is fundamentally about the memory image of the task, and the concept comes from whether it can core dump or not, and makes no sense when you don't have an associated mm." That is the statement of the bug. The deny clause was written for the case where the kernel could decide. The exit path produced the case where it could not.
Five and a half years between proposal and merge.
The Qualys advisory notes that Jann Horn proposed a patch for this exact issue in October 2020 on LKML. Two threads, both archived. Brad Spengler published a brief analysis on social media before the May 14 commit. Qualys says two public exploits dropped before they sent their advisory; both are the GitHub repositories cited above.
Linus's commit credits "Reported-by: Qualys Security Advisory" and CCs Oleg Nesterov and Kees Cook. It does not reference the 2020 LKML thread. The diff is functionally equivalent to what was proposed there.
During the intervening five years and seven months, every Linux kernel from 5.6 onward, when pidfd_getfd shipped, carried this primitive. The dumpability gate worked when consulted on a live process and failed open when consulted on a process the kernel was disassembling. Anyone who could spawn a SUID binary and call pidfd_getfd against it had a way to harvest its open file descriptors at exit.
Fail-open-intercept, written as a C conjunction.
Fail-open-intercept names security gates whose verdict-listener channel can be killed without the gate noticing. Most exhibits of the pattern are written in Java or PHP, with try { check } catch (e) { log; } followed by the action outside the try block. The DataEase CommunityTokenFilter exhibit is the canonical Java shape. The Next.js variant drops a verdict field at destructure time. The Burst Statistics variant tests the verdict for the wrong type. In every case, the action runs because the listener was wired to a channel the verdict never reached.
CVE-2026-46333 is the kernel-C dialect. The verdict-listener channel is the if statement that decides whether to return -EPERM. The verdict is "this process is not user-dumpable and the caller lacks ptrace cap." The channel that carries the verdict is get_dumpable(mm). The channel is gated by mm && .... When mm is null, the channel is dead. The function continues past the conditional, calls into the LSM, and on default kernels (Yama disabled or in mode 0) returns success. The verdict held; the listener could not hear it. Same wiring fault.
The kernel's fix is the kernel's spelling of "catch-then-rethrow": cache the verdict where the loss of mm cannot destroy it, then have the listener read both channels. The pattern's general lesson holds in C as well as in Java. A deny that requires a precondition the system can remove is a deny the system will, given the right teardown ordering, fail to perform.
The pre-patch check did its job on every process that had a memory image.
The exit path took the memory image away before it closed the file descriptors. The gate that protects SUID binaries did not protect the version of the SUID binary the kernel was currently disassembling. The patch caches the property; the patch is what Jann Horn wrote in 2020. The wait was the rest.
PoCs: 0xBlackash/CVE-2026-46333, studiogangster/CVE-2026-46333. Mailing list: oss-security 2026/q2/536. Kernel commit: 31e62c2ebbfd.
The check needed an mm to deny. The kernel took the mm away first.