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

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.

--- 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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2676 Β· 12 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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)

sys/vm/vm_page.c:

  • :3838 m = vm_page_lookup_busy_try(object, pindex, TRUE, &error); also_m_busy = TRUE means error = TRUE when the page exists and has PBUSY_LOCKED or 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 does goto 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:

  1. shmget_allocate_segment() prealloc loop (sysv_shm.c:576) holds vm_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).
  2. 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().
  3. 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_MAPPEDMULTI and already hash-entered. Pages the prealloc loop produces are marked PG_UNQUEUED by phys_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) and vm_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

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched 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']
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Aug 30 13:38:49 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.