vm_page_grab() NULL-pointer dereference when the page exists but is busy and VM_ALLOC_RETRY is not set
| Field | Value |
|---|---|
| ID | DF-2676 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-476 NULL Pointer Dereference |
| File | sys/vm/vm_page.c |
| Lines | 3841-3843 (falls into :3882; failed label :3891) |
| Area | vm |
| Confidence | certain |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
vm_page_grab() calls vm_page_lookup_busy_try(object, pindex, TRUE,
&error); when the page exists but is busy and the caller did not pass
VM_ALLOC_RETRY, the error path does m = NULL; break; (vm_page.c:3841-3843)
and then unconditionally executes if (m->valid == 0) (:3882) β a NULL
read at offsetof(struct vm_page, valid)=0x76 on x86_64. The
alloc-failure path correctly does goto failed (:3859); this path
forgot. The function's doc comment explicitly blesses non-RETRY usage.
Threat model & preconditions
Latent for unprivileged userland today: the only in-tree non-RETRY caller is the sysv_shm pre-allocation loop (sysv_shm.c:582), which provably cannot observe a busy page (exclusive object hold for its whole loop, non-blocking iteration, PG_UNQUEUED prealloc pages exclude the only token-free busier). Reachable today by root via kldload (demonstrated) and by any future caller following the documented non-RETRY contract. Ceiling: fixed-address NULL read β panic/DoS.
Proof of concept
Deterministic KLD trigger (findings/poc/DF-2676/df2676.c): thread A
holds a page busy in a fresh object; thread B calls vm_page_grab(obj,
0, VM_ALLOC_NORMAL) β stock INVARIANTS guest: Fatal trap 12 ...
fault virtual address = 0x76, Stopped at vm_page_grab.cold.30: movzbl
0x76,%eax, guest dies at ddb. Fix validated on a rebuilt kernel:
grab returned 0, no panic. Two userland racers kept in the pack as
the documented negative result proving current unprivileged
unreachability.
Recommended fix
--- a/sys/vm/vm_page.c
+++ b/sys/vm/vm_page.c
@@ -3840,7 +3840,7 @@ vm_page_grab(vm_object_t object, vm_pindex_t pindex, int flags)
vm_page_sleep_busy(m, TRUE, "pgrbwt");
if ((flags & VM_ALLOC_RETRY) == 0) {
m = NULL;
- break;
+ goto failed;
}
/* retry */
} else if (m == NULL) {
Timeline
- 2026-08-29 Discovered during pass-2 audit of vm_page.c (GLM 5.3); KLD-verified + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2676 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df2676.c | β | 2.9 KB | view raw | |
| fix.diff | β | 246 B | view raw | |
| panic.txt | β | 1.9 KB | view raw | |
| run_fixed.log | β | 1.0 KB | view raw | |
| env.txt | β | 566 B | view raw | |
| shm_grab_race.c | β | 4.7 KB | view raw | |
| shm_grab_race2.c | β | 3.8 KB | view raw | |
| VERDICT.md | β | 4.3 KB | β raw | |
| README.md | β | 3.0 KB | β raw | |
| build.sh | β | 301 B | view raw | |
| run.sh | β | 177 B | view raw | |
| verdict.json | β | 4.6 KB | view raw |
DF-2676 β vm_page_grab() NULL-pointer dereference (error path, no VM_ALLOC_RETRY)
What
vm_page_grab() (sys/vm/vm_page.c:3827-3894): when vm_page_lookup_busy_try()
reports the page exists but is busy (error = TRUE) and the caller did not
pass VM_ALLOC_RETRY, the code does:
if (error) {
vm_page_sleep_busy(m, TRUE, "pgrbwt");
if ((flags & VM_ALLOC_RETRY) == 0) {
m = NULL;
break; /* <-- falls into the block below */
}
/* retry */
}
...
if (m->valid == 0) { /* vm_page.c:3882 β m == NULL here */
break exits the loop into the m->valid read with m == NULL β kernel
page fault at offsetof(struct vm_page, valid) = 0x76 on x86_64.
The doc comment explicitly blesses non-RETRY usage (βif VM_ALLOC_RETRY is
not set then NULL is always returned if we had blockedβ), so returning NULL
is the intended behavior β the missing goto failed is the bug.
Contents
| file | what |
|---|---|
df2676.c |
deterministic KLD trigger (two kernel threads) |
shm_grab_race.c, shm_grab_race2.c |
syscall-level attempts via the only non-RETRY in-tree caller (sysv_shm prealloc) β see VERDICT.md for why they cannot reach the path today |
fix.diff |
one-line fix (goto failed) |
panic.txt |
captured Fatal trap 12, fault VA 0x76, movzbl 0x76,%eax in vm_page_grab |
verdict.json, manifest.json |
machine verdict |
Build & run the KLD trigger (root on the QEMU guest)
mkdir -p /root/df2676_mod && cd /root/df2676_mod
cp df2676.c .
printf 'KMOD= df2676\nSRCS= df2676.c\nSYSDIR= /usr/src/sys\n.include "${SYSDIR}/conf/kmod.mk"\n' > Makefile
make
kldload ./df2676.ko
Expected (vulnerable kernel)
~250 ms after kldload, the serial console shows the grabber/holder markers
and then:
Fatal trap 12: page fault while in kernel mode fault virtual address = 0x76 Stopped at vm_page_grab.cold.30: movzbl 0x76,%eax
Guest dies at the ddb prompt (DoS: kernel panic).
Expected (patched kernel, fix.diff applied)
DF-2676: grabber: grab returned (nil) (no panic?!)
vm_page_grab() returns NULL cleanly; guest stays up.
Threat model / reachability (read this)
The only in-tree caller that omits VM_ALLOC_RETRY is the SysV-shm
pre-allocation loop in shmget_allocate_segment() (sys/kern/sysv_shm.c:582,
flags VM_ALLOC_SYSTEM | VM_ALLOC_NULL_OK | VM_ALLOC_ZERO). Detailed
reachability analysis (VERDICT.md) shows that caller cannot observe a busy
page today β it holds the object token exclusively for the whole loop, the
loop never blocks mid-iteration, and the phys pager marks its pages
PG_UNQUEUED, which excludes the only token-free busier (the
vm_page_hash_get() soft-busy quick-fault path). The bug is therefore
latent for unprivileged attackers on a stock kernel but is a live
NULL-deref primitive for any kernel code (in-tree evolution or third-party
KLD) that calls vm_page_grab() without VM_ALLOC_RETRY on a contended
page β exactly what the KLD demonstrates. Root-triggerable today via kldload.
DF-2676 VERDICT β vm_page_grab() NULL dereference (no-RETRY error path)
Status: reproduced (deterministic kernel-module trigger; unprivileged
reachability currently latent β see below). Impact: kernel panic (DoS),
fixed NULL + 0x76 read, no control over the faulting pointer, no write
primitive, no escalation path.
The bug (source-level, certain)
- :3838
m = vm_page_lookup_busy_try(object, pindex, TRUE, &error);also_m_busy = TRUEmeanserror = TRUEwhen the page exists and hasPBUSY_LOCKEDor any soft-busy count (PBUSY_MASK != 0). - :3840-3844 on error without
VM_ALLOC_RETRY:m = NULL; break; - :3882
if (m->valid == 0)β unconditional dereference; the alloc-failure path correctly doesgoto failed(:3859) but the busy-error path forgot.
Reproduction (guest, 2026-08-30)
KLD df2676.c: kernel thread grabs a busied page's object; grabber thread
calls vm_page_grab(obj, 0, VM_ALLOC_NORMAL) (no RETRY) β error path β
vm_page_sleep_busy() sleeps once β holder thread vm_page_wakeup()s the
page β grabber wakes, sets m = NULL, breaks, executes movzbl 0x76,%eax
(m->valid):
DF-2676: grabber: calling vm_page_grab(obj, 0, VM_ALLOC_NORMAL) - NO VM_ALLOC_RETRY - on busy page DF-2676: holder: waking busy page 0xfffff80049701200 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x76 == offsetof(struct vm_page, valid) Stopped at vm_page_grab.cold.30: movzbl 0x76,%eax
Full capture in panic.txt. Attempts: 1 failed module iteration (token
accounting artifact of doing vm_object_hold in MOD_LOAD β fixed by moving
the work to a kernel thread), then clean reproduction.
Why the syscall-level attempts could NOT reach it (important negative result)
Two userland racers were built (shm_grab_race.c, shm_grab_race2.c;
thousands of shmget-prealloc races against 6-16 shmat+fault processes,
kern.ipc.shm_use_phys=2). No panic via vm_page_grab β and the source
analysis explains why, exhaustively:
shmget_allocate_segment()prealloc loop (sysv_shm.c:576) holdsvm_object_hold()β exclusive β for the entire loop, and no instruction inside the loop can block (vm_page_grabβlookup/alloc never tsleep unless memory-starved; phys_pager_getpage just zeroes; activate/ wakeup/yield do not drop LWKT tokens).- Every userland busier of a shm-object page needs the object token:
the vm_fault slow path holds the object (shared) for the whole fault,
so a fault either completes before the exclusive hold is granted or
blocks until the loop is over β it can never overlap a
grab(). - The one token-free busier β
vm_page_hash_get()in the vm_fault quick path (soft-busy, no object token; vm_page.c:1601) β only serves pages that are fully valid,PQ_ACTIVE,PG_MAPPEDMULTIand already hash-entered. Pages the prealloc loop produces are markedPG_UNQUEUEDbyphys_pager_getpage()(sys/vm/phys_pager.c:96) and are never mapped before the loop ends, so the quick path can never pick them up; and pages faulted by other processes before the exclusive hold begins can only exist for the microseconds between segment publication (sysv_shm.c:553) andvm_object_hold()(sysv_shm.c:576) β far too short for an shmat+fault to land, let alone stay in-flight.
Conclusion: with today's in-tree caller set the error path is unreachable from unprivileged userland; it is a latent API-contract NULL dereference (any future/3rd-party kernel caller without VM_ALLOC_RETRY trips it instantly β demonstrated by the KLD). Class paralleling the known DF-0942 latent-deref finding, but with a real (if protected) in-tree caller and a documented non-RETRY contract.
Fix validation
fix.diff (one line: break β goto failed):
- baseline (stock INVARIANTS kernel): KLD trigger β Fatal trap 12 @ 0x76 (see panic.txt).
- patched kernel (both DF-2676 and DF-2677 fixes applied, rebuilt with
make nativekernel KERNCONF=X86_64_GENERIC): KLD trigger reloaded βDF-2676: grabber: grab returned (nil) (no panic?!), guest stays up. See run_fixed.log.
Severity rationale
Low: kernel NULL read β panic; requires kernel-thread/caller context to reach today (root via kldload, or future kernel code). Fixed-address read at NULL β no exploitation beyond DoS even when reached.
Fix verification
fixedPatched kernel (fix.diff: break->goto failed; built via make -j6 nativekernel KERNCONF=X86_64_GENERIC + installkernel, uname #1 13:38:49) runs the identical KLD trigger to completion: vm_page_grab returns NULL cleanly ('grab returned 0'), no trap, guest stays up. Baseline stock kernel panics at the same trigger.
['run_fixed.log (patched-kernel dmesg/console capture)', 'fix.diff']
Confirmed kernel references
Detail
Exploit chain
KLD trigger (root via kldload, or any future kernel caller): thread A busies a page in an object; thread B calls vm_page_grab(obj, 0, VM_ALLOC_NORMAL) -> vm_page_lookup_busy_try(...,TRUE,...) returns error=TRUE (PBUSY_LOCKED or soft-busy) -> vm_page_sleep_busy sleeps once -> A wakes the page -> B resumes, m=NULL; break -> 'if (m->valid == 0)' reads NULL+0x76 -> Fatal trap 12 -> kernel panic. No user-controllable pointer; read-only primitive.
Evidence (decisive lines)
["panic.txt: Fatal trap 12, fault virtual address 0x76, 'Stopped at vm_page_grab.cold.30: movzbl 0x76,%eax' with the grabber/holder marker lines", 'df2676.c: deterministic KLD trigger (two kthreads)', "run_fixed.log: same trigger on patched kernel -> 'DF-2676: grabber: grab returned 0 (no panic?!)', guest survived", 'VERDICT.md: full reachability analysis why the sysv_shm prealloc caller cannot observe a busy page today']
PoC changes
Finding sketch proposed racing sysv_shm prealloc vs shmat faults; source analysis proved that caller cannot reach the error path (object held exclusively, non-blocking loop, PG_UNQUEUED pages), so the PoC was rewritten as a deterministic in-kernel KLD trigger. Two userland racers kept in the pack as the negative-result evidence. KLD iteration 1 did vm_object_hold in MOD_LOAD (syscall context) causing an unrelated token-accounting panic; fixed by moving all object work into a kernel thread. kthread_create signature fixed to (func,arg,tdp,fmt,...).
Verified recommended fix
vm_page_grab(): on the busy-error path without VM_ALLOC_RETRY, 'goto failed' instead of 'break' so the NULL is returned without dereferencing m->valid.
Verdict
vm_page_grab() dereferences m->valid at sys/vm/vm_page.c:3882 after setting m=NULL on the 'page exists but busy' error path when the caller did not pass VM_ALLOC_RETRY (vm_page.c:3841-3843); the alloc-failure path correctly does 'goto failed' (:3859) but this path forgot. Proven deterministically with a two-kernel-thread KLD: Fatal trap 12 at fault VA 0x76 (offsetof(struct vm_page, valid)), faulting instruction 'movzbl 0x76,%eax' inside vm_page_grab, on the stock INVARIANTS guest kernel. Unprivileged in-tree reachability is currently latent: the only non-RETRY caller (sysv_shm prealloc loop, sysv_shm.c:582) holds the object token exclusively for its whole loop, the loop cannot block mid-iteration, and phys_pager_getpage marks prealloc'd pages PG_UNQUEUED, which excludes the only token-free page busier (the vm_page_hash_get quick-fault soft-busy); two dedicated userland racers (thousands of shmget-prealloc vs shmat+fault races, shm_use_phys=2) could not reach it, matching the source analysis. Any kernel caller (future in-tree code or third-party KLD) using the documented non-RETRY contract trips the NULL deref immediately, as demonstrated. Fixed-address read at NULL - panic/DoS only, no escalation.
No comments yet.