
Executive Summary
GhostLock (CVE-2026-43499) is a use-after-free in the Linux kernel’s priority-inheritance rtmutex code that has been reachable since 2011. The bug lives in remove_waiter() in kernel/locking/rtmutex.c: the helper assumes the waiter being cleaned up belongs to current, which is true for a normal blocking thread but false on the requeue-PI path, where one thread rolls back a lock attempt on behalf of a different, sleeping waiter. When a proxy lock hits a -EDEADLK deadlock and unwinds, the code clears the requeuer’s pi_blocked_on instead of the real waiter’s — leaving the sleeping thread with a dangling pointer into a kernel-stack frame that is about to be freed. It needs only CONFIG_FUTEX_PI=y, no capabilities and no user namespaces, and the vulnerable range spans Linux v2.6.39 through v7.1-rc1.
The write-up turns that stack use-after-free into a fully unprivileged local-root exploit. A prefetch timing side channel recovers the KASLR and physmap bases; the freed waiter frame is reclaimed with prctl(PR_SET_MM_MAP), whose large aligned user_auxv stack buffer is used to forge a fake rt_mutex_waiter; a subsequent PI chain walk performs a single constrained rb-tree write that overwrites inet6_protos[IPPROTO_UDP]; the CPU Entry Area (reachable at a known direct-map address) hosts a fake inet6_protocol, pivot slots and a short ROP stack; a loopback IPv6 UDP packet fires the hijacked handler; and a “DirtyMode” trick flips the mode bits on core_pattern so the rest of the escalation is pure userspace. The exploit is 97% reliable and earned $92,337 from Google’s kernelCTF.
Vulnerability Summary
GhostLock lets an unprivileged local attacker:
- Obtain a dangling kernel pointer into kernel-stack memory using only standard threading/futex syscalls.
- Write a pointer to a nearly arbitrary address (subject to a few layout constraints).
- Hijack a kernel function-pointer table to gain control-flow hijacking and escalate to root.
- Affected versions: Linux 2.6.39 through 7.1-rc1 — introduced in commit
8161239a8bcc, fixed in commit3bfdc63936dd. - Requirement:
CONFIG_FUTEX_PI=yonly; no special privileges and no user namespaces. - Impact: every Linux distribution without the patch is affected — roughly fifteen years of exposure.
- Reward: Google paid $92,337 via kernelCTF for the exploit, which reached 97% stability.
Vulnerability Analysis
Overview
The flaw was introduced by the rtmutex rework in commit 8161239a8bcc (“rtmutex: Simplify PI algorithm and make highest prio task get lock”) and then sat untouched for about fifteen years until the April 2026 fix. The affected range is v2.6.39-rc1 to v7.1-rc1, and the only build requirement is CONFIG_FUTEX_PI=y — no capabilities, no namespaces.
Root cause
The root cause is a lifecycle mismatch. remove_waiter() was written for one situation only: a thread blocks on a lock, then cleans up after itself. It therefore assumes that current — the thread executing the cleanup — is also the thread that owns the waiter being removed. Requeue-PI breaks that assumption. Via rt_mutex_start_proxy_lock(), the helper now runs on behalf of a different, sleeping thread: here current is the thread issuing FUTEX_CMP_REQUEUE_PI, not the actual waiter.
When __rt_mutex_start_proxy_lock() returns -EDEADLK, the proxy path rolls back through remove_waiter():
int __sched rt_mutex_start_proxy_lock(struct rt_mutex_base *lock,
struct rt_mutex_waiter *waiter,
struct task_struct *task)
{
int ret;
raw_spin_lock_irq(&lock->wait_lock);
ret = __rt_mutex_start_proxy_lock(lock, waiter, task);
if (unlikely(ret))
remove_waiter(lock, waiter); // ret == -EDEADLK
raw_spin_unlock_irq(&lock->wait_lock);
return ret;
}
Inside the helper, the cleanup touches the wrong task:
static void __sched remove_waiter(struct rt_mutex_base *lock,
struct rt_mutex_waiter *waiter)
{
...
raw_spin_lock(¤t->pi_lock);
rt_mutex_dequeue(lock, waiter);
current->pi_blocked_on = NULL; // should be waiter->task
raw_spin_unlock(¤t->pi_lock);
...
}
The waiter object lives on the sleeping thread’s stack, but current here is the requeuing thread. The correct behavior is to take waiter->task->pi_lock and clear waiter->task->pi_blocked_on. Because it clears the wrong field, the real waiter keeps a pi_blocked_on pointer aimed at a stack frame that is freed as soon as it wakes.
Triggering the stack-UAF
Reaching the -EDEADLK rollback requires a PI dependency cycle built from three futex words and three threads:
- f_pi_chain — a PI futex, initially locked by the waiter thread.
- f_pi_target — a PI futex, initially locked by the owner thread (the requeue target).
- f_wait — a plain futex where the waiter blocks with
FUTEX_WAIT_REQUEUE_PI.
The sequence:
- The waiter thread takes
f_pi_chain, then blocks inFUTEX_WAIT_REQUEUE_PI(f_wait -> f_pi_target). Itsrt_mutex_waiternow sits on its stack. - The owner thread takes
f_pi_target, then blocks onf_pi_chain(held by the waiter). - The main thread calls
FUTEX_CMP_REQUEUE_PI(f_wait -> f_pi_target).
The requeue tries to proxy the waiter onto f_pi_target. Because f_pi_target‘s owner is itself blocked behind the waiter via f_pi_chain, the cycle closes: waiter → f_pi_target → owner → f_pi_chain → waiter. The chain walk returns -EDEADLK and runs the buggy rollback, so the waiter wakes with a dangling pi_blocked_on pointing into its freed stack frame.

-EDEADLK rollback clears the wrong task, and the waiter wakes with a dangling pi_blocked_on. Source: original article (rendered from the original SVG).Only the requeuer’s rollback timing relative to the freeing of the waiter object matters; once the cycle is staged it resolves on its own. The waiter is back in userspace with a dangling pi_blocked_on, and a later sched_setattr() chain walk can be triggered at any moment — the UAF window is wide open rather than a tight race. The one wrinkle is that the freed object lives on the kernel stack, so reclamation means finding a syscall that lands controlled bytes at the same stack depth and offset.
The initial primitive from GhostLock
With a dangling pointer into freed kernel stack, an attacker can spray controlled bytes and forge an rt_mutex_waiter. A single dereference of that forged object yields several primitives:
- Write a pointer to a constrained (but nearly arbitrary) address.
- Write 8 bytes of zeros to a constrained address.
Pointer dereferences and integrity checks run before the primitive fires, and the kernel returns normally afterward without crashing. Three questions then drive the exploit: how do you reclaim the freed stack memory (answer: PR_SET_MM_MAP stack reuse); how do you forge a valid rt_mutex_waiter that survives the structural checks (constrained pointer forgery); and which write primitive and target do you use (the inet6_protos[IPPROTO_UDP] write path).
Exploit Details
Exploit Summary
The complete chain:
- Prefetch → KASLR/physmap leak — time
prefetchinstructions to recover the kernel image slide and physmap base. - GhostLock trigger → dangling pointer — leave a dangling
rt_mutex_waiterin the waiter task’spi_blocked_on. - Stack-UAF reclaim — use
PR_SET_MM_MAPto reclaim the waiter’s kernel stack and forge a fakert_mutex_waiterover the freed frame. - Arbitrary-ish writer — the rtmutex rb-tree erase performs one constrained pointer write, overwriting the
inet6_protos[IPPROTO_UDP]function-pointer table entry. - CPU entry area spray — host a fake
inet6_protocol, pivot slots and the ROP stack at a known direct-map address. - Control-flow hijack — send a loopback IPv6 UDP packet so the kernel calls through the overwritten handler.
- DirtyMode privilege escalation — one write flips
core_pattern‘s mode bits; the remaining LPE is pure userspace.
Background of used tricks
Prefetch ASLR leak
A prefetch instruction against a given address takes a different number of cycles depending on whether that address is mapped in the current page tables. An unprivileged process can therefore time prefetch across kernel ranges and recover which addresses are mapped. Because Linux barely randomizes its default kernel image base (~9 bits of entropy for the text base), averaging timings recovers the KASLR base with near-100% reliability.
The technique needs a CPU that supports prefetch and the absence of proper Kernel Page-Table Isolation (KPTI); it is primarily an x86 method unless ARM targets run with KPTI disabled, and kernelCTF images disable KPTI by default. Even with KPTI enabled, prefetch combined with EntryBleed can still recover the kernel image base through the trampoline code.
CEA spray and randomization bypass
The CPU Entry Area (CEA) is a per-CPU x86 structure that holds the stacks and register context used for entry and exception handling. On an exception, interrupt or syscall, the CPU switches to a CEA stack and spills the register frame (pt_regs) there. An unprivileged process can trigger a software exception to write controlled register context into the CEA’s pt_regs. On pre-6.2 kernels the CEA sat at a completely fixed address, giving roughly 120 bytes of contiguous controlled memory at a known kernel address. On 6.2 and later, the CEA’s virtual address is randomized (per Project Zero’s “Bringing back the stack attack” work), but its physical offset stays fixed.
The workaround for post-6.2 kernels is to reach the CEA through its direct-map alias, derived from the physmap base:
cea_direct = physmap_base + CPU1_CEA_BASE
The direct-map leak is noisier than the text leak but reaches high accuracy with candidate-edge normalization. Each CPU’s CEA virtual address randomizes independently, yet the physical addresses remain fixed. On kernelCTF LTS 6.12.80 in the 3.5G-boot environment, cea_offset = 0x11c517000 (+0x1f58).
Reusing the stack: forging the waiter with PR_SET_MM_MAP
The dangling object is the rt_mutex_waiter on the waiter’s stack:
struct rt_mutex_waiter {
struct rt_waiter_node tree; // rb node, lives in lock->waiters
struct rt_waiter_node pi_tree;
struct task_struct *task;
struct rt_mutex_base *lock;
unsigned int wake_state;
struct ww_acquire_ctx *ww_ctx;
};
To reclaim it, the waiter thread returns from the futex syscall and immediately calls prctl(PR_SET_MM, PR_SET_MM_MAP, ...). Inside prctl_set_mm_map(), a user-supplied auxv is copied into a fixed-size unsigned long user_auxv[AT_VECTOR_SIZE] stack buffer at roughly the same depth as the freed waiter frame — a large, naturally-aligned, namespace-free block of controlled qwords landing directly on top of the old object. The forged layout:
tree— an rb node crafted so that erasing it promotes one chosen child pointer (W0_BASE) into the tree root.task— set to&init_task, a validtask_structfor safe task dereferences.lock— set to&inet6_protos[IPPROTO_UDP] - 8, the write target.wake_state— set to0.
The auxv backs a memfd positioned so the copy straddles a page boundary, and a sibling thread races fallocate(PUNCH_HOLE) on the trailing page during prctl to widen the copy_from_user window. The forged waiter stays live on the stack while another CPU runs sched_setattr() on the waiter to walk the PI chain. Other syscalls with large controlled stack locals — clone, setsockopt, pselect, keyctl and more — work identically; prctl is just convenient because its large aligned buffer needs no namespace.
From fake waiter to one controlled (limited) write
Controlling the waiter yields a single constrained pointer write, not a free arbitrary write. The chain walk does:
task->pi_blocked_on → fake waiter
fake waiter->lock → fake rt_mutex_base
rt_mutex_dequeue(lock, waiter) // rb_erase on lock->waiters
rt_mutex_dequeue() is an rb-tree erase, and erasing a single-child root writes that child into the root slot. Setting lock = target - 8 lines the rt_mutex_base fields up over the data around the target pointer:
target - 8 → raw_spinlock_t wait_lock (must read as "unlocked")
target → waiters.rb_root.rb_node (this slot gets written)
target + 8 → waiters.rb_leftmost
target + 16 → owner
The fake waiter’s rb node is crafted so the erase writes exactly one child pointer into rb_root.rb_node. The primitive is therefore *(uint64_t *)target = W0_BASE, subject to strict constraints:
*(u32 *)(target - 0x08) == 0
*(u64 *)(target + 0x08) == 0 // simplified
((*(u64 *)(target + 0x10)) & ~1ULL) == 0
// Then we can do:
*(u64 *)target = &W0->tree.entry // W0_BASE
In words: the qword before target must read as an unlocked spinlock (zero in the low four bytes), the rb_leftmost field must be zero, and the owner field must not steer the walk into an uncontrolled waiter. W0_BASE must point at memory that survives all of these comparisons and the no-owner wakeup within the same rt_mutex_adjust_prio_chain() call. Pointing it at the CEA’s direct-map alias serves two purposes: before the write, the CEA is controllable memory at a known address for forging a self-consistent fake waiter/lock that survives the walk; after the write, target now points into the CEA, so W0 no longer has to look like a waiter and can be re-sprayed with whatever structures are expected next. Before triggering, W0 is sprayed as a fake waiter/lock pair with task = &init_task, a legit priority and a benign lock owner, so dequeue, re-enqueue, priority update and wakeup all survive.

Use inet6_protos[IPPROTO_UDP] to help
After leaking KASLR and choosing the write target, scanning writable data tables turns up many pointer tables whose neighbors already satisfy the layout constraints. inet6_protos[IPPROTO_UDP] is especially clean:
inet6_protos[16] == NULL // fake wait_lock -> unlocked
inet6_protos[17] == &udpv6_protocol // <- target (IPPROTO_UDP)
inet6_protos[18] == NULL // fake rb_leftmost
inet6_protos[19] == NULL // fake owner
After the write, inet6_protos[IPPROTO_UDP] points into the CEA, where the kernel expects an inet6_protocol:
struct inet6_protocol {
int (*handler)(struct sk_buff *skb);
int (*err_handler)(...);
unsigned int flags;
};
W0 is re-sprayed as a fake inet6_protocol whose handler is the first pivot gadget (giving PC control), whose err_handler is unused, and whose flags are INET6_PROTO_NOPOLICY | INET6_PROTO_FINAL. Sending a loopback IPv6 UDP packet — connect then write to ::1 — makes the kernel dereference the malicious handler, handing over PC.
The pivot and DirtyMode
The CEA window hosts several structures at once: the fake inet6_protocol, JOP/pivot slots and the final ROP stack. On Google’s lts-6.12.80 kernel a single stack-pivot gadget is not available, so the chain needs one extra load/call to land the CEA address in rbp, then pivots with mov rsp, rbp; pop rbp; ret.
A full ret2usr or a complete /proc/%P/fd/x overwrite would need roughly ten gadget qwords — too long. Instead, DirtyMode supplies the final stage: a single write of an almost-garbage value flips a permission bit, after which the rest of the LPE is pure userspace. The target is the core_pattern sysctl’s mode flags:
static struct ctl_table coredump_sysctls[] = {
...
{ .procname = "core_pattern",
.data = core_pattern,
.maxlen = CORENAME_MAX_SIZE,
.mode = 0644,
.proc_handler = proc_dostring_coredump },
...
};
coredump_sysctls lives in writable kernel data and shares the kernel image’s KASLR slide, so the ROP writes a permissive value into coredump_sysctls[1].mode; any value with the write bit (the second LSB) set is enough. The chain uses a short pop reg; mov [reg], reg; ret gadget plus msleep to safely park the hijacked thread. With /proc/sys/kernel/core_pattern now world-writable, an unprivileged process opens it, writes |/proc/%P/fd/666 %P, and crashes a helper so its binary runs as root. (The initial rb-tree write cannot reach coredump_sysctls[1].mode directly due to the address constraints, so the mode flip happens from the short ROP stage.)
Bigger ROP or NPerm
kernelCTF is a speed competition, and the shortest reliable chains win. NPerm-backed memory can build a larger fake stack after the hijack, and heavier routes work too — including Lukas Maar’s heap-KASLR leak — but each adds another stage and more time. CEA plus DirtyMode is the shortest path to a one-write victory: on the remote target, the exploit captures the flag in about five seconds.
Key Takeaways
- The root cause is a task-identity confusion:
remove_waiter()clearscurrent->pi_blocked_onwhen, on the requeue-PI path, it should clearwaiter->task->pi_blocked_on. - The result is a stack use-after-free with a wide window — the dangling
pi_blocked_oncan be walked later viasched_setattr(), not just in a tight race. - Reclaiming a kernel-stack object is done with
PR_SET_MM_MAP, whose large aligneduser_auxvbuffer lands controlled qwords over the freedrt_mutex_waiter. - An rb-tree erase becomes a single constrained pointer write; choosing
inet6_protos[IPPROTO_UDP]satisfies the neighbor constraints for free. - The CPU Entry Area, reachable at a known direct-map address, does double duty as forgeable memory before the write and as the ROP/pivot host after it.
- “DirtyMode” reduces the whole post-hijack stage to one write: flip
core_pattern‘s mode bits and finish in userspace — 97% reliable, ~5 seconds, $92,337 from kernelCTF. - Only
CONFIG_FUTEX_PI=yis required — no privileges, no namespaces — and the bug shipped in effectively every distro for ~15 years.
Defensive Recommendations
- Patch now. Apply the kernel update carrying commit
3bfdc63936dd(and its stable backports) across every host; this is unprivileged local root with no exotic prerequisites. - Enable
RANDOMIZE_KSTACK_OFFSET. It breaks the deterministic stack-frame overlap the reclaim step relies on, turning it into a ~1/32 guess; many distro kernels leave it off by default. - Enable KPTI. Proper Kernel Page-Table Isolation blunts the
prefetchKASLR side channel (though EntryBleed-style variants can still leak the text base). - Consider
STATIC_USERMODE_HELPER. It closes the specificcore_patternDirtyMode path — but note the same idea generalizes to any/proc/sysknob whosectl_table::modegates access and whose table sits in predictable writable data. - Disable
CONFIG_FUTEX_PIwhere feasible. If PI-futexes are not needed for a given workload, removing them eliminates the requirement entirely. - Add depth-in-defense. Constrain unprivileged local attack surface with syscall filtering (seccomp), and monitor for anomalous
core_patternwrites and unexpected root-owned helper executions.
Mitigation
The patch
The kernel fix makes remove_waiter() clear the correct task’s pi_blocked_on and pass the waiter’s task down the chain walk:
diff --git a/kernel/locking/rtmutex.c b/kernel/locking/rtmutex.c
--- a/kernel/locking/rtmutex.c
+++ b/kernel/locking/rtmutex.c
@@ -1544,6 +1544,8 @@ static bool rtmutex_spin_on_owner(struct rt_mutex_base *lock,
*
* Must be called with lock->wait_lock held and interrupts disabled. It must
* have just failed to try_to_take_rt_mutex().
+ *
+ * When invoked from rt_mutex_start_proxy_lock() waiter::task != current !
*/
static void __sched remove_waiter(struct rt_mutex_base *lock,
struct rt_mutex_waiter *waiter)
@@ -1551,14 +1553,15 @@ static void __sched remove_waiter(struct rt_mutex_base *lock,
{
bool is_top_waiter = (waiter == rt_mutex_top_waiter(lock));
struct task_struct *owner = rt_mutex_owner(lock);
+ struct task_struct *waiter_task = waiter->task;
struct rt_mutex_base *next_lock;
lockdep_assert_held(&lock->wait_lock);
- raw_spin_lock(¤t->pi_lock);
- rt_mutex_dequeue(lock, waiter);
- current->pi_blocked_on = NULL;
- raw_spin_unlock(¤t->pi_lock);
+ scoped_guard(raw_spinlock, &waiter_task->pi_lock) {
+ rt_mutex_dequeue(lock, waiter);
+ waiter_task->pi_blocked_on = NULL;
+ }
/*
* Only update priority if the waiter was the highest priority
@@ -1594,7 +1597,7 @@ static void __sched remove_waiter(struct rt_mutex_base *lock,
raw_spin_unlock_irq(&lock->wait_lock);
rt_mutex_adjust_prio_chain(owner, RT_MUTEX_MIN_CHAINWALK, lock,
- next_lock, NULL, current);
+ next_lock, NULL, waiter_task);
An alternative patch sent to security@kernel.org passes the owning task explicitly:
static void __sched remove_waiter(struct rt_mutex_base *lock,
struct rt_mutex_waiter *waiter,
struct task_struct *task)
{
...
raw_spin_lock(¤t->pi_lock);
raw_spin_lock(&task->pi_lock);
rt_mutex_dequeue(lock, waiter);
current->pi_blocked_on = NULL;
raw_spin_unlock(¤t->pi_lock);
if (task->pi_blocked_on == waiter)
task->pi_blocked_on = NULL;
raw_spin_unlock(&task->pi_lock);
...
rt_mutex_adjust_prio_chain(owner, RT_MUTEX_MIN_CHAINWALK, lock,
next_lock, NULL, task);
}
RANDOMIZE_KSTACK_OFFSET
The stack-reuse step needs the freed waiter frame and the later user_auxv frame to overlap deterministically. With RANDOMIZE_KSTACK_OFFSET enabled they no longer line up, reducing the step to a ~1/32 (5-bit) stack-offset guess. Both kernelCTF targets leave it off by default; mitigation-focused targets turn it on, which makes this path unusable.
STATIC_USERMODE_HELPER
STATIC_USERMODE_HELPER closes this specific DirtyMode path. The underlying principle, however, generalizes to any /proc/sys knob whose ctl_table::mode gates access and whose table sits in predictable writable kernel data.
Timeline
- 2026-04-18 — Vulnerability reported to security@kernel.org; draft patch submitted.
- 2026-04-20 — Bug fixed with a kernel patch.
- 2026-05-04 — Fix v1 backported.
- 2026-06-30 — Google acknowledged the kernelCTF submission.
- 2026-07-07 — Technical write-up published.
Conclusion
GhostLock is a reminder that “clean up after yourself” is only safe when current really is the thread that needs cleaning up. A single wrong task pointer in remove_waiter() — correct for direct blocking, wrong for proxy requeue — created a stack use-after-free that survived fifteen years and every distribution. The exploit chain is a tour of modern kernel technique: a prefetch KASLR leak, a kernel-stack reclaim via PR_SET_MM_MAP, an rb-tree erase turned into one constrained write, the CPU Entry Area doing double duty, and DirtyMode collapsing the endgame to a single mode-bit flip. The fix is a few lines, and the mitigations (RANDOMIZE_KSTACK_OFFSET, KPTI, STATIC_USERMODE_HELPER) each remove a rung of the ladder — but the durable lesson is to be precise about task identity in shared cleanup paths. The kernel maintainers were credited for a fast turnaround from report to fix.
Original text: “IonStack part II: GhostLock, a stack-UAF that has existed in ALL Linux distributions for 15 years” by Nebula Security at NebuSec. Disclosure followed Nebula Security’s standard 90+30 day policy; a public PoC is referenced in the original write-up.


