DragonFlyBSD Kernel Audit
← triage · dashboard
DF-0894

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0894 · 16 files
FileTypeDescriptionSize
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
README.md readme human-readable summary + build/run/expected
↓ download 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_ballocufs_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 panicFatal 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).
VERDICT.md verdict full narrative analysis
↓ download raw

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)

  1. Trigger: root mounts a crafted UFS image with forged fs_nindir=8192 in the superblock (ffs_vfsops.c:642-646 — no fs_nindir check; copied at :729). Then writes 1 byte at file offset 8203 * 16384 = 134283264.

  2. Primitive: ffs_balloc (ffs_balloc.c:65) enters the single-indirect path at :290. ufs_getlbns (ufs_bmap.c:315) computes in_off = (8203-12) % 8192 = 8191. At :291, bread() reads the indirect block into a buffer of exactly fs_bsize=16384 bytes (= 4096 ufs_daddr_t entries). 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 + 32764 This reads 4 bytes at offset 32764 in a 16384-byte buffer — 16380 bytes past the end.

  3. Effect: On default GENERIC (INVARIANTS ON), the 16KB OOB read crosses into unmapped kernel virtual memory → page faultFatal trap 12: page fault while in kernel mode at ffs_balloc+0x5e7 (movl (%rbx,%rax,4),%eax), the exact bap[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, :479 would corrupt adjacent buffers/heap objects.

Confirmed evidence

  • Panic signature (panic.txt): Fatal trap 12: page fault while in kernel mode at ffs_balloc+0x5e7: movl (%rbx,%rax,4),%eax. Instruction pointer 0xffffffff809062e7, fault virtual address 0xfffff8006287dfe0, fault code supervisor read data, page not present. Reproduced 3× (identical RIP, varying fault VA due to KVA allocation).

  • Harness (harness.c): transcribes the ufs_getlbns + OOB index math with sentinels, confirming in_off=8191 indexes bap[8191] at bp->b_data + 32764 — 16380 bytes past the 16384-byte buffer.

  • Mount succeeds on unpatched #0: MOUNT_RC=0 with forged fs_nindir=8192. The trigger write reaches ffs_balloc and 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/:479 when nb==0. Content is not attacker-controlled (it's disk block addresses being written), but the OOB location is controlled by in_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: patches fs_nindir (4 bytes at SBOFF+116) in the UFS superblock to a forged value.
  • harness.c — new: transcribes ufs_getlbns and the OOB index math with a poisoned allocator, proving the OOB extent deterministically.
  • reproduce.sh — new: newfs default geometry → patch fs_nindir 4096→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 12 at ffs_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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Mon Jul 6 18:45:14 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

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.