ext2_indirtrunc bzero heap OOB write via ftruncate past indirect-block structural limit
Summary
ext2_inode.c:465 ext2_truncate only checks length<0 NO fs_maxfilesize check (unlike FFS ffs_inode.c:157). e2fs_maxfilesize=INT64_MAX under HUGE_FILE (vfsops.c:703). ext2_ind_truncate:310-313 computes lastiblock[TRIPLE] exceeding NINDIR^3 for length>4.4TB. :365 ext2_indirtrunc(level=TRIPLE) called. :138-140 last=lastbn/factor exceeds NINDIR(fs)-1. :173 bzero(&bap[last+1],(NINDIR-(last+1))*sizeof(daddr_t)) underflows size_t to ~2^64. Heap OOB write past bp->b_data (4096B). Crafted ext2 image e2di_size=6TB i_block[14]=valid. ftruncate(fd,5TB) triggers. Panic or heap corruption. Fix: add structural-limit check in ext2_truncate.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0888 · 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic arithmetic proof: OOB index + size_t underflow in ext2_indirtrunc bzero | 8.9 KB | view raw |
| trigger.c | trigger-source | kernel trigger: open + ftruncate(fd, 5 TiB) on the patched ext2 file | 1.5 KB | view raw |
| craft_img.py | trigger-source | HOST ext2 image crafter: mke2fs + debugfs + raw inode patch (size=6TiB, TIND=valid block) | 5.8 KB | view raw |
| df0888.ext2 | evidence | crafted 8 MiB ext2 image; inode 12 size=6TiB, e2di_blocks[14]=block 510 | 8.0 MB | ↓ download |
| build.sh | build-script | exact guest build of trigger + harness | 529 B | view raw |
| run.sh | run-script | exact guest run: vnconfig + mount_ext2fs + trigger | 1.1 KB | view raw |
| build.log | build-log | guest cc build of trigger+harness incl. harness run | 1.5 KB | view raw |
| run.log | run-log | baseline (#0) decisive run: ssh died mid-syscall -> panic | 1.8 KB | view raw |
| panic.txt | panic-signature | fatal trap 12 page fault in memset+0xf0 from boot.log | 658 B | view raw |
| fix.diff | suggested-fix | git-apply-able: structural-limit check in ext2_truncate + indirtrunc last-clamp | 1.7 KB | view raw |
| fix_build.log | build-log | full nativekernel build of patched #1 kernel | 5.6 MB | ↓ download |
| fix_run.log | run-log | patched #1 kernel: ftruncate returns EFBIG, 3x deterministic, no panic | 613 B | view raw |
| env.txt | environment | uname, cc, vfs.usermount, ext2 module, image features | 569 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, proof, reachability, fix validation | 6.1 KB | ↓ raw |
| README.md | readme | human reproduction guide | 1.6 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-0888 — PoC & evidence pack
Verdict: REPRODUCED (panic / heap OOB write on default GENERIC #0). Fix VALIDATED (#1).
ext2_truncate (sys/vfs/ext2fs/ext2_inode.c:465) has no structural-limit
check on length. For length in the triple-indirect range (>~4.4 TiB at 4 KiB
blocks), ext2_ind_truncate computes lastiblock[TRIPLE] > NINDR^3
(:313), and ext2_indirtrunc(level=TRIPLE) computes last = lastbn/factor
= 1278 > NINDR-1 (:140), so the bzero(&bap[last+1], (NINDR-(last+1))*4)
at :172-173 underflows size_t to ≈2^64 and writes out of bounds past
bp->b_data → kernel page fault.
See VERDICT.md for the full line-by-line analysis.
Build (guest, as maxx)
cc -O2 -Wall -o trigger trigger.c cc -O2 -Wall -o harness harness.c
Craft image (HOST — needs mke2fs, debugfs, python3)
python3 craft_img.py df0888.ext2 scp df0888.ext2 dfbsd:/root/df0888.ext2
Run (guest, as root — ext2fs is root-only-mount)
vnconfig -c /dev/vn0 /root/df0888.ext2 mount -t ext2fs /dev/vn0 /mnt/df0888 /root/trigger /mnt/df0888/target
- Unpatched #0:
Fatal trap 12: page faultatmemset+0xf0. Guest dead in DDB. - Patched #1 (
fix.diff):ftruncatereturnserrno=27 (EFBIG). No panic.
Reachability
ext2 is not user-mountable on DragonFly (SYSCAP_RESTRICTEDROOT,
vfs.usermount=0). The bug is reachable only by root mounting crafted media →
root→kernel DoS / heap corruption, not an unprivileged LPE. The bzero
zeroes (attacker cannot set arbitrary byte values), so the corruption ceiling is
a large heap zero-out; on GENERIC (INVARIANTS ON) it panics immediately.
DF-0888 — ext2_indirtrunc bzero heap OOB write via truncate past indirect-block structural limit
Verdict
REPRODUCED (kernel panic / heap OOB write) on the default GENERIC kernel
(6.5-DEVELOPMENT #0, INVARIANTS ON). The fix is VALIDATED on a single-fix
#1 kernel: the oversized truncate is rejected with EFBIG and no panic.
The bug (confirmed, line-by-line)
ext2_truncate() (sys/vfs/ext2fs/ext2_inode.c:465) guards length only
against length < 0 — there is no structural-limit check analogous to FFS's
if (length > fs->fs_maxfilesize) return (EFBIG) at ffs_inode.c:157. The only
e2fs_maxfilesize check in the ext2 truncate path is at ext2_inode.c:251, and
it sits on the lengthen branch (if (osize < length)) — so it never fires on
a shorten (the case where length < i_size). Worse, under HUGE_FILE
(ext2_vfsops.c:703) e2fs_maxfilesize == INT64_MAX, so even that check is
useless for classic indirect-block inodes.
When a regular file's on-disk i_size claims a value in the triple-indirect
range and ftruncate() targets a length that also lands in / past that range,
ext2_ind_truncate() computes (ext2_inode.c:310-313):
lastblock = lblkno(fs, length + bsize - 1) - 1 lastiblock[TRIPLE] = lastblock - 12 - NINDR - NINDR^2
For length = 5 TiB / 4 KiB blocks: lastiblock[TRIPLE] = 1341127667 >
NINDR^3 = 1073741824 → the value is positive, so it is not normalized to
-1 at :322-327 and is passed straight into
ext2_indirtrunc(level=TRIPLE, lastbn=lastiblock[TRIPLE]) at :365.
Inside ext2_indirtrunc (:135-173):
factor = NINDR^2 # :135-137
last = lastbn / factor = 1278 # :138-140 (> NINDR-1 = 1023)
bzero(&bap[last+1], # :172
(NINDR - (last+1)) * sizeof(daddr_t)) # :173 -> (1024-1279)*4
&bap[1279] is already past the end of bap[] (which has 1024 entries in a
4096-byte buffer), and the (NINDR-(last+1))*sizeof length underflows size_t
to ≈2^64. The resulting bzero is an effectively-unbounded zero-fill past
bp->b_data — a kernel heap OOB write.
Proof
1. Deterministic arithmetic harness (harness.c)
Transcribes ext2_ind_truncate/ext2_indirtrunc verbatim and prints:
lastiblock[TRIPLE] = 1341127667 > NINDR^3 (1073741824) ? YES -> OOB last = 1278 > NINDR-1 (1023) ? YES -> OOB index bzero start: &bap[1279] (bap has 1024 entries) start OOB ? YES bzero len = 18446744073709550596 bytes len underflow ? YES == RESULT: BUG CONFIRMED -- OOB heap bzero in ext2_indirtrunc ==
2. Kernel reproduction on #0 GENERIC
An ext2 image is crafted (craft_img.py) whose regular file inode has
e2di_size = 6 TiB, e2di_blocks[14] (triple-indirect) = a valid block, no
extents flag. Mounted ext2fs (root-only), ftruncate(fd, 5 TiB) on the file:
Fatal trap 12: page fault while in kernel mode fault virtual address = 0xfffff8007a6673fc fault code = supervisor write data, page not present instruction pointer = 0x8:0xffffffff80bca670 current process = 984 Stopped at memset+0xf0: movq %r10,(%rdi)
memset+0xf0 is the kernel bzero (resolve to memset) faulting on the first
out-of-bounds page of the underflowed zero-fill. Guest dies in DDB. (See
panic.txt.)
3. Fix validation on #1 patched kernel
With fix.diff applied, the same trigger returns cleanly:
[trigger] ftruncate returned rc=-1 errno=27 (File too large)
3/3 runs deterministic, guest stays up, no panic. (See fix_run.log.)
Reachability / impact ceiling
- ext2, like UFS, is NOT user-mountable on DragonFly (
get_fscap→SYSCAP_RESTRICTEDROOT;vfs.usermount=0). Mounting a crafted ext2 image requires root. There is therefore no unprivileged→root (uid0) chain from this bug: the privilege boundary (root→kernel) is already crossed by the time the vulnerable code runs. This is a root→kernel hardening gap / crafted-media DoS / heap-corruption issue, not an LPE. - On the default GENERIC kernel (INVARIANTS ON) the OOB write immediately
page-faults → reliable DoS (panic). The heap write itself (zeroing, attacker
cannot control the byte value beyond "zero") is not a classic
attacker-controlled-content corruption, so even on
noinvthe primitive is a large heap zero-out rather than a controlled-value overwrite. Impact is panic / DoS from crafted ext2 media, ceiling = memory corruption requiring root to trigger.
Files
| file | purpose |
|---|---|
harness.c |
deterministic arithmetic proof (OOB index + size_t underflow) |
trigger.c |
kernel trigger: open + ftruncate(fd, 5 TiB) on the patched file |
craft_img.py |
HOST-side ext2 image crafter (mke2fs + debugfs + raw inode patch) |
df0888.ext2 |
the crafted 8 MiB image (inode 12: size=6TiB, TIND=block 510) |
build.sh/run.sh |
exact build/run commands |
build.log |
guest cc build of trigger+harness |
run.log |
baseline (#0) decisive run incl. panic |
panic.txt |
fatal-trap-12 page-fault signature from boot.log |
fix.diff |
git-apply-able fix: structural-limit check in ext2_truncate + indirtrunc clamp |
fix_build.log |
full nativekernel build output of the patched kernel |
fix_run.log |
patched-kernel (#1) run: EFBIG, 3× deterministic, no panic |
env.txt |
guest uname, cc, sysctl, ext2 module, image features |
VERDICT.md |
this file |
manifest.json |
machine-readable catalog |
How to reproduce
On the HOST (Linux, needs mke2fs, debugfs, python3):
python3 craft_img.py df0888.ext2 scp df0888.ext2 dfbsd:/root/df0888.ext2 scp trigger.c dfbsd-maxx:poc/DF-0888/
On the GUEST (build as maxx, run as root):
ssh dfbsd-maxx 'cd poc/DF-0888 && cc -O2 -Wall -o trigger trigger.c'
ssh dfbsd 'cp /home/maxx/poc/DF-0888/trigger /root/trigger && \
vnconfig -c /dev/vn0 /root/df0888.ext2 && \
mount -t ext2fs /dev/vn0 /mnt/df0888 && \
/root/trigger /mnt/df0888/target'
Unpatched kernel: fatal trap 12 in memset+0xf0. Patched kernel: errno=27 (EFBIG).
Fix verification
fixedVALIDATED. fix.diff applies cleanly to /usr/src (both hunks succeeded via patch -p1) and the single-fix nativekernel builds with rc=0. On the unpatched #0 baseline the trigger produced a fatal-trap-12 page fault in memset+0xf0 (heap OOB write). On the patched #1 kernel (kern.version 'Sun Jul 5 18:46:44 UTC 2026'), the SAME trigger returns ftruncate rc=-1 errno=27 (EFBIG) -- ext2_truncate's new structural-limit check rejects the oversized length cleanly -- and the guest stays up. 3/3 runs deterministic, no panic in boot.log. The before/after contrast is unambiguous, so the fix closes the bug.
BEFORE (#0 unpatched): /root/trigger /mnt/df0888/target -> ssh channel died; boot.log: 'Fatal trap 12: page fault while in kernel mode / Stopped at memset+0xf0: movq %r10,(%rdi)' (guest dead in DDB). AFTER (#1 patched, 3x): [trigger] ftruncate returned rc=-1 errno=27 (File too large) / guest status: up / no panic in boot.log.
Confirmed kernel references
- sys/vfs/ext2fs/ext2_inode.c:465
- sys/vfs/ext2fs/ext2_inode.c:251
- sys/vfs/ext2fs/ext2_inode.c:310
- sys/vfs/ext2fs/ext2_inode.c:313
- sys/vfs/ext2fs/ext2_inode.c:322
- sys/vfs/ext2fs/ext2_inode.c:365
- sys/vfs/ext2fs/ext2_inode.c:138
- sys/vfs/ext2fs/ext2_inode.c:140
- sys/vfs/ext2fs/ext2_inode.c:172
- sys/vfs/ext2fs/ext2_vfsops.c:703
- sys/vfs/ext2fs/ext2_inode_cnv.c:148
- sys/vfs/ext2fs/ext2_inode_cnv.c:192
- sys/kern/vfs_syscalls.c:5397
- sys/vfs/ufs/ffs/ffs_inode.c:157
Detail
Exploit chain
Memory-corruption primitive (heap OOB write via underflowed size_t bzero) IS confirmed, but it is blocked from uid0 escalation by a VALID hard blocker: root-only reachability. ext2 mount requires SYSCAP_RESTRICTEDROOT (sys/kern/vfs_syscalls.c:5397 -- any FS not whitelisted as procfs/tmpfs/fuse returns RESTRICTEDROOT; vfs.usermount=0), so an unprivileged user cannot mount a crafted ext2 image and cannot reach ext2_truncate from userspace. There is no privilege boundary for an unpriv user to cross; the vulnerable code runs only after root has already mounted attacker media (root->kernel). The primitive itself is a large heap ZERO-out (bzero, attacker cannot control byte values beyond 'zero'), not a controlled-value overwrite, so even on noinv the ceiling is heap corruption/DoS, not arbitrary-write LPE. Demonstrated impact: panic/DoS from root mounting crafted ext2 media on default GENERIC. No uid0 chain attempted because the unprivileged path does not exist (valid blocker). Chain/grooming files live in harness.c (arithmetic proof) + trigger.c (kernel trigger) + craft_img.py (image patcher).
Evidence (decisive lines)
BASELINE #0 (ftruncate to 5TiB on crafted ext2 file): Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffff8007a6673fc / fault code = supervisor write data, page not present / instruction pointer = 0x8:0xffffffff80bca670 / current process = 984 / Stopped at memset+0xf0: movq %r10,(%rdi) [guest dead in DDB]. HARNESS: lastiblock[TRIPLE]=1341127667 > NINDR^3=1073741824; last=1278>NINDR-1=1023; bzero len=18446744073709550596 (size_t UNDERFLOW); BUG CONFIRMED
PoC changes
Created findings/poc/DF-0888/ from scratch. harness.c transcribes the ext2_ind_truncate/ext2_indirtrunc arithmetic verbatim to prove the OOB index + size_t underflow deterministically. trigger.c opens the patched file and ftruncate()s it to 5TiB. craft_img.py (HOST, uses mke2fs+debugfs+raw inode patch) builds an 8MiB ext2 image whose inode 12 has e2di_size=6TiB (lo|hi), e2di_blocks[14]=block 510 (valid triple-indirect), no EXT4_EXTENTS flag. Required fixes during iteration: debugfs has no -q flag; corrected ext2 superblock field offsets (s_log_block_size@24, s_inodes_per_group@40, s_rev_level@76, s_first_ino@84, s_inode_size@88).
Verified recommended fix
Add a structural-limit check in ext2_truncate (ext2_inode.c:~474): for VREG non-extent inodes, compute maxsize=(EXT2_NDADDR+NINDR+NINDR^2+NINDR^3)*e2fs_bsize and return EFBIG when length>maxsize -- this is the missing analogue of FFS's check at ffs_inode.c:157, needed because e2fs_maxfilesize is INT64_MAX under HUGE_FILE. Plus defense-in-depth clamp in ext2_indirtrunc (ext2_inode.c:~141): if(last>=NINDR(fs)) last=NINDR(fs)-1 so &bap[last+1]/bzero-size can never go OOB. fix.diff supersedes the finding proposal (which only mentioned the ext2_truncate check) by adding the indirtrunc clamp too. Full git-apply-able diff in findings/poc/DF-0888/fix.diff.
Verdict
REPRODUCED. The bug is real and confirmed two independent ways. (1) Deterministic arithmetic harness (harness.c) transcribes ext2_ind_truncate/ext2_indirtrunc verbatim: for length=5TiB @ 4KiB blocks, lastiblock[TRIPLE]=1341127667 > NINDR^3=1073741824 (ext2_inode.c:313), so ext2_indirtrunc(level=TRIPLE) computes last=lastbn/factor=1278 > NINDR-1=1023 (ext2_inode.c:140), making &bap[1279] OOB and the bzero length (1024-1279)*4 underflow size_t to 18446744073709550596 (ext2_inode.c:172-173) -> heap OOB write past bp->b_data. (2) Kernel reproduction on the default GENERIC #0 kernel (INVARIANTS ON): a crafted ext2 image (inode e2di_size=6TiB, e2di_blocks[14]=valid block, no extents) mounted root-only, then ftruncate(fd,5TiB) triggers Fatal trap 12 page fault in memset+0xf0 (supervisor write, page not present) -- the underflowed bzero faulting on the first OOB page. Guest dies in DDB. The root cause is ext2_truncate (ext2_inode.c:465) checking only length<0 with no structural-limit guard; the only maxfilesize check (:251) is on the LENGTHEN branch and e2fs_maxfilesize=INT64_MAX under HUGE_FILE (vfsops.c:703) anyway.
No comments yet.