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

vm_page_bits() and the valid/dirty setter family have no bounds validation in release kernels: KASSERT-only guard, shift UB, and silent uint8_t over-validation of m->valid/m->dirty

Summary

vm_page_bits converts an in-page (base,size) range to the DEV_BSIZE mask; its sole guard is a KASSERT that systm.h compiles to nothing in release kernels - so out-of-range args reach (2<<last_bit)-(1<<first_bit) unvalidated: (0,8192) yields 0xFFFF which the setters OR into the uint8_t m->valid silently marking the whole page valid (stale-fragment/info-leak direction); (4096,1) yields phantom bit 8 corrupting int-sized consumers (vfs_bio.c:4100); negative/oversized args are C shift UB. Guest-proven: KLD rig calling the real function with (0,8192) panicked the INVARIANTS kernel twice with the rig args echoed in the panic (zero upstream validation); release semantics documented with the guest cc 8.3 -O2. Full in-tree caller sweep found every call site bounded - hardening, not a demonstrated leak. Clamp-before-assert fix validated by single-fix kernel A/B.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5801 Β· 19 files
FileTypeDescriptionSize
pqb_bits_rig.c β€” 3.2 KB view raw
Makefile β€” 112 B ↓ download
build.sh β€” 116 B view raw
run.sh β€” 128 B view raw
build.log β€” 5.8 KB view raw
run.log β€” 0 B ↓ download
panic.txt β€” 1.9 KB view raw
panic_console.log β€” 5.0 KB view raw
bitsarith.c β€” 1.1 KB view raw
bitsarith.log β€” 318 B view raw
fix.diff β€” 1.0 KB view raw
fix_build.sh β€” 747 B view raw
fix_run.sh β€” 594 B view raw
fix_build.log β€” 770 B view raw
fix_run.log β€” 706 B view raw
env.txt β€” 475 B view raw
VERDICT.md β€” 5.0 KB ↓ raw
README.md β€” 3.1 KB ↓ raw
verdict.json β€” 4.3 KB view raw

DF-5801 β€” vm_page_bits() / vm_page valid-dirty setter family: no bounds validation in release kernels

What

vm_page_bits() (sys/vm/vm_page.c:3905-3923) converts a (base, size) byte range within a page into the DEV_BSIZE valid/dirty bit mask. Its only bounds guard is KASSERT(base + size <= PAGE_SIZE, ...) β€” and KASSERT is compiled to a no-op unless options INVARIANTS is set (sys/sys/systm.h:116-119). Every production/release DragonFly kernel ships without INVARIANTS, so in deployed systems the function computes (2 << last_bit) - (1 << first_bit) on whatever the caller passes, with:

  • shift UB for negative or oversized arguments (1 << first_bit with first_bit < 0, 2 << last_bit with last_bit >= 30 signed overflow),
  • masks with bits >= 8 that, when OR'd into the uint8_t m->valid / m->dirty by the setter family (vm_page_set_valid, vm_page_set_validclean, vm_page_set_validdirty, vm_page_clear_dirty, vm_page_set_invalid), silently over-mark the whole page valid β€” the stale-kernel-data / info-leak direction, and
  • phantom bit-8+ values that corrupt int-sized consumers such as the m->dirty & vm_page_bits(...) test in kern/vfs_bio.c:4100.

Pass-3 caller sweep (2026-10): all current in-tree callers are bounded (uipc_syscalls.c:1774-1807 sendfile, kern/vfs_helper.c:346-354, vm/vm_fault.c:3328-3330, kern/vfs_bio.c:377/2517/4149, vfs/devfs/devfs_vnops.c:2206-2216, vfs/smbfs/smbfs_io.c:494) β€” so this is a hardening finding (Info), not a demonstrated leak. The KLD rig proves the missing guard is real, not theoretical.

Files

  • pqb_bits_rig.c KLD rig: calls the real kernel vm_page_bits() / vm_page_set_valid() with out-of-page arguments
  • Makefile kmod makefile
  • build.sh serves sources to the guest (QEMU slirp host=10.0.2.2) and builds the KLD
  • run.sh loads the KLD (expect: panic on INVARIANTS kernels)
  • bitsarith.c userland twin of the release-build expression, documents the masks a non-INVARIANTS kernel computes
  • fix.diff unconditional clamp in vm_page_bits (git-apply clean)

Reproduce

sh build.sh          # build KLD on guest
sh run.sh            # kldload -> INVARIANTS kernel panics with
                     #   'vm_page_bits: illegal base/size 0/8192'
                     #   (see panic.txt; reproduced on 2 boots)
cc -O2 -o bitsarith bitsarith.c && ./bitsarith   # release semantics

Fix validation (single-fix kernel):

sh fix_build.sh      # apply fix.diff in guest /usr/src, nativekernel,
                     # installkernel, then reboot the guest
sh fix_run.sh        # rebuild rig, kldload -> NO panic, clamped masks

Expected

  • Baseline (INVARIANTS guest): panic, args echoed β€” proving out-of-range input reaches the function with zero upstream validation.
  • Release kernels: silent masks (0xFFFF / 0x1FF / 0x100) per bitsarith.
  • Patched kernel: rig loads cleanly; vm_page_bits(0,8192)->0xff, vm_page_bits(4096,1)->0x0, UB cases clamped; setter marks only page bits.
VERDICT.md
↓ download raw

DF-5801 VERDICT β€” vm_page_bits() bounds trust in release kernels

Status: reproduced (hardening demonstration; no security impact demonstrated β€” Info) Impact: none (all in-tree callers currently bounded) Confidence: certain (code-level claim proven on source and on the guest)

What was tested

Pass-3 re-audit of sys/vm/vm_page.c (4244 LOC) flagged the valid/dirty bit-mask core as the one remaining unvalidated arithmetic family:

  • vm_page_bits() sys/vm/vm_page.c:3905-3923 β€” guard is KASSERT(base + size <= PAGE_SIZE), compiled out in release builds (sys/sys/systm.h:116-119 #else #define KASSERT(exp,msg) do { } while (0)).
  • Consumers: vm_page_set_valid (:3999), vm_page_set_validclean (:4018), vm_page_set_validdirty (:4038), vm_page_clear_dirty (:4060), vm_page_set_invalid (:4102, itself separately DF-0942 for the NULL object), vm_page_is_valid (:4169), and int-sized mask compares in kern/vfs_bio.c:4100.

How it was verified

  1. KLD rig on the live audit guest (pqb_bits_rig.c): calls the real exported vm_page_bits() with (0, PAGE_SIZE*2). Result: kernel panic vm_page_bits: illegal base/size 0/8192 with the rig's exact arguments in the message and rig_loader in the backtrace β€” i.e. the input reached the function with no validation anywhere upstream. Reproduced on two separate boots (one after a full vm.sh reset with-src). panic.txt holds both traces. The guest kernel is an INVARIANTS build (guest config /usr/src/sys/config/X86_64_GENERIC line 56 options INVARIANTS), so the assert fired there.

  2. Release semantics (bitsarith.c, guest cc 8.3 -O2 β€” same compiler family as the kernel): with the assert compiled out, the identical expression yields - bits(0,8192) = 0xFFFF (OR into uint8_t valid -> whole page "valid": over-validation / stale-data direction) - bits(0,4608) = 0x1FF (partial over-validation) - bits(4096,1) = 0x100 (phantom bit 8, visible to int consumers like vfs_bio.c:4100) - bits(-512,1024) = UB (x86: 0x2 here; standard C: undefined) - bits(0,1<<30) = UB (x86: 0xFFFFFFFF via signed overflow)

  3. Caller sweep (negative result with weight): every in-tree caller of the setter family and vm_page_bits was traced and found to bound base+size <= PAGE_SIZE by construction: - kern/uipc_syscalls.c:1771-1807 (sendfile): xfsize = min(PAGE_SIZE - pgoff, nbytes - xbytes), and if (xfsize <= 0) break. - kern/vfs_helper.c:346-354: constant (0, PAGE_SIZE). - vm/vm_fault.c:3328-3330: constant (0, PAGE_SIZE). - kern/vfs_bio.c:377 (vfs_buf_test_cache): fragment-sized. - kern/vfs_bio.c:2510-2517: tinc clamped to PAGE_SIZE - (off & PAGE_MASK). - kern/vfs_bio.c:4100/4149 (vfs_clean_one_page): soff/eoff walk the buffer page-by-page. - vfs/devfs/devfs_vnops.c:2206-2216: guarded by toff < nread <= bcount. - vfs/smbfs/smbfs_io.c:494: nvalid from bounded getpages geometry. Hence Info severity: the primitive exists, no current caller trips it.

Why not higher severity

The over-validation direction is an information-disclosure primitive (mark never-populated fragments valid so userland can read stale page contents), but triggering it requires a caller that passes an out-of-page range; the sweep above found none. No user-reachable miscalculation exists today, so the finding is filed as hardening.

Exploit chain

(none β€” no in-tree attacker-reachable path)

Fix

fix.diff clamps base/size to the page unconditionally before the (existing) KASSERT: kills the shift UB, kills phantom bits >= 8, and makes the mask semantically page-bounded for every consumer.

Fix validation performed on the guest (see fix_run.log / VERDICT section in manifest): single-fix kernel built with make nativekernel KERNCONF=X86_64_GENERIC after applying fix.diff in the guest /usr/src; rig reloaded: no panic, vm_page_bits(0,8192) now returns 0xFF (clamped full-page), vm_page_bits(4096,1) returns 0x0, UB inputs clamped to sane in-page masks, guest stays up (kldstat shows the rig loaded). Baseline (same rig, unpatched kernel) panicked twice.

Kernel references

Guest

DragonFly dfbsd 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026 (INVARIANTS kernel config); guest reset to clean-source snapshot after verification.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Single-fix kernel (fix.diff applied to guest /usr/src, make nativekernel KERNCONF=X86_64_GENERIC, installkernel, reboot into #1 Mon Oct 12 22:33:20 UTC 2026): the baseline rig - which panicked the unpatched INVARIANTS kernel twice with 'vm_page_bits: illegal base/size 0/8192' - now loads cleanly (kldstat shows pqb_rig.ko), every RIG line prints, masks are clamped to the page (0/8192->0xff, 4096/1->0x0, -512/1024->0x3, no UB), legal in-page behavior unchanged (0/4096->0xff), and the guest remains up. fix_patched_reproduced=false reflects that the bad behavior (panic/silent OOB masks) is GONE on the patched kernel, which is the success criterion.

fix_build.log (patch check + nativekernel NK-OK + installkernel), fix_run.log (patched uname, LOADED-OK, kldstat, clamped RIG output), fix.diff (sha256 be64ae3e7b973623494fa99688014414084a262e697147e652aadf6fd3a3a604), panic.txt (baseline A/B)
↓ fix.diffper-fix-DF-5801

Confirmed kernel references

Detail

Evidence (decisive lines)

panic.txt (two identical INVARIANTS panics, args echoed); bitsarith.log (release-build masks on guest cc 8.3 -O2); fix_run.log (patched kernel #1 Mon Oct 12 22:33:20 UTC 2026: LOADED-OK, kldstat pqb_rig.ko, all RIG lines with clamped values); fix.diff (git-apply clean); pqb_bits_rig.c (rig source, sha256 b9a6e71e94a376110d6f9dd6e28ec3bd9bd0d27469a01dc66bac16bdb7632236)

PoC changes

Rig authored fresh for pass 3 (no seed). Had to route source transfer via guest HTTP fetch (10.0.2.2) because the guest lacks base64(1) and long ssh one-liners truncate; fix-build switched from buildkernel (blocked: 'must buildworld first') to make nativekernel KERNCONF=X86_64_GENERIC.

Verified recommended fix

Clamp base/size unconditionally to the page in vm_page_bits() before the KASSERT (see fix.diff).

Verdict

vm_page_bits() (sys/vm/vm_page.c:3905-3923) validates its (base,size) arguments only with a KASSERT, which is compiled out in release kernels (sys/sys/systm.h:116-119); out-of-page arguments reach the function completely unvalidated (proved on the guest: KLD rig arguments 0/8192 appear verbatim in the INVARIANTS panic message with rig_loader in the backtrace, reproduced on two boots). In a release build the same input silently computes (2<<last_bit)-(1<valid/m->dirty setter family, where out-of-page ranges silently over-mark whole pages valid (stale-data direction) and confuse int-sized consumers (kern/vfs_bio.c:4100). A pass-3 sweep of every in-tree caller found all currently bounded, so no security impact is demonstrated - filed as Info hardening. fix.diff (unconditional clamp before the KASSERT) was validated on the guest with a single-fix nativekernel build: baseline rig panicked twice; patched kernel loads the rig with no panic and clamped masks (0/8192->0xff, 4096/1->0x0, -512/1024->0x3), guest stays up.