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)
PoC verification
Evidence pack
findings/poc/DF-0803 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 amke2fsbase.harness.cβ deterministic userspace transcription of the kernel arithmetic.df0803_g0.img,df0803_g1.imgβ crafted images (built viacraft_img.py).build.sh,run.shβ reproducible build & run.VERDICT.mdβ full narrative analysis.panic.txtβ kernel panic signature fromboot.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 (
#0GENERIC):gcount=0panics on firstlswithStopped at e2fs_gd_get_i_tables: movl 0x28(%rdi),%eax, fault virtual address0x20. - Patched kernel + module (this
fix.diff): bothgcount=0andgcount=1are rejected withmount_ext2fs: β¦: Invalid argument(EINVAL); no panic, no silent mount. Normal ext2 images mount unchanged.
Fix summary
- Compute
gcountasuint64_tbefore the narrowing assignment; rejectgcount64 == 0and the existing upper bound. - Add
ext2_vgetbounds check:if (ino < EXT2_ROOTINO || ino_to_cg(fs, ino) >= fs->e2fs_gcount) return EINVAL;before anye2fs_gd[β¦]dereference.
See fix.diff and VERDICT.md for details.
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)
-
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_bcountisuint64_t(ext2fs.h:159); the 64bit feature is inEXT2F_INCOMPAT_SUPP(ext2fs.h:335) so any image with the0x80incompat bit is accepted byext2_check_sb_compat. -
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_gcountisuint32_t(ext2fs.h:175).howmany(sys/param.h:397) returnsuint64_twhen fed auint64_tdividend; the implicit narrowing touint32_tdiscards the high 32 bits before the range check on the next line. -
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-truncateduint32_t, promoted back touint64_tfor the compare. Max value is2^32 - 1. RHS is2^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 forgcount == 0(the2^32-wrap case) or any other wrap. -
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'smalloc(0)returnsZERO_LENGTH_PTR = (void *)-8(kern_slaballoc.c:193,888-890). The for-loop at:652-678runs 0 iterations (no GD blocks read).ext2_cg_validate(:366-451) iteratesi < 0times β zero validations. Mount returns 0 (success). -
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) callse2fs_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 thefor (i = 0; i < gcount; i++)write loop in both the load (ext2_vfsops.c:665-675) andext2_cg_validate(ext2_vfsops.c:379-448) is bounded by the samegcount, 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 withino_to_cg β₯ 1must be looked up, which requires a crafted root directory entry pointing into group 1. The kernel would thenbread(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 yielduid=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_PTRderef 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 amke2fs-created base image: setse4fs_bcount_hi, theEXT2F_INCOMPAT_64BITfeature,e3fs_desc_size=64, and disablesmetadata_csum/gdt_csumso no checksum gets in the way.harness.cβ deterministic userspace transcription of the kernel's exact arithmetic fore2fs_gcountand 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_ext2fseach crafted image.
Fix (fix.diff)
Two minimal changes:
-
ext2_compute_sb_data(ext2_vfsops.c:620-626): computegcount64asuint64_t, validategcount64 == 0 || gcount64 > 2^32 - DESC_PER_BLOCKbefore 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). -
ext2_vget(ext2_vfsops.c:1338-): bounds-checkino_to_cg(fs, ino) >= fs->e2fs_gcount(andino < EXT2_ROOTINO) before anye2fs_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
fixedVALIDATED. 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).
Confirmed kernel references
- sys/vfs/ext2fs/ext2_vfsops.c:594
- sys/vfs/ext2fs/ext2_vfsops.c:595
- sys/vfs/ext2fs/ext2_vfsops.c:620
- sys/vfs/ext2fs/ext2_vfsops.c:621
- sys/vfs/ext2fs/ext2_vfsops.c:622
- sys/vfs/ext2fs/ext2_vfsops.c:647
- sys/vfs/ext2fs/ext2_vfsops.c:649
- sys/vfs/ext2fs/ext2_vfsops.c:379
- sys/vfs/ext2fs/ext2_vfsops.c:1338
- sys/vfs/ext2fs/ext2_vfsops.c:1358
- sys/vfs/ext2fs/ext2fs.h:159
- sys/vfs/ext2fs/ext2fs.h:175
- sys/vfs/ext2fs/ext2fs.h:248
- sys/vfs/ext2fs/fs.h:108
- sys/vfs/ext2fs/fs.h:111
- sys/kern/kern_slaballoc.c:193
- sys/kern/kern_slaballoc.c:888
- sys/sys/param.h:397
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.
No comments yet.