When the Sanitizer Is the Wrong Oracle
Research / / 8 min read

When the Sanitizer Is the Wrong Oracle

Two recently-patched Linux kernel use-after-frees. Trivially reachable from an unprivileged process, they fire on demand and produce zero KASAN reports by construction. A study in the two most misread words in exploitation: 'reachable' and 'confirmed'.

There is a comfortable story about finding kernel bugs: drive a race under KASAN, wait for the red splat, and you have a use-after-free to weaponize. Here are two real, recently-patched UAFs that break that story at both ends. One because the sanitizer is structurally blind to it. Both because a race that fires is not a bug you can use.

Our previous post argued that a fuzzer can only find bugs that crash, and that a whole category of vulnerability produces no crash at all. This is the sequel, and it is less comfortable. Even when you do reach a genuine memory-safety bug, the sanitizer you reach for first is often blind to it. And even when you can make it fire on command, you may have nothing you can actually use.

We worked through two use-after-free races in LTS 6.12.94 to make the point concrete: the eventpoll __ep_remove unpinned-file UAF (fixed upstream in a6dc643c6931) and the af_unix garbage-collector gc_in_progress / MSG_PEEK UAF (fixed in 591f1ac21742). Both are patched. Neither cooperated with the story above, and the reasons are worth sitting with.

A race that fires, and a sanitizer that stays silent

__ep_remove() in fs/eventpoll.c takes a struct file * out of an epoll item and locks it without holding a reference to it:

struct file *file = epi->ffd.file;   /* unpinned: no reference taken */
...
spin_lock(&file->f_lock);
...
WRITE_ONCE(file->f_ep, NULL);        /* W1 */
hlist_del_rcu(&epi->fllink);         /* W2 */
spin_unlock(&file->f_lock);          /* W3 */

If the watched file is closing concurrently, that file can already be freed. The clean way to drive it is epoll-in-epoll: an outer epoll watching an inner epoll fd. Race EPOLL_CTL_DEL against close() on the inner. The close path only skips its cleanup when it reads file->f_ep == NULL, which is precisely what W1 sets. Win the window and the remaining writes land on freed memory.

The race is real and, pleasingly, tunable: nudge the timing and the collision rate climbs from about 1% to about 34%. We drove roughly twenty million of them.

Zero KASAN reports. Zero BUG. Zero panic.

And that clean result is correct, not a failure to trigger. The reason is how the freed objects are reclaimed:

  • The inner struct eventpoll is freed with kfree_rcu. Its KASAN poison isn’t applied when kfree_rcu() is called. It’s applied when the RCU callback runs kfree, after a grace period. But the corrupting write happens a couple of instructions later, under a spinlock. For KASAN to catch it, the writer would have to stall past an entire grace period mid-critical-section. Under real timing it writes into a still-valid, not-yet-poisoned object.
  • The struct file lives in filp_cachep, created SLAB_TYPESAFE_BY_RCU. KASAN deliberately does not poison objects in such caches on free, because re-reading a recycled object of that cache is legal under RCU. The slot is guaranteed to still be a validly-typed struct file. So the stray write is at worst logic corruption of whatever file now owns the slot, not a memory-safety violation the sanitizer is built to flag.

For this bug, a KASAN splat is simply the wrong oracle. The UAF is genuine and source-confirmed. But both freed objects travel RCU-flavored reclaim paths that suppress exactly the poison KASAN depends on.

RCU Reclaim Gap

This generalizes, and it is a trap

The pattern is not specific to eventpoll. It applies to a large and growing class of modern kernel objects: anything in a SLAB_TYPESAFE_BY_RCU cache (filp_cachep, and many networking and mm caches), and anything freed through kfree_rcu or call_rcu. In every case the gap between “logically dead” and “actually poisoned” is a full RCU grace period, and a tight race writes into that gap before the poison exists.

So a green KASAN run over such a cache is not evidence of safety. It can just mean the poison hadn’t landed yet. Anyone stress-testing races against these objects and seeing suspiciously clean results is likely looking through a blind oracle.

There is a switch that changes this: CONFIG_SLUB_RCU_DEBUG routes even SLAB_TYPESAFE_BY_RCU frees through RCU and applies poisoning after the grace period, so KASAN can catch these. It is off in ordinary builds, and it costs performance. But for auditing this bug class it is the difference between a working oracle and a blind one. Absent it, you need a functional oracle: observe the recycled object misbehave, rather than trusting a splat that will never come.

This is the same lesson we keep landing on, one layer deeper. Fuzzing’s oracle is blind to bugs that don’t crash. And within memory-safety, the default sanitizer is blind to bugs whose reclaim outruns its poison. An oracle is only ever as good as its coverage of the failure mode you actually have. Choosing it by reflex is how a real bug gets a clean bill of health.

”Reachable” is not “usable”

Suppose you fix the oracle and confirm the corruption. You still have to answer the question that actually decides whether a bug matters: what does the write give you?

For the eventpoll race, we characterized all three writes on freed memory, and corrected an early, too-optimistic reading of them:

  • W1 (f_ep = NULL) feels like the useful one, but it can never land on a recycled victim: the concurrent close only frees because it observed that NULL, so by value dependency the free happens strictly after W1 is visible. W1 always writes the still-valid original object. It gives nothing.
  • W2 (hlist_del_rcu) is not a write-what-where. Its destination is a fixed address, and the branch that arms the bug forces the stored value to NULL. Neither operand is controllable.
  • W3 (the spin_unlock) is the only recycle-controllable effect, and it is a single zero-byte store to the lock byte of whatever file now occupies the slot, weaponizable only through a further, narrow, multi-condition race.

Turning that into memory-unsafety needs a roughly five-way coincidence inside a two-instruction window. Each factor is small; the product is negligible. A dedicated escalation harness ran clean across the entire campaign.

So the honest status of this bug: live, unprivileged-reachable, tunable, and weak as an exploitation primitive. It is closer to reliability/DoS-grade than to a self-contained privilege escalation. The upstream fix, pinning the file for the duration of the critical section, closes it cleanly.

The second bug tells the same story from a different angle. The af_unix gc_in_progress race is, on paper, the better class: a data-only struct file UAF reachable from a strictly unprivileged process (socketpair + SCM_RIGHTS + MSG_PEEK + close). But triggering it requires a compound race. You must hit the tight window where the garbage collector runs with its peek-side barrier skipped, and land a MSG_PEEK on a genuinely-collectible socket in the same instant, on top of first building the fiddly collectible-while-peekable object graph. We drove roughly twenty-two million peeks against a saturated collector and got zero use-after-frees. Not because the bug isn’t real (the fix confirms it is) but because its exploitable window is conjoined with a second race that natural timing almost never aligns. A source-confirmed bug with no demonstrated primitive.

The two words that mislead

“Reachable” and “confirmed” are the two most over-trusted words in this work, and these bugs show why.

Reachability is the cheap half. Both races fire from an unprivileged process; one tunes to a third of attempts. That tells you almost nothing about whether the bug is worth anything. The quality of the primitive (how many bytes you control, at what offset, with what value, into an object you can groom) is where the difficulty actually lives, and it is a much higher wall than getting the race to fire.

Confirmation is only as trustworthy as the oracle. A clean sanitizer run against RCU-flavored reclaim is not a safety result; it’s a coverage gap. Confirmation has to be chosen to match the bug class, not applied by reflex. Otherwise you will confidently clear bugs the sanitizer simply cannot see.

Both of those are reasoning problems, not throughput problems. You do not fuzz your way to “this write is uncontrollable” or “this cache defeats the poison.” You read the code, model the reclaim, and account for every write. And when the honest conclusion is reachable, confirmed, and not weaponizable, you say exactly that. It is worth more than a splat you can’t explain, because it tells you whether a bug is worth another week or worth writing up and moving on.

That discipline is not a footnote to automated vulnerability research: pick the oracle to fit the bug, measure primitive quality rather than crash count, state the honest negative precisely. On a hardened, well-audited target, it is most of the job.


Analysis by 0sec’s automated security-research tooling (https://0.security). Both bugs are fixed upstream (eventpoll a6dc643c6931, af_unix 591f1ac21742) and shipped in v6.12.95. Update your kernels.