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)
PoC verification
Evidence pack
findings/poc/DF-3154 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 fielde2di_projidends 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()passesei = 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.)
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_dataallocation β 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
-
Deterministic harness (
harness.c, guestcc): 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). -
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). -
Crafting hurdles solved (
poc_changes): mke2fs leaves inode generations at 0; DFBSD'sext2_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)
- ext2_vget ei2i-error path refcount bug β umount panic (stock
kernel): after
statof a checksum-mismatching inode, any laterumountpanics:panic: assertion "(count & VREF_MASK) > 0" failed in vrele at vfs_lock.c:354viaext2_reclaim(). Backtrace in panic.txt. (ext2_vfsops.c error path / ext2_reclaim devvp imbalance β DF-3064 family, different site.) - Zero-inode vget success path leaves dangling vnode β
panic: unmount: dangling vnodeon umount (fix_run boot). ext2_readdirparses the metadata_csum dirent tail as an entry (reclen reads 0 β EIO,ext2_lookup.c:198-203): every readdir on every valid Linuxmetadata_csumext2 image fails; on RW mounts the same walk hitsext2_dirbadβ panic from a pristine image (ext2_lookup.c:777-780, DF-3083 mechanism, valid-image trigger).ext2_vgeti_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
fixedModule-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']
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.
No comments yet.