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

ext2_ei_csum_verify() memcmp over-reads 32 bytes past the 128-byte inode slot β€” next-inode bytes / kernel heap decide EIO-vs-accept (1-bit oracle)

Summary

ext2_csum.c:589 compares sizeof(struct ext2fs_dinode)==160 bytes against the zero template, but the on-disk slot is EXT2_INODE_SIZE(fs), mount-legal at 128 (vfsops :539-545), and ei points into a bsize bread() buffer. For a non-last inode the last 32 compared bytes belong to the NEXT inode's slot; for the last inode of a block they lie 32 bytes PAST the bp->b_data allocation (kernel heap). Post-mount unpriv stat/lookup of a dirent naming a crafted zero bad-csum inode on a metadata_csum ext2 image (vfs.usermount, attacker disk, or exported image): repeatable 1-bit all-zero oracle over 32 bytes of kernel heap adjacent to a chosen 1024-byte buffer (EIO vs EBADF), plus silent cross-inode checksum-suppression; page-fault/panic on unlucky adjacency. VERIFIED: variants differing ONLY in inode 16's first 32 bytes (0xAA vs 0x00) flip the stat outcome (EIO vs EBADF); last-slot variant reads past the buffer; guard-page harness deterministically SIGSEGVs at buffer+1024. Fix validated (imin(slot, 160) bound, module rebuild): outcome variant-independent.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3154 Β· 17 files
FileTypeDescriptionSize
craft_df3154.py β€” 6.9 KB view raw
harness.c β€” 3.4 KB view raw
run_baseline.sh β€” 2.1 KB view raw
instrument.sh β€” 941 B view raw
build.sh β€” 149 B view raw
run.sh β€” 169 B view raw
build.log β€” 536 B view raw
run.log β€” 688 B view raw
fix_run.log β€” 689 B view raw
run_baseline.raw.log β€” 877 B view raw
panic.txt β€” 655 B view raw
victims.txt β€” 21 B view raw
img_hashes.txt β€” 392 B view raw
fix.diff β€” 426 B view raw
README.md β€” 3.9 KB ↓ raw
VERDICT.md β€” 5.3 KB ↓ raw
verdict.json β€” 5.5 KB view raw

DF-3154 β€” ext2_ei_csum_verify() memcmp over-read past the 128-byte inode slot

The bug

sys/vfs/ext2fs/ext2_csum.c:589:

static const struct ext2fs_dinode ei_zero;              /* 160 bytes */
...
if (!memcmp(ei, &ei_zero, sizeof(struct ext2fs_dinode)))
        return (0);
  • sizeof(struct ext2fs_dinode) == 160 in this tree (last field e2di_projid ends at 156+4, sys/vfs/ext2fs/ext2_dinode.h:127-159).
  • The on-disk inode slot is EXT2_INODE_SIZE(fs), which mount validation (ext2_vfsops.c:539-545) allows to be 128 (power of two, β‰₯128).
  • ext2_vget() passes ei = bp->b_data + 128*ino_to_fsbo(ino) of a bsize (1024) bread() buffer (ext2_vfsops.c:1364-1374).

Consequences for isize == 128 (ipb = 8 per 1K block):

victim slot memcmp span within buffer effect
idx 0..6 [idx*128, idx*128+160) cross-inode read: the first 32 bytes of the NEXT inode's slot silently decide the verdict
idx 7 (last) [896, 1056) 32 bytes read PAST bp->b_data β€” kernel heap adjacent to the 1024-byte buffer allocation

The memcmp only runs when the stored checksum mismatches (provided != calculated, attacker-crafted), so the attacker controls whether the check fires; the deciding bytes are outside the stat'd inode.

Reproduce (host crafts, guest runs)

Host:

python3 craft_df3154.py /tmp/opencode/df3154     # mke2fs + debugfs + patching

Produces df3154.{A_nonzero,A_zero,B}.ext2 (bsize=1024, isize=128, metadata_csum, all inode generations set to 0x13579BDF β€” needed because ext2_vget() randomizes i_gen for gen==0 inodes, ext2_vfsops.c:1420, which would break every dirent/inode checksum against the image), plus .victims (f03=15 slot 6, neighbor f04=16 slot 7, f12=24 slot 7).

Copy images + run_baseline.sh to the guest, then (root):

sh /root/run_baseline.sh

The script mounts one image per vn unit (buffer-cache identities must not mix), all mounts before any stat, and stats as unprivileged maxx:

Expected output (stock kernel) β€” observed 2026-09-07

stat f00 on A_zero img : OK /mnt/e2z/f00 ino=12 mode=-rw-r--r--
ls A_zero img          :       41
stat f03 on A_zero     : stat: /mnt/e2z/f03: stat: Bad file descriptor   <- memcmp==0, suppressed
stat f12 on B (vn2)    : stat: /mnt/e2b1/f12: stat: Bad file descriptor   <- 32 heap bytes (zero here)
stat f12 on B (vn3)    : stat: /mnt/e2b2/f12: stat: Bad file descriptor
stat f03 on A_nonzero  : stat: /mnt/e2a/f03: stat: Input/output error     <- memcmp!=0 -> EIO

A_nonzero and A_zero differ only in the first 32 bytes of inode 16 (the neighbor of victim inode 15, never stat'd) β€” yet stat of inode 15 flips EIO ↔ EBADF. Those bytes are outside inode 15's slot: the kernel consumed them. For B, the deciding 32 bytes lie past the buffer β€” the image contains no such bytes; on this heap layout they were zero twice (suppression); DF-3049-style variance is layout-dependent.

The script's final umount -f of the EIO-poisoned mount panics the stock kernel (see panic.txt β€” separate, cross-file bug: ext2_vget()'s ei2i-error path leaves a devvp ref imbalance; ext2_reclaim β†’ vrele(count==0) assertion, vfs_lock.c:354).

Deterministic harness (guest userspace, no kernel needed)

cc -O2 -o harness_df3154 harness.c && ./harness_df3154

Proves the address math: slot-6 memcmp spans [768,928) (next slot), slot-7 spans [896,1056) β†’ SIGSEGV at a guard page placed at buffer+1024.

Fix (validated)

fix.diff bounds the memcmp by the slot size: imin(EXT2_INODE_SIZE(fs), sizeof(struct ext2fs_dinode)).

Patched module, same test (fix_run.log): A_nonzero β†’ EBADF (was EIO), A_zero β†’ EBADF β€” the outcome is now independent of the neighbor's bytes; the out-of-slot consumption is gone. (The subsequent umount panic becomes unmount: dangling vnode β€” the degenerate zero-inode vget lifecycle bug, unrelated to this fix.)

VERDICT.md
↓ download raw

DF-3154 β€” VERDICT

REPRODUCED (in-kernel behavioral proof + deterministic harness) β€” Low-severity kernel OOB read / 1-bit oracle in ext2_ei_csum_verify().

Root cause (line-accurate)

sys/vfs/ext2fs/ext2_csum.c:589 compares 160 bytes (sizeof(struct ext2fs_dinode), fields end at e2di_projid+4, ext2_dinode.h:127-159) against a zero template, but the inode slot is EXT2_INODE_SIZE(fs) == 128 (mount-legal: ext2_vfsops.c:539-545 accepts any power of two in [128, bsize]; REV0 forces 128 at :522-523). ext2_vget() (ext2_vfsops.c:1364-1374) and ext2_reload_scan() (:752-762) call ext2_ei2i() β†’ ext2_ei_csum_verify() with ei = bp->b_data + 128*ino_to_fsbo(ino) of a 1024-byte bread() buffer.

  • victim at slot idx ≀ 6: the memcmp's last 32 bytes are the next inode's on-disk slot (cross-inode read inside the buffer);
  • victim at slot idx 7 (last in block): the last 32 bytes are past the bp->b_data allocation β€” kernel heap.

The memcmp only executes when the stored inode checksum mismatches (ext2_csum.c:583), i.e. exactly on attacker-corrupted images.

How it was proven

  1. Deterministic harness (harness.c, guest cc): transcribes the memcmp with the same offsets against a guard-page-backed buffer: slot-6 [768,928) returns nonzero iff the next slot's head is nonzero; slot-7 [896,1056) SIGSEGVs at buffer+1024 β€” the 32-byte over-read is real (build.log/run captured in run.log header).

  2. In-kernel, unprivileged (uid=1001 maxx), stock kernel (run.log, craft_df3154.py): two images differing ONLY in the first 32 bytes of inode 16 (the neighbor of victim inode 15 β€” never stat'd): * A_nonzero (neighbor head 0xAA): stat f03 β†’ EIO * A_zero (neighbor head 0x00): stat f03 β†’ EBADF (csum error suppressed by the zero-tail memcmp, vget succeeds on the mode-0 inode) The flip proves the kernel's EIO decision consumed bytes outside the stat'd inode's 128-byte slot. B (victim at last slot, deciding bytes entirely past the buffer): suppression observed (heap bytes happened to be zero on both vn units tried β€” variance is heap-layout-dependent, as with DF-3049/DF-0877).

  3. Crafting hurdles solved (poc_changes): mke2fs leaves inode generations at 0; DFBSD's ext2_vget() randomizes i_gen for gen==0 inodes (ext2_vfsops.c:1420-1424), which silently breaks every metadata_csum (dirent tails, inode csums) against stock e2fsprogs images β€” root of the initial all-EIO. The craft sets gen=0x13579BDF on all allocated inodes and recomputes (a) all inode checksums and (b) the root directory tail checksum, matching the kernel's seed/ino/gen chain.

Impact

  • 32-byte kernel-heap OOB read (read-only) per mismatching last-in-block inode read; page-fault/panic only on unlucky adjacency.
  • 1-bit all-zero oracle: EIO-vs-accept reveals whether the 32 bytes past a chosen 1K buffer allocation were all zero β€” repeatable per inode, post-mount, by any unprivileged user with read access to the mount. (Needs a crafted image: unprivileged mount via vfs.usermount, or any attacker-supplied disk/NFS-exported image.)
  • Cross-inode variant silently lets the neighbor's content decide whether corruption is reported β€” integrity-check weakening.

Exploit chain

Not escalated (Low class): oracle/over-read only. No write primitive.

Fix validation

fix.diff: imin(EXT2_INODE_SIZE(fs), sizeof(struct ext2fs_dinode)). Module-level rebuild + reload, same test (fix_run.log): * baseline: A_nonzero→EIO, A_zero→EBADF (variant-dependent) * patched: A_nonzero→EBADF, A_zero→EBADF (variant-independent; out-of-slot bytes no longer consumed) The B case is unchanged (its deciding bytes were zero on this layout).

Cross-file bugs surfaced during verification (NOT DF-3154, for the

orchestrator to file against their files)

  1. ext2_vget ei2i-error path refcount bug β†’ umount panic (stock kernel): after stat of a checksum-mismatching inode, any later umount panics: panic: assertion "(count & VREF_MASK) > 0" failed in vrele at vfs_lock.c:354 via ext2_reclaim(). Backtrace in panic.txt. (ext2_vfsops.c error path / ext2_reclaim devvp imbalance β€” DF-3064 family, different site.)
  2. Zero-inode vget success path leaves dangling vnode β†’ panic: unmount: dangling vnode on umount (fix_run boot).
  3. ext2_readdir parses the metadata_csum dirent tail as an entry (reclen reads 0 β†’ EIO, ext2_lookup.c:198-203): every readdir on every valid Linux metadata_csum ext2 image fails; on RW mounts the same walk hits ext2_dirbad β†’ panic from a pristine image (ext2_lookup.c:777-780, DF-3083 mechanism, valid-image trigger).
  4. ext2_vget i_gen randomization (ext2_vfsops.c:1420-1424) breaks all metadata_csum verification against gen-0 (stock e2fsprogs) images β€” interop/correctness: entire fs EIOs.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Module-level validation: stock A_nonzero=EIO/A_zero=EBADF (variant-dependent, bug signature); patched A_nonzero=EBADF/A_zero=EBADF (variant-independent) -- out-of-slot consumption eliminated. Panic-on-umount class remains (separate ext2_vget lifecycle bug).

['findings/poc/DF-3154/fix_run.log', 'findings/poc/DF-3154/fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0 (same kernel, ext2fs.ko module rebuilt with fix.diff and kldload'ed)

Confirmed kernel references

Detail

Exploit chain

crafted ext2 image (metadata_csum, isize=128) with zeroed bad-csum victim inode reachable via dirent -> unprivileged stat -> ext2_vget -> ext2_ei2i -> ext2_ei_csum_verify -> memcmp 160B over 128B slot -> EIO/EBADF outcome decided by next-inode bytes or 32B of kernel heap past the 1024B buffer (1-bit all-zero oracle, repeatable per inode). No write primitive; no escalation.

Evidence (decisive lines)

["findings/poc/DF-3154/run.log: A_nonzero stat -> EIO vs A_zero stat -> EBADF (only difference: bytes of inode 16, never stat'd)", 'findings/poc/DF-3154/build.log: harness slot-7 memcmp SIGSEGVs at buffer+1024 (guard page); slot-6 memcmp=170 with 0xAA next-slot head', 'findings/poc/DF-3154/fix_run.log: patched module -> A_nonzero -> EBADF (variant-independence restored)', 'findings/poc/DF-3154/panic.txt: stock-kernel vrele assertion panic on umount after failed vget (separate cross-file bug)', 'findings/poc/DF-3154/craft_df3154.py: image construction incl. gen-reassignment + inode/dirent-tail csum recompute']

PoC changes

Seed PoC was conceptual only. Built from scratch: (1) discovered ext2_vget randomizes i_gen for gen==0 inodes (ext2_vfsops.c:1420) which breaks ALL metadata_csum verification vs stock e2fsprogs images (initial all-EIO red herring, root-caused via instrumented ext2fs.ko kprintfs); craft now assigns gen=0x13579BDF to all allocated inodes and recomputes every inode csum + the root dir tail csum; (2) buffer-cache staleness across vn re-attach forced one vn unit per image; (3) ext2_vget's ei2i-EIO path panics umount (vrele assertion, stock) so all mounts precede all stats; (4) harness rebuilt with fork+guard-page for the fault proof.

Verified recommended fix

Bound the zero-inode memcmp by the slot: imin(EXT2_INODE_SIZE(fs), sizeof(struct ext2fs_dinode)) at ext2_csum.c:589 (validated in fix.diff)

Verdict

REPRODUCED. ext2_ei_csum_verify() (sys/vfs/ext2fs/ext2_csum.c:589) memcmp's sizeof(struct ext2fs_dinode)==160 bytes against the zero template, but the inode slot is EXT2_INODE_SIZE(fs), mount-legal at 128 (ext2_vfsops.c:539-545), and ei points into a 1024-byte bread() buffer (ext2_vfsops.c:1364-1374). For any non-last inode the last 32 compared bytes belong to the NEXT inode's slot (cross-inode read); for the last inode of a block the 32 bytes lie PAST the bp->b_data allocation (kernel heap). Proven in-kernel on the stock guest kernel: two crafted metadata_csum images (bsize=1024, isize=128, all inode gens set nonzero to defeat ext2_vget's gen-0 randomization) differing ONLY in the first 32 bytes of inode 16 -- never stat'd -- flip an unprivileged stat of inode 15 between EIO (neighbor bytes 0xAA, memcmp!=0) and EBADF (neighbor bytes 0x00, csum error suppressed). The deciding bytes are outside the stat'd inode's slot. B-variant (victim last-in-block) decision comes from bytes that do not exist in the image (kernel heap; all-zero on this layout, suppression). Deterministic guard-page harness proves the [896,1056) span SIGSEGVs at buffer+1024. Impact: 32-byte kernel-heap OOB read per crafted bad-csum inode read + 1-bit all-zero oracle (EIO vs accept), post-mount unprivileged; Low severity. fix.diff (imin(isize, sizeof(dinode))) validated: A_outcome becomes variant-independent (EIO->EBADF) under the patched module.