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

sizeof(struct fnode)=520 > FNODESIZE=512: unconditional 8-byte out-of-bounds write past the buffer in hpfs_update, mirror OOB read in hpfs_vget β€” cross-file page-cache corruption with attacker-crafted content, or deterministic kernel page-fault panic

Summary

struct fnode is 520 bytes on amd64 (u_int64_t fn_readhist forces 8-byte alignment over a 516-byte field tail; fn_int[0x13c] at offset 200 ends at 516, padded to 520) while FNODESIZE=512. hpfs_update getblk's a 512-byte buffer and bcopy's sizeof(struct fnode)=520 bytes into it - an 8-byte heap/buffer-cache OOB write on every fnode writeback (any truncate/extend + vnode reclaim; trigger an unpriv ftruncate/pwrite). hpfs_vget mirror-reads 520 bytes out of the 512-byte buffer on every inode load, filling h_fn[512..519] with the neighboring sector's cached bytes - the exact content the write side later sprays. Because all DFly vnode buffers are VMIO with b_data at loffset&PAGE_MASK: LSN%8 in 0..6 -> the 8 OOB bytes land on the NEXT DISK SECTOR's cached bytes (cross-file data corruption; reaches disk when that page is dirty); LSN%8==7 -> the OOB access hits the next unmapped page of the buffer's MAXBSIZE KVA reservation -> Fatal trap 12 (deterministic panic from unpriv stat(2), works on RO mounts). VERIFIED corr variant: file A fnode LSN 0x48, file B's data sector 0x49 (same VMIO page); uid 1001 pwrite B 'YYYYYYYY' + ftruncate(A,0) + reclaim -> kernel-observed B tail8 changed to 'XXXXXXXX' (attacker content). VERIFIED panic variant: fnode at LSN 0x4F -> stat as uid 1001 -> Fatal trap 12 supervisor read at page+0x1000, guest dead. Fix validated (fn_int[0x13c]->[0x138], sizeof==512, rebuilt kernel): upd pre==post, B intact, stat clean.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3141 Β· 10 files
FileTypeDescriptionSize
craft_img.py β€” 6.6 KB view raw
poc.c β€” 2.8 KB view raw
poc_panic.c β€” 800 B view raw
mount_rw.c β€” 1.3 KB view raw
diag_instrument.diff β€” 2.0 KB view raw
panic.txt β€” 2.3 KB view raw
run.log β€” 2.1 KB view raw
fix.diff β€” 334 B view raw
env.txt β€” 150 B view raw
build.log β€” 544 B view raw

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline: corr run shows the 8 bytes at b_data+512 changing to attacker content; panic variant kills the guest. Patched (fn_int[0x138], sizeof=512): identical corr run leaves b_data+512 untouched (upd pre==post), B intact in cache and on disk, umount clean; panic variant 'stat returned, size=512 (no panic)', guest stays up. Bad behavior gone.

run.log (fix section); dfchk lines quoted in VERDICT.md #3
↓ fix.diffDragonFly 6.5-DEVELOPMENT #2 (quickkernel with fn_int fix, Sep 7 02:57 build)

Confirmed kernel references

Detail

Exploit chain

unprivileged ftruncate/extend on a mounted HPFS -> H_CHANGE -> vnode reclaim (vnlru/namecache eviction, or umount) -> hpfs_inactive -> hpfs_update -> bcopy(h_fn, b_data, 520) into 512-byte buffer -> 8 bytes land on the cached bytes of the NEXT disk sector (same VMIO page; content = bytes captured by vget's OOB read at inode-load time = image/neighbor-sector controlled) -> cross-file data corruption (page-cache; reaches disk when the neighbor page is dirty). Geometric variant: fnode LSN==7 mod 8 -> OOB access past the buffer's last mapped page -> deterministic kernel panic (unprivileged DoS). Not pushed to uid=0: OOB target is fixed at next-sector-in-page (file data), not an arbitrary kernel object.

Evidence (decisive lines)

['run.log: DFCHK upd pre tail8=5959595959595959 / upd post tail8=5858585858585858 hfn_tail=5858585858585858', 'panic.txt: Fatal trap 12, fault VA 0xfffff800563b7000 (=page+0x1000), supervisor read, Stopped at memmove+0x95 (the vget bcopy)', "run.log fix section: sizeof(fnode)=512, upd pre==post 5959..., on-disk 0x49 YYYYYYYY, 'stat returned, size=512 (no panic)'"]

PoC changes

poc v1 relied on close() to trigger hpfs_update, but the namecache keeps a vnode reference, so inactive/update is deferred to reclaim (umount or eviction); v3 reads B's crafted sector first so vget captures attacker bytes into h_fn[512..519]. Panic-image dirent reclen fixed to 0x34 (0x30 truncated the 4-char name and tripped the KNOWN DF-0864 toupper NULL-deref instead).

Verified recommended fix

Shrink struct fnode's fn_int from 0x13c to 0x138 so sizeof(struct fnode) == FNODESIZE == 512 (single-line hpfs.h change; validated on the guest)

Verdict

Reproduced on the QEMU guest from unprivileged uid-1001 triggers. sizeof(struct fnode)=520 vs FNODESIZE=512 confirmed in the live kernel. Write side: hpfs_update's bcopy observed overwriting the 8 bytes at b_data+512 (the neighbor sector's cached data) with attacker-crafted content (upd pre 5959595959595959 -> post 5858585858585858 == hfn_tail). Read side: fnode at LSN%8==7 puts the buffer at page+0xE00 and the 520-byte read faults at page+0x1000 -> Fatal trap 12, guest dead. Fix (fn_int 0x13c->0x138) validated on a rebuilt kernel: sizeof=512, upd pre==post, B's data intact, panic variant returns cleanly.