-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The deny clause needs the property it denies on.\n\nThe pre-patch logic in `kernel/ptrace.c` reads, with surrounding context omitted:\n\n```c\nstatic int __ptrace_may_access(struct task_struct *task, unsigned int mode)\n{\n const struct cred *cred = current_cred(), *tcred;\n struct mm_struct *mm;\n /* ... uid and capability checks ... */\n\n smp_rmb();\n mm = task->mm;\n if (mm &&\n ((get_dumpable(mm) != SUID_DUMP_USER) &&\n !ptrace_has_cap(mm->user_ns, mode)))\n return -EPERM;\n\n return security_ptrace_access_check(task, mode);\n}\n```\n\nThe 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.\n\nThe function exists to protect SUID binaries. When a process `exec`s 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.\n\nThe 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.\n\n## `do_exit()` frees the memory before it closes the files.\n\nThe kernel's process teardown is `kernel/exit.c`'s `do_exit()`. The relevant ordering, abbreviated to the two calls that matter:\n\n```c\nvoid __noreturn do_exit(long code)\n{\n /* ... pre-teardown work ... */\n exit_mm();\n /* ... many other exit_* calls ... */\n exit_files(tsk);\n /* ... */\n}\n```\n\n`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.\n\nThe window between those two calls is short. On a kernel running a real workload, it is a few microseconds. During that window:\n\n- `task->mm` is null.\n- `task->files` is intact. Every file descriptor the task opened during its lifetime is still pointed at a real `struct file`.\n\nThe 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.\n\n## `pidfd_getfd` is the API that walks through the open window.\n\n`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)`.\n\n`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.\n\nThe 0xBlackash PoC, `CVE-2026-46333.c`, runs this loop:\n\n```c\nfor (int round = 0; round < MAX_ROUNDS && !success; round++) {\n pid_t child = fork();\n if (child == 0) {\n /* redirect stdio to /dev/null, exec ssh-keysign */\n execl(binary, \"ssh-keysign\", NULL);\n _exit(127);\n }\n\n int pidfd = pidfd_open(child, 0);\n /* ... */\n\n for (int attempt = 0; attempt < MAX_TRIES && !success; attempt++) {\n for (int fd = 3; fd < MAX_FDS_SCAN; fd++) {\n int stolen_fd = pidfd_getfd(pidfd, fd, 0);\n if (stolen_fd < 0) continue;\n /* readlink, check for ssh_host_*_key, read contents */\n }\n }\n}\n```\n\n`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.\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\n## The patch caches what the teardown removes.\n\nLinus's commit on May 14, 2026, `31e62c2ebbfd`, rewrites the dumpability check. The diff in `kernel/ptrace.c`:\n\n```diff\n+static bool task_still_dumpable(struct task_struct *task, unsigned int mode)\n+{\n+ struct mm_struct *mm = task->mm;\n+ if (mm) {\n+ if (get_dumpable(mm) == SUID_DUMP_USER)\n+ return true;\n+ return ptrace_has_cap(mm->user_ns, mode);\n+ }\n+\n+ if (task->user_dumpable)\n+ return true;\n+ return ptrace_has_cap(&init_user_ns, mode);\n+}\n+\n static int __ptrace_may_access(struct task_struct *task, unsigned int mode)\n {\n const struct cred *cred = current_cred(), *tcred;\n- struct mm_struct *mm;\n kuid_t caller_uid;\n /* ... */\n smp_rmb();\n- mm = task->mm;\n- if (mm &&\n- ((get_dumpable(mm) != SUID_DUMP_USER) &&\n- !ptrace_has_cap(mm->user_ns, mode)))\n- return -EPERM;\n+ if (!task_still_dumpable(task, mode))\n+ return -EPERM;\n\n return security_ptrace_access_check(task, mode);\n }\n```\n\nThe 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.\n\nLinus'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.\n\n## Five and a half years between proposal and merge.\n\nThe 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.\n\nLinus'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.\n\nDuring 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.\n\n## Fail-open-intercept, written as a C conjunction.\n\n[Fail-open-intercept](/posts/tomcat-encryptinterceptor-fails-open) 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.\n\nCVE-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.\n\nThe 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.\n\n## The pre-patch check did its job on every process that had a memory image.\n\nThe 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.\n\nPoCs: [0xBlackash/CVE-2026-46333](https://github.com/0xBlackash/CVE-2026-46333), [studiogangster/CVE-2026-46333](https://github.com/studiogangster/CVE-2026-46333). Mailing list: [oss-security 2026/q2/536](https://seclists.org/oss-sec/2026/q2/536). Kernel commit: [31e62c2ebbfd](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=31e62c2ebbfdc3fe3dbdf5e02c92a9dc67087a3a).","closing_line":"The check needed an mm to deny. The kernel took the mm away first.","hook_md":"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.","post_id":321,"slug":"linux-cve-2026-46333-dumpability-needs-an-mm","title":"CVE-2026-46333: The Dumpability Check Needs an mm. exit_mm() Runs Before exit_files().","type":"initial","unreadable_sentence":"The deny clause's predicate is `mm && ...`. When `mm` is null, the predicate is false and the deny never fires."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCah9D8AAKCRDeZjl4jgkQ JpafAP99BO2rTLV9W5SSqovKv3J2XJdppjas9ll1WoXZ2Gq+dgD7BmP3MhXn7fPp eB+3/pqnwvUrLhBxJAMolOyV6HzpwQE= =8hjN -----END PGP SIGNATURE-----