Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 280 additions & 0 deletions pocs/linux/kernelctf/CVE-2025-37964_lts/docs/exploit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
# Exploiting CVE-2025-37964 on kernelCTF `lts-6.12.96`

## Result

The exploit starts as an unprivileged UID 1000 process and uses no user
namespace, capability, `io_uring`, nftables, or external KASLR address. It
captured a signed flag from the official `lts-6.12.96` endpoint.

The immutable archive submitted with the flag is `original.tar.gz`:

```text
SHA-256: 066f6d3622f4d88ca99d518fefebcbf2ff80ab1cce826cd08bb73627703db85c
```

The archive contains exactly the original `Makefile`, compiled `exploit`, and
`exploit.c`. The reviewable copy under `exploit/lts-6.12.96/` adds repository
integration without changing the preserved archive.

The submitted conversion requires two online x86-64 CPUs with PCID, RDTSCP,
cross-CPU TSC synchronization, 4-KiB pages, readable `/proc/self/pagemap`, one
page of `RLIMIT_MEMLOCK`, and approximately 1 GiB of available virtual address
space. The relevant exploit-side configuration is listed in `metadata.json`.

## Primitive ladder

```text
missed PCID shootdown
-> stale writable and dirty secretmem translation
-> stale alias survives the final empty munmap
-> backing folio is synchronously zeroed and released
-> the same PFN is allocated as an attacker-owned PTE page
-> a structured fingerprint identifies the owning mapping
-> one never-walked PTE maps a checked physical page
-> core_pattern is overwritten
-> a memfd usermode helper reads only /flag
```

Every transition has a separate runtime gate. The exploit does not perform its
first page-table write until it has proved the stale translation, physical-page
release, PTE-page identity, and target calculations.

## 1. Reaching the vulnerable switch edge

The race needs a specific address-space transition on CPU0 while CPU1 removes a
leaf PTE from the incoming `mm`.

The productive schedule uses:

1. A helper process with a distinct `mm`, pinned to CPU0.
2. `SCHED_IDLE` on the helper, which is an unprivileged priority reduction.
3. A normal-priority reader in the target `mm`, also pinned to CPU0.
4. A shared futex used to wake the reader from the helper.
5. CPU1 performing the PTE-clearing operation.
6. A measured phase delay before the helper wakes the reader.

When the idle helper wakes the normal-priority reader, syscall return strongly
biases the scheduler toward the required helper-to-reader
`switch_mm_irqs_off()` transition. The exploit calibrates the relevant operation
with RDTSCP and sweeps a bounded timing interval around it.

Bounded controls using direct reader wakeups and an unaligned helper schedule did
not produce the stale oracle in the tested setup. These finite controls do not
prove that every other schedule is impossible; they show why the modeled
transition was used.

## 2. Retryable secretmem carrier

The carrier is one shared read/write page created with `memfd_secret(2)`.
Immediate neighbor VMAs remain mapped in the same PMD so the final unmap cannot
free the enclosing PTE table and force a broad invalidation.

Before every attempt, CPU0 stores a changing epoch marker through the mapping.
This makes the cached translation writable and dirty before CPU1 clears the
software PTE. CPU1 then applies `MADV_DONTNEED_LOCKED`.

For this mapping, `MADV_DONTNEED_LOCKED` clears the leaf while retaining the
secret folio. A failed attempt can fault the same page back in and retry. This
separates repeatable stale-state creation from the one-shot final physical
release.

## 3. False-positive-resistant stale oracle

Seeing the marker is insufficient: an ordinary fault could reinstall the PTE
and return the same bytes. The exploit combines data and page-table state:

```text
pagemap before zap = present
pagemap after zap = absent
CPU0 ordered load = exact current epoch marker
pagemap after load = still absent, checked twice
```

For this controlled leaf, a normal missing-page fault would reinstall a present
software PTE. The exact marker while the PTE remains absent demonstrates that
CPU0 used a cached stale translation.

The reader stops issuing syscalls after the winning load. This preserves CPU0's
address-space state and avoids accidental context-switch flushing before the
physical conversion.

## 4. Draining the hidden folio reference

An early conversion attempt won the stale race but could not reclaim the page.
Tracing the carrier PFN showed that CPU0's `mlock_fbatch` still retained a
reference created by deferred mlock/LRU work.

Blindly increasing spray pressure cannot reclaim a page whose refcount has not
reached zero. Each attempt therefore performs a small beyond-EOF
`POSIX_FADV_DONTNEED` operation against page-cache ranges beyond the end of the
regular `/bin/sh` file on CPU0. It does not create or lock another mapping. The
operation drives the relevant per-CPU pagevec/LRU drain and releases the pending
mlock reference before the next final-unmap attempt.

This drain is CPU-local and is run on the CPU that accumulated the deferred
reference.

## 5. Final empty unmap

After the stale oracle fires, CPU1 verifies that:

- every software PTE in the one-page secret range is absent;
- both same-PMD neighbors are still present;
- no target-`mm` page-table mutation occurred after the winning zap.

The exploit requires the expected batched-TLB state and detects downstream
success or failure, but userspace cannot inspect that kernel state directly.

It then performs one final `munmap()` of the empty secret VMA. Secretmem release
zeroes the folio and frees it synchronously. CPU0 immediately performs one
post-unmap load through the stale translation and must observe zero. A guarded
fault handler records a lost stale alias rather than allowing an ambiguous
crash.

At this point the process has proved both required facts: the VMA and software
PTE are gone, and CPU0 still has access to the released physical page.

## 6. Conditioning immediate PTE reuse

Before racing, the exploit creates 256 sparse PMD regions and faults one leaf in
each. Their PTE pages are retained while the leaf mappings are removed. The
retained pages condition the page-table allocation path and absorb allocator
noise.

After the final unmap, regions 257 through 511 are populated one at a time.
Each first leaf fault requires a new order-0 user PTE page.

The exploit does not assume a particular PCP freelist order. It scans only the
bounded post-win region set and accepts a page only after a complete ownership
fingerprint.

## 7. Locating the reclaimed PTE page

Blind writes through the stale alias would corrupt an unknown page table. Each
spray region therefore encodes its identity using only attacker-owned mappings:

- stable zero-page PTEs at two anchor slots;
- nine pair-coded identity bits, represented as zero-page PTE versus zero;
- an initially zero sentinel at the future canary-source slot; and
- one destination slot that has never been walked and must still be zero.

CPU0 reads two full snapshots of the candidate through the stale alias. A page
is accepted only if both snapshots are identical and satisfy every expected
zero, anchor, identity, source-sentinel, and destination check. The identity is
then cross-checked against the region whose allocation just completed.

Only after candidate selection does CPU1 fault an attacker-owned canary into the
fixed source slot. CPU0 then validates that new source PTE before installing a
copy in proof mode or the checked physical target in flag mode.

This turns allocator reuse into a self-describing object rather than relying on
an address leak or a destructive probe.

## 8. Benign PTE-control proof

In proof mode, CPU0 copies the source canary PTE into the never-walked
destination slot. CPU1 accesses the destination for the first time and verifies
that source and destination alias the same attacker-owned physical page in both
directions.

The reader then clears the forged destination PTE through the stale alias before
normal teardown. This is important because a copied PTE has no matching
refcount or reverse-mapping accounting.

## 9. Deriving the `core_pattern` physical page

The accepted PTE page contains checked PTEs for `empty_zero_page`. Their PFN
provides a physical anchor. The exploit validates the target-specific symbol
relationship and computes the physical page containing `core_pattern`.

For the immutable original target:

```text
empty_zero_page = 0xffffffff85651000
core_pattern = 0xffffffff846117a0
page delta = -0x01040000
core offset = 0x7a0
```

The polished repository build resolves the two symbols through kernelXDK and
derives the same page delta at runtime. It also checks physical-address width,
alignment, overflow, the guest RAM bounds, PTE flags, and two equivalent target
calculations before installing the mapping.

## 10. One never-walked target PTE

CPU0 writes one checked user-writable PTE for the physical `core_pattern` page
into the destination slot. Because that virtual address has never been
accessed, no CPU can hold an older translation for it and no invalidation is
needed before CPU1's first walk.

CPU1 writes the following value and reads it back exactly:

```text
|/proc/%P/fd/666 %F
```

CPU0 then clears the forged PTE through the stale alias and verifies that the
entry is zero before any fork or ordinary `mm` teardown.

## 11. Flag helper

Before the race, the exploit copies its own executable into memfd 666. After the
checked `core_pattern` update, a dumpable child terminates with `SIGSEGV`.

On this target, `%P` selects the crashing process in the initial PID namespace
and `%F` supplies helper fd 3, a pidfd for the coredumping process. The root
helper uses the pidfd to duplicate the original output and completion
descriptors, opens `/flag` directly, copies only the challenge flag, restores
`/proc/sys/kernel/core_pattern` on a best-effort basis, and exits. It installs no
persistence.

## Runtime modes

The repository build supports:

```text
./exploit --stage stale --expect vulnerable
./exploit --stage pte-proof --expect vulnerable
./exploit --stage flag --expect vulnerable
./exploit --vuln-trigger
```

`--stage stale` stops after the stale oracle. `--stage pte-proof` performs only
the self-owned canary alias and restores the PTE. `--stage flag` runs the full
kernelCTF chain. `--vuln-trigger` is reserved for the repository's differential
vulnerability verifier.

The program emits a final JSON record. A runner process exiting successfully is
not itself an exploitation verdict; the stage-specific `result` value must be
checked.

## Reliability boundary

The public submission record confirms one official flag capture for the exact
immutable archive. No numerical reliability percentage or stability bonus is
claimed. The repository reproduction workflow is the authoritative public
reliability measurement.

## Cleanup and safety invariants

The exploit enforces the following before and after its only PTE write:

1. Exact stale marker plus persistent software-PTE absence.
2. Synchronous post-unmap zero observation.
3. Two identical complete PTE-page snapshots.
4. Exact structured ownership fingerprint.
5. A zero never-walked destination.
6. A separate proof mode that validates a benign attacker-owned alias before
final-mode development is enabled.
7. Runtime symbol and physical-range validation.
8. Exact write/readback of `core_pattern`.
9. Removal and verification of the forged PTE before teardown.
10. A bounded helper that reads only the challenge flag.

## Why the fixed control matters

The 6.12.97 control contains the corrected stable ordering and completed its
bounded run without the stale oracle. This differential separates the security
primitive from ordinary scheduler noise, page faults, or an oracle that merely
recognizes the marker after a refault.
Loading
Loading