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

FFS allocator uses unvalidated on-disk cylinder-group header fields as kernel-memory offsets/indexes β€” crafted image gives attacker-offset OOB read/write on first allocation

Field Value
ID DF-3035
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:L/I:H/A:H
CWE CWE-787 / CWE-125 / CWE-20
File sys/vfs/ufs/ffs_alloc.c
Lines 1010, 1128-1218, 1294, 1370, 1383-1463, 1532-1609, 1836, 1893-1963
Area vfs/ufs
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

Every cg consumer in ffs_alloc.c reads the on-disk cg block with bread(), applies only cg_chkmagic() (a magic comparison), then uses raw on-disk fields as pointer offsets and array indexes into kernel memory: cg_cgx β†’ fs->fs_csp[16Β·cg_cgx].cs_nbfreeβˆ’βˆ’ and fs->fs_maxcluster[4Β·cg_cgx] stores (signed 32-bit index β‡’ Β±8/Β±32 GiB around heap objects); cg_freeoff/cg_iusedoff/cg_boff/cg_btotoff/ cg_clustersumoff/cg_clusteroff β†’ bit/int16/int32 read-modify-write through cgbuf+offset (Β±2 GiB); cg_rotor/cg_frotor/cg_irotor/ cg_nclusterblks β†’ OOB scan bounds; plus inode-content-derived bpref β†’ dtog() β†’ fs_csp[] OOB read before bread. Mount validates none of it (ffs_vfsops checks only fs_magic/fs_bsize). Distinct validation surface from DF-0820 (superblock) and DF-0794 (fs_ncg). Crafted FFS media mounted by root (DF-0820/DF-3015 model) with a single creat/write as the poisoned first allocation: arbitrary-offset relative kernel-memory corruption (silent int32 inc/dec/store and bit writes when the offset lands on mapped pages, fatal trap when it doesn't) and reliable local DoS; on vfs.usermount=1 or auto-mounted-media setups the corruption itself is unprivileged. VERIFIED (stock INVARIANTS, DF-3015 harness): cg_cgx=0x40000000 β†’ deterministic Fatal trap 12 supervisor WRITE at ffs_clusteracct (the fs_maxcluster[cg_cgx] store at base+4Β·index, 3 boots); cg_iusedoff=0x20000000 β†’ Fatal trap 12 supervisor READ at ffs_nodealloccg (inosused[ipref] read at cgbuf+512MiB); control image allocates cleanly. userβ†’root: N/A on default configs (mount requires root); primitive characterized as attacker-offset Β±8..32GiB relative write + bit-level writes β€” escalation chain only meaningful under usermount, where it is direct kernel R/W. Fix validated in-guest (ffs_chkcg validation at every cg consumer + bprefβ†’cg clamp): both poisoned images now fail gracefully ENOSPC, control image still allocates cleanly.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of ffs_alloc.c (GLM 5.3); deterministic crafted-image kernel R/W faults reproduced + fix validated. DF-0793/0794 re-verified, not re-reported.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3035 Β· 18 files
FileTypeDescriptionSize
README.md β€” 5.3 KB ↓ raw
VERDICT.md β€” 6.4 KB ↓ raw
cgxtool.c β€” 3.9 KB view raw
setup3035.sh β€” 1.3 KB view raw
fs.h β€” 24.2 KB view raw
dinode.h β€” 5.6 KB view raw
fix.diff β€” 4.2 KB view raw
build.log β€” 1.9 KB view raw
run.log β€” 4.0 KB view raw
run.setup.log β€” 1.1 KB view raw
run.patched.log β€” 878 B view raw
panic1.txt β€” 687 B view raw
panic2.txt β€” 686 B view raw
panic3.txt β€” 686 B view raw
panic4.txt β€” 677 B view raw
env.txt β€” 709 B view raw
manifest.json β€” 2.0 KB view raw
verdict.json β€” 8.7 KB view raw

DF-3035 β€” FFS allocator trusts unvalidated on-disk cylinder-group headers

(sys/vfs/ufs/ffs_alloc.c) β†’ attacker-offset OOB kernel read/write on a crafted filesystem image

What

Every allocator in sys/vfs/ufs/ffs_alloc.c reads a cylinder-group block with bread(), applies only cg_chkmagic() β€” a magic comparison (sys/vfs/ufs/fs.h:398) β€” and then uses raw on-disk cg-header fields as byte offsets into the cg buffer and array indexes into kernel memory:

on-disk field sink in ffs_alloc.c primitive
cg_cgx fs->fs_cs(fs, cgp->cg_cgx).cs_nbfree-- β€” ffs_alloccgblk:1213; fs->fs_maxcluster[cgp->cg_cgx] = i β€” ffs_clusteracct:1963; blkno = cg_cgx * fs_fpg + bno β€” :1218 OOB write (int32 inc/dec/store) at fs_csp + 16Β·cg_cgx + 4 / fs_maxcluster + 4Β·cg_cgx β€” signed 32-bit index β‡’ Β±8/Β±32 GiB around the heap objects
cg_freeoff cg_blksfree(cgp) base for ffs_clrblock/setbit/ffs_isblock/scanc β€” ffs_alloccgblk:1208-1209, ffs_alloccg:1081-1082, ffs_mapsearch:1837-1847, ffs_blkfree_cg:1532-1609 OOB read+write of single bits at cgbuf + cg_freeoff + bit/8 (32-bit offset β‡’ Β±2 GiB)
cg_iusedoff cg_inosused(cgp) base for the inode-bitmap scan and setbit β€” ffs_nodealloccg:1383-1463, ffs_freefile:1791-1799 OOB read+write (movzbl scan, then setbit)
cg_boff / cg_btotoff cg_blks(fs,cgp,cylno)[...]--, cg_blktot(cgp)[cylno]-- β€” ffs_alloccgblk:1215-1216, ffs_blkfree_cg:1550-1551 OOB int16/int32 write
cg_clustersumoff / cg_clusteroff cg_clustersum(cgp)[len] rw-loop, sump[i] += cnt β€” ffs_clusteralloc:1257-1275, ffs_clusteracct:1893-1955 OOB int32 read/write
cg_rotor / cg_frotor / cg_irotor scan start offsets β€” ffs_alloccgblk:1129, ffs_mapsearch:1836, ffs_nodealloccg:1410 OOB read (scanc length underflow)
cg_nclusterblks loop bound reading *mapp++ β€” ffs_clusteralloc:1294-1308, ffs_clusteracct:1907 OOB read
inode i_db[] β†’ ffs_blkpref β†’ cg = dtog(bpref) fs->fs_cs(fs, cg) guard before bread β€” ffs_alloccg:1010, ffs_nodealloccg:1370 OOB read at fs_csp + 16Β·cg

The mount path never validates a single one of these fields (ffs_mountfs checks only fs_magic/fs_bsize, ffs_vfsops.c:642-646), and nothing re-checks them when the cg block is read at allocation time. Nothing checks them at free time either (ffs_blkfree_cg, ffs_freefile).

Additionally, inconsistent-but-in-range cg metadata (per-cg cg_cs vs fs_cstotal, bitmap vs counters) converts the kernel's own consistency panic()s into fail-stop DoS from a crafted image: ffs_valloc:630 (dup alloc), ffs_nodealloccg:1451 (block not in map), ffs_blkfree_cg:1542/1581 (freeing free block/frag), ffs_clusteralloc:1317 (map mismatch).

Threat model

Crafted FFS media mounted by root (same model as DF-0820 / DF-3015). The unprivileged trigger afterwards is a single creat/write in the mounted filesystem β€” the very first allocation touches the poisoned cg. On systems with vfs.usermount=1 (or in any scenario where an unprivileged user can get a crafted image mounted β€” e.g. removable media auto-mounting), this becomes unprivileged-user β†’ arbitrary-offset kernel memory corruption.

Primitive (verified on the guest, stock INVARIANTS kernel)

  • cg_cgx = 0x40000000 β†’ deterministic Fatal trap 12, supervisor write, page not present, in ffs_clusteracct: Stopped at ffs_clusteracct.isra.0+0x1eb: movl %eax,(%rdx,%rcx,4) β€” the store fs->fs_maxcluster[cg_cgx] = i at base + 4Β·cg_cgx (panic1/2/3.txt). The same value three times β†’ three fault addresses base + 0x1_0000_0000, with boot-varying heap bases.
  • cg_iusedoff = 0x20000000 β†’ deterministic Fatal trap 12, supervisor read, in ffs_nodealloccg: Stopped at ffs_nodealloccg+0x13f: movzbl (%rdi,%rax,1),%ecx β€” the inosused[ipref] bitmap read at cgbuf + 0x20000000 (panic4.txt).
  • Control run (identical image, no field patched) β†’ allocation succeeds, no panic (run.setup.log, run.log).

A cg_cgx that lands on mapped memory instead of the guard page yields a silent single-int32 inc/dec at an attacker-chosen Β±offset β€” a corruption primitive, not a crash.

Reproduce (baseline, stock kernel)

# on the host, from the repo root:
tar czf /tmp/p.tgz -C findings/poc/DF-3035 cgxtool.c setup3035.sh fs.h dinode.h
ssh -F dfbsd-qemu/config dfbsd '/bin/sh -c "mkdir -p /root/poc && cat > /tmp/p.tgz && tar xzf /tmp/p.tgz -C /root/poc && cc -O -o /tmp/cgxtool /root/poc/cgxtool.c"'

# in the guest (root):
sh /root/poc/setup3035.sh cgx 0x40000000      # panics in ffs_clusteracct (write)
sh /root/poc/setup3035.sh iusedoff 0x20000000 # panics in ffs_nodealloccg (read)
sh /root/poc/setup3035.sh cgx none            # control: no panic (see script)

# panic evidence: dfbsd-qemu/vm.sh log  (serial console)

Expected baseline: Fatal trap 12: page fault while in kernel mode, fault code supervisor write data, page not present (cgx) or supervisor read data, page not present (iusedoff), guest drops to db>.

Expected with fix.diff (validated, run.patched.log): no panic; dd: /mnt/t0: No space left on device β€” the poisoned cg is rejected (ffs_chkcg) and the allocator fails gracefully with ENOSPC; a clean filesystem still allocates normally.

VERDICT.md
↓ download raw

DF-3035 β€” VERDICT

Status: reproduced (impact: kernel memory corruption, attacker-offset OOB read + write; panic used as the observable). Fix validated: fixed.

What was tested

sys/vfs/ufs/ffs_alloc.c β€” every allocation/free path in the FFS allocator reads the on-disk cylinder-group header and uses its fields (cg_cgx, cg_freeoff, cg_iusedoff, cg_boff, cg_btotoff, cg_clustersumoff, cg_clusteroff, cg_rotor, cg_frotor, cg_irotor, cg_nclusterblks) as raw kernel-memory offsets/indexes after nothing more than cg_chkmagic() (fs.h:398 β€” a magic comparison). Mount-time validation does not exist for these fields (ffs_vfsops.c:642-646 checks only fs_magic and fs_bsize bounds).

Baseline runs (stock INVARIANTS kernel, guest

DragonFly dfbsd 6.5-DEVELOPMENT ... #1: Mon Jun 29 14:18:01 UTC 2026)

All runs use a 128 MB image built by the guest's own newfs (UFS1, 1 cylinder group, bsize 16384 / fsize 2048 / frag 8, contigsumsize 7), with one 32-bit field of cg0's on-disk header patched by cgxtool (offsets computed from the image's own superblock). The mount itself never reads cg headers, so the crafted image mounts read-write without complaint; the first allocation on it executes the poisoned path.

run patched field value result
0 (control) β€” β€” first creat+16 KiB write succeeds, clean unmount (run.log)
1 cg_cgx 0x40000000 Fatal trap 12, supervisor write, page not present, ffs_clusteracct.isra.0+0x1eb: movl %eax,(%rdx,%rcx,4) @ 0xfffff8021776a200 (panic1.txt)
2 cg_cgx 0x40040000 same trap + same stopped instruction @ 0xfffff8018dce3600 (panic2.txt)
3 cg_cgx 0x40000000 same trap + same stopped instruction @ 0xfffff80218519500 (panic3.txt)
4 cg_iusedoff 0x20000000 Fatal trap 12, supervisor read, page not present, ffs_nodealloccg+0x13f: movzbl (%rdi,%rax,1),%ecx @ 0xfffff8007c136000 (panic4.txt)

Why this is a write/read primitive, not "just a panic"

  • The stopped instruction in runs 1–3 is the single indexed store fs->fs_maxcluster[cgp->cg_cgx] = i (ffs_alloc.c:1963): movl %eax,(%rdx,%rcx,4) β€” address = base + 4 Γ— (int32 index). The index is the on-disk cg_cgx (here 0x40000000 β†’ +4 GiB). The fault is only observed because the chosen offset lands on an unmapped page; any cg_cgx whose target is mapped yields a silent attacker-offset int32 store/inc/dec (fs_cs(cg_cgx).cs_nbfree-- at ffs_alloc.c:1213 is the same class, Β±8 GiB reach). With the heap base ~0xfffff801xxxxxxxx, the reachable window covers the whole kernel heap and then some β€” signature: the three write faults sit at bootbase + 0x100000000 with boot-varying bases (0xfffff8011776a200, 0xfffff8008dbe3600, 0xfffff80118519500), matching fs_maxcluster + 4Β·cg_cgx in every run.
  • Run 4's stopped instruction is inosused[ipref] (ffs_alloc.c:1415/ 1393) β€” a byte read at cgbuf + cg_iusedoff, here +512 MiB. The same pointer is later written through setbit(inosused, ipref) (ffs_alloc.c:1463) when a zero bit is found: read primitive first, write primitive on the same field.
  • cg_freeoff/cg_boff/cg_btotoff/cg_clustersumoff/cg_clusteroff are the identical pattern for the block bitmap and cylinder/cluster arrays (bit-level writes at Β±2 GiB, int16/int32 incs/decs) β€” traced in the finding table, not separately run: one field per boot is the observability limit (the first wild access traps), and the mechanism is line-for-line identical.

Exploitability assessment

  • Trigger is a crafted filesystem image + one creat/write β€” the mount itself needs root (or vfs.usermount=1 setups / auto-mounting removable media, where the trigger becomes unprivileged).
  • The primitive is a relative kernel-memory write with a Β±8–32 GiB 32-bit-controlled displacement (csum dec/inc at 16-byte stride, fs_maxcluster store at 4-byte stride) plus bit-level writes through the bitmap offsets. No KASLR/SMAP/SMEP on this guest; heap base is boot-stable within a session, so a repeatable offset to a target object is realistic (e.g. point cg_cgx so that fs_csp[16Β·cg_cgx] lands on a neighbouring mount's fs_csp, function-pointer-bearing structures, or refcounts). A full uid0 chain was not developed because the mount already requires root on the default configuration β€” the ceiling is arbitrary kernel memory corruption from a malicious disk image (or unprivileged corruption wherever users can get their image mounted).
  • The fail-stop panic()s reachable with in-range inconsistent cg metadata (ffs_valloc:630 dup alloc, ffs_nodealloccg:1451 block not in map, ffs_blkfree_cg:1542/1581 freeing free block/frag, ffs_clusteralloc:1317 map mismatch) make the same image a reliable local DoS even without an out-of-range field.

Fix validation

fix.diff adds ffs_chkcg() β€” validating cg_cgx against the cg's real index, rotors against their map sizes, and every map offset/size against fs_cgsize (with int64 math, ordered like the on-disk layout) β€” called after cg_chkmagic() in ffs_fragextend, ffs_alloccg, ffs_clusteralloc, ffs_nodealloccg, ffs_blkfree_cg, and ffs_freefile; plus a clamp of the preference-derived cg in ffs_hashalloc() before it indexes fs_csp[].

Applied to the guest's /usr/src, make nativekernel KERNCONF=X86_64_GENERIC (ffs_alloc.c compiled -Werror-clean), installed, rebooted (run.patched.log for the full sequence):

  • identical cgx 0x40000000 image β†’ no panic; dd fails No space left on device (poisoned cg rejected β†’ allocator returns 0 β†’ ENOSPC);
  • identical iusedoff 0x20000000 image β†’ no panic; same graceful ENOSPC;
  • control (unpatched image) β†’ first allocation still succeeds (legit filesystem unaffected), clean unmount.

Baseline bad behaviour GONE; legitimate allocation behaviour preserved.

Non-goals / notes

  • DF-0793 (async-TRIM lifetime) and DF-0794 (fs_ncg==0) were given and are not re-verified here.
  • Superblock-geometry div-by-zeros reachable from this file (fs_maxbpg==0 at ffs_blkpref:805, fs_spc/fs_nsect/fs_npsect in cbtocylno/cbtorpos, fs_frag>8 indexing fragtbl/around/ inside in ffs_mapsearch) are the DF-0820/DF-0794 family (mount-time superblock validation) and are not re-filed.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src (9 hunks clean), make nativekernel KERNCONF=X86_64_GENERIC -Werror-clean (ffs_alloc.o), installkernel, rebooted into #1 Sat Sep 5 14:02:46. Identical crafted images: cgx=0x40000000 -> NO panic, dd fails 'No space left on device' (poisoned cg rejected by ffs_chkcg -> ENOSPC); iusedoff=0x20000000 -> same graceful ENOSPC. Control image (cgx rewritten to its legitimate value 0): first allocation still succeeds (16384-byte file created) and unmounts cleanly, proving the validator does not reject legitimate newfs geometry. Baseline bad behaviour GONE.

findings/poc/DF-3035/run.patched.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly v6.5.0.1712.g89e6a-DEVELOPMENT #1: Sat Sep 5 14:02:46 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

crafted UFS1 image (cg_chkmagic-valid cg0, one poisoned field) mounted by root -> first creat/write on the mount -> ffs_balloc -> ffs_blkpref -> ffs_alloc -> ffs_hashalloc -> ffs_alloccg -> ffs_alloccgblk/ffs_nodealloccg -> wild write fs_maxcluster[cg_cgx]/fs_csp[cg_cgx] (attacker 32-bit index, store or inc/dec) or wild bitmap RMW at cgbuf+cg_freeoff/cg_iusedoff (attacker bit offset) -> with a heap-layout-stable target (no KASLR on this guest) point cg_cgx at a neighbouring kernel object: single-int32 decrement/Bit-flip corruption at an attacker-chosen relative address; panic when the offset is unmapped (the demonstrated observable). Full uid0 chain not developed: the mount precondition already requires root on the default configuration, so escalation adds nothing; the ceiling is arbitrary-offset kernel memory corruption (and unprivileged corruption wherever users can get images mounted).

Evidence (decisive lines)

['findings/poc/DF-3035/panic1.txt β€” Fatal trap 12, supervisor write, Stopped at ffs_clusteracct.isra.0+0x1eb: movl %eax,(%rdx,%rcx,4), fault 0xfffff8021776a200 (cg_cgx=0x40000000)', 'findings/poc/DF-3035/panic2.txt β€” same sink, cg_cgx=0x40040000, fault 0xfffff8018dce3600 (linear cg_cgx addressing)', 'findings/poc/DF-3035/panic3.txt β€” same sink, cg_cgx=0x40000000, fault 0xfffff80218519500 (boot-varying heap base + fixed 4*index displacement)', 'findings/poc/DF-3035/panic4.txt β€” Fatal trap 12, supervisor READ, Stopped at ffs_nodealloccg+0x13f: movzbl (%rdi,%rax,1), fault 0xfffff8007c136000 (cg_iusedoff=0x20000000)', 'findings/poc/DF-3035/run.setup.log β€” image build, cgxtool superblock/cg dump, patch confirmation, mount succeeds (mount never validates cg headers)', 'findings/poc/DF-3035/run.patched.log β€” fix.diff kernel: both poisoned images fail gracefully with ENOSPC, no panic; unpatched control image still allocates (16384-byte file), clean unmount', 'findings/poc/DF-3035/cgxtool.c + setup3035.sh β€” minimal crafted-image generator/patcher + trigger', 'findings/poc/DF-3035/fix.diff β€” ffs_chkcg() validator called from all six cg consumers + ffs_hashalloc cg clamp; applied to guest /usr/src, nativekernel -Werror-clean, installed, rebooted']

PoC changes

No prior PoC seed β€” authored fresh. cgxtool.c computes all offsets from the image's own superblock via the tree's fs.h (needed the ufs_types.h 32-bit ufs_daddr_t/ufs_time_t typedefs; an int64 first attempt misaligned the superblock). setup3035.sh gained a notrigger mode so setup output could be captured before the panic kills sshd. Two early validator bugs in fix.diff were caught against the real newfs layout before building (btotoff=168 == offsetof(struct cg,cg_space), and the fpg-sized bitmap scan region legitimately overlapping clustersumoff by 4 bytes) β€” the fix must reject poison without rejecting legitimate filesystems, and the control run proves it does not.

Verified recommended fix

Add ffs_chkcg() validating every cg-header field (cg_cgx, rotors, and all map offsets/sizes against fs_cgsize) after each cg bread() in ffs_alloc.c, and clamp the bpref-derived cg in ffs_hashalloc() before it indexes fs_csp[] (fix.diff, fix-validated in-guest).

Verdict

Attacker-offset out-of-bounds kernel READ and WRITE reproduced deterministically on the stock INVARIANTS guest kernel from a crafted UFS1 image whose cylinder-group header carries a magic value plus one poisoned field. ffs_mountfs validates only fs_magic/fs_bsize (ffs_vfsops.c:642-646) and the allocator applies only cg_chkmagic() (fs.h:398, a magic compare) before using on-disk cg fields as kernel-memory offsets and indexes: cg_cgx -> fs_csp[16cg_cgx].cs_nbfree-- and fs_maxcluster[4cg_cgx] stores (ffs_alloc.c:1213,1963); cg_freeoff/cg_iusedoff/cg_boff/cg_btotoff/cg_clustersumoff/cg_clusteroff -> bit/int16/int32 read-modify-write through cgbuf+offset (ffs_alloc.c:1208-1216,1383-1463,1532-1609,1893-1955); cg_rotor/cg_frotor/cg_irotor/cg_nclusterblks -> OOB scan bounds (1129,1294,1410,1836); plus inode-derived bpref -> dtog() -> fs_csp[] OOB read before bread (1010,1370). Proven by: cg_cgx=0x40000000 -> Fatal trap 12 supervisor WRITE page-not-present at ffs_clusteracct.isra.0+0x1eb movl %eax,(%rdx,%rcx,4) with fault address = fs_maxcluster + 4*0x40000000 in three independent boots (panic1/2/3.txt); cg_iusedoff=0x20000000 -> Fatal trap 12 supervisor READ at ffs_nodealloccg+0x13f movzbl (%rdi,%rax,1), the inosused[] bitmap read at cgbuf+512MiB (panic4.txt); identical unpatched image allocates cleanly (control run). Panic is only the observable: any offset landing on mapped memory yields a silent attacker-relative int32 inc/dec/store (csum counters) or bit write (block/inode bitmaps), reach +/-8..32GiB around kernel heap objects via the signed 32-bit fields. Fail-stop panics from merely inconsistent in-range cg metadata (dup alloc 630, block-not-in-map 1451, freeing-free 1542/1581, map-mismatch 1317) make the same image a reliable DoS. Trigger model = crafted media mounted by root (DF-0820/DF-3015 class) with a single creat/write as the poisoned allocation; with vfs.usermount=1 or auto-mounted removable media the corruption itself is unprivileged. Root cause is distinct from DF-0820 (superblock geometry at mount) and DF-0794 (fs_ncg==0): this is the per-cylinder-group header consumed lazily at alloc/free time with no validation anywhere.