Crafted fs_nindir in superblock causes OOB read/write of indirect-block buffers in ffs_balloc
Summary
ffs_balloc.c:291 bread bp fs_bsize bytes. :296 bap=(ufs_daddr_t*)bp->b_data holds fs_bsize/4 entries. :297 bap[indirs[i].in_off] in_off from ufs_getlbns = bn%MNINDIR(ump)=bn%fs_nindir. fs_nindir on-disk field copied at ffs_vfsops.c:729 NO validation that equals fs_bsize/sizeof(ufs_daddr_t). Only mount checks magic+bsize range (:642-646). Crafted fs_nindir=8192 vs bsize/4=2048: in_off up to 8191 bap[8191]=bp->b_data+32764 ~24KB OOB. OOB read :297. OOB write :333/:378/:479. Same root cause as DF-0820 different exploitation path. Crafted FFS image write to file triggers. Heap corruption slab grooming priv-esc or panic.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0894 · 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| craft_img.c | trigger-source | UFS superblock fs_nindir patcher (forges fs_nindir to a value > fs_bsize/4) | 4.5 KB | view raw |
| harness.c | trigger-source | deterministic OOB characterizer: transcribes ufs_getlbns + bap[in_off] with sentinels | 6.1 KB | view raw |
| reproduce.sh | trigger-source | full live trigger: newfs -> patch fs_nindir -> mount RW -> write -> panic | 2.4 KB | view raw |
| build.sh | build-script | cc -o craft_img craft_img.c; cc -o harness harness.c | 296 B | view raw |
| run.sh | run-script | calls reproduce.sh | 136 B | view raw |
| build.log | build-log | final successful build output | 217 B | view raw |
| run.log | run-log | decisive baseline run: harness + live trigger + Fatal trap 12 panic | 2.8 KB | view raw |
| fix_run.log | run-log | patched-kernel run: mount rejected, no panic | 2.3 KB | view raw |
| panic.txt | panic-signature | Fatal trap 12 page fault at ffs_balloc+0x5e7 | 673 B | view raw |
| fix_build.log | build-log | single-fix kernel build log (make nativekernel) | 5.6 MB | ↓ download |
| env.txt | environment | guest kern.version, kernel sha256, sysctl state | 389 B | view raw |
| fix.diff | suggested-fix | validate fs_nindir == fs_bsize/sizeof(ufs_daddr_t) at mount time | 830 B | view raw |
| VERDICT.md | verdict | full narrative analysis | 4.9 KB | ↓ raw |
| README.md | readme | human-readable summary + build/run/expected | 3.7 KB | ↓ raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | ↓ download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0894 — OOB read/write in ffs_balloc via crafted fs_nindir in UFS superblock
Bug
sys/vfs/ufs/ffs_balloc.c:296-297 — the indirect-block array index
bap[indirs[i].in_off] is computed using in_off = bn % MNINDIR(ump),
where MNINDIR(ump) = ump->um_nindir = fs->fs_nindir (an on-disk
superblock field). The bread() at line 291 allocates the indirect-block
buffer as exactly fs->fs_bsize bytes (= fs_bsize/sizeof(ufs_daddr_t)
entries). The mount path ffs_vfsops.c:642-646 validates only fs_magic
and the fs_bsize range; fs_nindir is copied verbatim into
ump->um_nindir at ffs_vfsops.c:729 with no check that it equals
fs_bsize / sizeof(ufs_daddr_t). A crafted fs_nindir=8192 (vs the
correct fs_bsize/4 = 4096) allows in_off values up to 8191, driving
bap[in_off] up to ~16KB past the indirect-block buffer:
ffs_balloc.c:291 bread(vp, ..., (int)fs->fs_bsize, &bp); // 16384-byte buffer ffs_balloc.c:296 bap = (ufs_daddr_t *)bp->b_data; // 4096 valid entries ffs_balloc.c:297 nb = bap[indirs[i].in_off]; // OOB read (up to +16KB) ffs_balloc.c:333 bap[indirs[i - 1].in_off] = nb; // OOB write ffs_balloc.c:378 bap[indirs[i].in_off] = nb; // OOB write ffs_balloc.c:479 bap[indirs[unwindidx].in_off] = 0; // OOB write (unwind path)
Trigger path: mount crafted UFS image → write to a file at a large
offset → ffs_balloc → ufs_getlbns computes in_off = (lbn-12) %
MNINDIR(ump) → OOB index into the indirect-block buffer.
Same root cause as DF-0820 (unvalidated fs_nindir), different
exploitation path (single-indirect block via file write, vs direct block
path).
Build / run / expected
# in guest as root
sh ./build.sh # cc -o craft_img craft_img.c; cc -o harness harness.c
sh ./run.sh # calls reproduce.sh
Expected on the unpatched #0 GENERIC kernel (INVARIANTS ON):
- harness 16384 8192 8203 prints OOB READ of 16380 bytes past the 16384-byte buffer.
- Live trigger: kernel panic — Fatal trap 12: page fault while in kernel mode at
ffs_balloc+0x5e7: movl (%rbx,%rax,4),%eax (the bap[in_off] instruction).
Expected on the patched #1 single-fix kernel: the crafted mount is rejected
with mount_ufs: ... incorrect super block and dmesg ffs_mountfs: bad fs_nindir
8192 (expected 4096). No OOB, no panic.
Impact ceiling
UFS is not user-mountable on DragonFly (vfs.usermount=0,
SYSCAP_RESTRICTEDROOT). This is a root-context mount of attacker-supplied
media → kernel OOB read/write → page-fault panic (DoS / hardening gap), not
an unprivileged→root escalation. On default GENERIC (INVARIANTS ON), the OOB
access (with in_off=8191, ~16KB past the buffer) deterministically page-faults
into unmapped kernel memory → panic. Smaller OOB offsets (4 bytes past) silently
corrupt adjacent kernel heap/buffers without an immediate panic.
Files
| File | Purpose |
|---|---|
craft_img.c |
UFS superblock fs_nindir patcher: sets the field to a forged value. |
harness.c |
Deterministic primitive characterizer (transcribes ufs_getlbns + the OOB index math with sentinels). |
reproduce.sh |
full live trigger: newfs → patch fs_nindir → mount RW → write → panic. |
build.sh |
cc -o craft_img craft_img.c; cc -o harness harness.c. |
run.sh |
calls reproduce.sh. |
fix.diff |
validated single-fix patch (validate fs_nindir at mount time). |
DF-0894 — VERDICT
Verdict: REPRODUCED (panic), fix VALIDATED
The bug is real: fs_nindir (an on-disk UFS superblock field) is copied
verbatim into ump->um_nindir at ffs_vfsops.c:729 with no validation
that it equals fs_bsize / sizeof(ufs_daddr_t). The mount-time check at
ffs_vfsops.c:642-646 validates only fs_magic and the fs_bsize
range. A forged fs_nindir=8192 (vs the correct 4096 for
fs_bsize=16384) drives bap[indirs[i].in_off] in ffs_balloc.c:297
up to ~16KB past the indirect-block buffer, because in_off = bn %
MNINDIR(ump) uses the forged value.
Mechanism (trigger → primitive → effect)
-
Trigger: root mounts a crafted UFS image with forged
fs_nindir=8192in the superblock (ffs_vfsops.c:642-646— nofs_nindircheck; copied at:729). Then writes 1 byte at file offset8203 * 16384 = 134283264. -
Primitive:
ffs_balloc(ffs_balloc.c:65) enters the single-indirect path at:290.ufs_getlbns(ufs_bmap.c:315) computesin_off = (8203-12) % 8192 = 8191. At:291,bread()reads the indirect block into a buffer of exactlyfs_bsize=16384bytes (= 4096ufs_daddr_tentries). At:296-297:c bap = (ufs_daddr_t *)bp->b_data; // 16384 bytes = 4096 entries nb = bap[indirs[i].in_off]; // bap[8191] = bp->b_data + 32764This reads 4 bytes at offset 32764 in a 16384-byte buffer — 16380 bytes past the end. -
Effect: On default GENERIC (INVARIANTS ON), the 16KB OOB read crosses into unmapped kernel virtual memory → page fault →
Fatal trap 12: page fault while in kernel modeatffs_balloc+0x5e7(movl (%rbx,%rax,4),%eax), the exactbap[in_off]instruction. Kernel panic. Smaller OOB offsets (e.g.,in_off=4096, just 4 bytes past) silently corrupt adjacent kernel memory without an immediate fault — the OOB write paths at:333,:378,:479would corrupt adjacent buffers/heap objects.
Confirmed evidence
-
Panic signature (
panic.txt):Fatal trap 12: page fault while in kernel modeatffs_balloc+0x5e7: movl (%rbx,%rax,4),%eax. Instruction pointer0xffffffff809062e7, fault virtual address0xfffff8006287dfe0, fault codesupervisor read data, page not present. Reproduced 3× (identical RIP, varying fault VA due to KVA allocation). -
Harness (
harness.c): transcribes theufs_getlbns+ OOB index math with sentinels, confirmingin_off=8191indexesbap[8191]atbp->b_data + 32764— 16380 bytes past the 16384-byte buffer. -
Mount succeeds on unpatched
#0:MOUNT_RC=0with forgedfs_nindir=8192. The trigger write reachesffs_ballocand faults.
Impact ceiling
- Privilege: UFS is not user-mountable on DragonFly
(
vfs.usermount=0,SYSCAP_RESTRICTEDROOT). The attack vector is root-context mount of attacker-supplied media → kernel OOB. This is a DoS/hardening gap, not an unprivileged→root LPE. - OOB extent: up to ~16KB (forged
fs_nindir=8192,in_off_max=8191). OOB read at:297; OOB write at:333/:378/:479whennb==0. Content is not attacker-controlled (it's disk block addresses being written), but the OOB location is controlled byin_off. - On GENERIC (INVARIANTS ON): deterministic page-fault panic for large OOB offsets. For small offsets (4 bytes past), silent heap/buffer corruption — potentially exploitable for further privesc if combined with heap grooming, but the root-only mount precondition makes this a root→kernel vector.
Why this is NOT a uid0 escalation
The trigger requires mount -t ufs, which needs root privileges. A
non-root user cannot mount UFS on DragonFly. Therefore this is
root→kernel corruption (a hardening gap), not unprivileged→root.
The Phase 6 escalation chain is not applicable: there is no privilege
boundary to cross (root→kernel is game-over by definition).
PoC changes
craft_img.c— new: patchesfs_nindir(4 bytes at SBOFF+116) in the UFS superblock to a forged value.harness.c— new: transcribesufs_getlbnsand the OOB index math with a poisoned allocator, proving the OOB extent deterministically.reproduce.sh— new:newfsdefault geometry → patchfs_nindir4096→8192 → mount RW → prime indirect block (lbn=12) → trigger write (lbn=8203, in_off=8191, ~16KB OOB) → page-fault panic.
Fix validation (Phase 8)
fix.diff adds a check at ffs_vfsops.c:647 (after the existing
magic/bsize validation):
if (fs->fs_nindir != fs->fs_bsize / sizeof(ufs_daddr_t)) {
kprintf("ffs_mountfs: bad fs_nindir %d (expected %d)\n", ...);
error = EINVAL;
goto out;
}
- Before (unpatched #0): mount succeeds, write triggers
Fatal trap 12atffs_balloc+0x5e7. - After (patched #1): mount rejected —
ffs_mountfs: bad fs_nindir 8192 (expected 4096)in dmesg,mount_ufs: ... incorrect super block,MOUNT_RC=1. No OOB access, no panic, guest stays up. Reproduced 2×.
Fix verification
fixedVALIDATED the fix: PoC triggers Fatal trap 12 page fault at ffs_balloc+0x5e7 on the unpatched 6.5-DEVELOPMENT #0 baseline (mount succeeds, OOB read at bap[8191] page-faults) and does NOT on the single-fix #1 kernel (mount rejected with 'ffs_mountfs: bad fs_nindir 8192 (expected 4096)', MOUNT_RC=1, no OOB access, no panic) => fix closes the bug. Reproduced 2x on patched kernel for determinism.
baseline #0: MOUNT_RC=0 -> Fatal trap 12 at ffs_balloc+0x5e7 (movl (%rbx,%rax,4),%eax). patched #1: ffs_mountfs: bad fs_nindir 8192 (expected 4096) -> mount_ufs: incorrect super block -> MOUNT_RC=1, guest stays up.
Confirmed kernel references
- sys/vfs/ufs/ffs_balloc.c:291
- sys/vfs/ufs/ffs_balloc.c:296
- sys/vfs/ufs/ffs_balloc.c:297
- sys/vfs/ufs/ffs_balloc.c:333
- sys/vfs/ufs/ffs_balloc.c:378
- sys/vfs/ufs/ffs_balloc.c:479
- sys/vfs/ufs/ffs_vfsops.c:642
- sys/vfs/ufs/ffs_vfsops.c:646
- sys/vfs/ufs/ffs_vfsops.c:729
- sys/vfs/ufs/ufs_bmap.c:315
- sys/vfs/ufs/ufsmount.h:104
Detail
Exploit chain
NOT an LPE -- valid hard blocker: UFS mount requires root (vfs.usermount=0, SYSCAP_RESTRICTEDROOT), so this is root->kernel OOB corruption (a hardening gap), not unprivileged->root. The primitive is an OOB array index read (ffs_balloc.c:297) and write (:333/:378/:479) of up to ~16KB past the indirect-block buffer, with the OOB offset controllable via the target lbn. On GENERIC (INVARIANTS ON), large OOB offsets (in_off=8191, ~16KB past) deterministically page-fault into unmapped kernel memory -> panic. Small OOB offsets (in_off=4096, 4 bytes past) silently corrupt adjacent kernel heap/buffers. Since the mount itself requires root, there is no privilege boundary to cross for escalation; root->kernel is game-over by definition.
Evidence (decisive lines)
Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff8006419dfe0 / fault code = supervisor read data, page not present / instruction pointer = 0x8:0xffffffff809062e7 / Stopped at ffs_balloc+0x5e7: movl (%rbx,%rax,4),%eax (= bap[indirs[i].in_off] at ffs_balloc.c:297). --- patched #1 kernel --- ffs_mountfs: bad fs_nindir 8192 (expected 4096) / mount_ufs: /dev/vn0 on /mnt/test: incorrect super block / MOUNT_RC=1 (mount rejected, no OOB access).
PoC changes
Created findings/poc/DF-0894/ from scratch: craft_img.c (UFS superblock fs_nindir patcher at SBOFF+116), harness.c (deterministic OOB characterizer transcribing ufs_getlbns + bap[in_off] with sentinels), reproduce.sh (newfs default geometry -> patch fs_nindir 4096->8192 -> mount RW -> prime indirect block at lbn=12 -> trigger write at lbn=8203 in_off=8191 -> page-fault panic), build.sh/run.sh, fix.diff (validate fs_nindir at mount).
Verified recommended fix
Add a check at ffs_vfsops.c:647 (after the existing magic/bsize validation at :642-646): reject the mount with EINVAL if fs->fs_nindir != fs->fs_bsize / sizeof(ufs_daddr_t). This is the root-cause fix -- it closes the unvalidated-superblock-field path that drives the OOB index in ffs_balloc. The fix.diff in the evidence pack is the git-apply-able diff. This supersedes any finding-proposal that only bounds in_off in ffs_balloc (defense-in-depth there is optional but the mount-time check is the correct primary fix).
Verdict
REPRODUCED. The bug is real: fs_nindir (an on-disk UFS superblock field at struct fs offset 116) is copied verbatim into ump->um_nindir at ffs_vfsops.c:729 with NO validation that it equals fs_bsize/sizeof(ufs_daddr_t). The mount-time check at ffs_vfsops.c:642-646 validates only fs_magic and the fs_bsize range. A crafted fs_nindir=8192 (vs correct 4096 for fs_bsize=16384) allows ufs_getlbns (ufs_bmap.c:315) to compute in_off up to 8191, which drives bap[indirs[i].in_off] at ffs_balloc.c:297 up to ~16KB past the fs_bsize-sized indirect-block buffer. Confirmed by Fatal trap 12 page fault at ffs_balloc+0x5e7 (movl (%rbx,%rax,4),%eax = the bap[in_off] instruction), reproduced 3x with identical RIP 0xffffffff809062e7.
No comments yet.