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

Integer truncation in e2fs_gcount computation bypasses overflow check β€” heap OOB from crafted ext2 64bit image

Summary

ext2_vfsops.c:620 fs->e2fs_gcount=howmany(bcount-first_dblock,blocks_per_group) β€” howmany returns uint64 but e2fs_gcount is uint32 (ext2fs.h:175) TRUNCATES before check. :622 if(e2fs_gcount > (1ULL<<32)-DESCS_PER_BLOCK) β€” compares already-truncated uint32 against uint64 threshold near 2^32 only catches 64 values [4294967232,4294967295]. EXT2F_INCOMPAT_64BIT (0x80) in EXT2F_INCOMPAT_SUPP default. e4fs_bcount_hi at :595 reads high 32 bits. gcount=0: howmany(2^47,2^15)=2^32 truncated to 0 malloc(0)=ZERO_LENGTH_PTR(-8) ext2_cg_validate 0 iterations mount succeeds ext2_vget ino_to_fsba fs->e2fs_gd[0] deref (-8) panic. gcount=1: malloc(4096) 64 GD entries only entry 0 validated by ext2_cg_validate entries 1-63 attacker-controlled from GD block. Root dir references inode group>=1 ext2_vget fs->e2fs_gd[cg] unvalidated attacker block pointers arbitrary disk block read/write. group>=64: OOB past 4096-byte allocation heap corruption. Fix: uint64_t gcount64=howmany(...) check before assign to uint32 + ext2_vget bounds check ino_to_cg<gcount.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0803 Β· 21 files
FileTypeDescriptionSize
craft_img.py trigger-source python superblock patcher; produces df0803_g{0,1,64}.img from an mke2fs base 7.8 KB view raw
harness.c trigger-source deterministic userspace transcription of the kernel's e2fs_gcount arithmetic + downstream malloc/validate/deref flow 6.1 KB view raw
df0803_g0.img crafted-image 1 MB ext2 image, gcount wraps to 0 (howmany = 2^32) β€” panics on first inode deref 1.0 MB ↓ download
df0803_g1.img crafted-image 1 MB ext2 image, gcount wraps to 1 (howmany = 2^32 + 1) β€” mount succeeds, only GD[0] validated 1.0 MB ↓ download
build.sh build-script cc -O2 -Wall -o harness harness.c 236 B view raw
run.sh run-script runs harness; if root, vnconfig+mount_ext2fs each crafted image 1.3 KB view raw
build.log build-log harness build output on guest 421 B view raw
run.log run-log harness run output (deterministic arithmetic for all 4 variants) 2.0 KB view raw
panic.txt panic-signature Fatal trap 12 in e2fs_gd_get_i_tables, fault addr 0x20 (baseline #0) 1.5 KB view raw
boot.log.baseline-confirm boot-log raw serial log of the baseline re-confirmation run 13.7 KB ↓ download
boot.log.pre-df0803 boot-log raw serial log of the very first reproduction 13.7 KB ↓ download
boot.log.fix-test2 boot-log raw serial log of the patched-kernel #1 + patched-module test (no panic) 13.5 KB ↓ download
fix.diff suggested-fix git apply-able: uint64 gcount + ext2_vget bounds check (supersedes finding proposal) 1.9 KB view raw
fix_build.log build-log Phase 8 nativekernel build summary (rc=0, ~8 min) 1.8 KB view raw
fix_run.log run-log Phase 8 validation: gcount=0/1 rejected with EINVAL, normal ext2 mounts OK 2.0 KB view raw
env.txt environment guest uname, cc version, sysctls 804 B view raw
VERDICT.md verdict full narrative analysis: mechanism, impact, fix 7.8 KB ↓ raw
README.md readme human-readable reproduction guide 3.0 KB ↓ raw
manifest.json manifest this file 4.6 KB view 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 reproduction guide
↓ download raw

DF-0803 β€” Integer-truncation in e2fs_gcount

Integer-truncation bug in ext2_vfsops.c:620 (ext2_compute_sb_data). The kernel computes the number of block groups as

fs->e2fs_gcount = howmany(fs->e2fs_bcount - first_dblock,
                          EXT2_BLOCKS_PER_GROUP(fs));

but e2fs_gcount is uint32_t (ext2fs.h:175) while e2fs_bcount is uint64_t (ext2fs.h:159). howmany returns uint64_t, so the implicit narrowing to uint32_t discards the high 32 bits before the post-assignment check at ext2_vfsops.c:622 ever sees the value. That check (if (gcount > 2^32 - DESC_PER_BLOCK)) only catches the top 64 values of the uint32_t range β€” every wrap-through-zero (or any other wrap) bypasses it.

Variants

Variant howmany true value gcount stored Effect
g0 2^32 0 malloc(0) β†’ ZERO_LENGTH_PTR (-8); mount "succeeds"; first ls derefs e2fs_gd[0] at addr 0x20 β†’ panic
g1 2^32 + 1 1 malloc(4096) (64 GD entries); only GD[0] validated; mount proceeds silently
g64 2^32 + 64 64 fills the 4096-byte allocation exactly

Files in this folder

  • craft_img.py β€” produces crafted ext2 images from a mke2fs base.
  • harness.c β€” deterministic userspace transcription of the kernel arithmetic.
  • df0803_g0.img, df0803_g1.img β€” crafted images (built via craft_img.py).
  • build.sh, run.sh β€” reproducible build & run.
  • VERDICT.md β€” full narrative analysis.
  • panic.txt β€” kernel panic signature from boot.log (baseline #0).
  • fix.diff β€” the verified fix (git apply-able).
  • fix_build.log, fix_run.log β€” Phase 8 build + validation logs.
  • manifest.json β€” artifact catalog.

How to reproduce

# 1. Craft the images (host with mke2fs)
python3 craft_img.py gcount=0 df0803_g0.img
python3 craft_img.py gcount=1 df0803_g1.img

# 2. On the DragonFly guest (as root, with /dev/vn available)
vnconfig -c vn0 /path/to/df0803_g0.img
mount_ext2fs -o ro /dev/vn0 /mnt     # succeeds
ls /mnt                              # PANIC: e2fs_gd_get_i_tables @ movl 0x28(%rdi)

Expected outcome (per variant)

  • Unpatched kernel (#0 GENERIC): gcount=0 panics on first ls with Stopped at e2fs_gd_get_i_tables: movl 0x28(%rdi),%eax, fault virtual address 0x20.
  • Patched kernel + module (this fix.diff): both gcount=0 and gcount=1 are rejected with mount_ext2fs: …: Invalid argument (EINVAL); no panic, no silent mount. Normal ext2 images mount unchanged.

Fix summary

  1. Compute gcount as uint64_t before the narrowing assignment; reject gcount64 == 0 and the existing upper bound.
  2. Add ext2_vget bounds check: if (ino < EXT2_ROOTINO || ino_to_cg(fs, ino) >= fs->e2fs_gcount) return EINVAL; before any e2fs_gd[…] dereference.

See fix.diff and VERDICT.md for details.

VERDICT.md verdict full narrative analysis: mechanism, impact, fix
↓ download raw

DF-0803 β€” Integer-truncation in e2fs_gcount (ext2_vfsops.c:620)

Verdict

REPRODUCED + FIX VALIDATED. The integer-truncation bug is real, deterministically demonstrable in a userspace harness, and causes an unconditional kernel panic on the default GENERIC kernel when a 1 MB crafted ext2 image is mounted. The fix (compute gcount as uint64_t before assigning to the uint32_t field, plus an ext2_vget bounds check) rejects the malicious images cleanly with EINVAL and exhibits no regression on a normal ext2 filesystem.

Mechanism (every hop cited path:line)

  1. Attacker-controlled 64-bit block count. ext2_vfsops.c:594-598: c if (EXT2_HAS_INCOMPAT_FEATURE(fs, EXT2F_INCOMPAT_64BIT)) { fs->e2fs_bcount |= (uint64_t)(le32toh(es->e4fs_bcount_hi)) << 32; ... } e2fs_bcount is uint64_t (ext2fs.h:159); the 64bit feature is in EXT2F_INCOMPAT_SUPP (ext2fs.h:335) so any image with the 0x80 incompat bit is accepted by ext2_check_sb_compat.

  2. The truncating assignment. ext2_vfsops.c:620-621: c fs->e2fs_gcount = howmany(fs->e2fs_bcount - le32toh(es->e2fs_first_dblock), EXT2_BLOCKS_PER_GROUP(fs)); e2fs_gcount is uint32_t (ext2fs.h:175). howmany (sys/param.h:397) returns uint64_t when fed a uint64_t dividend; the implicit narrowing to uint32_t discards the high 32 bits before the range check on the next line.

  3. The post-truncation check that doesn't catch truncation. ext2_vfsops.c:622-626: c if (fs->e2fs_gcount > ((uint64_t)1 << 32) - EXT2_DESCS_PER_BLOCK(fs)) { ... return (EINVAL); } LHS is the already-truncated uint32_t, promoted back to uint64_t for the compare. Max value is 2^32 - 1. RHS is 2^32 - 64 (with 4 KB block / 64-byte 64bit GD). So the check only fires for the 64 values [2^32 - 64, 2^32 - 1] β€” and never for gcount == 0 (the 2^32-wrap case) or any other wrap.

  4. Downstream effect for gcount == 0. ext2_vfsops.c:638-650: e2fs_descpb = 64, e2fs_gdbcount_alloc = howmany(0, 64) = 0, e2fs_gdbcount = 0, fs->e2fs_gd = malloc(0 * 4096, ...). DragonFly's malloc(0) returns ZERO_LENGTH_PTR = (void *)-8 (kern_slaballoc.c:193,888-890). The for-loop at :652-678 runs 0 iterations (no GD blocks read). ext2_cg_validate (:366-451) iterates i < 0 times β€” zero validations. Mount returns 0 (success).

  5. The panic on first inode access. The mount-VFS_ROOT call chain reaches ext2_vget(EXT2_ROOTINO=2) (ext2_vfsops.c:1337-). ino_to_cg(fs, 2) = (2-1)/ipg = 0 (fs.h:108). ino_to_fsba (fs.h:111-112) calls e2fs_gd_get_i_tables(&fs->e2fs_gd[0]), which dereferences address (-8) + offsetof(ext2_gd, ext4bgd_i_tables_hi)*… = 0xFFFFFFFFFFFFFFF8 + 0x28, i.e. 0x20. The kernel page-faults:

Fatal user address access from kernel mode from ls at ffffffff82601960 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x20 instruction pointer = 0x8:0xffffffff82601960 Stopped at e2fs_gd_get_i_tables: movl 0x28(%rdi),%eax

(Fault address 0x20 = ZERO_LENGTH_PTR + 0x28. Deterministic.)

Variants characterized

Variant true howmany stored gcount Mount result on #0 GENERIC
gcount=0 2^32 0 mount OK, panic on first ls
gcount=1 2^32 + 1 1 mount OK, ls returns 0 entries; only GD[0] validated; GD[1..63] populated from disk GD block but never validated
gcount=64 2^32 + 64 64 mount OK; exactly fills 4096-byte alloc; all entries validated by cg_validate
gcount=65 2^32 + 65 65 mount OK; needs 8192-byte alloc (gdbcount_alloc=2), 65 entries validated

The harness (harness.c) transcribes all of these deterministically.

Impact ceiling (honest)

  • Primary demonstrated impact: kernel panic / local DoS. Any user able to cause the kernel to mount (or auto-mount, fsck, or otherwise evaluate the superblock of) a 1 MB crafted ext2 image can panic the system deterministically. On the audit guest vfs.usermount=0, so mounting requires root; the realistic threat is a malicious image presented to root (USB stick, downloaded VM image, mount-on-insert, backup-restore, fsck-on-boot), which is the standard "crafted filesystem" threat model.

  • Heap-OOB-write claim (gcount β‰₯ 65): Not confirmed. The GD allocation is always howmany(gcount, descpb) * bsize, and the for (i = 0; i < gcount; i++) write loop in both the load (ext2_vfsops.c:665-675) and ext2_cg_validate (ext2_vfsops.c:379-448) is bounded by the same gcount, so the in-bounds relationship holds. There is no slab-corruption primitive on the default GENERIC kernel from this bug.

  • Arbitrary-disk-block read/write (gcount = 1): Characterized but not escalated. With gcount=1, malloc(4096) allocates 64 GD entries, all populated from the on-disk GD block, but only GD[0] is validated. To exercise GD[1..63] an inode with ino_to_cg β‰₯ 1 must be looked up, which requires a crafted root directory entry pointing into group 1. The kernel would then bread(devvp, attacker_block, …) on the mounted device β€” i.e. read/write inside the same disk image the attacker already controls. This is not a privilege-boundary crossing (the attacker already owns the image bytes), so it does not yield uid=0. There is no path from this bug to kernel-memory corruption on GENERIC.

  • Valid Phase-6 hard blocker (read-only / disk-only primitive): The bug's only write capability is the ZERO_LENGTH_PTR deref at a fixed kernel address (0xFFFFFFFFFFFFFFF8 + offset), which traps immediately β€” it is a read-class fault, not a write primitive. There is no surviving corrupted state after the panic, so there is no escalation chain to develop. This is the explicit Phase-6 "valid hard blocker: read-only primitive / panic-only" case.

PoC & harness

  • craft_img.py β€” produces three crafted 1 MB images (gcount=0, gcount=1, gcount=64) by binary-patching the superblock of a mke2fs-created base image: sets e4fs_bcount_hi, the EXT2F_INCOMPAT_64BIT feature, e3fs_desc_size=64, and disables metadata_csum / gdt_csum so no checksum gets in the way.
  • harness.c β€” deterministic userspace transcription of the kernel's exact arithmetic for e2fs_gcount and the downstream malloc/validate/deref flow. Proves the truncation bug without needing the kernel; built and run on the guest.
  • run.sh β€” runs the harness, then (as root) vnconfig + mount_ext2fs each crafted image.

Fix (fix.diff)

Two minimal changes:

  1. ext2_compute_sb_data (ext2_vfsops.c:620-626): compute gcount64 as uint64_t, validate gcount64 == 0 || gcount64 > 2^32 - DESC_PER_BLOCK before the narrowing assignment. This catches the wrap-to-0 case (and the gcount=1 wrap case where true howmany = 2^32 + 1 > 2^32 - 64).

  2. ext2_vget (ext2_vfsops.c:1338-): bounds-check ino_to_cg(fs, ino) >= fs->e2fs_gcount (and ino < EXT2_ROOTINO) before any e2fs_gd[…] deref. Defense in depth against future field-size mismatches and against corrupted images that survive mount-time validation.

Both changes apply cleanly with git apply and compile under the GENERIC KERNCONF. The patched kernel + module rejects every variant of the malicious image with EINVAL while still mounting normal ext2 filesystems without regression.

The fix supersedes the finding markdown's proposal (which suggested only the pre-assignment check); the runner additionally adds the ext2_vget bounds check as defense-in-depth.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. df0803_g0.img (gcount wraps to 0) and df0803_g1.img (gcount wraps to 1) both panic-or-silently-mount on the unpatched #0 baseline (#0 confirmed panic in e2fs_gd_get_i_tables at fault addr 0x20). On the patched #1 kernel + patched ext2fs.ko, both are rejected with 'mount_ext2fs: /dev/vn0: Invalid argument' (EINVAL) BEFORE the e2fs_gd allocation/deref path is reached - no panic. A normal mke2fs-created ext2 image still mounts cleanly on the patched kernel (regression test: MOUNT_EXIT=0, ls shows lost+found, umount OK). The fix closes the bug.

baseline #0 (df0803_g0.img): MOUNT_EXIT=0; ls /mnt -> Fatal trap 12: page fault, fault virtual address 0x20; Stopped at e2fs_gd_get_i_tables: movl 0x28(%rdi),%eax. patched #1 (df0803_g0.img): mount_ext2fs: /dev/vn0: Invalid argument; MOUNT_EXIT=71 (no panic). patched #1 (df0803_g1.img): mount_ext2fs: /dev/vn0: Invalid argument; MOUNT_EXIT=71 (no panic). patched #1 (normal_ext2.img regression): MOUNT_EXIT=0; ls /mnt -> lost+found (clean mount & umount).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Jul 5 15:52:17 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (single-fix kernel + ext2fs.ko, nativekernel KERNCONF=X86_64_GENERIC, ~8 min)

Confirmed kernel references

Detail

Exploit chain

none (panic-only / valid Phase-6 hard blocker: read-class fault). The bug's only write effect is the ZERO_LENGTH_PTR deref at a fixed kernel address (0xFFFFFFFFFFFFFFF8 + 0x28 = 0x20), which traps immediately as a supervisor-read page fault - there is no surviving corrupted state, so there is no escalation chain to develop. The 'heap OOB / arbitrary disk-block read' variants claimed in the finding were characterized and ruled out as privilege-boundary crossings: (a) the GD allocation is always howmany(gcount, descpb)*bsize and the per-group loops are bounded by the same gcount, so no slab overflow; (b) the gcount=1 'unvalidated GD[1..63]' path reads/writes disk blocks within the mounted image the attacker already controls, not kernel memory. No path to uid=0 exists from this bug on the default GENERIC kernel. Harness transcribing the kernel arithmetic is in harness.c; image crafter in craft_img.py; crafted images df0803_g0.img / df0803_g1.img bundled.

Evidence (decisive lines)

Baseline #0 (df0803_g0.img mount + ls): Fatal user address access from kernel mode from ls at ffffffff82601960; Fatal trap 12: page fault while in kernel mode; fault virtual address = 0x20; Stopped at e2fs_gd_get_i_tables: movl 0x28(%rdi),%eax. Harness (deterministic, gcount=0 variant): true howmany(bcount-fdb, bpg) = 4294967296 (0x100000000); stored e2fs_gcount (u32) = 0 (truncation!); post-trunc check trips? = NO (mount proceeds); e2fs_gd alloc size = 0 (ptr=0xfffffffffffffff8); ext2_cg_validate iterations = 0.

PoC changes

Created findings/poc/DF-0803/ from scratch. craft_img.py binary-patches an mke2fs base image (sets e4fs_bcount_hi=0x8000 for gcount=0, =0x8000 plus low=1 for gcount=1; sets EXT2F_INCOMPAT_64BIT in e2fs_features_incompat; sets e3fs_desc_size=64; clears METADATA_CKSUM/GDT_CSUM so no checksum blocks validation). harness.c transcribes the kernel's exact e2fs_gcount arithmetic + downstream malloc/cg_validate/ino_to_cg deref flow to prove the truncation deterministically. fix.diff is git-apply-able against sys/vfs/ext2fs/ext2_vfsops.c.

Verified recommended fix

Two minimal changes in sys/vfs/ext2fs/ext2_vfsops.c. (1) At line 620 in ext2_compute_sb_data: compute gcount as uint64_t gcount64 = howmany(...) BEFORE the narrowing assignment; reject if gcount64 == 0 || gcount64 > (1ULL<<32) - EXT2_DESCS_PER_BLOCK(fs); only then assign fs->e2fs_gcount = (uint32_t)gcount64. (2) In ext2_vget at line 1338: add a bounds check 'if (ino < EXT2_ROOTINO || ino_to_cg(fs, ino) >= fs->e2fs_gcount) return EINVAL;' before any e2fs_gd[] deref. SUPERSEDES the finding markdown's proposal (which suggested only the pre-assignment check) by additionally adding the ext2_vget defense-in-depth bounds check.

Verdict

REPRODUCED. The integer-truncation bug at sys/vfs/ext2fs/ext2_vfsops.c:620 is real: howmany() returns uint64 (param.h:397) from a uint64 dividend (e2fs_bcount, ext2fs.h:159) but is assigned to uint32_t e2fs_gcount (ext2fs.h:175), discarding the high 32 bits BEFORE the post-assignment check at :622 (which can only catch the top 64 uint32 values). For bcount=2^47 with the 64bit incompat feature, howmany wraps to 2^32 and truncates to 0; malloc(0) returns ZERO_LENGTH_PTR (-8) per kern_slaballoc.c:888-890; ext2_cg_validate iterates 0 times; mount succeeds; the first ext2_vget(EXT2_ROOTINO=2) derefs e2fs_gd[0] at addr -8+0x28=0x20 and page-faults. Confirmed deterministically by the userspace harness AND by mounting df0803_g0.img on the #0 GENERIC kernel - panic signature 'Stopped at e2fs_gd_get_i_tables: movl 0x28(%rdi),%eax', fault virtual address 0x20, captured in panic.txt. The gcount=1 variant (howmany wraps to 2^32+1) mounts silently with only GD[0] validated.