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

mlock()/mlockall()/wiring faults zero-fill non-resident (swapped-out) pages instead of paging them in β€” silent memory destruction in privileged processes

Field Value
ID DF-2685
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
CWE CWE-440 Expected Behavior Violation (POSIX mlock residency guarantee)
File sys/vm/vm_fault.c
Lines 383-385 (TRYPAGER), 2327 (zero_fill), 2632 (entry)
Area vm
Confidence certain
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket privesc
Reported pending
Known CVE none
CVE match novel

Summary

TRYPAGER() excludes every VM_FAULT_WIRE_MASK fault from the pager (vm_fault.c:383-385), so vm_fault_wire()'s vm_fault(..., VM_FAULT_USER_WIRE) never asks swap/vnode pagers for non-resident pages; vm_fault_object() allocates a fresh page and vm_page_zero_fill()s it (:2327), which pmap_enter() installs wired. mlock() returns success and the process observes ZEROS where its swapped-out data was. Empirically verified: 16,253,937 of 16,777,216 words destroyed across the mlocked 64MB with 1026MB in swap. Also reaches brk-under-MAP_WIREFUTURE (vm_unix.c:151) and NVMM guest-RAM wiring (nvmm_dragonfly.c:193) via VM_FAULT_CHANGE_WIRING.

Threat model & preconditions

On stock x86-64 mlock is SYSCAP_RESTRICTEDROOT-gated (vm_mmap.c:1029-1035), so the victims are privileged mlock users β€” key-holding daemons (sshd-style key mlock, gpg-agent, openvpn/tor --mlock, mlockall services). Any local unprivileged user can create the precondition (swap pressure); when the privileged process next locks its range its keys/buffers silently become zeros with no error anywhere (dmesg clean, mlock returns 0). Impact ceiling: silent zeroing of root-owned cryptographic/auth state. Not a kernel-memory primitive.

Proof of concept

VERIFIED (findings/poc/DF-2685/mlockswap.c): mmap 4.6GB anon, dirty with pattern, sleep for pageout, mlock first 64MB, verify β†’ "BUG REPRODUCED: mlock zero-filled swapped pages", rc=2 on stock 6.5-DEVELOPMENT #0. Fix validated on rebuilt kernel: bad=0, "OK: data intact after mlock", rc=0 under equivalent swap pressure.

--- a/sys/vm/vm_fault.c
+++ b/sys/vm/vm_fault.c
@@ -382,7 +382,8 @@
 #define TRYPAGER(fs)   \
        (fs->ba->object->type != OBJT_DEFAULT &&        \
-       (((fs->fault_flags & VM_FAULT_WIRE_MASK) == 0)))
+       (((fs->fault_flags & VM_FAULT_WIRE_MASK) == 0) ||   \
+        ((fs->fault_flags & VM_FAULT_USER_WIRE) != 0)))

(allow the pager for user-wiring faults; kernel-wiring still skips β€” consider extending to nvmm's kernel wiring of user pages as follow-up). Validated: baseline rc=2 β†’ patched rc=0.

Timeline

  • 2026-08-30 Discovered during pass-2 audit of vm_fault.c (GLM 5.3); reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2685 Β· 13 files
FileTypeDescriptionSize
mlockswap.c β€” 2.2 KB view raw
build.sh β€” 254 B view raw
run.sh β€” 334 B view raw
baseline_stock_run.log β€” 641 B view raw
patched_run.log β€” 600 B view raw
run.log β€” 641 B view raw
run.patched.log β€” 600 B view raw
build.log β€” 2.4 KB view raw
env.txt β€” 443 B view raw
VERDICT.md β€” 3.6 KB ↓ raw
README.md β€” 3.2 KB ↓ raw
fix.diff β€” 395 B view raw
verdict.json β€” 5.0 KB view raw

DF-2685 β€” mlock()/mlockall() wiring faults zero-fill non-resident pages

What

mlock() (and every user-wire / kernel-wire fault that goes through vm_fault_wire()) on memory whose pages are not currently resident (swapped-out anonymous memory, or file pages reclaimed from the vnode object) returns success but presents ZERO-FILLED pages instead of paging the data back in. Silent, unreported memory destruction in the faulting process.

Root cause: TRYPAGER() at sys/vm/vm_fault.c:383-385 disables the pager for every fault with a VM_FAULT_WIRE_MASK flag:

#define TRYPAGER(fs)    \
        (fs->ba->object->type != OBJT_DEFAULT &&        \
        (((fs->fault_flags & VM_FAULT_WIRE_MASK) == 0)))

so vm_fault_object() (vm_fault.c:1845) never calls vm_pager_get_page() for a wiring fault; the backing-chain walk terminates at the terminal object and executes vm_page_zero_fill(fs->mary[0]) at sys/vm/vm_fault.c:2327, destroying the mapping's view of the data. pmap_enter() then installs the zero page (wired), so subsequent accesses see zeros with no fault.

POSIX requires mlock to make the pages resident with their contents.

Reproduce

./build.sh          # cc -O2 -o /tmp/mt/mlockswap mlockswap.c
./run.sh            # ~3-4 min (dirties 4.6GB to force ~1GB into swap)

Baseline (stock kernel) β€” baseline_stock_run.log:

/dev/vbd0s1b        4096M     1026M    3070M    25%    Interleaved
mismatch off=0 i=1 got=00000000 want=00000001
mlock-verify: bad=16253937 of 16777216 words
BUG REPRODUCED: mlock zero-filled swapped pages (silent data destruction)
rc=2

Patched kernel (fix.diff applied, rebuilt) β€” patched_run.log: OK: data intact after mlock, rc=0.

Reachability / threat

  • On x86-64 stock DFly, mlock()/mlockall() are gated by caps_priv_check_td(td, SYSCAP_RESTRICTEDROOT) (sys/vm/vm_mmap.c:1031, the #else branch β€” pmap_wired_count is not defined on pc64), so the direct caller must be root / hold that capability. Verified live: plain user mlock => EPERM.
  • The victims are therefore privileged processes that wire memory β€” exactly the population that uses mlock (key material in sshd/gpg-agent/ openvpn/tor style daemons, mlockall'd services). Under memory pressure (which any local user can create by allocating), their swapped pages are silently zeroed at the next mlock/mlockall of the range: authentication keys, database buffers, disk-encryption keys become zeros with no error.
  • mlockall(MCL_FUTURE) + brk() extension wires new heap pages through the same path (sys/vm/vm_unix.c:151), and NVMM wires guest RAM through vm_map_kernel_wiring() (sys/dev/virtual/nvmm/nvmm_dragonfly.c:193) β€” the same TRYPAGER hole.
  • On platforms/configurations where mlock is not root-gated (i386-style pmap_wired_count builds enforce only RLIMIT_MEMLOCK), any user limited by RLIMIT triggers it directly.

Files

  • mlockswap.c β€” trigger source (self-verifying)
  • build.sh/run.sh β€” exact commands
  • baseline_stock_run.log β€” full stock output (16.25M/16.77M words zeroed)
  • patched_run.log β€” output on fix-validated kernel
  • env.txt β€” guest environment
  • fix.diff β€” the fix (TRYPAGER allows VM_FAULT_USER_WIRE)
  • verdict.json, manifest.json
VERDICT.md
↓ download raw

DF-2685 VERDICT β€” mlock()/wiring faults zero-fill non-resident pages

Status: REPRODUCED (stock) / FIXED (validated by kernel rebuild + rerun)

How it was reproduced

Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, INVARIANTS), 4GB RAM, 4GB swap, QEMU/KVM.

  1. mlockswap.c: mmap 4.6GB MAP_PRIVATE|MAP_ANON, dirty with a page-index-derived pattern, sleep 8s (page daemon moves ~1GB to swap β€” swapinfo shows 1026M used).
  2. mlock() the first 64MB (the earliest-swapped region).
  3. Verify the pattern.

Stock kernel (baseline_stock_run.log):

/dev/vbd0s1b        4096M     1026M    3070M    25%    Interleaved
mismatch off=0 i=1 got=00000000 want=00000001
mismatch off=0 i=2 got=00000000 want=00000002
mlock-verify: bad=16253937 of 16777216 words
BUG REPRODUCED: mlock zero-filled swapped pages (silent data destruction)
rc=2

16.25M of 16.77M words are zeros; mlock() returned success. (First reproduction on the previous boot: 16340977 bad words β€” see notes in baseline log.) The kernel never reports anything: the corruption is silent.

Why (line-level trace)

The user's swapped data is never read back. POSIX (mlock(2): "pages ... become resident") is violated with silent data destruction instead.

Cross-checks performed: * File-backed variant: freshly-written file pages survive mlock (pages still resident in the vnode object β€” lookup succeeds, no pager needed), which is why this bug is normally masked; it fires exactly when pages are non-resident (swapped-out anon, reclaimed clean file pages). * Unprivileged reachability on this x86-64 stock config: plain user gets EPERM (sys/vm/vm_mmap.c:1029-1035, SYSCAP_RESTRICTEDROOT β€” pc64 has no pmap_wired_count). The direct caller must be privileged; any memory-hungry local user can create the swap-out precondition for a privileged mlocker.

Exploit chain

Not a memory-corruption primitive: this is silent user-memory destruction (integrity/availability) in the process that performs the wiring. Attack shape: local unprivileged user drives the box into swap pressure; a privileged daemon that (re)locks its memory (ssh key material, database buffers, crypto keys) silently loses it β€” keys become zeros with no error anywhere (dmesg clean). Impact ceiling: authentication/DoS/data-loss in root-owned security software; no kernel-memory disclosure or overwrite.

Fix validation

fix.diff (TRYPAGER additionally allows VM_FAULT_USER_WIRE so wiring faults page in from swap/vnode pagers like normal faults):

  • baseline (stock): bad=16253937, rc=2 β€” see baseline_stock_run.log
  • patched (same guest, kernel rebuilt with make nativekernel, rebooted): OK: data intact after mlock, rc=0 β€” see patched_run.log

Kernel-wiring (VM_FAULT_CHANGE_WIRING) still skips the pager (historical deadlock avoidance for kernel wiring paths); the POSIX-defined user-wire paths (mlock/mlockall/brk-under-MAP_WIREFUTURE) are the ones fixed. NVMM's vm_map_kernel_wiring() of guest RAM remains exposed β€” noted in the finding as residual hardening.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline reproduced on stock #0 (16.25M words zeroed, rc=2). Patched kernel #1 with the one-macro TRYPAGER fix returns bad=0 / rc=0 under equivalent swap pressure (1121MB swapped at verify time). Bad behavior eliminated; data paged back in correctly.

findings/poc/DF-2685/patched_run.log; findings/poc/DF-2685/build.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Aug 30 19:03:41 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

Local unprivileged user allocates ~4.6GB on a 4GB box driving a privileged mlock-using process's pages to swap; the privileged process (re)locks its memory via mlock/mlockall; the kernel zero-fills the locked region instead of paging it in (vm_fault.c:2327 via TRYPAGER exclusion at vm_fault.c:383-385); key material / auth state / database buffers silently become zeros -> authentication breakage, permanent data loss, availability collapse of the locking process. Not a kernel memory-corruption primitive; no kernel-memory disclosure or overwrite; impact is silent user-memory destruction (integrity/availability) in privileged processes.

Evidence (decisive lines)

["findings/poc/DF-2685/baseline_stock_run.log - stock run: swapinfo 1026M used, mismatch lines, 'bad=16253937 of 16777216 words', 'BUG REPRODUCED', rc=2", "findings/poc/DF-2685/patched_run.log - patched kernel: swapinfo 1121M used, 'bad=0 of 16777216 words', 'OK: data intact after mlock', rc=0", 'findings/poc/DF-2685/build.log - validated kernel identity (#1 Aug 30 19:03:41), build/install record, patch verification', 'findings/poc/DF-2685/VERDICT.md - full line-level trace: sys/vm/vm_mmap.c:1036 -> vm_map.c:2654 -> vm_fault.c:2632 -> TRYPAGER vm_fault.c:383-385 -> zero_fill vm_fault.c:2327 -> pmap_enter vm_fault.c:793']

PoC changes

Self-written for this verification (no seed). Pattern-based verifier with page-index-derived words; 4.6GB churn against 4GB RAM + 4GB swap; verifies 64MB earliest-swapped region after mlock. Also probed: file-backed mlock (masked while pages resident), unprivileged reachability (EPERM from SYSCAP_RESTRICTEDROOT on x86-64 stock, documented).

Verified recommended fix

TRYPAGER must not exclude user-wiring faults from the pager: allow VM_FAULT_USER_WIRE so mlock/mlockall page data in instead of zero-filling (see fix.diff).

Verdict

REPRODUCED on stock DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC): mlock() on swapped-out anonymous memory returns success but presents ZERO-FILLED pages (16,253,937 of 16,777,216 words destroyed in the decisive run; 16,340,977 in the first run). Root cause: TRYPAGER() (sys/vm/vm_fault.c:383-385) disables the pager for ALL VM_FAULT_WIRE_MASK faults, so vm_fault_object() never calls vm_pager_get_page() for wiring faults and instead zero-fills a fresh page at vm_fault.c:2327 and pmap_enter()s it wired. POSIX mlock semantics (pages become resident WITH their contents) are violated with fully silent data destruction. On stock x86-64 the mlock syscall is SYSCAP_RESTRICTEDROOT-gated (vm_mmap.c:1029-1035), so the direct victim population is privileged software that wires memory (key-holding daemons, mlockall services, NVMM guest RAM via vm_map_kernel_wiring, brk under MAP_WIREFUTURE) precisely when a local user has forced the box into swap pressure; no error is reported anywhere. FIX VALIDATED: with TRYPAGER allowing VM_FAULT_USER_WIRE, rebuilt kernel (#1 Aug 30 19:03:41) returns 'OK: data intact after mlock' (bad=0, rc=0) under equivalent swap pressure (1121MB swapped vs 1026MB baseline).