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

allocbuf() maps 17 pages into the 16-page MAXBSIZE per-header KVA slot when (loffset & PAGE_MASK)+size > MAXBSIZE β€” PTE written into the next buffer header's slot, aliasing one page between two live kernel buffers (cross-buffer R/W, info leak, panic)

Field Value
ID DF-2675
Status new
Severity Critical
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
CWE CWE-787 OOB Write (pmap PTE past the slot), CWE-125 OOB Read (aliased page via readdir)
File sys/kern/vfs_bio.c
Lines 638-640 (fixed slots), 3039-3046 + 3211-3216 (qenter), 1885 (dead guard), 2736 (size-only panic); reachability msdosfs_vfsops.c:423-457, msdosfs_vnops.c:513-522/:1368
Area kern
Confidence certain
Discovered 2026-08-29
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

Every buffer header owns a fixed contiguous MAXBSIZE (64KB) KVA slot (bufinit, vfs_bio.c:638-640). allocbuf() computes desiredpages = ((loffset & PAGE_MASK) + roundup2(size, DEV_BSIZE) + PAGE_MASK) >> PAGE_SHIFT and pmap_qenter_noinval()s that many pages at trunc_page(b_data) == b_kvabase (:3044-3046, :3211-3216). Neither guard β€” getblk's size > MAXBSIZE panic (:2736) nor allocbuf's b_kvasize < size panic (:3039) β€” accounts for the page offset, and getnewbuf's size/maxsize parameters (where getblk DOES compute size+pgoff at :2939) are dead code (:1885). Any getblk/bread with (loffset & PAGE_MASK) + size > MAXBSIZE therefore maps 17 pages into the 16-page slot: page 16's PTE is installed at b_kvabase+65536 = the NEXT header's slot base, and teardown (vfs_vmio_release:1854, allocbuf shrink:3077) later removes the neighbor's page-0 PTE.

Reachable from an attacker-supplied filesystem image: msdosfs accepts 64KB clusters (512B sectors Γ— 128 sec/cluster passes the :427 check since 128 is not >128) and with an odd FAT size every data cluster sits at a non-page-aligned device offset, so every directory operation issues bread(devvp, 6144+k*65536, 65536) β€” a plain ls triggers.

Threat model & preconditions

Mounting an attacker-crafted msdosfs image (removable media, downloaded image, restored backup; unprivileged with vfs.usermount=1). Each directory access silently aliases one page of two live kernel buffers: - reads through the 17-page buffer return the neighbor's bytes — readdir leaks kernel-cached file content cross-principal (demonstrated: /boot/kernel/*.ko bytes surfaced as FAT dirents); - writes (dirent names, ~11 attacker-chosen bytes per 32-byte slot, 64 slots per aliased page) land in the neighbor's page — since the buffer cache is merged with the VM cache (B_VMIO), the victim page is a file's VM page, making cached-page corruption of a setuid-root binary the credible unpriv→root route; - recycling the 17-page buffer unmaps the neighbor's page-0, faulting/wedging the kernel; - demonstrated terminal effect: aliased write into root-filesystem hammer2 metadata → flush panic.

Proof of concept

VERIFIED on the stock INVARIANTS kernel (findings/poc/DF-2675/): mkfat64k.py forges FAT12 (bps=512, spc=128, odd FATsecs → xoff=2048; dir entries pre-filled to slot 1983). ls /mnt/D00 → ovldump (kvm /dev/mem PTE walk) shows live buffers with xio_npages=17, b_bufsize=65536, b_data=b_kvabase|0x800. ladder.sh uses usched_set-pinned allocations (header n lives on cpu n%6; overflow always targets header n+1) to place neighbor buffers at the aliased slots: p16watch shows the overflow PTEs mapping /boot/kernel/*.ko content (ELF headers, module text) — 25 simultaneous aliases. Success criteria, all met: (1) ls returns 1986 entries vs crafted 1984, the extras are raw kernel-cache bytes (info leak); (2) touch /mnt/D09/PWNED2675; sync corrupts an in-cache hammer2 page of the root fs → panic: base_insert 2 24,16,32 fail in hammer2_flush_core (panic.txt); (3) umount of the tainted mount reproduces the wedge class. user→root route (not completed): placement control + write primitive demonstrated; exec-path grooming documented in VERDICT.md.

fix.diff (build-validated in-guest, kernel #1): bound the requested KVA by the page offset in getblk (size + (loffset & PAGE_MASK) > MAXBSIZE panic), defense-in-depth in allocbuf (b_kvasize < size + pgoff), and reject the dangerous mount configuration in msdosfs (64KB clusters at non-page-aligned first-cluster offset β†’ EINVAL; page-aligned 64KB clusters still mount and read β€” fix is precise; normal newfs_msdos filesystems unregressed).

References

  • vfs_bio.c:638 KVA slot scheme (DF-2616's verification localized it), DF-2663 (bio_page_alloc family), DF-2618 (the RB overlap panic the corrupted hammer2 flush reproduced)

Timeline

  • 2026-08-29 Discovered during pass-2 audit of vfs_bio.c (GLM 5.3); leak + corruption + panic all reproduced on stock, fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2675 Β· 8 files
FileTypeDescriptionSize
CHAIN_ENG.md β€” 4.6 KB ↓ raw
chain15.c β€” 5.0 KB view raw
chain16.c β€” 5.7 KB view raw
solve10.py β€” 9.2 KB view raw
genplan4.py β€” 5.3 KB view raw
mkchain5.py β€” 2.4 KB view raw
panic_chainwalk.txt β€” 1.4 KB view raw
verdict.json β€” 7.5 KB view raw
CHAIN_ENG.md
↓ download raw

DF-2675 uid=0 chain engineering (complete, offline-verified; runtime-blocked)

The engineered chain (every piece built and verified)

Victim: /lib/libc.so.8 64KB cluster buffer @ loff 0x100000 (hammer2 block 16; np<=16, legal buffer). Window = libc[0x100000..0x100800) mapped at 0x800740000+ (no user ASLR: vm.randomize_mmap=0). Landing = fflush() entry (window +0x4a4 = record 37 offset 4). Trigger = /usr/bin/chpass (setuid -root): its stdio flush at exit executes the stub with euid=0. "/bin/sh\0" lives in chpass rodata at vaddr 0x403c18 (S_lo=0x18 -> the mov-8.3's StartCluster field = 0x18BF = 6335 -> small FAT, no mount-time FAT monster).

Payload layout (all dirent records land at window slots 37..52 through the aliased page): g1@37 parts=10 (name 120): LFN 37..46, 8.3@47 spare g2@48 parts=1 (name 11) : 8.3@49 = MOV edi,0x403c18 [26..30] = BF 18 3C 40 00 (cluster 0x18BF, size 0x403C) [31] = 00 bridge: 00 42 01 (add [rdx+1],al, rdx=rsp) g3@50 parts=2 (name 25) : LFN 50..51, 8.3@52: [26,27] = cluster 0x5E54 = '54 5E' (push rsp; pop rsi) [28,29] = size 0x050F = '0F 05' (execve) isatty@win+0x675 (record 51 offset 21 -- corrupted by g3's records) gets a 'push rax; RET' guard (C3 verified encodable) so chpass's stdio setup (isatty via __smakebuf) survives; memset@+0x740 untouched.

Byte-encoding: mount_msdos -L zh_CN.GB18030 (kldload libiconv+msdos_iconv); LFN records hold UCS-2LE chars -> 26 of every 32 bytes attacker-controlled, restricted to the empirically-calibrated kernel xlat16 pair map (5842 + verified pairs; pairmap_gb.json / avail_emp.json; GB18030-to-UCS2 quirk mappings measured in-guest, e.g. U+74EB->U+75EB). The stub was solved as a pair-constrained x86 layout (solve10.py) with EB-rel8 hop chains; the final flow (genplan4.py trace, byte-exact):

1188: 50 (pad) / 31 FF (rdi=0) / EB -> 1294 1294: 54 5A (rdx=rsp) / 57 (push 0: argv[1]) / 6A 59 58 (rax=59=execve) 1300->1381->1474: hop chain / 1477: jmp 1594 1594: BF 18 3C 40 00 (mov edi,&"/bin/sh") / 00 42 01 bridge 1602: 57 (push S: argv[0]) / filler pushes / 1609: EB 4F -> 1690 1690: 54 5E (rsi=rsp=&argv{S,0}) / 1692: 0F 05 execve("/bin/sh", {"/bin/sh",NULL}, NULL) -- euid=0 in chpass -> root shell (sh keeps euid!=uid privileges: bin/sh/options.c:98 privileged mode, drops only via explicit -p clearing)

Image: mkchain5.py (FAT16, 64KB clusters, 400 dirs, forged free-cluster list 6335/24148 for the two data-writing files; 1.59GB sparse).

Grooming rig: chain15.c/chain16.c -- per attempt [junk churn][readdir D_k (17-page O on ocpu)][libc window read (V on vcpu)][oracle readdir]. The oracle: when V sits at O's neighbor slot the aliased readdir returns !=1984 entries.

Where it blocked (the honest terminal)

The write lands in libc's VM page only if V's qenter happens AFTER the 17-page O's qenter (the last qenter for the aliased KVA page wins: vfs_bio.c:3211 pmap_qenter_noinval(b_kvabase,...,17) vs the neighbor's own qenter). hammer2 allocates exactly ONE buffer per (vnode, 64KB block) (vnops.c:938 cluster_readx with lbase = off & ~0xFFFF) and it stays cached until the whole 12634-header pool recycles it -- so a fresh-after-O V for the fixed victim block needs ~nbuf allocations of churn per attempt.

Attempted and observed on the guest (this session): - junk-burn churn (13000x64KB reads/round): the churn recycles the live msdosfs root/FAT/dir buffers too; their re-reads create new 17-page monsters that clobber live neighbors -> 2x msdosfs_readdir teardown panics, 1x hammer2_base_delete flush panic, root-buffer deget corruption to wedge (panic_chainwalk.txt) -- before O/V adjacency statistics favor us. - kern.nbuf=2048 boot tunable (set + rebooted, vfs.nbuf confirmed): pool recycle ~2048 allocations; the constant steal pressure destabilized the guest (wedge) within one 150s run. - 396 dirs x 6 cpu pairings per round, V-first ordering (write hits the device page, oracle fires as count=2048 but the write is useless) -- 3 boots, no adjacency with correct ordering achieved.

Conclusion: the primitive's cross-buffer write requires the victim buffer to be allocated immediately after the 17-page directory buffer. For a hammer2-backed code page (single buffer per block) this forces pool-wide recycling churn between attempts, and the churn itself -- through the same bug -- corrupts live filesystem metadata faster than the adjacency lands. The offline-complete chain above is sound; the runtime placement is the open problem (a many-block victim class, e.g. hammer2 volume/metadata buffers as in the original session's touch+sync demo, places easily but corrupts non-code pages -> panic, not exec).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

FIXED. Baseline (stock #0): crafted msdosfs image mounts and every directory read creates npages=17 buffers overflowing the 16-page KVA slot (ovrdump), with demonstrated cross-buffer aliasing, readdir info leak of /boot/kernel/*.ko bytes, and a hammer2 flush panic after an aliased write. Patched (#1, nativekernel+fix.diff): the crafted mount is rejected with 'msdosfs: 64KB clusters at non-page-aligned device offset 6144' (EINVAL, rc=71) and ovrdump finds ZERO overflow buffers system-wide; the page-ALIGNED 64KB-cluster variant still mounts and reads correctly (1984 entries, 0 overflow buffers) proving the fix is precise, and a normal newfs_msdos filesystem mounts/mkdir/write/umounts unregressed.

findings/poc/DF-2675/fix_run.log (mount rejection + ovrdump counts + aligned-64K + normal-image regression); findings/poc/DF-2675/fix_build.log (kernel build tail, BUILD-DONE); fix.diff applied with patch -p1 (hunks at vfs_bio.c:2733/:3045, msdosfs_vfsops.c:507)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sun Aug 30 12:36:11 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

ENGINEERED (offline-verified, runtime-blocked): crafted FAT16 image (64KB clusters @ xoff 2048; forged free-cluster list 6335/24148) -> root mounts with -L zh_CN.GB18030 (attacker-crafted-media gating, same as the finding) -> unprivileged grooming: per attempt [churn][readdir D_k = 17-page O on cpu o][64KB read of libc@0x100000 = V on cpu o+1][oracle readdir !=1984 entries] -> on adjacency, 3 file creates write the LFN/8.3 records through the aliased page into libc's window page -> unprivileged exec of /usr/bin/chpass -> exit-flush lands on the stub at fflush+0 -> setuid-held euid 0 + execve("/bin/sh") -> uid=0 shell. BLOCKER (path-accurate): requires V.qenter AFTER O.qenter; hammer2's single cached buffer per (vnode,64KB-block) (hammer2_vnops.c:938) means V refresh needs ~vfs.nbuf allocations of churn; the churn itself re-triggers the same bug against live fs metadata (3 panics + wedge observed) before adjacency statistics complete. RUNTIME-DEMONSTRATED this session instead: unpriv grooming -> msdosfs_readdir teardown panics x2, hammer2_base_insert/delete-class flush panic, root-buffer cache corruption (deget) -- on top of the original verified readdir leak + aliased-write + panic.

Evidence (decisive lines)

['pack/CHAIN_ENG.md - complete chain engineering + the honest blocker analysis', "pack/solve10.py - the pair-constrained x86 stub solver (run: 'solve: True'; 344 placed bytes)", 'pack/genplan4.py + pack/chain_plan.json - the byte-exact flow trace (1188 pad/xor edi -> ... -> 1692 execve) and the 3 payload filenames', 'pack/mkchain5.py - the FAT16 image forger (400 dirs, free@6335,24148)', 'pack/chain15.c, pack/chain16.c - the grooming/trigger runners (readdir oracle, cpu pairings, junk churn, chpass exec)', 'pack/pairmap_gb.json, pack/avail_emp.json, pack/bulkcal3.c, pack/pairprobe.sh - the guest-calibrated GB18030 xlat16 pair map + quirk measurements', 'pack/panic_chainwalk.txt - the three panic signatures captured this session from unprivileged grooming', 'original pack: panic.txt, run.log, fix.diff, fix_run.log (leak + write + panic + validated fix)']

PoC changes

exploit-dev session: (1) mapped the whole msdosfs dirent-write byte surface (LFN records = 26/32 attacker bytes; 8.3 StartCluster/FileSize = 4 more with cluster/size forging); (2) discovered mount_msdos -L + kldload msdos_iconv enables UCS-2 LFN control and calibrated the kernel xlat16 pair map in-guest (5842 pairs + GB18030 vendor-quirk divergences e.g. U+74EB->U+75EB, verified-encodable (EB,73) etc.); (3) identified the no-ASLR guest (vm.randomize_mmap=0) and the >32-bit libc base problem, solved via a non-PIE setuid binary's own rodata "/bin/sh" at 0x403c18 encoding into the 8.3 cluster/size fields; (4) picked chpass/fflush (window block 0x100000) as the landing with an isatty RET-guard; (5) built the pair-constrained stub solver with rel8 hop chains, bridge instructions over structural dirent bytes, and cluster/size-encoded instructions (54 5E / 0F 05); (6) grooming runners with readdir oracle, 6 cpu pairings, junk-pool churn and a kern.nbuf=2048 boot tunable.

Verified recommended fix

unchanged (fix.diff, already validated): getblk(): bound size + (loffset & PAGE_MASK) <= MAXBSIZE; allocbuf(): account page offset in the b_kvasize check; msdosfs: reject 64KB clusters at non-page-aligned data offsets at mount

Verdict

uid0-CHAIN ATTEMPT (exploit-dev mode): NOT COMPLETED; finding remains REPRODUCED with Critical impact. The full escalation chain was engineered end-to-end and verified byte-exact offline (see pack/CHAIN_ENG.md + solve10.py + genplan4.py flow trace): victim = the 64KB cluster buffer of /lib/libc.so.8 @0x100000 (window = libc[0x100000..0x100800)); landing = fflush() entry +0x4a4; trigger = setuid-root /usr/bin/chpass whose exit-time stdio flush executes the stub with euid=0; payload = three crafted LFN filenames (mount_msdos -L zh_CN.GB18030; 26/32 bytes per dirent record attacker-controlled, restricted to the guest-calibrated xlat16 pair map incl. measured GB18030 quirk mappings) whose records, landed through the aliased page, form: xor edi,edi; rdx=rsp; push 0 (argv[1]); rax=59; hop-chain; mov edi,&"/bin/sh" (chpass rodata 0x403c18, encoded via the 8.3 StartCluster=0x18BF and FileSize=0x403C fields); bridge add [rsp+1],al; push S; jmp; push rsp/pop rsi (8.3 cluster 0x5E54); execve syscall (8.3 size 0x050F) -> root shell. A C3 'RET' guard preserves the corrupted isatty. The chain blocked at RUNTIME PLACEMENT: the aliased write reaches libc's VM page only when the victim buffer is qentered AFTER the 17-page directory buffer (last pmap_qenter wins, vfs_bio.c:3211 vs the neighbor's own qenter), but hammer2 allocates exactly one cached buffer per (vnode, 64KB block) (hammer2_vnops.c:933-942, lbase = off & ~0xFFFF), so a fresh-after-O victim for the fixed code block needs ~nbuf allocations of pool-churn per attempt. Demonstrated on the guest while grooming (3 boots): the churn recycles live msdosfs root/FAT/dir buffers whose re-reads create new 17-page monsters -- 2x panic at msdosfs_readdir+0x477 (teardown fault on the aliased KVA), 1x panic in hammer2_base_delete->hammer2_flush_core (a 17th PTE overwrote a live metadata buffer's page-0; hammer2 consumed foreign bytes as chain metadata), and root-buffer deget cache corruption wedging the box; kern.nbuf=2048 (set via loader.conf, vfs.nbuf confirmed) made recycling cheap but destabilized the guest within one run. Net: unprivileged grooming alone reproducibly corrupts live kernel filesystem metadata to panic; the uid=0 code-page route is engineering-complete but the O-then-V adjacency could not be stabilized on this single-tenant guest before the bug's own side effects took the box down.