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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0877 Β· 21 files
FileTypeDescriptionSize
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
README.md readme summary + reproduce instructions
↓ download 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.

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

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:

  1. mke2fs -t ext2 -b 1024 -O metadata_csum,dir_index,^64bit (enables the dx_csum path and skips 64-bit desc_size validation).
  2. Create a directory (the root or a subdirectory).
  3. 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)
  4. 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 via ls/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=0 chain 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 transcribing ext2_dx_csum and ext2_dx_csum_verify verbatim. 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,^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). Produces df0877.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).

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 baseline with hot-swapped patched ext2fs.ko (SHA256 152aa381ba010ca9563ce0dea1622070ec0847c21ef585043899e2f3c257a1ab)

Confirmed kernel references

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.