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)
PoC verification
Evidence pack
findings/poc/DF-5801 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_bitwith first_bit < 0,2 << last_bitwith last_bit >= 30 signed overflow), - masks with bits >= 8 that, when OR'd into the
uint8_t m->valid/m->dirtyby 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.cKLD rig: calls the real kernelvm_page_bits()/vm_page_set_valid()with out-of-page argumentsMakefilekmod makefilebuild.shserves sources to the guest (QEMU slirp host=10.0.2.2) and builds the KLDrun.shloads the KLD (expect: panic on INVARIANTS kernels)bitsarith.cuserland twin of the release-build expression, documents the masks a non-INVARIANTS kernel computesfix.diffunconditional 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.
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 isKASSERT(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
-
KLD rig on the live audit guest (
pqb_bits_rig.c): calls the real exportedvm_page_bits()with (0, PAGE_SIZE*2). Result: kernel panicvm_page_bits: illegal base/size 0/8192with the rig's exact arguments in the message andrig_loaderin the backtrace β i.e. the input reached the function with no validation anywhere upstream. Reproduced on two separate boots (one after a fullvm.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 56options INVARIANTS), so the assert fired there. -
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) -
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 bytoff < 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
- sys/vm/vm_page.c:3905-3923 (vm_page_bits)
- sys/vm/vm_page.c:3940-4003 (_vm_page_zero_valid / vm_page_set_valid)
- sys/vm/vm_page.c:4017-4030, 4037-4047, 4059-4067, 4101-4110 (setters)
- sys/sys/systm.h:90-119 (KASSERT/KKASSERT INVARIANTS-only)
- sys/vm/vm_page.h:197-198 (uint8_t valid / dirty)
- kern/vfs_bio.c:4100 (int-sized mask consumer)
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
fixedSingle-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)
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<
No comments yet.