ext2_dx_csum OOB heap read via unvalidated htree entry count
Summary
ext2_csum.c:280-281 limit=h_entries_max count=h_entries_num from disk. :282-284 validates limit*8<=bsize-tail but NEVER checks count<=limit. :253 size=count_offset+count*sizeof(htree_entry). :261 calculate_crc32c(buf,size). count=65535 size=524288 reads ~520KB past 4K dir buffer. Reachable POST-mount by ANY user: ls/stat/readdir triggers ext2_blkatoff->ext2_dir_blk_csum_verify->ext2_dx_csum_verify. Panic or heap over-read.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0877 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic C harness transcribing ext2_dx_csum/ext2_dx_csum_verify verbatim; proves OOB read length 524312 + page-boundary SIGSEGV | 10.4 KB | view raw |
| craft_img.py | trigger-source | host-side image crafter: mke2fs -O metadata_csum,dir_index,^64bit + debugfs bmap + binary-patch htree count=65535 | 8.8 KB | view raw |
| df0877.ext2 | crafted-input | 4MB crafted ext2 image (htree count=65535, metadata_csum on, 64bit off) | 4.0 MB | β download |
| df0877_control.ext2 | crafted-input | control image with count=1 (legitimate, for path comparison) | 4.0 MB | β download |
| df0877_clean.ext2 | crafted-input | clean ext2 image (no poisoning, for regression test) | 4.0 MB | β download |
| build.sh | build-script | cc -O2 -o harness harness.c | 379 B | view raw |
| run.sh | run-script | ./harness; documents in-kernel reproduce steps | 1.1 KB | view raw |
| build.log | build-log | harness compile output | 18 B | view raw |
| run.log | run-log | harness run output (CASE A/B/C + SIGSEGV) | 798 B | view raw |
| inkernel_run.raw.log | run-log | in-kernel unprivileged trigger: mount OK + ls EIO (OOB read happened), guest survives | 575 B | view raw |
| fix_baseline.raw.log | run-log | baseline (unpatched ext2fs.ko) in-kernel reproduction: mount OK + EIO | 69 B | view raw |
| fix_run.log | run-log | patched ext2fs.ko in-kernel result: mount OK + EIO (count>limit early return, no OOB read), root dir OK (no regression) | 283 B | view raw |
| fix_build.log | build-log | full patched ext2fs.ko module build output (rc=0) | 16.3 KB | view raw |
| panic.txt | panic-signature | no kernel panic observed; OOB read walks mapped buffer-cache pages silently (info-leak); harness demonstrates the page-fault path deterministically | 1.2 KB | view raw |
| fix.diff | suggested-fix | git-apply-able two-hunk fix: if (count > limit) return EIO/return; in ext2_dx_csum_verify and ext2_dx_csum_set | 832 B | view raw |
| env.txt | environment | guest uname, compiler version, ext2fs module info, sysctls | 359 B | view raw |
| VERDICT.md | verdict | full narrative: mechanism, impact, fix validation | 11.1 KB | β raw |
| README.md | readme | summary + reproduce instructions | 2.7 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-0877 β ext2_dx_csum OOB heap read via unvalidated htree entry count
Status: REPRODUCED | Impact: leak (info-leak / potential panic) | Confidence: certain
Summary
ext2_dx_csum_verify (sys/vfs/ext2fs/ext2_csum.c:268) reads count
(h_entries_num) and limit (h_entries_max) from disk. It validates
limit*8 <= bsize-tail but NEVER checks count <= limit. The unvalidated
count drives a CRC32C read of 32 + count*8 bytes from a bsize-byte
directory block buffer. With count=65535, that is a 524312-byte read β
523288 bytes (511 KB) past a 1024-byte buffer.
Reachable POST-mount by ANY unprivileged user with read permission on the
mountpoint: ls/stat/readdir triggers
ext2_blkatoff β ext2_dir_blk_csum_verify β ext2_dx_csum_verify β ext2_dx_csum.
Files
| File | Description |
|---|---|
harness.c |
Deterministic C harness transcribing ext2_dx_csum verbatim |
craft_img.py |
Host-side image crafter (mke2fs + debugfs + binary patch) |
df0877.ext2 |
4MB crafted ext2 image with poisoned htree count=65535 |
df0877_control.ext2 |
Control image with count=1 (legitimate) |
df0877_clean.ext2 |
Clean ext2 image (no poisoning, for regression) |
build.sh / run.sh |
Exact build/run commands |
fix.diff |
Git-apply-able fix: if (count > limit) return EIO; |
VERDICT.md |
Full narrative with path:line citations |
Reproduce
A) Deterministic harness (no kernel required)
./build.sh && ./run.sh
Expected: CASE B shows OOB read = 523288 bytes (511 KB) past the buffer;
CASE C shows SIGSEGV during dx_csum OOB read at the predicted page boundary.
B) In-kernel (root mounts, unprivileged triggers)
Host:
python3 craft_img.py df0877.ext2 4
scp df0877.ext2 dfbsd:/root/
Guest (root setup):
kldload ext2fs
vnconfig -c vn0 /root/df0877.ext2
mkdir -p /mnt/t1
mount_ext2fs -o ro /dev/vn0 /mnt/t1
Guest (unprivileged maxx):
ls /mnt/t1/testdir/
# ls: /mnt/t1/testdir/: Input/output error (EIO β OOB read happened during csum verify)
C) Fix validation (Phase 8)
# Apply fix, rebuild module, hot-swap:
scp fix.diff dfbsd:/root/
ssh dfbsd 'cd /usr/src && patch -p1 < /root/fix.diff'
ssh dfbsd 'cd /usr/src/sys/vfs/ext2fs && make'
ssh dfbsd 'kldunload ext2fs; cp /usr/obj/usr/src/sys/vfs/ext2fs/ext2fs.ko /boot/kernel/ext2fs.ko; kldload ext2fs'
# Re-run: ls /mnt/t1/testdir/ still returns EIO but the 520KB OOB read is prevented
# (count>limit early return at ext2_csum.c:285, before ext2_dx_csum is called)
Fix
if (count > limit) return EIO; in ext2_dx_csum_verify (and return; in
ext2_dx_csum_set), after the existing limit check, before calling
ext2_dx_csum. See fix.diff.
DF-0877 β ext2_dx_csum OOB heap read via unvalidated htree entry count
Verdict
REPRODUCED. The bug is real: ext2_dx_csum_verify reads count
(h_entries_num) from disk and validates limit (h_entries_max) against the
block size but NEVER checks count <= limit. The unvalidated count drives
a CRC32C read of count_offset + count*8 bytes from a bsize-byte directory
block buffer. With count=65535, that is a 524312-byte read β 523288 bytes
(511 KB) past a 1024-byte buffer. The fix.diff adds if (count > limit)
return EIO; and is VALIDATED by hot-swapping a patched ext2fs.ko and
confirming the poisoned directory is rejected cleanly (EIO) without the OOB
read, with no regression on legitimate images.
Mechanism (trigger β primitive β effect)
The vulnerable code
ext2_dx_csum_verify (sys/vfs/ext2fs/ext2_csum.c:268-293):
limit = le16toh(cp->h_entries_max); /* :280 β from disk */
count = le16toh(cp->h_entries_num); /* :281 β from disk, NOT validated */
/* :282-284 β limit IS validated against block size */
if (count_offset + (limit * sizeof(struct ext2fs_htree_entry)) >
ip->i_e2fs->e2fs_bsize - sizeof(struct ext2fs_htree_tail))
return (EIO);
/* *** NO count <= limit CHECK HERE *** */
tp = (struct ext2fs_htree_tail *)(((struct ext2fs_htree_entry *)cp) + limit);
calculated = ext2_dx_csum(ip, ep, count_offset, count, tp); /* :287 */
ext2_dx_csum (ext2_csum.c:240-266):
size = count_offset + (count * sizeof(struct ext2fs_htree_entry)); /* :253 */
...
crc = calculate_crc32c(crc, (uint8_t *)buf, size); /* :261 β OOB READ */
sizeof(struct ext2fs_htree_entry) = 8 (sys/vfs/ext2fs/htree.h:58-61).
With count=65535 and count_offset=32:
size = 32 + 65535 * 8 = 524312 bytes
The buffer buf = (char *)ep = bp->b_data is a single directory block of
bsize (1024) bytes, allocated by bread() in the buffer cache. The
calculate_crc32c call reads 524312 bytes starting at buf, walking
523288 bytes (511 KB) past the 1024-byte buffer into adjacent kernel
memory.
Post-mount reachability (UNPRIVILEGED)
The caller chain from readdir/lookup/stat:
ext2_readdir (ext2_lookup.c:185)
β ext2_blkatoff (ext2_subr.c:78)
β bread(vp, ..., bsize, &bp) /* reads bsize bytes from disk */
β ext2_dir_blk_csum_verify(ip, bp) /* ext2_subr.c:95 */
β ext2_get_dx_count(ip, ep, NULL) /* ext2_csum.c:311 β is this an htree block? */
β ext2_dx_csum_verify(ip, ep) /* ext2_csum.c:312 */
β ext2_dx_csum(ip, ep, count_offset, count, tp) /* ext2_csum.c:287 */
β calculate_crc32c(crc, buf, 524312) /* ext2_csum.c:261 β OOB */
Any user with read permission on the mountpoint triggers this via
ls, stat, or readdir on a directory whose first block is forged to
look like an htree root. The OOB read happens during the checksum
computation at ext2_csum.c:261, BEFORE the checksum comparison at :289
β so even though the csum mismatches (causing EIO), the 520 KB over-read
already occurred.
Image-crafting attacker model
Attacker controls an ext2 filesystem image. To trigger the OOB read they:
mke2fs -t ext2 -b 1024 -O metadata_csum,dir_index,^64bit(enables the dx_csum path and skips 64-bit desc_size validation).- Create a directory (the root or a subdirectory).
- Patch the directory's first data block to look like an htree root:
-
.dirent (reclen=12) +..dirent (reclen=bsize-12, stretched to kill the dirent tail so the dx_csum path runs, not the dirent_csum path) - htree_root_info at offset 24 (reserved1=0, info_len=8) - htree_count at offset 32: h_entries_max=1 (limit), h_entries_num=65535 (count) - Mount the image (root), then any unprivileged user reads the directory.
craft_img.py performs all steps using mke2fs + debugfs + binary patching.
Reproduction
A) Deterministic C harness (no kernel required)
harness.c transcribes ext2_dx_csum and ext2_dx_csum_verify verbatim
and runs against a 1024-byte buffer placed at the end of a page with a
PROT_NONE guard page. Three cases:
| Case | count | limit | Result |
|---|---|---|---|
| A | 1 | 1 | size=40, OOB=0 (legitimate) |
| B | 65535 | 1 | size=524312, OOB=523288 bytes (511 KB) |
| C | 65535 | 1 | full read faults at predicted page boundary (SIGSEGV) |
Output (run.log):
[A] count=1 limit=1 (legitimate):
size=40 oob=0 (expected size=40, oob=0)
[B] count=65535 limit=1 (attacker-controlled):
limit check PASSED (limit=1: 32+8=40 <= 1016)
ext2_dx_csum size = 524312 bytes
buffer size = 1024 bytes
OOB read = 523288 bytes (511 KB) past the buffer
[C] Invoking ext2_dx_csum_faulting (full ext2_csum.c:261 read):
buf at 0x800473c00 (end of page 1); PROT_NONE guard at 0x800474000
Expecting SIGSEGV near 0x800474000 (start of guard page).
[!] SIGSEGV during dx_csum OOB read at addr 0x0000000800474000
B) In-kernel manifestation (unprivileged, post-mount)
On the default 6.5-DEVELOPMENT #0 GENERIC kernel (INVARIANTS ON):
# Root mounts the crafted image: vnconfig -c vn0 /root/df0877.ext2 mount_ext2fs -o ro /dev/vn0 /mnt/t1 # Unprivileged maxx (uid=1001) triggers the OOB read: $ ls /mnt/t1/testdir/ ls: /mnt/t1/testdir/: Input/output error # EIO from dx_csum_verify $ echo $? 1
The mount succeeds (the image is structurally valid β all metadata checksums
are correct; only the directory data block is patched). The ls triggers
ext2_blkatoff β ext2_dir_blk_csum_verify β ext2_dx_csum_verify β ext2_dx_csum
which reads 524312 bytes from the 1024-byte buffer. The csum mismatches β EIO.
The OOB read already happened.
No kernel panic was observed on this heap layout β the 520 KB read walked through adjacent MAPPED buffer-cache pages without hitting an unmapped page. This is the info-leak manifestation (same class as DF-0876): the read is silent, and the leaked bytes are fed into a CRC32C and discarded (blind leak). On a production kernel with different heap layout (or a smaller buffer cache), the read crosses an unmapped page and page-faults (panic/DoS). Both outcomes are valid manifestations of the OOB read.
Impact ceiling
- Class: OOB heap read (CWE-125). Read-only primitive β no write, no UAF, no type confusion. No escalation chain is possible.
- Info leak: the 520 KB read is "blind" β the result feeds a csum and is compared/discarded. The attacker cannot directly observe the leaked bytes. On this heap layout (GENERIC, INVARIANTS ON), the read is silent. The realistic impact is primarily a potential panic/DoS on heap layouts where the read hits unmapped memory.
- Potential panic: heap-layout-dependent; the 520 KB read will eventually cross an unmapped page on many kernel configurations, DoSing the system.
- Privilege boundary: the mount requires root (
SYSCAP_RESTRICTEDROOT), but the trigger is unprivileged β any local user with read permission on the mountpoint can trigger the OOB read vials/stat/readdir. The realistic threat: admin mounts attacker-supplied ext2 media (USB, downloaded image), any local user triggers a ~520 KB heap disclosure or DoS. - No escalation attempted: read-only primitive β no
uid=0chain to develop. Valid hard blocker per Phase 6 ("the primitive is genuinely read-only").
PoC changes
Built the entire evidence pack from scratch:
harness.cβ deterministic C harness transcribingext2_dx_csumandext2_dx_csum_verifyverbatim. Three cases (legitimate count=1, attacker count=65535, faulting full read) proving the 524312-byte OOB read.craft_img.pyβ host-side image crafter.mke2fs -O metadata_csum,dir_index,^64bitdebugfsto find the testdir block + binary patch the htree count to 65535 (with ".." reclen stretched to kill the dirent tail so the dx_csum path runs). Producesdf0877.ext2.df0877.ext2β 4MB crafted ext2 image with the poisoned htree count.build.sh/run.shβ exact reproduce commands.fix.diffβ git-apply-able two-hunk fix (see below).
Recommended fix (fix.diff)
Add if (count > limit) return EIO; (and return; in the _set variant)
in BOTH ext2_dx_csum_verify and ext2_dx_csum_set, after the existing
limit validation and before calling ext2_dx_csum. This rejects any
filesystem with h_entries_num > h_entries_max β an impossible state for a
legitimate htree β and prevents the unvalidated count from driving the
CRC32C read length.
/* ext2_csum.c:284-285 (verify) / :362-363 (set) */
if (count > limit)
return (EIO); /* or return; in ext2_dx_csum_set */
This matches the finding markdown's initial proposal (clamp count to limit / reject count > limit). It is the minimal targeted fix at the root cause.
Fix validation (Phase 8)
Built a single-fix ext2fs.ko module by applying fix.diff to the
in-guest /usr/src/sys/vfs/ext2fs/ext2_csum.c and running make in
sys/vfs/ext2fs/ (~15 s, gcc 8.3, MODULE_BUILD_RC=0). Hot-swapped via
kldunload ext2fs && cp .../ext2fs.ko /boot/kernel/ext2fs.ko && kldload ext2fs.
| Test | Unpatched ext2fs.ko |
Patched ext2fs.ko |
|---|---|---|
| Poisoned image (count=65535) | EIO β ext2_dx_csum called, 524312-byte OOB read happens (silent on this heap layout) |
EIO β count > limit early return at :285, ext2_dx_csum NEVER called, no OOB read |
| Legitimate image (no htree) | mounts + reads OK | mounts + reads OK (no regression) |
| Root dir (legitimate dirents) | reads OK | reads OK |
Both return EIO for the poisoned image, but the internal behavior is
fundamentally different: the fix's count > limit check at line 285
returns BEFORE the ext2_dx_csum call at line 289, so the 520 KB OOB read
cannot occur. The harness proves what the OOB read WOULD do without the fix;
the fix prevents it entirely.
Patched module SHA256: 152aa381ba010ca9563ce0dea1622070ec0847c21ef585043899e2f3c257a1ab
Unpatched module SHA256: 497134c238ec6f4b42bd04f9c49d658e3698dc6f4ba7948bc68a1f48eedf29fb
fix_status: fixed (OOB read prevented on patched module; legitimate-image
regression test clean).
Kernel references
sys/vfs/ext2fs/ext2_csum.c:240-266βext2_dx_csum(the OOB read)sys/vfs/ext2fs/ext2_csum.c:253βsize = count_offset + count*8(unvalidated count)sys/vfs/ext2fs/ext2_csum.c:261βcalculate_crc32c(crc, buf, size)(the OOB read)sys/vfs/ext2fs/ext2_csum.c:268-293βext2_dx_csum_verify(missing count check)sys/vfs/ext2fs/ext2_csum.c:280-284β limit validated, count NOTsys/vfs/ext2fs/ext2_csum.c:286-287β passes unvalidated count to ext2_dx_csumsys/vfs/ext2fs/ext2_csum.c:296-318βext2_dir_blk_csum_verify(caller)sys/vfs/ext2fs/ext2_csum.c:339-364βext2_dx_csum_set(symmetric bug, also fixed)sys/vfs/ext2fs/ext2_subr.c:78-106βext2_blkatoff(the post-mount entry point)sys/vfs/ext2fs/ext2_lookup.c:185βext2_readdircallsext2_blkatoffsys/vfs/ext2fs/htree.h:53-61βstruct ext2fs_htree_count/ext2fs_htree_entry(count=4B, entry=8B)
Fix verification
fixedVALIDATED the fix: on the unpatched ext2fs.ko (497134c2...), the poisoned image (htree count=65535) triggers ext2_dx_csum_verify which calls ext2_dx_csum performing the 524312-byte OOB read (harness-proven; in-kernel manifests as EIO with the read having already happened). On the patched ext2fs.ko (152aa381...), the 'if (count > limit) return EIO;' check at ext2_csum.c:285 fires BEFORE ext2_dx_csum is called at :289, so the 520KB OOB read CANNOT occur. Both return EIO to userspace (the corrupted block is rejected either way), but the fix eliminates the OOB read entirely. Legitimate ext2 image (no htree) mounts and reads correctly on the patched module -- no regression. Module built clean (MODULE_BUILD_RC=0, gcc 8.3).
baseline (unpatched 497134c2): mount OK; ls /mnt/t1/testdir/ -> 'Input/output error' (EIO) -- ext2_dx_csum called, 524312-byte OOB read HAPPENED (harness-proven length). patched (152aa381): mount OK; ls /mnt/t1/testdir/ -> 'Input/output error' (EIO) -- count>limit (65535>1) early return at :285, ext2_dx_csum NEVER called, NO OOB read. Root dir (legitimate dirents): ROOT_LS_RC=0 (no regression). Guest alive.
Confirmed kernel references
- sys/vfs/ext2fs/ext2_csum.c:240
- sys/vfs/ext2fs/ext2_csum.c:253
- sys/vfs/ext2fs/ext2_csum.c:261
- sys/vfs/ext2fs/ext2_csum.c:268
- sys/vfs/ext2fs/ext2_csum.c:280
- sys/vfs/ext2fs/ext2_csum.c:281
- sys/vfs/ext2fs/ext2_csum.c:282
- sys/vfs/ext2fs/ext2_csum.c:286
- sys/vfs/ext2fs/ext2_csum.c:287
- sys/vfs/ext2fs/ext2_csum.c:296
- sys/vfs/ext2fs/ext2_csum.c:311
- sys/vfs/ext2fs/ext2_csum.c:312
- sys/vfs/ext2fs/ext2_csum.c:339
- sys/vfs/ext2fs/ext2_subr.c:78
- sys/vfs/ext2fs/ext2_subr.c:95
- sys/vfs/ext2fs/ext2_lookup.c:185
- sys/vfs/ext2fs/htree.h:53
- sys/vfs/ext2fs/htree.h:58
Detail
Exploit chain
none -- read-only primitive (CWE-125 OOB heap read). The 524312-byte read result feeds a CRC32C and is compared/discarded (blind leak). There is no write, no UAF, no type confusion. This is a valid Phase 6 hard blocker: 'the primitive is genuinely read-only.' No escalation chain is possible. The realistic threat ceiling is info-leak (520KB heap over-read) and potential panic/DoS on heap layouts where the read hits unmapped memory -- both reachable by unprivileged users post-mount.
Evidence (decisive lines)
[B] count=65535 limit=1 (attacker-controlled): limit check PASSED (limit=1: 32+8=40 <= 1016) / ext2_dx_csum size = 524312 bytes / buffer size = 1024 bytes / OOB read = 523288 bytes (511 KB) past the buffer. [C] Invoking ext2_dx_csum_faulting (full ext2_csum.c:261 read): [!] SIGSEGV during dx_csum OOB read at addr 0x0000000800474000. In-kernel (unprivileged maxx uid=1001): $ ls /mnt/t1/testdir/ -> ls: /mnt/t1/testdir/: Input/output error (OOB read happened during dx_csum_verify; guest survives -- info-leak manifestation).
PoC changes
Built the entire evidence pack from scratch. harness.c transcribes ext2_dx_csum/ext2_dx_csum_verify verbatim (3 cases: legitimate count=1, attacker count=65535 proving 524312-byte OOB, faulting variant proving SIGSEGV at predicted page boundary). craft_img.py uses mke2fs -O metadata_csum,dir_index,^64bit + debugfs to find the testdir block + binary-patch the htree count to 65535 (with '..' reclen stretched to kill the dirent tail so the dx_csum path runs, not the dirent_csum path). Also created df0877_control.ext2 (count=1) and df0877_clean.ext2 (no poisoning) for path-verification and regression tests. Fixed DragonFlyBSD-specific includes (sys/endian.h, unistd.h) in the harness.
Verified recommended fix
Add 'if (count > limit) return (EIO);' in ext2_dx_csum_verify and 'if (count > limit) return;' in ext2_dx_csum_set, after the existing limit*8 check and before calling ext2_dx_csum (ext2_csum.c:285 and :363). This rejects any filesystem with h_entries_num > h_entries_max -- an impossible state for a legitimate htree -- preventing the unvalidated count from driving the CRC32C read length. Matches the finding markdown's initial proposal. Full git-apply-able diff in findings/poc/DF-0877/fix.diff.
Verdict
REPRODUCED. The bug is real: ext2_dx_csum_verify (ext2_csum.c:268-293) reads count (h_entries_num) from disk at :281 and validates limit at :282-284 but NEVER checks count <= limit. The unvalidated count drives ext2_dx_csum (:287) which computes size=count_offset+count*8 (:253) and reads that many bytes via calculate_crc32c(crc, buf, size) at :261. With count=65535 and sizeof(htree_entry)=8 (htree.h:58-61), size=32+524280=524312 bytes -- 523288 bytes (511 KB) past the 1024-byte directory block buffer. The deterministic harness proves this read length with SIGSEGV at the predicted page boundary. The in-kernel path is reachable POST-mount by ANY unprivileged user via readdir: ext2_readdir:185 -> ext2_blkatoff:95 -> ext2_dir_blk_csum_verify:312 -> ext2_dx_csum_verify -> ext2_dx_csum. Confirmed in-kernel: mount_ext2fs succeeds on the crafted image, ls /mnt/t1/testdir as uid=1001 maxx returns EIO (the dx_csum path ran, 520KB OOB read happened before the csum comparison at :289). No panic on this heap layout (adjacent buffer-cache pages mapped -- info-leak manifestation, same class as DF-0876); would panic on layouts with unmapped pages.
No comments yet.