OOB kernel heap write in ext2_cg_block_bitmap_init via buggy ext2_block_in_group divisor β setbit at wild offset
Summary
ext2_alloc.c:856-861 ext2_get_group_number divides by e2fs_bsize (block size in BYTES e.g. 4096) instead of e2fs_bpg (blocks per GROUP e.g. 32768). Mount enforces bpg==bsize*8 (ext2_vfsops.c:568) so divisor wrong by factor 8. ext2_block_in_group inherits bug. :870-925 ext2_cg_block_bitmap_init when EXT2_BG_BLOCK_UNINIT set: setbit(bp->b_data, tmp-start) at :895/:900/:908. tmp from attacker-controlled group descriptor b_bitmap/i_bitmap/i_tables (64-bit block nr). Under EXT2F_INCOMPAT_FLEX_BG gd pointers can live anywhere in fs. With bitmap pointer placed in group 0 at offset cg*bsize buggy ext2_block_in_group returns TRUE for cg 1..7 but tmp<start=cg*bpg+first_dblock so (uint64)(tmp-start) wraps to ~2^64-7*bsize. setbit indexes bp->b_data[wrapped/8] = wild kernel heap write. ext2_b_bitmap_validate invoked AFTER init (:1006 vs :1016) and skipped under FLEX_BG (:934). Features EXT2F_INCOMPAT_FLEX_BG + EXT2F_ROCOMPAT_GDT_CSUM all in EXT2F_*_SUPP default. Trigger: crafted ext4 image mount then file extension write. Fix: divide by e2fs_bpg + bounds check bit index < bsize*NBBY before setbit.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0811 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic transcription of buggy ext2_get_group_number/ext2_block_in_group + setbit index math with guard-paged allocator | 7.5 KB | view raw |
| craft_img.py | trigger-source | creates crafted ext2 FLEX_BG+GDT_CSUM image; patches group-1 b_bitmap to trigger uint64 wrap; recomputes CRC16 GDT checksum | 8.3 KB | view raw |
| mount_trigger.sh | trigger-source | root-only mount + dd trigger for in-kernel panic | 1.5 KB | view raw |
| build.sh | build-script | cc -O2 -Wall -o harness harness.c | 131 B | view raw |
| run.sh | run-script | runs the deterministic harness | 82 B | view raw |
| build.log | build-log | harness build output (clean, BUILD_EXIT=0) | 13 B | view raw |
| run.log | run-log | harness output: wild byte offset 2.3e18 + SIGSEGV proof | 1.3 KB | view raw |
| panic.txt | panic-signature | Fatal trap 9 GPF at ext2_alloccg+0x811: orb %sil,(%rdx) (the setbit wild write) | 580 B | view raw |
| fix_run.log | run-log | patched ext2fs.ko: mount + dd => NO PANIC, guest alive, files created | 1.0 KB | view raw |
| fix_build.log | build-log | patched ext2fs.ko module build output (rc=0) | 16.7 KB | view raw |
| fix.diff | suggested-fix | divisor fix e2fs_bsize->e2fs_bpg + bounds check before each setbit | 1.6 KB | view raw |
| env.txt | environment | uname, kern.version, cc version, INVARIANTS/SMAP/SMEP/KASLR status | 429 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, citations, reproduction, fix validation | 6.2 KB | β raw |
| README.md | readme | build/run instructions and expected behavior | 1.8 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-0811 β Proof of Concept
Bug
OOB kernel heap write in ext2_cg_block_bitmap_init via buggy
ext2_get_group_number divisor (divides by e2fs_bsize bytes instead of
e2fs_bpg blocks-per-group). Under FLEX_BG + GDT_CSUM, a crafted group
descriptor's block-bitmap pointer wraps the tmp - start uint64 subtraction,
producing a wild setbit(bp->b_data, ~2^64) write.
Files
harness.cβ deterministic transcription of the buggy kernel math with a guard-paged allocator. Proves the wild byte offset without needing a kernel.craft_img.pyβ creates a crafted ext2 image (FLEX_BG + GDT_CSUM) with a patched group-1 descriptor that triggers the uint64 wrap.mount_trigger.shβ root-only mount + write trigger (in-kernel panic).fix.diffβ the verified fix (divisor + bounds check).VERDICT.mdβ full analysis and citations.panic.txtβ the kernel panic signature from the baseline run.fix_run.logβ the patched-module run (no panic).run.logβ harness output.
Build & run (harness β deterministic proof)
./build.sh # cc -O2 -Wall -o harness harness.c
./run.sh # shows wild offset + SIGSEGV
Craft image & trigger in-kernel panic (root on DragonFlyBSD guest)
python3 craft_img.py df0811.img # craft the image (needs mke2fs)
# on the guest:
kldload ext2fs
vnconfig -c /dev/vn0 df0811.img
mount -t ext2fs /dev/vn0 /mnt/df0811
dd if=/dev/zero of=/mnt/df0811/trigger bs=1024 count=1 # PANIC
Expected behavior
- Bug present (unpatched
ext2fs.ko): kernel panic βFatal trap 9: general protection faultatext2_alloccg+0x811: orb %sil,(%rdx). - Bug fixed (patched
ext2fs.ko): dd exits 0, no panic, file created.
Fix
Apply fix.diff to sys/vfs/ext2fs/ext2_alloc.c, rebuild the ext2fs.ko
module (or kernel):
cd /usr/src/sys/vfs/ext2fs && make obj && make
DF-0811 β VERDICT
Verdict: REPRODUCED (panic / OOB kernel heap write). Fix VALIDATED.
Bug summary
ext2_get_group_number() in sys/vfs/ext2fs/ext2_alloc.c:855-861 divides the
block offset by fs->e2fs_bsize (block size in bytes, e.g. 4096) instead
of fs->e2fs_bpg (blocks per group, e.g. 32768). Mount enforces
e2fs_bpg == e2fs_bsize * 8 (ext2_vfsops.c:568), so the divisor is wrong
by a factor of 8. The bug propagates into ext2_block_in_group() (line 864-868).
In ext2_cg_block_bitmap_init() (line 870-925), when EXT2_BG_BLOCK_UNINIT
is set and the filesystem has EXT2F_ROCOMPAT_GDT_CSUM (gate at :1004-1006),
the function calls:
start = (uint64_t)cg * fs->e2fs_bpg + le32toh(fs->e2fs->e2fs_first_dblock); /* :888 */
tmp = e2fs_gd_get_b_bitmap(&fs->e2fs_gd[cg]); /* :892 */
if (!EXT2_HAS_INCOMPAT_FEATURE(fs, EXT2F_INCOMPAT_FLEX_BG) ||
ext2_block_in_group(fs, tmp, cg)) /* BUGGY: returns TRUE for wrong group */
setbit(bp->b_data, tmp - start); /* :895 β WILD WRITE if tmp < start */
Under EXT2F_INCOMPAT_FLEX_BG, a group's block-bitmap pointer (b_bitmap)
may legitimately reside in a different group's block range β mount
validation (ext2_vfsops.c:400-409) uses correct bounds and accepts
this. But ext2_block_in_group() uses the buggy divisor. An attacker
crafts a group descriptor whose b_bitmap is physically in group 0 but
whose buggy group-number computes to the target cg (e.g. b_bitmap=1025
with bsize=1024: buggy (1025-1)/1024 = 1). Then:
start = cg * bpg + first_dblockis large (e.g. 8193 for cg=1).tmp - start = 1025 - 8193wraps asuint64_tto0xFFFFFFFFFFFFE400.setbit(bp->b_data, 0xFFFFFFFFFFFFE400)indexesbp->b_data[0xFFFFFFFFFFFFE400 / 8]β a wild out-of-bounds kernel heap write.
The same buggy gate applies to the inode-bitmap (:898-900) and inode-table
(:906-908) setbit calls. ext2_b_bitmap_validate() (:927-974) would catch
this, but it is called after init (:1016 > :1006) and is skipped under
FLEX_BG (:934-942), so the wild write lands before any sanity check.
setbit(a,i) is ((a)[(i)/NBBY] |= 1<<((i)%NBBY)) (sys/sys/param.h:390).
Confirmed citations
| Fact | Location |
|---|---|
Divisor bug: / e2fs_bsize instead of / e2fs_bpg |
sys/vfs/ext2fs/ext2_alloc.c:859-860 |
ext2_block_in_group inherits the bug |
sys/vfs/ext2fs/ext2_alloc.c:864-868 |
| Wild setbit site (b_bitmap) | sys/vfs/ext2fs/ext2_alloc.c:895 |
| Wild setbit site (i_bitmap) | sys/vfs/ext2fs/ext2_alloc.c:900 |
| Wild setbit site (i_tables loop) | sys/vfs/ext2fs/ext2_alloc.c:908 |
Mount invariant bpg == bsize*8 (wrong by factor 8) |
sys/vfs/ext2fs/ext2_vfsops.c:568 |
| Init gate: GDT_CSUM or METADATA_CKSUM | sys/vfs/ext2fs/ext2_alloc.c:1004-1006 |
| BLOCK_UNINIT gate for init | sys/vfs/ext2fs/ext2_alloc.c:876 |
| Validate called AFTER init | sys/vfs/ext2fs/ext2_alloc.c:1016 (> :1006) |
| Validate SKIPPED under FLEX_BG | sys/vfs/ext2fs/ext2_alloc.c:934-942 |
setbit(a,i) macro = a[i/NBBY] |
sys/sys/param.h:390 |
Correct divisor used elsewhere (dtog macro) |
sys/vfs/ext2fs/fs.h:122-123 |
| Mount validation uses CORRECT bounds (accepts crafted image) | sys/vfs/ext2fs/ext2_vfsops.c:400-409 |
| Block allocator quadratic rehash (skips full cg β cg+1) | sys/vfs/ext2fs/ext2_alloc.c:750-757 |
ext2_alloccg returns 0 immediately if nbfree==0 |
sys/vfs/ext2fs/ext2_alloc.c:994-995 |
Reproduction β two proofs
1. Deterministic harness (harness.c)
Transcribes ext2_get_group_number, ext2_block_in_group, and the setbit
index math verbatim from the kernel source. Shows:
- BUGGY ext2_block_in_group(4096, cg=1) returns 1 (TRUE) β because
(4096-0)/4096 = 1.
- CORRECT ext2_block_in_group(4096, cg=1) returns 0 (FALSE) β because
(4096-0)/32768 = 0.
- setbit bit_index = 4096 - 32768 = 0xFFFFFFFFFFFF9000 (uint64 wrap).
- byte_index = 0x1FFFFFFFFFFFF200 β 2305843009213690368 bytes past the
4096-byte buffer.
- The write faults (SIGSEGV) against a guard-paged mmap β proving OOB.
2. In-kernel panic on #0 GENERIC (real ext2 mount)
Crafted ext2 image (craft_img.py): 16MB, bsize=1024, FLEX_BG + GDT_CSUM,
2 groups. Group 0's nbfree zeroed (allocator skips to cg=1). Group 1's
b_bitmap set to block 1025 (in group 0; buggy group = 1), BLOCK_UNINIT set,
GDT checksum recomputed. Mount + dd β Fatal trap 9: general protection
fault at ext2_alloccg+0x811: orb %sil,(%rdx) (the setbit instruction
dereferencing the wild address). Guest DOWN.
Fatal trap 9: general protection fault while in kernel mode instruction pointer = 0x8:0xffffffff82602ff1 current process = 979 Stopped at ext2_alloccg+0x811: orb %sil,(%rdx) db>
Impact ceiling
Wild kernel heap write from a crafted ext2/ext4 filesystem image mount + file write. The write offset is a non-canonical x86-64 address (~2^60 bytes past the buffer), so on the default GENERIC kernel (INVARIANTS ON) it manifests as an immediate panic (trap 9 GPF). On a kernel without INVARIANTS, the same primitive is a controllable OOB write whose target depends on the buffer's kernel virtual address + the wrapped offset. The trigger is root-only (mount), but the realistic threat model is an admin mounting an attacker-supplied image (USB, VM disk, container layer, downloaded filesystem image).
Fix (fix.diff)
Two changes in sys/vfs/ext2fs/ext2_alloc.c:
1. Root cause: fix the divisor e2fs_bsize β e2fs_bpg in
ext2_get_group_number() (line 860).
2. Defense in depth: add a bounds check tmp >= start && tmp - start <
(uint64_t)fs->e2fs_bsize * NBBY before each of the three setbit calls
(:894, :900, :909), so even if the group-membership test is wrong, the bit
index is clamped to the bitmap buffer size.
Fix validation (Phase 8)
| Phase | Kernel/module | Trigger | Result |
|---|---|---|---|
| Baseline | stock ext2fs.ko (#0, INVARIANTS ON) |
mount crafted image + dd | PANIC (trap 9 at ext2_alloccg+0x811) |
| Patched | rebuilt ext2fs.ko with fix.diff |
same crafted image + dd | NO PANIC, dd RC=0, guest alive, files created |
Clean before/after: the fix closes the bug.
Fix verification
fixedVALIDATED the fix: on the unpatched baseline, mounting the crafted ext2 image + dd triggers Fatal trap 9 GPF at ext2_alloccg+0x811 (panic, guest DOWN). On the single-fix ext2fs.ko module (divisor fixed + bounds check added), the same crafted image mount + dd exits cleanly (DD_RC=0), guest stays alive, files are created with no panic. Clean before/after => fix closes the bug.
BASELINE (unpatched): Fatal trap 9: general protection fault / Stopped at ext2_alloccg+0x811: orb %sil,(%rdx) / db> (guest DOWN). PATCHED: DD_RC=0 / STILL_ALIVE_AFTER_WRITE / 10+0 records in 10+0 records out 40960 bytes / STILL_ALIVE_AFTER_WRITE2 (guest UP, files created).
Confirmed kernel references
- sys/vfs/ext2fs/ext2_alloc.c:859
- sys/vfs/ext2fs/ext2_alloc.c:860
- sys/vfs/ext2fs/ext2_alloc.c:864
- sys/vfs/ext2fs/ext2_alloc.c:868
- sys/vfs/ext2fs/ext2_alloc.c:876
- sys/vfs/ext2fs/ext2_alloc.c:895
- sys/vfs/ext2fs/ext2_alloc.c:900
- sys/vfs/ext2fs/ext2_alloc.c:908
- sys/vfs/ext2fs/ext2_alloc.c:934
- sys/vfs/ext2fs/ext2_alloc.c:1004
- sys/vfs/ext2fs/ext2_alloc.c:1006
- sys/vfs/ext2fs/ext2_alloc.c:1016
- sys/vfs/ext2fs/ext2_vfsops.c:568
- sys/vfs/ext2fs/ext2_vfsops.c:400
- sys/vfs/ext2fs/fs.h:122
- sys/sys/param.h:390
Detail
Exploit chain
Root-only mount trigger (ext2fs mount requires root), so this is a root-to-kernel write, not an unpriv-to-root escalation -- no privilege boundary is crossed (valid hard blocker per Phase 6: write reachable only from an already-root context). The primitive is a wild OOB kernel heap write (uint64-wrapped setbit index ~2^60 bytes past a bsize buffer). On GENERIC (INVARIANTS ON) it manifests as an immediate panic (trap 9 GPF -- the wild address is non-canonical). The wild write content is a single bit-set (1<<shift) at a non-canonical address, so on a noinv kernel it would fault before corrupting a useful victim. Realistic threat model: admin mounts attacker-supplied ext2/ext4 image (USB, VM disk, container layer, downloaded filesystem image). No slab-grooming/uid0 chain is applicable or attempted because the trigger requires root and the write is a non-canonical-address fault. Impact is honestly reported as panic (wild write manifesting as kernel GPF).
Evidence (decisive lines)
PANIC (baseline #0): Fatal trap 9: general protection fault while in kernel mode / instruction pointer = 0x8:0xffffffff82602ff1 / Stopped at ext2_alloccg+0x811: orb %sil,(%rdx) / db>. HARNESS: BUGGY ext2_block_in_group(4096, cg=1) = 1 / bit_index = 0xffffffffffff9000 / byte_index = 2305843009213690368 / write to bp->b_data[+2.3e18] FAULTED (SIGSEGV). FIX (patched ext2fs.ko): DD_RC=0 / STILL_ALIVE_AFTER_WRITE / files created, no panic.
PoC changes
Created findings/poc/DF-0811/ from scratch: harness.c (verbatim transcription of buggy ext2_get_group_number/ext2_block_in_group + setbit math with guard-paged allocator); craft_img.py (creates FLEX_BG+GDT_CSUM ext2 image, patches group-1 b_bitmap to block 1025 for the uint64 wrap, zeroes cg=0 nbfree to force allocator to cg=1, recomputes CRC16 GDT checksums); mount_trigger.sh (vnconfig+mount+dd trigger); build.sh/run.sh; fix.diff (divisor fix e2fs_bsize->e2fs_bpg + bounds check before each setbit); VERDICT.md, manifest.json, env.txt, README.md.
Verified recommended fix
In sys/vfs/ext2fs/ext2_alloc.c:860 change the divisor from fs->e2fs_bsize to fs->e2fs_bpg (root cause). Additionally add a bounds check 'tmp >= start && tmp - start < (uint64_t)fs->e2fs_bsize * NBBY' before each of the three setbit calls at lines 894, 900, 909 (defense in depth). The full git-apply-able diff is in findings/poc/DF-0811/fix.diff. Supersedes finding proposal (finding proposed the same divisor fix + bounds check; this diff implements both and has been build-validated).
Verdict
REPRODUCED. The divisor bug at ext2_alloc.c:859-860 divides by e2fs_bsize (block size in BYTES, e.g. 4096) instead of e2fs_bpg (blocks per GROUP, e.g. 32768); mount enforces bpg==bsize*8 (ext2_vfsops.c:568) so the divisor is wrong by 8x. ext2_block_in_group (ext2_alloc.c:864-868) inherits the bug. In ext2_cg_block_bitmap_init (ext2_alloc.c:870-925), when EXT2_BG_BLOCK_UNINIT is set under FLEX_BG+GDT_CSUM, a crafted group descriptor's b_bitmap pointer physically in group 0 makes the buggy ext2_block_in_group return TRUE for cg=1, causing setbit(bp->b_data, tmp-start) where tmp(1025)-start(8193) wraps uint64 to 0xFFFFFFFFFFFFE400. setbit (param.h:390) indexes bp->b_data[wrap/8] = wild OOB heap write. Validated by two proofs: (1) deterministic harness transcribing the kernel math verbatim showing byte offset 2305843009213690368 + SIGSEGV; (2) in-kernel mount of crafted ext2 image (FLEX_BG+GDT_CSUM, patched group-1 descriptor) + dd -> Fatal trap 9 GPF at ext2_alloccg+0x811: orb %sil,(%rdx) (the setbit instruction dereferencing the wild non-canonical address), guest DOWN.
No comments yet.