β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-5244

pmap_unwire() PTE teardown is non-atomic and unsynchronized vs cmpset-based removal β†’ double vm_page_unwire (panic) / PHYS_TO_VM_PAGE(0) = vm_page_array[-1] out-of-bounds

Summary

Both branches clear PG_W with atomic_clear_long then RE-READ the PTE frame unprotected while the PMAP_PAGE_BACKING_SCAN cmpset teardown (pmap_remove_all/specific, cross-process via msync(MS_INVALIDATE) on a shared object) may zero the PTE in the gap: m = PHYS_TO_VM_PAGE(0) = &vm_page_array[atop(0)-first_page] (index -1) forged vm_page, or both sides unwire one wire -> panic('vm_page_unwire: invalid wire count'). Guest-proof: mlock/munlock vs msync stress raced 20+min clean on stock, but with a ~10us instrument delay in the window the guest PANICKED in <40s (panic.txt, sys_munlock path); fixed kernel (cmpset-clear-and-capture both branches) ran identical stress 8+min clean. Root-gated on stock pc64 (mlock SYSCAP_RESTRICTEDROOT); unpriv on standard-BSD configs permitting user wiring. Latent forged-vm_page corruption primitive in the same window.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5244 Β· 13 files
FileTypeDescriptionSize
df5244.c β€” 4.0 KB view raw
build.sh β€” 89 B view raw
run.sh β€” 91 B view raw
panic.txt β€” 633 B view raw
serial_panic.log β€” 2.2 KB view raw
run.stock.log β€” 14.8 KB view raw
run.fixed.log β€” 58 B view raw
env.txt β€” 241 B view raw
instrument.diff β€” 629 B view raw
fix.diff β€” 2.2 KB view raw
VERDICT.md β€” 6.4 KB ↓ raw
README.md β€” 3.0 KB ↓ raw
verdict.json β€” 6.0 KB view raw

DF-5244 β€” pmap_unwire() PTE teardown race vs backing-scan removal (double vm_page_unwire / PHYS_TO_VM_PAGE(0) OOB)

What

pmap_unwire() (user branch, sys/platform/pc64/x86_64/pmap.c:5627-5635) clears PG_W with atomic_clear_long() and then re-reads *ptep & PG_FRAME as two separate operations. Concurrent cmpset-based PTE teardown (pmap_remove_all(), pmap.c:4816 β€” reached cross-process from msync(MS_INVALIDATE) β†’ vm_object_page_remove() β†’ wired-page branch (vm_object.c:1543) β†’ vm_page_protect(p, VM_PROT_NONE)) takes neither the victim pmap's vm_map lock, nor the pt_pv, nor the PTE placemarker, so nothing serializes the two sides.

Consequences of the interleaving: 1. B's cmpset lands between A's W-clear and A's frame re-read β†’ A computes m = PHYS_TO_VM_PAGE(0) = &vm_page_array[atop(0) - first_page] (index βˆ’1 when first_page > 0) β†’ out-of-bounds vm_page_t handed to vm_page_busy_wait()/vm_page_unwire() (kernel heap corruption). 2. A returns the correct page but both sides unwire the single wire β†’ panic("vm_page_unwire: invalid wire count: 0") (proven, see panic.txt).

Trigger privilege

root-only on stock DragonFly: mlock()/mlockall()/MAP_WIREFUTURE are gated by SYSCAP_RESTRICTEDROOT (sys/vm/vm_mmap.c:1030,1196) because pc64 does not define pmap_wired_count. On any configuration where unprivileged users can wire memory (the common BSD state β€” DF is the outlier here), this is an unprivileged local kernel-memory-corruption/panic.

Build

cc -O2 -pthread -o df5244 df5244.c

Run (as root on the DragonFly guest)

./df5244 6

6 pairs of processes: "A" mlock/munlock on a 1 MB MAP_SHARED hammer2 file mapping; "B" msync(MS_INVALIDATE) on its own mapping of the same file.

Expected output

Stock kernel: races for >20 minutes in this audit without hitting (the window is only a few instructions wide). With the audit instrumentation kernel (instrument.diff β€” a ~10 Β΅s delay inserted between the W-clear and the frame re-read in pmap_unwire), the guest panics within ~40 s:

panic: vm_page_unwire: invalid wire count: 0
vm_page_unwire() at vm_page_unwire+0xa9
vm_page_unwire() at vm_page_unwire+0xa9
vm_fault_unwire() at vm_fault_unwire+0xc6
vm_map_user_wiring() at vm_map_user_wiring+0x30a
sys_munlock() at sys_munlock+0x80
syscall2() at syscall2+0x11e

With fix.diff applied (atomic cmpset clear-and-capture) plus the same 10 Β΅s delay inside the cmpset loop, the identical stress runs clean (8+ minutes verified vs <40 s to panic unfixed).

Files

df5244.c          final PoC source (A/B process pairs)
instrument.diff   kernel instrumentation (widens the race window; NOT a fix)
fix.diff          the verified fix (git-apply-able, never applied to sys/)
panic.txt         captured panic from the instrumented baseline kernel
serial_panic.log  full serial console capture
run.stock.log     stock-kernel attempt (no hit in ~20 min)
run.fixed.log     fixed-kernel run log (no panic)
env.txt           guest uname
verdict.json      machine verdict
VERDICT.md
↓ download raw

DF-5244 VERDICT β€” REPRODUCED (panic on instrumented kernel; race certain by inspection)

Bottom line

pmap_unwire()'s user branch clears PG_W and re-reads the PTE frame as two non-atomic steps, and nothing excludes pmap_remove_all()'s cmpset-based PTE teardown. A cross-process msync(MS_INVALIDATE) on a shared MAP_SHARED vnode mapping drives that teardown without any lock the unwire path holds. The race was reproduced as a kernel panic on an instrumented kernel (the few-ns window widened to ~10 Β΅s), and the proposed atomic cmpset fix was validated on the same instrumented kernel: identical stress, no panic.

Root cause chain (path:line)

  1. sys_munlock β†’ vm_map_user_wiring (unwire) β†’ vm_fault_unwire (sys/vm/vm_fault.c:2686-2697) β†’ pmap_unwire (sys/platform/pc64/x86_64/pmap.c:5610-5637). pmap_unwire holds only the victim pmap's pt_pv (pv_get at pmap.c:5610): 5627 if (pmap_pte_w(pmap, ptep)) { // test PG_W 5628 atomic_add_long(&...wired_count, -1); 5630 atomic_clear_long(ptep, pmap->pmap_bits[PG_W_IDX]); // clear W 5632 pa = *ptep & PG_FRAME; // RE-READ, racy 5633 m = PHYS_TO_VM_PAGE(pa); // pa==0 β†’ array[-1] then the caller does vm_page_busy_wait(m); vm_page_unwire(m, 1); (vm_fault.c:2692-2694). vm_page_unwire panics on wire_count ≀ 0 (sys/vm/vm_page.c:3304).

  2. Attacker-side teardown: msync(MS_INVALIDATE) β†’ vm_map_madvise (sys/vm/vm_map.c:3164-3169) β†’ vm_object_page_remove on the shared vnode object β†’ wired-page branch (sys/vm/vm_object.c:1543-1551) β†’ vm_page_protect(p, VM_PROT_NONE) β†’ pmap_remove_all (pmap.c:4777) β†’ pmap_inval_smp_cmpset(ipmap, iva, iptep, ipte, 0) (pmap.c:4816) β†’ pmap_removed_pte β†’ vm_page_unwire(m, -1) (pmap.c:439-440). The backing scan (PMAP_PAGE_BACKING_SCAN, pmap.c:176-222) holds only iobj->backing_lk; it takes neither the victim's vm_map lock, nor pt_pv, nor the PTE placemarker β€” so pt_pv (the only lock pmap_unwire holds) does not exclude it. ftruncate does NOT work as the B-side on this system: neither tmpfs (tmpfs_subr.c:1051-1063 β€” page removal skipped for the v_object bookkeeping case) nor hammer2 invalidate already-mapped pages in practice (verified with kernel counters: pmap_remove_all's cmpset never fired under 500k truncates).

  3. Interleavings: - B's cmpset between A's clear and A's re-read β†’ pa = 0 β†’ PHYS_TO_VM_PAGE(0) = &vm_page_array[atop(0) - first_page] (sys/vm/vm_page.h:454-455; first_page = phys_avail[0]/PAGE_SIZE, sys/vm/vm_page.c:410 β€” typically > 0, so index βˆ’1: memory before the vm_page array) β†’ vm_page_busy_wait/vm_page_unwire on a forged vm_page (32-bit decrement at a fixed offset before the array). - B's cmpset after A's re-read (both observed PG_W) β†’ both sides unwire the single wire β†’ panic "vm_page_unwire: invalid wire count: 0".

Reproduction history (attempts)

  1. tmpfs + mlock/munlock + ftruncate (user maxx): mlock β†’ EPERM β€” on DF, mlock is SYSCAP_RESTRICTEDROOT-gated (vm_mmap.c:1030) because pc64 lacks pmap_wired_count. Switched to root.
  2. Stock kernel, tmpfs + paced ftruncate, 8 lock threads (5 min): 176k lock/unlock cycles, no panic (tmpfs never tears the PTEs down).
  3. Stock kernel, hammer2 (/var) + ftruncate, 10 processes (12 min): 190k cycles Γ— 256 pages, 1.8M truncates, no panic.
  4. Counter-instrumented kernel: pmap_unwire W-hit counter rose to millions; pmap_remove_all entry counter did NOT move under ftruncate (confirmed the B-side was inert).
  5. B-side switched to cross-process msync(MS_INVALIDATE) (vm_map.c:3164 path) on a hammer2 MAP_SHARED mapping; instrumented kernel (10 Β΅s window inside pmap_unwire) β†’ panic in <40 s (panic.txt): exactly the predicted double-unwire (sys_munlock β†’ vm_map_user_wiring β†’ vm_fault_unwire β†’ vm_page_unwire).
  6. Fixed kernel (fix.diff: atomic cmpset clear-and-capture) with the same 10 Β΅s delay inside the cmpset loop, 12 processes, 8+ minutes: no panic, no hang, all children completed. Fix validated.

Why the stock kernel was not panicked directly

The exploitable window on stock code is the gap between atomic_clear_long and *ptep & PG_FRAME (a few instructions, ~1-2 ns). With ~2.5M pmap_unwire events and ~90k remove_all cmpsets per 40 s (counter-measured), the expected hit rate is on the order of one per many minutes to hours; the audit's 20 min of stock racing did not win the lottery. The instrumented run proves the interleaving is reachable and fatal when the window opens; the fix removes the window entirely (clear and capture are one atomic cmpset, and losing the cmpset correctly drops out of the unwire).

Exploitability / impact ceiling

  • Proven: local DoS (panic) β€” root-triggered on stock DF (mlock is privileged here); unprivileged on any system where mlock is allowed (nonstandard for DF, standard elsewhere).
  • Present but not separately triggered: the PHYS_TO_VM_PAGE(0) OOB path yields a forged vm_page_t (memory preceding the vm_page array) that the kernel busy-waits on and atomically decrements β€” a weak but real kernel heap corruption primitive at a fixed location, plus possible indefinite thread hangs (tsleep on a garbage busy channel).
  • The kernel-pmap branch (pmap.c:5598-5603) has the identical non-atomic pattern with no locking at all (comment: "Assume elements in the kernel pmap are stable"); it is believed unreachable racing today because kernel wired-map teardown is serialized by kernel_map, but it is fixed by the same patch defensively.

Fix

fix.diff in this directory (also summarized in the finding markdown): replace the clear-then-reread with an atomic cmpset loop that clears PG_W and captures the frame from the PTE it won, in both branches of pmap_unwire. Losing the cmpset means a concurrent teardown handled the wire; the code then correctly declines to unwire. Validated as described above.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied fix.diff semantics (atomic cmpset clear-and-capture) to the guest kernel WITH the same ~10us adversarial delay moved inside the cmpset retry loop; the identical PoC stress (12 processes, 8+ minutes) that panicked the unfixed instrumented kernel in <40s produced no panic, no hang, and clean completion. Baseline panic vs patched silence is decisive.

run.fixed.log (fixed kernel, no panic); compare panic.txt (baseline instrumented kernel, panic in <40s under identical stress); fix.diff (git apply --check verified against pristine sys/)
↓ fix.diffper-fix-DF-5244

Confirmed kernel references

Detail

Exploit chain

unprivileged-user route exists only where mlock is permitted (root-only on stock DF): (1) process A mmap(MAP_SHARED) of a file on a vnode-pager FS (hammer2) and mlock the mapping (PG_W PTEs, page wired once); (2) process B maps the same file and loops msync(MS_INVALIDATE) -> vm_object_page_remove -> pmap_remove_all cmpset-zeroes A's PG_W PTE and unwires the page; (3) A loops munlock -> pmap_unwire: if B's cmpset lands between A's W-clear and frame re-read, A builds m = &vm_page_array[atop(0)-first_page] (index -1: memory before the array) and the kernel runs vm_page_busy_wait/vm_page_unwire on the forged vm_page (32-bit decrement at a fixed pre-array offset, or a thread hung on a garbage busy channel); if it lands just after, both sides unwire -> deterministic panic. Proven stage: panic (double-unwire). Corruption stage present in code (same window), not separately triggered.

Evidence (decisive lines)

panic.txt (panic: vm_page_unwire: invalid wire count: 0; backtrace sys_munlock -> vm_map_user_wiring -> vm_fault_unwire -> vm_page_unwire); serial_panic.log (full console capture); VERDICT.md (full path:line chain and 6-attempt history); run.stock.log (stock-kernel attempt, no hit in ~20 min); run.fixed.log (fix-validated run); instrument.diff (window-widening instrumentation); fix.diff (git-apply-able verified fix)

PoC changes

seed evolved: (1) mlock as unprivileged user maxx failed EPERM (DF gates mlock by SYSCAP_RESTRICTEDROOT) -> run as root; (2) B-side ftruncate on tmpfs does NOT tear down mapped PTEs (tmpfs_reg_resize bookkeeping skips removal) -> moved file to hammer2 /var; (3) ftruncate on hammer2 also never reached pmap_remove_all in practice (proven with in-kernel counters: pmap_remove_all entry/cmpset counters stayed 0 across 500k truncates) -> B-side switched to cross-process msync(MS_INVALIDATE) on a second MAP_SHARED mapping of the same file (vm_map.c:3164 path); (4) single-process threaded version serialized by the vm_map lock -> A/B split into separate processes; (5) stock-kernel window (~ns) too tight to hit in 20 min -> instrumented kernel (instrument.diff) widens the gap between W-clear and frame re-read to ~10us, panic in <40s; fix validated with the same delay inside the cmpset retry loop.

Verified recommended fix

In both branches of pmap_unwire(), replace atomic_clear_long(W) + re-read of the frame with a single cmpset loop that clears PG_W and captures opte & PG_FRAME atomically from the PTE the race was won on; decline to unwire when the cmpset is lost to a concurrent teardown (fix.diff, validated).

Verdict

pmap_unwire() (user branch, pmap.c:5627-5635) clears PG_W and re-reads the PTE frame non-atomically; a concurrent cmpset-based teardown from pmap_remove_all() (reached cross-process via msync(MS_INVALIDATE) -> vm_object_page_remove -> wired-page branch -> vm_page_protect(NONE)) holds none of the unwire path's locks, so both sides can unwire the single wire -> panic('vm_page_unwire: invalid wire count: 0'), or the unwire can read a zeroed PTE and compute PHYS_TO_VM_PAGE(0) = &vm_page_array[-1] (out-of-bounds forged vm_page_t that the kernel busy-waits and atomically decrements). Reproduced as the exact predicted panic on a kernel instrumented with a ~10us delay inside the few-ns window (panic.txt: sys_munlock -> vm_map_user_wiring -> vm_fault_unwire -> vm_page_unwire); the stock kernel raced 20+ minutes without a lottery win. The atomic cmpset clear-and-capture fix was validated on the same instrumented kernel: identical stress that panicked in <40s ran 8+ minutes clean. NOTE: on stock DF, mlock()/mlockall() are SYSCAP_RESTRICTEDROOT-gated (vm_mmap.c:1030), so the trigger requires root here; on any system permitting unprivileged memory wiring this is an unprivileged kernel corruption/panic.