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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2675 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
fixedFIXED. 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)
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
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.
No comments yet.