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

/dev/lpmap fault races lwp teardown: orphaned lpmap allocation leaks 4KB M_UPMAP kernel heap per hit (unprivileged kmem exhaustion)

Field Value
ID DF-2839
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-362 β†’ CWE-401
File sys/kern/kern_memio.c
Lines 801-824 (fault handler); teardown kern_proc.c:1356-1381; WEXIT kern_exit.c:688 vs :695
Area kern
Confidence certain
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

user_kernel_mapping(), the UKSMAPOP_FAULT handler for /dev/lpmap (minor 7, mode 0666), reads lp->lwp_lpmap and calls lwp_usermap(lp,-1) without lifetime synchronization against the mapping's LWP exiting. lwp_userunmap() running from lwp_exit() NULLs lwp_lpmap then sleeps for milliseconds in its vm_map_remove drain with LWP_MP_WEXIT not yet set (only set after return). A fault on any not-yet-removed page of a mapping the exiting thread created sees lwp_lpmap==NULL, passes lwp_usermap's WEXIT guard, and installs a brand-new lpmap into the exiting LWP; the drain already saved/freed the old one and no other free site exists β€” the LWP is reaped with the allocation installed. Narrow same-window edges (pmap_enter landing after the drain's pmap_remove; pmap_kextract of the freed page) could expose a freed kernel heap page RW to userspace β€” source-proven only, speculative. Same-window AB-BA deadlock disproved (LWKT tokens re-acquired across tsleep).

Threat model & preconditions

Any local unprivileged user (lpmap is 0666) permanently consumes kernel heap at ~150 pages/s per 4 processes β†’ M_UPMAP cap (390M) exhaustion / kmem denial within minutes, plus per-hit orphaned kernel page-table mappings.

Proof of contest

VERIFIED (findings/poc/DF-2839/lpdeadlock3.c, unpriv uid 1001): 6 victim threads (mmap 6 lpmap regions, fault, exit) + 6 storm threads (drain-window stretching) + 3 touchers (shuffled jittered faults) β†’ upmap live count +7.0K/+6.8K allocations (+28.1MB/+27.6MB) per 45s run on stock with live-process count constant; fresh-boot baseline 37. Fix (set LWP_MP_WEXIT BEFORE lwp_userunmap in lwp_exit so lwp_usermap's post-allocation guard refuses late installs) authored.

See findings/poc/DF-2839/fix.diff (ordering change in kern_exit.c).

Timeline

  • 2026-08-31 Discovered during pass-2 audit of kern_memio.c (GLM 5.3); unpriv kmem leak quantified monotonic across runs same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2839 Β· 17 files
FileTypeDescriptionSize
README.md β€” 1.8 KB ↓ raw
VERDICT.md β€” 5.5 KB ↓ raw
lpdeadlock.c β€” 6.4 KB view raw
lpdeadlock2.c β€” 6.4 KB view raw
lpdeadlock3.c β€” 5.7 KB view raw
build.sh β€” 141 B view raw
run.sh β€” 382 B view raw
run.log β€” 74 B view raw
run.2.log β€” 68 B view raw
run.3.log β€” 329 B view raw
leak_sample.txt β€” 460 B view raw
env.txt β€” 216 B view raw
fix.diff β€” 1.4 KB view raw
fix.build.log β€” 906 B view raw
fix.run.log β€” 362 B view raw
manifest.json β€” 1.1 KB view raw
verdict.json β€” 5.3 KB view raw

DF-2839 β€” /dev/lpmap fault vs lwp-exit race: orphaned lpmap allocation (unprivileged kernel heap leak)

Build

On the DragonFly guest (any user; binary drops privileges itself):

cc -O -o /tmp/lpdeadlock3 /tmp/lpdeadlock3.c -lpthread

Run

vmstat -m | grep -w upmap          # baseline
/tmp/lpdeadlock3 4 45              # 4 unprivileged children, 45s
vmstat -m | grep -w upmap          # sample again
/tmp/lpdeadlock3 4 45
vmstat -m | grep -w upmap          # and again

Expected (stock kernel β€” reproduced)

upmap live allocation count / MemUse grows monotonically by ~7,000 allocations (~28 MB) per 45 s run while the live process count stays constant. Observed 2026-09-02 on DragonFly dfbsd 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 X86_64_GENERIC:

15.4K / 61.4M  ->  22.4K / 89.5M  ->  29.2K / 117M

The M_UPMAP pool is capped (390M here) β€” sustained unprivileged racing exhausts it in minutes and permanently consumes kernel heap.

Expected (kernel with fix.diff β€” validated)

Allocation count returns to its pre-run baseline (growth 0) for the same workload: LWP_MP_WEXIT is set before lwp_userunmap() in lwp_exit(), so lwp_usermap() refuses to install an lpmap into an LWP whose teardown already ran.

Files

lpdeadlock.c     v1 (deadlock probe β€” disproved: LWKT tokens are
                 re-acquired by the scheduler across tsleep)
lpdeadlock2.c    v2 (storm variant of v1)
lpdeadlock3.c    v3 β€” leak reproducer (this is the PoC of record)
fix.diff         verified one-hunk fix (sys/kern/kern_exit.c)
run.log          v1 run (no deadlock β€” negative result)
run.2.log        v2 run (no deadlock β€” negative result)
run.3.log        v3 leak reproduction, 3 samples, 2 runs (decisive)
leak_sample.txt  the three samples
env.txt          guest uname / sysctl context
VERDICT.md
↓ download raw

VERDICT β€” DF-2839

Status: reproduced (impact: dos β€” unprivileged kernel heap exhaustion via orphaned M_UPMAP allocations). Confidence: certain (for the leak); the freed-page stray-PTE edge is speculative.

What the finding is

user_kernel_mapping() (sys/kern/kern_memio.c:801-824) β€” the UKSMAPOP_FAULT handler for /dev/lpmap (minor 7, mode 0666, open to any user) β€” reads lp->lwp_lpmap and, when NULL, calls lwp_usermap(lp, -1) (kern_memio.c:813) without any lifetime synchronization against the lwp leaving the map other than the vm_map read lock held by vm_fault().

The teardown side, lwp_userunmap() (sys/kern/kern_proc.c:1356-1384), runs from lwp_exit() (sys/kern/kern_exit.c:688) and:

  1. lwkt_gettoken(&lp->lwp_token) (kern_proc.c:1363) β€” released whenever the thread sleeps (LWKT tokens are re-acquired by the scheduler, lwkt_getalltokens() in lwkt_switch),
  2. saves and NULLs lp->lwp_lpmap (1365-1366),
  3. drains every lpmap backing with vm_map_remove() (1374) β€” this sleeps for the map write lock, for milliseconds under mapping churn, with LWP_MP_WEXIT not yet set (it is only set after lwp_userunmap() returns, kern_exit.c:695),
  4. frees the saved lpmap (1381) β€” the only lwp_lpmap free site in the kernel (verified by grep).

A page fault on a mapping created by the exiting thread (any other thread of the same process β€” the map is process-wide) that lands in the drain window:

  • lp->lwp_lpmap reads NULL (step 2 already ran),
  • lwp_usermap() acquires lwp_token (free β€” the exiter released it to sleep), passes the LWP_MP_WEXIT guard (kern_proc.c:1326 β€” flag not set yet), and installs a brand-new lpmap into the exiting lwp,
  • the fault even succeeds (pmap_kextract of the fresh page, kern_memio.c:821),
  • the exiter wakes, finishes lwp_userunmap() having already saved the OLD pointer, and the LWP is reaped with the NEW allocation still installed.

Nothing ever frees it: 4096 bytes of M_UPMAP kernel heap per hit, triggerable by an unprivileged user at will.

How it was proven (run.3.log, leak_sample.txt)

Stock kernel DragonFly dfbsd 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 X86_64_GENERIC, unprivileged uid 1001, /dev/lpmap crw-rw-rw-:

upmap  15.4K / 61.4M   (residual of earlier probe runs)
+45 s  22.4K / 89.5M   (+7.0K allocs, +28.1 MB)
+45 s  29.2K / 117M    (+6.8K allocs, +27.6 MB)
ps ax | wc -l == 196 at every sample

Monotonic growth across two independent runs with constant live-process count = orphaned allocations, ~150 pages/s from four unprivileged processes. M_UPMAP is capped at 390M on this guest; sustained racing exhausts the cap in minutes and permanently consumes kernel heap (local DoS; each wedged page also pins a kernel page table mapping).

Why this is not the deadlock I first suspected (negative results)

v1/v2 (run.log, run.2.log) probed an AB-BA deadlock (map-read-lock held while lwkt_gettoken(lwp_token) vs lwp_token-held vm_map_remove). It cannot deadlock: DFly LWKT tokens are per-thread and are re-acquired by the scheduler when the thread is rescheduled (lwkt_getalltokens, sys/kern/lwkt_thread.c:707/755), so the exiting thread does not hold lwp_token while sleeping in the drain. The lockmgr is also reader/excl fair (lockmgr_shared, sys/kern/kern_lock.c:107: new shared acquisitions block behind LKC_EXREQ). Both probes ran 90 s with zero stalls. The leak above is what the same interleaving actually produces.

Impact ceiling (speculative, source-proven only)

Two narrow edges of the same unsynchronized window:

  • the fault's pmap_enter() (vm_fault.c:577) can land after the drain's pmap_remove() of the entry range, leaving a stray user PTE to a page freed at kern_proc.c:1381 (RW window onto recycled kernel heap) β€” needs the faulting thread to stall between callback return and pmap_enter across the whole drain: not demonstrated;
  • with a real lpmap (victim faults one page first), the pre-NULL window allows pmap_kextract of the page freed at 1381 β€” same stall caveat.

Reported as Medium/dos on the strength of the demonstrated leak; the memcorrupt edges are documented for triage, not claimed.

Fix validation (fix.diff β€” verified on this guest)

LWP_MP_WEXIT is now set in lwp_exit() before lwp_userunmap() (kern_exit.c). This is airtight for the leak: lwp_usermap()'s check is a token-serialized check-and-store evaluated after its (sleeping) kmalloc, and lwp_userunmap()'s save happens under the same token β€” so any store that slips in before the save is freed by the drain, and any store after the save is refused by the flag. It also closes the stray-pmap_enter edge, because faults in the window now fail with EINVAL (allocation refused) instead of succeeding.

Rebuilt with make -j6 nativekernel + make installkernel, rebooted; identical PoC workload ran twice with zero M_UPMAP growth (see verdict.json fix_* fields and fix run log).

Kernel references

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied fix.diff (move LWP_MP_WEXIT set before lwp_userunmap in lwp_exit) to the guest /usr/src, make -j6 nativekernel + installkernel + reboot. Stock kernel: +~7,000 upmap allocations (~28MB) per 45s PoC run. Patched kernel: 37/148K flat across two identical runs (96.6K allocation requests served, wave rates unchanged ~750/child/45s, live-proc count constant). Leak eliminated; guard analysis shows every token interleaving now either frees the late allocation in the drain or refuses it (also closes the stray pmap_enter edge since in-window faults now fail EINVAL).

['findings/poc/DF-2839/fix.diff', 'findings/poc/DF-2839/fix.run.log (patched: baseline 37/148K -> 37/148K -> 37/148K)', 'findings/poc/DF-2839/fix.build.log (uname #1 + /boot/kernel/kernel mtime Sep 2 05:50)', 'findings/poc/DF-2839/run.3.log (baseline: +7.0K/+6.8K allocations per run)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 05:47:01 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv user opens /dev/lpmap (0666); thread V mmaps lpmap regions (+faults one for a real lpmap); V pthread_exit -> lwp_exit -> lwp_userunmap parks in vm_map_remove drain; sibling thread T faults a not-yet-removed V-created page -> vm_fault holds map read-lock -> user_kernel_mapping sees lwp_lpmap==NULL -> lwp_usermap passes the LWP_MP_WEXIT guard (not set yet) and allocates+installs a new lpmap; V finishes teardown having already saved/freed the old one; LWP reaped with the new allocation installed -> 4KB M_UPMAP leaked. Repeat at kHz rates -> kmem exhaustion DoS.

Evidence (decisive lines)

['run.3.log/leak_sample.txt: upmap 15.4K/61.4M -> 22.4K/89.5M -> 29.2K/117M across two 45s unpriv runs, ps count constant 196', 'fix.run.log: patched kernel (uname #1 Sep 2 05:47:01) stays at 37/148K across two identical runs (96.6K requests, zero growth)', 'VERDICT.md: full interleaving trace with path:line, deadlock negative results (run.log, run.2.log)']

PoC changes

v1 (deadlock probe) and v2 (storm) never wedged because LWKT tokens do not persist across tsleep; v3 retargeted the same interleaving at the leak: victims mmap 6 regions each + fault one page (real lpmap), 6 storm threads churn the map to stretch the drain window, 3 touchers sweep victims' regions shuffled with time jitter so faults land inside the drain; children setuid(1001) themselves; detection via vmstat -m upmap live-count deltas instead of unkillable-child detection.

Verified recommended fix

In lwp_exit() set LWP_MP_WEXIT before lwp_userunmap() so lwp_usermap()'s token-serialized post-allocation guard refuses to install an lpmap into an LWP whose teardown already ran (sys/kern/kern_exit.c, one hunk - see fix.diff).

Verdict

Unprivileged /dev/lpmap page fault on a mapping created by a concurrently-exiting thread lands in the wide drain window of lwp_userunmap() (which sleeps in vm_map_remove with LWP_MP_WEXIT not yet set) and installs a fresh lpmap into the exiting LWP via lwp_usermap(); nothing ever frees it (lwp_userunmap is the only free site and already ran) - a permanent 4KB M_UPMAP kernel heap leak per hit. Demonstrated at ~150 pages/s (~28MB per 45s) from 4 unprivileged processes with constant live-process count, monotonic across runs; the 390M M_UPMAP cap is exhaustible in minutes (local kernel-memory-exhaustion DoS). Narrow same-window edges (pmap_enter racing the drain's pmap_remove; pmap_kextract of the page freed at kern_proc.c:1381) could expose a freed kernel heap page RW to userspace - source-proven only, not demonstrated, documented as speculative. The initially-suspected AB-BA deadlock was disproved: LWKT tokens are re-acquired by the scheduler across tsleep (lwkt_getalltokens), so the exiter does not hold lwp_token while sleeping; two 90s probe runs showed zero stalls.