HIT A COW IN THE HEAD RIGHT NOW!!!!
"vulnerable" is not a version number. i learned this the stupid way, over about an hour, while a kernel i built specifically to be exploitable sat there refusing to be exploited, and every layer of my own setup took turns lying to my face. here's the whole autopsy, because the dead ends taught me more than the win did.
the goal was simple. build linux 4.4, boot it in qemu, run dirtycow (CVE-2016-5195) as an unprivileged user, watch a root-owned read-only file get rewritten. i wrote up the mechanics in the kernel debugging post. this is what happened when i actually tried it.
the compiler is from 2026, the code is from 2016
first swing, make, and it dies in the 16-bit boot stub:
error: cannot use keyword 'false' as enumeration constant
note: 'false' is a keyword with '-std=c23' onwardsgcc 15 defaults to C23 now, where bool, true, and false are real keywords. 4.4 typedefs them the old way. the actual kernel compiled FINE (it pins -std=gnu89), only the boot code broke, because its little Makefile doesn't pin anything :) two lines of -std=gnu11 on the boot flags and the bzImage popped out. first lie, smallest lie. i was happy back then!!
it booted. it did nothing.
kernel up, tiny initramfs, my exploit drops to uid 1000, tries the honest write first (correctly denied), then races. and...
==== victim after ====
root owns this file and you may only read it.unchanged????!?!?!! no crash, no error, just a file refusing to change. this is the worst kind of bug!!!! the one where everything "works" and nothing happens. so began the hunt, and it turned out my setup was lying in FOUR more places.
lie #2: i built a kernel that was already cured
i cloned the tip of the linux-4.4.y stable branch. just THE TIP!! as in 4.4.302, as in six years of patches, as in the dirtycow fix got backported in 4.4.26 and i built a kernel that was immune by construction. checked out the vanilla v4.4 tag instead, confirmed mm/gup.c still had the buggy FOLL_FORCE dance, rebuilt.
lie #3: the file lived somewhere dirtycow can't reach
initramfs rootfs is ramfs. dirtycow no-ops on ramfs, because the page cache page IS the file, there's no separate read-only page to sneak the write onto. the whole bug is about a private copy diverging from a shared file page, and ramfs doesn't play that game. so i built a real ext2 image, put the victim on it (root-owned, mode 0444, forged host-side with debugfs), and handed it to the guest as a virtio disk.
lie #4: /proc/self/mem quietly said no
the exploit writes through /proc/self/mem. it kept returning success... after i finally checked the return value, which, yeah. turns out when you setuid() down from root, the kernel clears your process's "dumpable" flag, and that makes /proc/self/mem refuse the write. the classic PoC never trips on this because it runs as a normal login user who was never root. one prctl(PR_SET_DUMPABLE, 1) after the priv drop and the writes went through.
lie #5: two threads, one core
a race needs two things actually running at once. qemu defaults to a single vCPU, and tinyconfig had built me a uniprocessor kernel anyway (CONFIG_SMP off, NR_CPUS=1). so my two "racing" threads were just politely taking turns. flipped on SMP, rebuilt, booted with -enable-kvm -cpu host -smp 4 (fuck TCG). now the writes were real, parallel, millions of them.
still nothing.
rocky balboa vs thin air
this is the one. at this point the file had eaten one hundred and seventy-seven million writes across twenty retries and not budged a single byte. i'd been so busy blaming the race that i never checked the OTHER half of it. so i finally logged what madvise was returning:
attempt 1: no (writes=8800090 madvise_ok=0 madvise_fail=50785167 errno=38)errno 38. ENOSYS. madvise WAS NOT FUCKING IMPLEMENTED. tinyconfig had also silently dropped CONFIG_ADVISE_SYSCALLS, so every single MADV_DONTNEED for the whole hour was a no-op that just incremented a counter and returned "fuck you, no." the private copy was never being dropped. i wasn't losing the race. there was no race. i was throwing punches at a ghost and diligently counting them.
one config flag. rebuild. run.
==== victim before ====
root owns this file and you may only read it.
(owner uid=0, mode=0444)
==== dropped to uid=1000, trying the normal way first ====
open(O_WRONLY) -> Permission denied (correct, we don't own it)
==== now racing dirtycow as uid=1000 ====
attempt 1: WON (writes=207244 madvise_ok=7571 madvise_fail=0 errno=0)
==== victim after ====
PWNEDowns this file and you may only read it.won on the FIRST attempt in about 200k writes. a working primitive tips dirtycow in milliseconds. the file an unprivileged user was only allowed to READ now says PWNED at the front, on disk, because for a few nanoseconds the kernel couldn't decide which page it was holding.
i spent an hour "debugging the exploit" and the exploit was never wrong. every dead end was my environment quietly not being what i assumed. "vulnerable" isn't a git tag, it's the kernel AND the config AND the filesystem AND the cpu count AND the syscalls actually being compiled in, all at once. the fix for every single lie was the same one line i kept skipping: check what the call gave back before believing it did anything.
anyway. it works now. i have a museum in a folder, a kernel that's sick on purpose, and a file that learned some manners.
THOSE WHO TRY TO BURN HIM PAY, YOU DONT DO THAT TO JOHNNY BLADE!!