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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0811 Β· 16 files
FileTypeDescriptionSize
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
README.md readme build/run instructions and expected behavior
↓ download 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 fault at ext2_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
VERDICT.md verdict full narrative: mechanism, citations, reproduction, fix validation
↓ download raw

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_dblock is large (e.g. 8193 for cg=1).
  • tmp - start = 1025 - 8193 wraps as uint64_t to 0xFFFFFFFFFFFFE400.
  • setbit(bp->b_data, 0xFFFFFFFFFFFFE400) indexes bp->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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (stock kernel #0 + patched ext2fs.ko module built from /usr/src/sys/vfs/ext2fs with fix.diff applied, hot-swapped via kldload)

Confirmed kernel references

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.