The do_exit File Descriptor Heist
People think process termination in Linux is a clean, atomic event. You pull the trigger. The process dies. Memory vanishes. Resources get freed. Done.
It isn't. CVE-2026-46333 proves it.
Process death is a multi-step bureaucracy managed by do_exit(). And like any bad bureaucracy, if you move fast enough during the paperwork, you can steal the office furniture.
Inside exit.c, the kernel cleans up the corpse in stages. This creates a temporal gap that is ripe for exploitation.
Here is what that looks like:
[ Process Active ]
|
do_exit() called
|
v
+---------------+
| exit_mm() | ---> task->mm = NULL (Memory map destroyed)
+---------------+
|
| <--- VULNERABILITY WINDOW (The Zombie Phase)
| task->mm == NULL, but task->files is STILL VALID!
v
+---------------+
| exit_files() | ---> File descriptors finally closed
+---------------+
|
[ Process Dead ]
There is a narrow race window. The process has no memory map. But its file descriptor table is still fully populated with the privileged files it was authorized to open. Attackers cannot read arbitrary files; they abuse specific SUID binaries like "chage" or "ssh-keysign" to steal the exact file descriptors those binaries had legitimately opened before dying.
Security systems frequently use a null memory map as a proxy for "the process is gone." That is a lie. The process is a brain-dead zombie. But its hands are still clutching loaded guns.
This is compounded by a severe logic regression. In the v4.10-rc1 development window, commit bfedb589 (titled “mm: Add a user_ns owner to mm_struct and fix ptrace permission checks”) rewrote part of the ptrace authorization path. By the time the kernel shipped to production in early 2017, the authorization logic had been silently inverted.
Before the refactor, the kernel explicitly denied access if a task's memory map was null. After the refactor, the check simply fell through to a successful "allow" state.
But the tragedy is not the bug. The tragedy is the ecosystem.
In 2020, Jann Horn from Google Project Zero noticed the exact architectural flaw. He proposed a patch. However, the concern did not result in a merged fix. The time bomb kept ticking across affected Linux deployments for nearly six more years.
On May 11, 2026, the Qualys Threat Research Unit (TRU) found the live wire and reported it privately to the upstream Linux kernel security contact, engaging linux-distros before moving to public OSS-Security. Three days later, on May 14, Linus Torvalds pushed a patch to the public repository with his classic deadpan description: "ptrace: slightly saner 'get_dumpable()' logic". A cute way to describe patching a gaping hole.
Hours later, an independent researcher known as "_SiCk" looked at the public commit, reverse-engineered the logic, and dropped public exploits on GitHub: "ssh-keysign-pwn" and "chage_pwn".
By May 15, the embargo had completely collapsed. Distributions accelerated patch releases while attackers were already running the code.
In the open-source kernel, the patch is the exploit documentation. If you rely on the speed of your vendor's patches, you have already lost the race.
The exploit is not that Linux leaves arbitrary secrets lying around after process death. The exploit is that, during a narrow exit window, ptrace-style authorization can misclassify a dying privileged task, allowing an attacker to duplicate file descriptors that the privileged process already opened.
The upstream kernel finally patched it in stable tags like versions 7.1, 7.0.8, 6.18.31, 6.12.89, 6.6.139, 6.1.173, and 5.15.207 (note that distribution package versions will vary wildly). Debian pushed fixes for Bullseye, Bookworm, and Trixie. RHEL, AlmaLinux, and Rocky Linux patched branches 8, 9, and 10.
But here is a grim detail about exploitability versus vulnerability.
AlmaLinux 8 has the exact flawed kernel logic. However, the current public Proof of Concept exploits fail against it. Meanwhile, AlmaLinux 9 and 10 fall over immediately when hit with the same PoC.
This proves exactly why defenders must separate the exploitability of a system from its underlying vulnerability. Just because a GitHub script fails today does not mean the kernel will not betray you tomorrow.
To pull off the heist, an attacker targets a privileged SUID binary via pidfd_open. Then they spam pidfd_getfd. Because the attacker spawned the target SUID binary, they are technically the parent process.
They race the teardown. When they hit that exact exit_mm window in the diagram above, the target's memory map is null. The access check falls through. They clone the target's file descriptors.
Standard user-path telemetry struggles with this descriptor cloning because path attribution and file descriptor provenance are lost.
A SIEM is looking for open() or openat2() syscalls. But the attacker didn't open the target file. The SUID binary did.
The attacker just copied the file descriptor from the target's pocket while it was dying. A file descriptor number like "FD 3" is meaningless without process context. When the target process dies a microsecond later, that context is gone forever.
Path-based auditing is a lock on a door when the thief is already inside, copying the keys.
High-level eBPF frameworks like Tetragon will tell you who the thief is. They enrich system calls with UIDs, container namespaces, and Kubernetes pod metadata perfectly. A simplified tracepoint event for this behavior looks like this:
{
"process_tracepoint": {
"process": {
"binary": "/tmp/exploit",
"uid": 33,
"pod": { "namespace": "prod", "name": "web-frontend-hash" }
},
"sys_enter": {
"syscall_name": "sys_pidfd_getfd"
},
"args": [
{ "pidfd": 5 }, { "fd": 3 }
]
}
}
But they don't inspect the internal memory pointer or the exiting flags of the target. High-level context tells you who is acting. To confirm the exact mechanism of the vulnerability, we need a custom surgical eBPF sensor. We need the ground truth.
To get it, you might think about dropping into the Linux Security Module (LSM) layer. You could hook lsm/ptrace_access_check to block the attack.
But here is the grim reality of enterprise infrastructure. Many cloud VMs and managed distributions do not ship with CONFIG_BPF_LSM=y enabled by default. Or they require modifying GRUB boot parameters, which is a non-starter in managed environments.
If you want truly portable, passive defensive observability, you don't use LSM. You use standard tracing trampolines. You use fentry.
You could write a full fentry probe in C, but dumping the whole file here is a waste of time. Let's look at the core logic that actually matters.
First, we hook the authorization function.
SEC("fentry/security_ptrace_access_check")
int BPF_PROG(on_security_ptrace_access_check, struct task_struct *child, unsigned int mode) {
We chose fentry/security_ptrace_access_check. This lets us read the memory states directly without altering the kernel execution path. We don't need LSM enforcement privileges to just watch the gate.
Second, we read the target's internal state.
target_flags = BPF_CORE_READ(child, flags);
We use the BPF CO-RE macro BPF_CORE_READ. Assuming the target kernel has BTF metadata enabled (CONFIG_DEBUG_INFO_BTF), this vastly improves portability across different kernel versions, assuming compatible types and verifier constraints.
Third, we filter out the noise.
if (target_flags & PF_KTHREAD) return 0;
We explicitly discard true kernel threads. We check if the target has the PF_KTHREAD flag set. If it does, we bail. We only care about user-space processes masquerading as kernel threads because their memory map is gone.
Fourth, we extract the smoking gun.
mm = BPF_CORE_READ(child, mm); event->target_mm_null = mm == NULL;
We check if the target's memory map is null. But a single condition does not equal compromise. You must correlate a pidfd_getfd syscall with a non-kthread target whose mm == NULL during exit. This correlation is a high-signal indicator of the exploitation phase.
Finally, we ship the evidence.
bpf_ringbuf_submit(event, 0);
We package this high-fidelity evidence and push it asynchronously to user-space using a BPF ring buffer.
If you are not in a position to deploy custom eBPF sensors, you need to rely on static mitigation while operations applies the kernel patches.
Because pidfd_getfd relies on ptrace authorization, the Yama Linux Security Module governs it. By default, most distributions ship with kernel.yama.ptrace_scope = 1.
Level 1 allows a parent process to attach to its child. Since the attacker's script spawned the target SUID binary, the attacker is technically the parent. Yama happily assumes this is safe and allows the attach. The race condition succeeds.
You can break the chain entirely by raising the scope to Admin-only attach.
sysctl -w kernel.yama.ptrace_scope=2
At Level 2, Yama strictly requires the CAP_SYS_PTRACE capability for any process attachment. The unprivileged attacker does not have this capability. The kernel returns -EPERM immediately, rendering the race unreachable.
Keep in mind that you can dynamically lower the scope from 2 to 1 at runtime. But if you set it to 3 to disable ptrace entirely, you cannot lower it again without rebooting the host. Plan accordingly.
The risk does not end on monolithic servers. Containers share the underlying worker node kernel. An unprivileged pod can pack its own SUID binary and attack the host kernel from the inside.
The RuntimeDefault seccomp profile acts as a highly effective surface reduction. In many standard Docker or containerd setups, it blocks the pidfd_getfd syscall entirely, though this remains environment-specific.
But there is a trap in Kubernetes Pod Security Standards (PSS). The Baseline standard allows minimal/default pod configs which can be an illusion of security in this context Juliet Security. PSS Baseline explicitly prohibits "Unconfined" profiles, but it allows you to leave the seccomp field empty (unset).
Unless kubelet seccompDefault is enabled, an unset seccomp field can translate to Unconfined. The vulnerability is totally exposed.
The PSS Restricted profile is designed to stop this attack. It requires seccomp to be explicitly set to RuntimeDefault or an approved Localhost profile, and it also forces allowPrivilegeEscalation: false, which activates NoNewPrivs and nullifies the effect of any SUID binary inside the container.
If a vulnerable host was exposed to untrusted local users before patching, assume compromise.
You must rotate all SSH host private keys. If host-based authentication is not strictly required, remove the SUID bit from utilities like ssh-keysign.
chmod u-s /usr/lib/openssh/ssh-keysign
User-space logs describe what the process admitted doing. Kernel state describes what actually happened.