I Missed One TLB Shootdown and Somehow Ended Up Controlling a Page Table
Table of Contents
TLDR⌗
I used a public Linux kernel bug to make one CPU keep using a memory mapping after another CPU had removed it. I then persuaded Linux to recycle the physical page behind that stale mapping as a page table. My unprivileged process could still write to the old page, while Linux now treated the same bytes as part of its memory map.
That chain produced one real flag from Google’s kernelCTF service. This was a 1/n day exploit, not a new vulnerability: the bug, reproducer and fix were already public. My work was the long and occasionally silly trip from “stale read” to a complete exploit.
official flag: yes, one publicly evidenced capture
submission: exp659
first base slot: no, exp609 was earlier
source public: yes, in open PR #418
new bug: no
(It took me a too mcuh time to write this blog in a way it is useful, soemthign what I would have prefered from other blogs, Feel free to let me know if it can be improved and written more reader/begginer friendly)
Authorized lab only: I tested Google’s kernelCTF target and an isolated local QEMU virtual machine with networking disabled. Do not run kernel exploits on everyday computers, shared machines, production systems or anything you do not own and have permission to test.
I had some free time and a slightly unreliable memory⌗
I had some free time (again), so I started writing a kernel exploit as a hobby project. This is apparently what passes for recreation once normal hobbies stop looking complicated enough.
The previous kernel exploit I had developed was during a training class, It was useful and I learned a lot, but there is a small problem with learning kernel exploitation: if you do not practice afterward, the knowledge does not remain neatly arranged in your head and mine had become a box of unlabeled dataset.
I remembered the broad ideas that there are pages, page tables, allocators, races and many ways to crash a virtual machine. The precise details had quietly left the building.
This work is also not my day job I do not do offensive security or exploit development professionally and learning this area takes a lot of focused free time. Reading one function often means reading five structures, three commits and a mailing list thread before realizing the first function was not important.
My first instinct was to re read all the theory until I felt ready (That is a comfortable trap) There is always one more memory management sections to understand and “ready” can remain three chapters away forever.
So I decided to choose a real finish line instead. I wanted an exact kernel, a public bug, an objective success condition and nowhere to hide behind “the idea should work.” If the exploit did not cross the boundary and produce the expected result, it did not work.

Finding a finish line that could argue back⌗
Google kernelCTF is a continuous Linux kernel exploitation challenge. Google boots an exact kernel build inside a virtual machine and runs the submitted exploit as user ID or UID, 1000. UID 1000 is an ordinary user, not root.
The protected flag is available to root in the initial guest environment, outside the challenge process’s NsJail sandbox. The job is to begin inside that restricted process and gain enough control of the guest Linux kernel to reach the flag.
That boundary: QEMU runs the guest, but I did not escape QEMU. Google’s host was not a target. The outer jail was not a target The solid line in this diagram is the entire claim.
My local lab reproduced the important guest details so I could reboot, add logs, attach a debugger and compare two kernels without wasting the official service’s time. A local root shell or synthetic flag is evidence that a chain is developing. Only Google’s endpoint can return an official kernelCTF flag.
The flag could not be impressed by a crash, a screenshot or a paragraph explaining why the final stage probably worked.
There are also two common labels worth clearing up. A zero-day is a vulnerability not publicly known to defenders when exploitation starts. A one-day uses a bug or patch that is already public. This project was firmly a 1/n day I was learning how to exploit a known defect.
Each kernelCTF submission receives an ID. Mine became exp659 An earlier submission, exp609, already occupied the version/base slot for lts-6.12.96, so mine was not the first submission for that base That changes reward and review possibilities, but it does not change whether exp659 returned a flag.
Google can separately review a submission for a novel exploitation technique and for reliability. Public evidence shows one official capture for exp659. It does not reveal which numbered run succeeded or a success percentage, so I will not manufacture either number. “One flag” is both less exciting and more accurate.
Choosing the bug, then discovering the paperwork bug⌗
I wanted a target with a public patch and a public reproducer. That gave me somewhere concrete to begin while still leaving the exploit engineering unsolved.
The target I chose was tracked as CVE-2025-37964. The vulnerable build was lts-6.12.96, at exact commit 6d15a1029d425b15c59463910ebdccc4afe760d6. The fixed comparison was lts-6.12.97, containing stable commit 0650f1c8b6b02b3edd489848fb9daa325eccf42c.
The public report and reproducer showed stale memory access caused by an ordering problem in x86 Translation Lookaside Buffer handling. The actual vulnerable component is the target’s arch/x86/mm/tlb.c. That sentence is important because the eventual exploit uses enough other kernel machinery to make blame wander around.
| Component | What it did in my chain | Vulnerable? |
|---|---|---|
arch/x86/mm/tlb.c |
coordinated x86 memory-map switches and invalidations | yes, the ordering bug is here |
| scheduler and futex | arranged the useful CPU transition | no, exploit tool |
memfd_secret |
supplied a retryable one-page carrier | no, exploit tool |
| pagemap | told me whether Linux considered the mapping present | no, exploit tool |
| allocator, PTEs, and page tables | turned page reuse into control | no, exploit tools |
core_pattern |
provided a known kernelCTF finish | no, exploit tool |
| QEMU, host, outer NsJail | ran or contained the authorized guest | no, not targeted |
The naming became stranger than the code. Stable 6.12 had an attempted backport related to upstream commit fea4e317, but operations relevant to the protocol landed in the wrong order on that branch. The .97 stable commit corrected that order.
The current public CVE records do not map the stable-only correction cleanly. CVE-2025-40174 appears in later paperwork and discussion, but I am not relabeling the exploit as definitely targeting that CVE. The code relationship between .96 and .97 is clear. The administrative relationship between the CVE records remains unresolved.
The patch made me rebuild the memory picture⌗
I started by reading the patch and immediately met mm, tlb_gen, loaded_mm, mm_cpumask and PCID. This was the point where my box of training-class cables tipped onto the floor.
The kernel documentation helped me rebuild the picture. The kernel is the trusted part of the operating system that schedules work, manages memory, talks to hardware and enforces access between programs.
My exploit begins in user space, where normal applications run with limited rights. When a program needs the kernel to do something privileged, it uses a system call, usually shortened to syscall. Calling mmap() to request memory, munmap() to release it or opening a file all cross that user/kernel boundary through syscalls.
I kept one imaginary program in mind while reading I Call it Maple, Maple asks Linux for one writable page and stores 42 there.
Maple receives a virtual address, perhaps 0x7f00...1000. That address belongs to Maple’s process address space, the private-looking range of addresses it can use. Inside the target kernel, the whole address space is represented by struct mm_struct, while regions within it are represented by struct vm_area_struct. Threads that share an address space share the same mm_struct.
When Maple requested one mapped region, Linux recorded a Virtual Memory Area or VMA. A VMA says that one continuous address range exists and has particular properties: writable or read-only, file-backed or anonymous, and so on. The official process-address documentation describes the VMA as the metadata for the range. It is not the physical memory itself.
That distinction initially felt fussy, then became central to the exploit. Linux can retain a VMA while removing the page-table entry for one page inside it. The region still exists as an idea, but the route to the physical bytes is temporarily gone.
Linux divides virtual and physical memory into pages. On this target, the pages I cared about were 4 KiB. Maple’s virtual page is connected to a page in physical Random Access Memory, or RAM, through a hierarchy of page tables. The Linux page-table documentation explains the real multilevel walk; my working picture was simpler:
Maple's virtual page V
|
v
page tables -> final page-table entry (PTE)
|
v
physical RAM page P -> value 42
The final Page Table Entry or PTE, stores the physical page number and permission bits It is one row in the official map. The CPU follows those rows when Maple reads or writes V.
Following several page table levels on every memory access would be painfully slow, so each CPU core keeps recent answers in a fast local cache called the Translation Lookaside Buffer or TLB. The page table says where V officially goes. The TLB lets a core remember “V goes to P” without walking the map again.
Modern x86 processors can cache translations from multiple address spaces. A Process-Context Identifier or PCID, tags entries so the CPU can tell which address space an answer belongs to. The exact target’s struct tlb_state and struct tlb_context track which memory map and TLB generation a CPU believes it has loaded.
Now Maple runs on CPU0 and CPU1. CPU0 has cached V -> P. CPU1 removes Maple’s PTE because Maple asked Linux to discard that page. The page table no longer contains the route, but CPU0’s hardware cache does not read minds.
Linux must make CPU0 forget. That coordinated invalidation is a TLB shootdown. Usually Linux identifies processors using the address space and sends the needed notification, often through an inter processor interrupt. When the protocol works, CPU0’s next access cannot use its old shortcut. It walks the current table and sees that the page is absent.
The bug was a schedule where both ways of noticing the change were missed.
Watching CPU0 and CPU1 pass in the hallway⌗
I found it easiest to picture two people updating a sign while passing through a doorway.
CPU0 was switching from a helper task into my reader task. CPU1 was removing the reader’s PTE. Linux maintained both a generation number saying how new the address space was and a CPU mask saying which cores were using it.
Each core looked at a piece of state that was reasonable at that instant. Together, those observations created a hole.
CPU0: helper is running CPU1: reader's mapping exists
| |
| starts switch to reader |
| reads reader's old tlb_gen |
| | clears reader's PTE
| | increments tlb_gen
| | checks reader mm_cpumask
| still appears with helper | does not find CPU0
| | sends no shootdown to CPU0
| publishes reader as loaded |
| compares against old generation |
| decides local TLB is current |
v v
CPU0 reads V through stale V -> P even though the PTE is gone
CPU1 missed CPU0 when selecting remote recipients because CPU0 had not yet published the new map. CPU0 missed the newer generation locally because it had already sampled the old value. The remembered translation survived between those two decisions.
The optional source-level version
The race sits around switch_mm_irqs_off() and the mm_cpumask/TLB-generation protocol in the exact arch/x86/mm/tlb.c. struct tlb_state documents the generation invariant. The stable fix restores the ordering; its compiler barrier() is not a magic cross-CPU hardware fence.
I could observe stale access on .96. The same phased test ran two million attempts on .97 without reporting stale access. That is a useful vulnerable-versus-fixed comparison, not a proof about every schedule on every machine.
The reproducer had answered the first question. CPU0 could still read P. But P still belonged to my own mapping, and an unusual route to my own bytes is not root.
Then came the useful thought: the TLB remembers a physical destination, not the reason those bytes were allocated. If Linux releases P and later reuses P as a page table, CPU0’s stale route still ends at P. My process thinks it is writing an old data page. Linux thinks those same bytes describe mappings.
That was the whole exploit in one diagram. Unfortunately, there were many ways to fall between the boxes.
First I had to make the race happen on purpose⌗
I began in the traditional way: two threads, two CPUs and optimism. The writer on CPU1 removed the mapping while the reader ran on CPU0. Millions of attempts later, optimism had produced a very stable failure.
The public reproducer showed that the bug existed, but my exploit needed one particular context switch. CPU0 had to move from a helper’s address space into the reader’s address space during CPU1’s removal. Random wakeups did not reliably create that edge.
I changed the scheduler shape instead of merely increasing the attempt count. The helper and reader were pinned to CPU0; the writer was pinned to CPU1. The reader slept and the helper woke it near the measured point where CPU1 would remove the PTE.
The sleep/wake mechanism was a fast userspace mutex or futex. A futex lets a thread wait efficiently in the kernel until another thread wakes it I was not controlling the scheduler, I was arranging the room so the helper to reader switch became the obvious next move.
Direct wakeups and an unphased helper each completed recorded campaigns of two million attempts without a stale result. Scheduler shaping produced the stale oracle The answer was not more lottery tickets, It was changing which lottery I was entering.
One page that could lose repeatedly⌗
Most attempts still had to fail and that created the next practical problem. Every try needed to remove a PTE, test for stale access and restore the page for another try. After a win, however, I needed to stop restoring it and eventually release the physical page.
I chose one page from memfd_secret(2), a Linux interface that creates memory with special secret memory handling. The useful property here was not secrecy It’s lifecycle control.
During retries, madvise(2) with MADV_DONTNEED_LOCKED could remove the page’s PTE while the underlying memory object stayed alive. A losing read could fault the page back in and prepare another attempt After a stale win, I could take a one time path toward releasing it.
Linux calls the bookkeeping object for one or more physically contiguous pages a folio. My carrier was one page, so “secret folio” mostly meant “the kernel object still owning these 4 KiB.”
The retry carrier in four lines
before try: PTE present, folio alive
retry: remove PTE -> lose race -> fault it back -> try again
win: remove PTE -> stale read while PTE stays absent
after win: unmap VMA -> release folio without rebuilding the PTE
The local secretmem-stale.c in this security_flow checkout is an older safe milestone with an eight-page oracle pool. It does not reclaim the page or write a PTE. The immutable complete source contains the one-page chain.
A stale-looking read was not enough⌗
My first success check was embarrassingly trusting: put known bytes in the page, remove the mapping and celebrate if CPU0 reads the known bytes.
There was another explanation, CPU0 might take a normal page fault. Linux could rebuild the PTE and return exactly the bytes I expected The output would look successful while the stale translation had never survived.
So every attempt received a changing marker Then I checked /proc/self/pagemap, the kernel interface that reports properties of a process’s virtual pages. An oracle is just an observable test for hidden state mine combined the marker with the present bit.
The sequence I accepted was strict: pagemap said present before removal, absent after removal, CPU0 returned the current attempt’s marker, then pagemap still said absent twice. The marker proved the bytes were current. The absent checks proved a normal fault had not quietly recreated the PTE.
At this point I had the stale route I had wanted, I assumed releasing and reclaiming the page would be the easier part. This was incorrect in a way the allocator was happy to demonstrate for quite some time.
The page that was free except for not being free⌗
I won the race, unmapped the page and allocated replacements. Nothing reused it I allocated more, Still nothing my first response to an allocator mystery was, naturally, more spray.
It was the exploit-development version of pressing an elevator button harder.
Eventually I stopped trying to persuade the allocator and traced the page’s lifetime. Tracefs showed an invisible temporary reference in CPU0’s mlock_fbatch, a deferred batch used for locked-memory accounting. Linux still had one hand on the folio, so it could not recycle the physical page regardless of how much memory I sprayed around it.

The hidden reference came from refaulting the locked secret memory VMA during retries. This was where the earlier VMA distinction stopped being vocabulary and became the answer: the mapping’s region remained valid and faulting inside it could put the folio into deferred accounting.
On this exact target, a page-aligned posix_fadvise(POSIX_FADV_DONTNEED) beyond the end of /bin/sh reached lru_add_drain() and emptied CPU0’s deferred batch. I performed that drain before the race. A successful stale read then avoided another refault, so it did not add the hidden reference back.
The allocator had not been random or stubborn. My ownership model had been wrong.
The final unmap had to do almost nothing⌗
Draining the reference made release possible, but release usually comes with a dangerous side effect. munmap() removes a VMA and normally participates in invalidating translations. A fresh flush would erase the stale TLB entry I had spent all this effort preserving.
I needed the final unmap to complete the ownership paperwork without finding anything left to clear.
The race had already removed the leaf PTE. I kept neighboring mappings alive in the same Page Middle Directory or PMD, region, then unmapped only the one page secret VMA. On this target, one PTE page contains 512 entries and covers a 2 MiB PMD region. The neighbors kept the surrounding page-table structure alive.
With no leaf entry left and no enclosing table becoming empty, the final munmap() had no useful translation left to invalidate. CPU0 could then watch the physical page being cleared through its stale route. That runtime observation became the gate before reclaim.
This was fragile. A context switch, unrelated mapping change, pending flush or PCID rollover could still destroy the stale entry. The exploit checked that the route survived rather than assuming the diagram had authority over the machine.
Building a landing zone for one recycled page⌗
Now I could release P while CPU0 retained V -> P. The next question was how to make Linux reuse exactly P as a PTE page, then identify which PTE page it had become.
I reserved a sparse 1 GiB virtual arena. “Sparse” means the address range was large without immediately consuming 1 GiB of physical RAM. I created its upper page-table levels first. Later, touching one address in each new 2 MiB region mainly forced Linux to allocate PTE pages.
This is allocator grooming: arranging allocations and frees to influence what receives a reusable chunk. A Page Frame Number, or PFN, identifies a physical RAM page. I wanted the old secret-page PFN to return as the PFN of one arena PTE page.
PTE-shaped bytes were not enough. Random values become persuasive after a long debugging session. I needed each candidate table to identify its owner.
I encoded the arena region number with pairs of PTE slots. In each pair, one populated slot represented a zero bit and the other represented a one bit. Start and commit anchors showed that construction had completed. Slots that should be empty also had to remain empty.
CPU0 took two full 4 KiB snapshots through the stale alias and required them to match. The decoder checked the anchors, pair code, common zero-page frame, permissions, physical range, and required empty slots. Only then did I accept both “this is a PTE page” and “this is the PTE page for arena region N.”
One PTE page maps 2 MiB, so the 1 GiB arena offered 512 candidates. On this target the exploit retained 256 conditioner tables and resumed candidate allocation at region 257. Those numbers are measured behavior for this build, not laws of the Linux allocator.
Proving control without immediately reaching for root⌗
Recognizing a page table was exciting, but the bytes could still have been a convincing coincidence. I wanted a harmless proof before using a system wide target.
The exploit therefore has a separate pte-proof mode. It allocates a canary page containing known data and reads that page’s legitimate PTE through the reclaimed stale window. Then it copies the entry into a destination PTE slot.
The destination address had never been accessed, or walked, by a CPU. That detail mattered. If a CPU had already cached a translation for the destination, changing its PTE would require another invalidation and that invalidation could also destroy my stale window. An untouched slot had no old TLB answer to forget.
The canary became readable through both virtual addresses. A write through one appeared through the other and the raw entry matched. The exploit then cleared the forged entry and checked cleanup.
That was the page-table-control proof. It ran in pte-proof mode. The official capture came from a separate flag mode. They were not two stages observed in one lucky official run, and I do not want a smooth story to blur that boundary.
Turning page-table control into the challenge flag⌗
For the authorized challenge finish, I used a known technique involving core_pattern. Linux consults this setting when a process crashes and generates a core dump. If the pattern begins with a pipe, the kernel launches the named helper with root privileges.
This finish was prior art, not my new idea. The interesting work in my chain was reaching controlled PTEs reliably enough to use it.
PTEs in my fingerprint pointed to Linux’s shared read-only zero page, which revealed the physical location of that known page. Symbols from the exact .96 vmlinux gave the fixed relationship between empty_zero_page and the physical page containing core_pattern. I installed a writable mapping for that target page in the never-walked destination slot.
CPU1 wrote and read back a NUL-terminated helper pattern. A copy of the exploit was already held in memory-backed file descriptor 666. After a controlled crash, the root helper used the supplied process information to recover the required descriptors and read only the challenge flag.
Before triggering it, the exploit cleared the forged PTE. The helper later made a best-effort reset of core_pattern to core\n. It did not save and restore the previous value, and a reset failure was silent. “Best effort” is the honest description.
Exact target constants and checks
empty_zero_page = 0xffffffff85651000
core_pattern = 0xffffffff846117a0
page delta = -0x01040000
in-page offset = 0x7a0
helper pattern = |/proc/%P/fd/666 %F
The exploit validates physical-address width, page alignment, guest RAM bounds, PTE flags, kernel physical alignment, and two equivalent symbol calculations before writing. On this target %P identifies the crashing process in the initial process namespace and %F supplies a process file descriptor. The helper uses pidfd_getfd() to recover descriptors.
Locally, the chain now had a harmless proof mode and a separate flag mode. I packaged the official archive and expected the race to be the unreliable part.
The archive refused to race at all.
The clock that failed before the race started⌗
My first official archive checked for an invariant Time Stamp Counter or TSC. The TSC is a fast processor clock and I used it to coordinate the two CPUs. The code required the processor feature bit promising that the counter behaved consistently.
Google’s CPU profile hid that feature bit. The exploit exited before making one attempt.

The check had confused a label with the property I needed. The two virtual CPUs’ counters could still be usable together even when that advertised bit was absent.
I replaced the feature-bit assumption with measurement. The corrected code exchanged timestamps between CPUs 32 times, used the minimum round trip to estimate offset, bounded the result, and required at least 75 percent of samples to agree within a latency-based tolerance.
A preserved local run measured an offset of -8 cycles while reporting invariant_tsc=false. The corrected archive went on to produce the one official flag capture.
That proves one official capture. It does not reveal a run ordinal or establish a success rate. The stale-only local milestone still requires invariant, constant, nonstop TSC; the immutable submitted exploit contains the measured cross-CPU version.
The whole chain again, slowly⌗
After all those detours, this is the end to end path without introducing anything new.
- The exact two-CPU
lts-6.12.96guest starts my exploit as UID 1000 inside the challenge jail. - The exploit reserves a sparse 1 GiB arena and prepares page-table candidates that can later identify themselves with pair coded region numbers.
- It creates one
memfd_secretpage, writes a changing marker, and drains CPU0’s deferred locked memory batch so no hidden reference keeps the folio alive. - A helper and reader share CPU0 while the writer runs on CPU1. Futex wakeups shape the helper-to-reader switch around the writer’s
MADV_DONTNEED_LOCKEDoperation. - In the winning interleaving, CPU1 removes the PTE but does not include CPU0 in the shootdown. CPU0 has already read the old generation and skips its local flush.
- CPU0 reads the current marker through its stale
V -> PTLB entry. Pagemap says the PTE remains absent before and after the read, ruling out a normal fault. - The exploit unmaps the already-empty one-page VMA while keeping neighboring mappings alive. The physical page is released without an effective invalidation, and CPU0 verifies that its stale route survived.
- Touching conditioned arena regions makes Linux allocate PTE pages. The old secret page is recycled as one of them.
- Two identical snapshots plus anchors, zero-page entries, permissions, empty slots, and the pair code identify the owning arena region.
- In a separate
pte-proofrun, the exploit copies an owned canary PTE into a never-walked destination and proves both aliases reach the same page. - In
flagmode, the exploit instead maps the physical page containingcore_pattern, writes and verifies the helper pattern, clears the forged entry, and triggers the helper. - The helper reads the protected flag and attempts a best-effort reset. Google’s service records one successful flag for
exp659.
The evidence tools I used when the story stopped making sense⌗
Normal exploit logs told me what my program believed: markers, pagemap state, decoded arena region and which checks passed. They were cheap enough for repeated race attempts, but they could not explain an invisible kernel reference.
The GNU Debugger or GDB, let me stop a virtual CPU inside functions such as switch_mm_irqs_off() and inspect registers and control flow. It was excellent for understanding a path and terrible for preserving race timing. A breakpoint can make the execution you wanted to observe disappear simply by observing it.
Tracefs is Linux’s event tracing interface. It records selected kernel events while the system continues running. Allocation, free and drain traces answered “who still owns this page?” more directly than another breakpoint could.
The two kernel files in the lab also have annoyingly similar jobs and very different forms. bzImage is the compressed bootable kernel image QEMU loads. vmlinux is the uncompressed kernel executable with symbols and, when available, debug information. GDB needs the exact vmlinux matching the booted bzImage; a nearby build can produce beautifully named nonsense.
Tracing and debugging both distort the experiment. GDB pauses CPUs. Tracefs changes timing and memory activity. I used them to understand mechanisms, then returned to fresh untraced boots for race evidence.
The local checkout contains only the stale milestone. The complete stale, pte-proof, and flag implementation and build integration are in the public PR branch.
Local QEMU command sheet
# Point the scripts at verified artifacts for both exact releases.
export KCTF_ROOT="$HOME/kctf/kernelctf-lts-6.12.96-diff-6.12.97"
# Build the tracked stale-only milestone as a static x86-64 binary.
cd /absolute/path/to/security_flow-main/results/security-flow/google-kernel-ctf/exploit
make CC=gcc -B secretmem-stale
file ./secretmem-stale
# Boot vulnerable .96 as UID 1000 and save its serial log.
cd /absolute/path/to/security_flow-main/results/security-flow/google-kernel-ctf
KCTF_QEMU_TIMEOUT=300 KCTF_EXPLOIT_ARGS='--expect vulnerable --seconds 180' \
bash environment/run-qemu.sh lts-6.12.96 exploit/secretmem-stale
# Run the same oracle against fixed .97.
KCTF_QEMU_TIMEOUT=300 KCTF_EXPLOIT_ARGS='--expect fixed --seconds 240 --attempts 2000000' \
bash environment/run-qemu.sh lts-6.12.97 exploit/secretmem-stale
A runner [pass] means the virtual machine booted and output was captured. The security result is "result":"stale-win" on .96 or "result":"fixed-clean" on .97. Networking remains disabled.
GDB command sheet
Start a debug copy of the matching QEMU command with -s -S. The first option opens a local debugger port; the second pauses the guest before execution.
# Start with: gdb /path/to/lts-6.12.96/vmlinux
set pagination off
set architecture i386:x86-64
target remote :1234
info files
info registers rip rsp rbp cr3 cr4
break switch_mm_irqs_off
break flush_tlb_mm
continue
directory /absolute/path/to/linux-6.12.96
list arch/x86/mm/tlb.c:1
break kernel/sched/core.c:finish_task_switch
disassemble /m switch_mm_irqs_off
These breakpoints are for learning the path. Remove them and use fresh boots for timing observations.
Tracefs command sheet
# The wrapper is privileged only so it can mount tracefs and collect events.
KCTF_TRACE_KMEM=1 KCTF_QEMU_TIMEOUT=300 \
KCTF_EXPLOIT_ARGS='--expect vulnerable --seconds 180' \
bash environment/run-qemu.sh lts-6.12.96 exploit/secretmem-stale
# Minimal guest-side sequence for available allocation/free events.
mount -t tracefs tracefs /sys/kernel/tracing
echo 0 > /sys/kernel/tracing/tracing_on
: > /sys/kernel/tracing/trace
echo 1 > /sys/kernel/tracing/events/kmem/mm_page_alloc/enable
echo 1 > /sys/kernel/tracing/events/kmem/mm_page_free/enable
echo 1 > /sys/kernel/tracing/tracing_on
# Run the isolated experiment here.
echo 0 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace
Check that optional event paths exist before enabling them. This privileged diagnostic run is not the UID-1000 exploit proof, and tracing can change both timing and allocation behavior.
Screenshot checklist
- Exact release and UID 1000 in QEMU.
- GDB stopped in
switch_mm_irqs_off()with matching symbols. - Pagemap showing present, absent, current marker, still absent.
- Tracefs showing the relevant physical page’s release and allocation.
- Separate harmless
pte-proofoutput. - Local stale marker and fixed
.97comparison. - Redacted
exp659ledger entry containing no secret data.
Do not fabricate output. Do not publish the signed flag or the sensitive root screenshot.
The failures that actually moved the exploit forward⌗
The public reproducer proved stale access but not the page lifecycle I needed. Random wakeups lost millions of attempts, which forced me to shape the helper to reader transition instead of running longer.
My first marker could not separate a stale hit from a normal fault; pagemap made that claim testable. More spray could not beat mlock_fbatch; tracefs found the hidden reference. Finally, the first official archive trusted a missing TSC feature bit and never raced; measuring the clocks fixed it.
None was cinematic Together they were most of the work.
What I built and what I borrowed⌗
The vulnerability was public, Stale-TLB exploitation after physical-page reuse was public. Generic freed-page-to-PTE conversion, zero-page anchoring, and the core_pattern helper finish all had prior art. Project Zero’s stale-TLB work and page-use-after-free to PTE work are useful background.
My narrower contribution was fitting a retryable one-page secret memory carrier, changing marker plus pagemap oracle, deferred-reference drain, empty final unmap, conditioned PTE allocation, pair-coded owner fingerprint, separate harmless canary proof and never-walked destination into one end-to-end chain for this exact target.
I searched Google’s public kernelCTF corpus at commit db52c93a319df7b7a8deea1b47472175e0a09d92 from 2026-08-06. I found no match there for memfd_secret, MADV_DONTNEED_LOCKED, mlock_fbatch, this CVE, or the pair-coded protocol. That supports a novelty case; it cannot search private work or every unindexed experiment, and Google decides the category.
Code, archive, PR and CVE status⌗
The complete submission is public in Google security-research PR #418. The PR is open and unmerged. Its immutable head is 5d4fc091c20f1eb98ec4f03876583f8f41dfa584.
Pinned links are available for the full exploit source, Makefile and kernelXDK integration, and technical documentation.
The repository review source is 3,226 lines. The exploit.c inside the submitted archive is 3,179 lines because the review copy includes the kernelXDK symbol-wrapper route. Both counts are correct for different copies.
The successful archive was named measured-tsc.tar.gz in my private working tree and original.tar.gz in the public submission layout. Its SHA-256 is:
066f6d3622f4d88ca99d518fefebcbf2ff80ab1cce826cd08bb73627703db85c
It contains exactly Makefile, exploit, and exploit.c. The earlier archive that stopped at the TSC check has different bytes and a different hash.
PR #418’s checks and discussion expose the CVE mapping dispute. The technical stable correction is 0650f1c8; the public CNA and spreadsheet mapping remains unresolved, with CVE-2025-40174 complicating the record. Public code does not mean the PR has been reviewed, merged or that the metadata has been adjudicated.
exp659 has public evidence for one official flag. exp609 already occupied the version/base slot and that fact alone does not reveal which vulnerability exp609 used. The remaining review question was the discretionary novel-technique category. A working exploit, a first slot and a reward are three different facts.
If you are beginning, start much smaller than this post⌗
- Write a tiny C program that opens and reads a file. Run
straceand watch the syscalls cross from user space into the kernel. - Allocate one page with
mmap(), write a value, and inspect/proc/self/maps. Connect the address range to a VMA before worrying about PTE bits. - Draw the path from virtual page to PTE to physical RAM to TLB. The official page-table and process-address documents are good companions once the picture is clear.
- Write two harmless threads, pin them to CPUs, and use a futex to change their wakeup order. Observe timing; do not begin with a kernel bug.
- Boot a disposable QEMU guest with networking disabled. Learn which file is
bzImage, which isvmlinux, and where the guest/QEMU/host boundaries sit. - Attach GDB to the guest and stop at a harmless syscall. Then enable one tracefs event and connect one trace line to one action in your program.
- Read a public patch and reproducer. Try to state the broken invariant in one sentence before building anything.
- Build evidence in small rungs: controlled observation, controlled read, controlled write, harmless canary, then the authorized target outcome.
- Count failed attempts and preserve raw logs. A success proves exploitability; it does not retroactively make every failed run disappear.
- Keep vulnerable-kernel work inside an isolated system you own or are explicitly authorized to test.
Final thoughts⌗
The finished chain can be compressed into one sentence: one CPU kept an obsolete route, Linux recycled the destination as a page table and my ordinary process edited the map.
Building it felt nothing like one sentence.
I had to relearn memory-management basics, make the scheduler produce one edge, distinguish a stale hit from a fault, find a reference I could not see, make an unmap boring, label hundreds of page tables, preserve one untouched destination and finally replace a confident clock check with an actual clock measurement.
The flag was a clean finish line, but the useful part was being forced to answer each question with evidence. Rereading theory would have made me feel less rusty. Watching a supposedly free page refuse to move taught me why it was not free.
The honest ending is still the scoreboard: exp659 captured one official flag; exp609 had the base slot; PR #418 is public and unmerged; the CVE metadata is unresolved. No new bug, no invented reliability number, no mysterious years of kernel wisdom.
Just some free time, a lot of rebooting, and one CPU that forgot to forget.
If you are still reading this, you are awesome. Thanks for sticking with me!
References and further reading⌗
- Linux kernel documentation
- kernelCTF rules
- Linux CNA record for CVE-2025-37964
- Public CVE discussion and reproducer
- Original upstream ordering fix
- Correct 6.12.y stable fix
- Exact vulnerable x86 TLB source
- Exact target memory-management structures
- Exact target x86 TLB state structures
- Linux page-table documentation
- Linux process-address and VMA documentation
- Linux pagemap documentation
memfd_secret(2)madvise(2)futex(2)core_patterndocumentation- Project Zero stale-TLB exploitation precedent
- Project Zero page-UAF-to-PTE precedent
- Google’s public kernelCTF exploit corpus
- Public submission PR #418
- Immutable full exploit source
- Immutable exploit documentation