Cached at:
07/11/26, 12:14 AM
# IonStack part II: GhostLock, a stack-UAF that has existed in ALL Linux distributions for 15 years
Source: [https://nebusec.ai/research/ionstack-part-2/](https://nebusec.ai/research/ionstack-part-2/)
1. [https://nebusec.ai/](https://nebusec.ai/)
2. [Research](https://nebusec.ai/research)
3. IonStack part II: GhostLock, a stack\-UAF that has existed in ALL Linux distributions for 15 years
> GhostLock \(CVE\-2026\-43499\) is a Linux kernel vulnerability found by[VEGA](https://nebusec.ai/vega/)that exists in every major distribution since 2011\. Triggering the bug does not require any special kernel config or privilege\. By turning it into a 97% stable privilege escalation and container escape, Google has rewarded us $92,337 in kernelCTF\. This writeup covers the technical details of the exploit\.
## Vulnerability Summary
GhostLock \(CVE\-2026\-43499\) lets an unprivileged local attacker:
- Get a dangling kernel pointer to kernel stack memory with only regular threading syscalls\.
- Write a pointer to an almost arbitrary address\.
- Hijack a function table to get control flow hijack and eventually get root access\.
GhostLock was introduced in Linux 2\.6\.39 and fixed in Linux 7\.1\. It has existed in the Linux kernel for more than 15 years\.**Every Linux distribution**without the patch is affected and should consider upgrading to the latest LTS version\.
Your browser does not support the video tag\.
## Vulnerability Analysis
### Overview
GhostLock was introduced with the rtmutex rework in[`8161239a8bcc`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=8161239a8bcc)\(“rtmutex: Simplify PI algorithm and make highest prio task get lock”\), and sat untouched for about fifteen years until the April 2026 fix in[`3bfdc63936dd`](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=3bfdc63936dd4773109b7b8c280c0f3b5ae7d349)\(“rtmutex: Use waiter::task instead of current in remove\_waiter\(\)”\)\. The affected range is`v2\.6\.39\-rc1`to`v7\.1\-rc1`, with`CONFIG\_FUTEX\_PI=y`the only requirement and no capabilities or user namespaces needed\.
`remove\_waiter\(\)`in`kernel/locking/rtmutex\.c`clears`current\-\>pi\_blocked\_on`\. That is correct on the normal slow path, where`current`is the task that owns the waiter\. It is wrong on the proxy path\.`rt\_mutex\_start\_proxy\_lock\(\)`enqueues, and on error rolls back, an`rt\_mutex\_waiter`on behalf of another task, so`current`is the requeuer rather than the waiter\.
The waiter object lives on the stack of a task sleeping in`FUTEX\_WAIT\_REQUEUE\_PI`\. A`FUTEX\_CMP\_REQUEUE\_PI`then proxies that waiter onto the target PI futex\. When the rtmutex chain walk reports a deadlock, the rollback dequeues the waiter from the lock but clears`pi\_blocked\_on`on the requeuer\. The waiter task keeps`pi\_blocked\_on`pointing at its own stack frame, which is popped the moment the waiter returns to userspace\. Any later PI chain walk through that task follows the dangling pointer\.
### Root cause
This is the same shape as many other life\-cycle bugs: a function reused by a caller it was never written for\.
The helper function`remove\_waiter\(\)`was originally written for exactly one scenario: a thread blocks on its own, then cleans up after itself\. So it has always assumed that`current`\(whichever thread happens to be running\) is the`waiter`it needs to clean up, and clears`current\-\>pi\_blocked\_on`accordingly\.
However, Requeue\-PI breaks that assumption\. Through`rt\_mutex\_start\_proxy\_lock\(\)`, this helper is now used to clean up on behalf of a different, sleeping thread\. In that path,`current`is the thread that issued`FUTEX\_CMP\_REQUEUE\_PI`rather than the actual`waiter`\.
When`\_\_rt\_mutex\_start\_proxy\_lock\(\)`returns`\-EDEADLK`, it rolls back via`remove\_waiter\(\)`, the misused helper\.
```
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;}
```
`remove\_waiter\(\)`then scrubs 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); ...}
```
`waiter`is the object that lives on the sleeping thread’s own stack, while`current`here is the thread that requested the requeue\. The fix locks`waiter\-\>task\-\>pi\_lock`and clears`waiter\-\>task\-\>pi\_blocked\_on`instead\. This issue slips past lockdep, which only checks that a`pi\_lock`is held but not whose it is\.
**Triggering the \-EDEADLK Path\.**Reaching the`\-EDEADLK`rollback needs a PI dependency cycle built from three futex words and three threads\.
- `f\_pi\_chain`, a PI futex, locked first by the**waiter**thread\.
- `f\_pi\_target`, a PI futex, locked first by the**owner**thread\. This is the requeue target\.
- `f\_wait`, the plain futex the waiter blocks on with`FUTEX\_WAIT\_REQUEUE\_PI`\.
The sequence is:
1. The waiter takes`f\_pi\_chain`, then blocks in`FUTEX\_WAIT\_REQUEUE\_PI\(f\_wait \-\> f\_pi\_target\)`\. Its`rt\_mutex\_waiter`is now on its stack\.
2. The owner takes`f\_pi\_target`, then blocks on`f\_pi\_chain`, which the waiter holds\.
3. The main thread calls`FUTEX\_CMP\_REQUEUE\_PI\(f\_wait \-\> f\_pi\_target\)`\.

The requeue tries to proxy the waiter onto`f\_pi\_target`\. The owner of`f\_pi\_target`is already blocked behind the waiter through`f\_pi\_chain`, so the chain walk closes the loop`waiter \-\> f\_pi\_target \-\> owner \-\> f\_pi\_chain \-\> waiter`\. It returns`\-EDEADLK`and takes the buggy rollback\. The waiter wakes with a dangling`pi\_blocked\_on`\.
Here the only ordering that matters is the requeuer rolling back the waiter while the waiter still owns the soon\-to\-be\-freed object, and once the cycle is staged that happens on its own\. After it resolves there is no time pressure at all\. The waiter sits in userspace with a dangling`pi\_blocked\_on`, and the follow\-up`sched\_setattr\(\)`that walks the chain can fire whenever it likes\. The UAF window is wide open\.
The catch is where the freed object lives on the kernel stack \(stack\-UAF if we call`ret`out of the futex syscall a “free”\)\. To reclaim it, we need to find a syscall that can land controlled bytes back on the same stack at the same depth \(offset\)\.
### Triggering the stack\-UAF
Staging the three\-futex cycle leaves the waiter task in userspace with`pi\_blocked\_on`dangling into its old`FUTEX\_WAIT\_REQUEUE\_PI`frame\. Everything below rides on that one pointer\.
> Note that three threads is for better understanding\. To win the race and trigger UAF, you only need one CPU core\.
#### The initial primitive from GhostLock
By now we hold a pointer into freed kernel stack, and we can trigger, at will, a kernel access that dereferences it as an`rt\_mutex\_waiter`\. We can spray controlled bytes onto that stack and forge the`rt\_mutex\_waiter`outright\. Depending on the shape we forge, this one access yields several primitives, two main ones:
- write a pointer to an arbitrary \(but constrained\) address
- write 8 bytes of zero to an arbitrary \(but constrained\) address
Several pointer dereferences and integrity checks run before the primitive fires, and after it fires the kernel returns normally, no crash\.
So our main questions, each answered in a section below:
- how do we get the freed stack memory back \(spray\)?[\-\> Reusing the stack](https://nebusec.ai/research/ionstack-part-2/#reusing-the-stack-forging-the-waiter-with-pr_set_mm_map)
- how do we get the fake`rt\_mutex\_waiter`past its built\-in structural checks, and forge pointers that read as valid?[\-\> From fake waiter to a write](https://nebusec.ai/research/ionstack-part-2/#from-fake-waiter-to-one-controlled-limited-write)
- which write primitive, and what do we write where? what does the primitive constrain about the “arbitrary” address?[\-\> Use inet6\_protos](https://nebusec.ai/research/ionstack-part-2/#use-inet6_protosipproto_udp-to-help)
## Exploit Details
### Exploit Summary
- **prefetch**\-\> Leak the kernel image slide and the physmap base\.
- **GhostLock**\-\> Leave a dangling`rt\_mutex\_waiter`in the waiter task’s`pi\_blocked\_on`\.
- **\(stack\-\)UAF Reclaim**\-\> Use`PR\_SET\_MM\_MAP`to reclaim the waiter’s own kernel stack and forge a fake`rt\_mutex\_waiter`over the freed frame\.
- **Arb address writer**\-\> Rtmutex rb\-tree erase: one constrained pointer write \(which we can reclaim its content\), overwrite struct which contains a function table:`inet6\_protos\[IPPROTO\_UDP\] = <CEA pointer\>`\.
- **CPU entry area**\-\> Host \{fake`inet6\_protocol`, pivot slots, ROP stack\} all together at a known direct\-map address\.
- **Trigger CFH**\-\> Trigger a loopback IPv6 UDP packet calls through the overwritten handler and pivots\.
- **DirtyMode**\-\> One write flips`core\_pattern`’s mode bits, then the rest LPE is pure userspace\.
What about Android?This part we are focusing on basic exploit steps of generic x86 Linux systems, our next blog will discuss how to exploit GhostLock on Android, reclaiming stack frame, bypassing both ASLR and CFI\.
### Background of used tricks
#### Prefetch ASLR Leak
A`prefetch`on a given address runs in a different number of cycles depending on whether that address is mapped in the current page tables, so an unprivileged process can time`prefetch`across the kernel range and read off which addresses are mapped \(the[prefetch paper](https://gruss.cc/files/prefetch.pdf)has the details\)\.
It works here as Linux barely randomizes the base of its default kernel image \(~9 bits of entropy for text base\), so a little averaging can recover the KASLR base with near 100% reliability\.
In theory any CPU with`prefetch`and without proper Kernel Page\-Table Isolation is affected\. But in practice it is more of an x86 technique \(unless the ARM target runs KPTI off\)\. kernelCTF images keep KPTI disabled\.
> kernelCTF images keep KPTI disabled, but even with KPTI on,`prefetch`paired with[EntryBleed](https://www.willsroot.io/2022/12/entrybleed.html)can still recover the kernel image base through the trampoline\.
#### CEA spray and randomization bypass
The CEA \(CPU entry area\) is a per\-CPU x86 structure holding the stacks and register context used for entry and exception handling: on an exception, interrupt, or syscall the CPU switches to a stack that lives in the CEA, and the entry code spills the register frame \(`pt\_regs`\) there\. An unprivileged userspace program can trigger a software exception and write its own register context into the`pt\_regs`saved on a CEA exception stack\. Before`6\.2`the CEA sat at a completely fixed address, so we can place about 120 bytes of contiguous controlled memory at a known kernel address, which is very handy for forging structures, for absorbing the side effects of the pointer dereferences along the way, and for staging a ROP stack\.
After Project Zero’s[Bringing back the stack attack](https://googleprojectzero.blogspot.com/2022/12/exploiting-CVE-2022-42703-bringing-back-the-stack-attack.html)writeup, the kernel started strongly randomizing the CEA’s virtual address \(since`6\.2`\)\. But the virtual address of the CPU entry area is never needed, as the CEA’s physical offset is fixed, so its direct\-map alias follows from the physmap base \(same observation[@kqx](https://kqx.io/writeups/zenerational/)used\)\.
That direct\-map address is easy to leak with`prefetch`, plus candidate\-edge normalization and a check against the predicted CEA page to reject neighbouring aliases\. \(The direct\-map leak is noisier than the text one and may need a little more tuning, but it lands at very high accuracy on the target in the end\.\) So we can always compute the CEA’s other virtual\-address mapping:
```
cea_direct = physmap_base + CPU1_CEA_BASE
```
Note that each CPU’s CEA virtual address is randomized to a different place\. Their physical addresses are all fixed, though, and this offset depends mainly on the target’s kernel version and boot memory size\. In the kernelCTF LTS`6\.12\.80`3\.5G\-boot environment, it is`0x11c517000\(\+0x1f58\)`\.
### Reusing the stack: forging the waiter with`PR\_SET\_MM\_MAP`
The dangling object is the waiter’s own stack`rt\_mutex\_waiter`\.
```
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;};
```
Controlled bytes have to land back over that exact frame, on the waiter thread’s own stack, and stay there long enough to be read\. The waiter thread returns from the futex syscall and immediately calls`prctl\(PR\_SET\_MM, PR\_SET\_MM\_MAP, \.\.\.\)`\. Inside,`prctl\_set\_mm\_map\(\)`copies a user\-supplied auxv into a fixed\-size`unsigned long user\_auxv\[AT\_VECTOR\_SIZE\]`stack buffer\. That buffer sits at roughly the same stack depth as the freed waiter, so it is a large, naturally\-aligned, namespace\-free block of controlled qwords landing right on top of the old object\.
The auxv is laid out so the overlapping qwords become:
- `tree`, an rb node shaped so erasing it promotes one chosen child pointer \(`W0\_BASE`, below\) into the tree root\.
- `task`, set to`&init\_task`, a valid`task\_struct`so the chain walk’s task derefs are safe\.
- `lock`, set to`&inet6\_protos\[IPPROTO\_UDP\] \- 8`, the write target\.
- `wake\_state`, set to`0`\.
The auxv is backed by a memfd and positioned so the copy straddles a page boundary\. A sibling thread races`fallocate\(PUNCH\_HOLE\)`on the trailing page during the`prctl`, which stretches the`copy\_from\_user`window\. The forged waiter stays live on the stack while, on another CPU, a consumer thread fires`sched\_setattr\(\)`on the waiter to walk the PI chain\. The race window is wide and we believe GhostLock is also exploitable on a single\-core CPU\.
> `clone`/`setsockopt`/`pselect`/`keyctl`and other syscalls with large controlled stack locals work the same way\.`prctl`is just convenient here\. The buffer is large, aligned, and needs no namespace\. Here’s more useful syscalls that can reclaim the stack frame in our[open\-sourced PoC code](https://github.com/NebuSec/CyberMeowfia/blob/main/IonStack/CVE-2026-43499/poc/poc.c)\.
### From fake waiter to one controlled \(limited\) write
Controlling the waiter does not give an arbitrary write\. The chain walk only does:
```
task->pi_blocked_on -> fake waiterfake waiter->lock -> fake rt_mutex_basert_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\. Pointing`lock`at`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_leftmosttarget + 16 -> owner
```
The fake waiter’s rb node is shaped so the erase writes exactly one child pointer into`rb\_root\.rb\_node`\. The write primitive itself is a single constrained store:`\*\(uint64\_t \*\)target = W0\_BASE`\.
**The constraints are also highly strict**: The qword before the target must read as an unlocked spinlock, meaning zero in the low 4 bytes, or the trylock fails and the walk exits without writing\. The qwords after it \(`rb\_leftmost`,`owner`\) must not steer the walk into an uncontrolled top waiter or owner\. An unmapped value there faults and panics the box\. The equivalent target address constraint is roughly as follows \(\*target will be written to a pointer\):
```
*(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
```
Here the`W0\_BASE`has to point at something that stays valid through the comparisons and the no\-owner wakeup later in the same`rt\_mutex\_adjust\_prio\_chain\(\)`\. We point it at the direct\-map alias of the CPU entry area, which pays off twice:
- Before the write: the CEA is controllable memory at a known address, so we can forge a self\-consistent fake waiter and lock at`W0`that survives the walk\.
- After the write: the target now points into the CEA\. Once the walk is over,`W0`no longer has to look like a waiter at all, so we can re\-spray the CEA with whatever the kernel expects the target to point at \(if we overwritten a function table pointer with`W0`, we can now fake function pointer in CEA to get Control Flow Hijack\)\.
Why the CEA?There’s several ways to spray controlled memory at a fixed \(knowned\) kernel address\. The CEA is one of the more efficient, and its main limit is the ~120\-byte small size\. NPerm, kernelsnitch and other tricks can do the same job with more room\.
Before the trigger,`W0`is spraied as that fake waiter and lock pair:`task = &init\_task`, a legit`prio`, and a`lock`whose`wait\_lock`reads unlocked and whose owner is benign, so the dequeue, re\-enqueue, priority update and wakeup all survive\.
The following figure shows how CPU entry area is used to first hold fake`rt\_mutex\_waiter`and`lock`structures, then serve`inet6`\(next section\), ROP stack and JOP gadgets for stack pivoting at the same time, and eventually use a very short ROP to perform the DirtyMode and safely halt the core\.

### Use`inet6\_protos\[IPPROTO\_UDP\]`to help
Start from now the exploit path would differ from targets, as of regular x86\_64 Linux kernel, we can pick a shorter path by just overwriting some function table \(or any object that contains one\), as we already have KASLR leaked and ready to get a CFH\.
A scan of writable data turns up many pointer tables whose neighbours satisfy the layout above\.`inet6\_protos\[IPPROTO\_UDP\]`is a nice one\. The neighbours fall out for free, and the trigger is a trivial unprivileged loopback packet\.
```
inet6_protos[16] == NULL // fake wait_lock -> unlockedinet6_protos[17] == &udpv6_protocol // <- target (IPPROTO_UDP)inet6_protos[18] == NULL // fake rb_leftmostinet6_protos[19] == NULL // fake owner
```
After the write,`inet6\_protos\[IPPROTO\_UDP\]`points into the CEA page, where the kernel expects an`inet6\_protocol`\.
```
struct inet6_protocol { int (*handler)(struct sk_buff *skb); int (*err_handler)(...); unsigned int flags;};
```
So`W0`is re\-spraied as a fake`inet6\_protocol`\.`handler`is the first pivot gadget,`err\_handler`is unused, and`flags`is`INET6\_PROTO\_NOPOLICY \| INET6\_PROTO\_FINAL`\. Once we send a loopback IPv6 UDP \(`connect`then`write`to`::1`\), the kernel will dereference the`handler`and give us a PC control\.
### The pivot and DirtyMode
We use the same compact CEA window to holds multiple objects: \{the fake`inet6\_protocol`, a few JOP/pivot slots, the final ROP stack\}\. On Google’s lts\-6\.12\.80 kernel target we are not lucky enough to find a nice single stack pivot target, so the chain takes one extra load/call to land the CEA address in`rbp`, then pivots with`mov rsp, rbp; pop rbp; ret`\.
A`ret2usr`or a full`/proc/%P/fd/x`overwrite would run to around ten gadget qwords, which is too long\. So we use**DirtyMode**as the final exploit stage: a single write, with an almost\-garbage value, that flips a permission bit\. After it, LPE can be done purely in userspace\.
Here we target at 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 \(share same KASLR slide with kernel image\)\. The ROP writes a permissive value to`coredump\_sysctls\[1\]\.mode`\. Any value with the write bit \(2nd LSB\) set is enough\.
Here we uses a short`pop reg; mov \[reg\], reg; ret`plus an`msleep`to park the hijacked thread safely\. And now`/proc/sys/kernel/core\_pattern`is now world\-writable, so an unprivileged process opens it, writes`\|/proc/%P/fd/666 %P`, and crashes a helper to trick kernel runs our binary as root\.
> The initial write primitive \(the rb\-tree write\) cannot reach`coredump\_sysctls\[1\]\.mode`directly because of where it lands, so the mode flip is done from the short ROP stage\.
## Appendix
The full exploit code can be found in our[open source security research project, CyberMeowfia](https://github.com/NebuSec/CyberMeowfia/tree/main/IonStack/CVE-2026-43499)\.
### bigger ROP or NPerm
kernelCTF is a race, and the shortest reliable chain wins\.[NPerm](https://github.com/google/security-research/pull/268)\-backed memory makes a fine large fake stack after the hijack, and there are heavier routes that would also work, including[Lukas Maar’s heap\-KASLR leak](https://lukasmaar.github.io/posts/heap-kaslr-leak/index.html)\. Each adds another stage and increases time cost\. CEA plus DirtyMode is the shortest path to a one\-write win, and on the remote it win us the flag in about 5 seconds\.
### Mitigation
#### The patch
```
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);
```
We had also sent a fix to`security@kernel\.org`before v1 landed\. Its core:
```
static void __sched remove_waiter(struct rt_mutex_base *lock, struct rt_mutex_waiter *waiter) 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, current); next_lock, NULL, task);}
```
Instead of reading the task out of`waiter\-\>task`, the callers pass in the owning task \(`current`on the self\-blocking path, the proxied`task`on the`rt\_mutex\_start\_proxy\_lock\(\)`rollback\), and`pi\_blocked\_on`is cleared only when it still points at this waiter\.`task`is always a valid task and the clear is guarded\.
#### RANDOMIZE\_KSTACK\_OFFSET
The stack\-reuse step relies on the freed waiter frame and the later`user\_auxv`frame overlapping deterministically\. With`RANDOMIZE\_KSTACK\_OFFSET`on they no longer do, and the step becomes a roughly 1/32 \(5\-bit\) stack\-offset guess\. Both submitted targets leave it off by default\. The mitigation target turns it on, so this path was not used there\.
#### STATIC\_USERMODE\_HELPER
`STATIC\_USERMODE\_HELPER`would close this particular DirtyMode path\. But the same idea can be generalized to any`/proc/sys`knob whose`ctl\_table::mode`gates access and whose table sits in predictable writable kernel data\.
### Timeline
- 2026\-04\-18: We reported the bug and sent a draft patch to[security@kernel\.org](mailto:
[email protected])\.
- 2026\-04\-20: The bug was fixed with another patch\.
- 2026\-05\-04: The fix v1 was backported\.
- 2026\-06\-30: Google acknowledged our kernelCTF submission\.
- 2026\-07\-07: We published this blog post\.
### Disclosure policy
For all bugs found by[VEGA](https://nebusec.ai/vega/), we follow our standard 90\+30 days disclosure policy as described on our[About page](https://nebusec.ai/about/)\.
[Previous PostLonginus: 2 Boundaries in One Bug, Piercing Chrome’s Renderer and V8 Sandbox with a Single Vulnerability, CVE\-2026\-6307](https://nebusec.ai/research/v8-cve-2026-6307-writeup#post-title)[Next PostYou're at the newest post\!](https://nebusec.ai/research/ionstack-part-2/#)