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.
Recommended fix
--- 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)
PoC verification
Evidence pack
findings/poc/DF-2685 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 bycaps_priv_check_td(td, SYSCAP_RESTRICTEDROOT)(sys/vm/vm_mmap.c:1031, the#elsebranch βpmap_wired_countis not defined on pc64), so the direct caller must be root / hold that capability. Verified live: plain usermlock=> 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 throughvm_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_countbuilds enforce only RLIMIT_MEMLOCK), any user limited by RLIMIT triggers it directly.
Files
mlockswap.cβ trigger source (self-verifying)build.sh/run.shβ exact commandsbaseline_stock_run.logβ full stock output (16.25M/16.77M words zeroed)patched_run.logβ output on fix-validated kernelenv.txtβ guest environmentfix.diffβ the fix (TRYPAGER allows VM_FAULT_USER_WIRE)verdict.json,manifest.json
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.
mlockswap.c: mmap 4.6GB MAP_PRIVATE|MAP_ANON, dirty with a page-index-derived pattern, sleep 8s (page daemon moves ~1GB to swap βswapinfoshows 1026M used).mlock()the first 64MB (the earliest-swapped region).- 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)
sys/vm/vm_mmap.c:1036mlock βvm_map_user_wiring()sys/vm/vm_map.c:2654βvm_fault_wire(map, entry, TRUE, 0)sys/vm/vm_fault.c:2632βvm_fault(map, va, VM_PROT_READ, VM_FAULT_USER_WIRE)sys/vm/vm_fault.c:383-385TRYPAGER(fs)is false becausefs->fault_flags & VM_FAULT_WIRE_MASK== VM_FAULT_USER_WIRE β 0 β the pager is never consulted invm_fault_object()sys/vm/vm_fault.c:2327vm_page_zero_fill(fs->mary[0])β a fresh page is zero-filled in the top object andsys/vm/vm_fault.c:793pmap_enter()installs it (wired per FW_WIRED).
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 β seebaseline_stock_run.log - patched (same guest, kernel rebuilt with
make nativekernel, rebooted):OK: data intact after mlock, rc=0 β seepatched_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
fixedBaseline 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
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).
No comments yet.