DragonFlyBSD Kernel Audit
← triage · dashboard
DF-0888

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0888 · 17 files
FileTypeDescriptionSize
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
README.md readme human reproduction guide
↓ download 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 fault at memset+0xf0. Guest dead in DDB.
  • Patched #1 (fix.diff): ftruncate returns errno=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.

VERDICT.md verdict full narrative: mechanism, proof, reachability, fix validation
↓ download raw

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_fscapSYSCAP_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 noinv the 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 5 18:46:44 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

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.