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

memcchr.c live longword over-read (proven page-contained and result-filtered) + unreachable goto done at :102

Summary

Unlike bcmp.c (dead), memcchr is LIVE in every kernel with the ext2 bitmap scans as sole consumers - and shares the DF-5700 over-read shape: align-down first word reads up to 7B below begin, the word-walk up to 7B past end (2,282,570 below-begin + 61,553 past-end loads measured over 2.6M differential cases). Proven benign by construction: every load is a naturally-aligned granule containing >=1 in-bounds byte (8|4096, cannot straddle a page; 762,160 both-side PROT_NONE guard calls, zero faults) and mismatches in over-read bytes are rejected by the done-filter (10,948/10,948 correct, no disclosure). 2.6M-case differential vs naive reference: 0 failures; DF-5701 truncation shape NOT shared (size_t end-to-end, all 4 consumer lengths bounded at mount). Filed as hardening so future consumers at guard-page boundaries inherit a provably in-bounds primitive; in-bounds-rewrite fix validated in-rig (zero out-of-range loads, differential unchanged). Unreachable goto noted.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5706 Β· 30 files
FileTypeDescriptionSize
README.md β€” 6.2 KB ↓ raw
VERDICT.md β€” 7.3 KB ↓ raw
extract.sh β€” 551 B view raw
build.sh β€” 150 B view raw
run.sh β€” 119 B view raw
shim/prelude.h β€” 634 B view raw
memcchr_kernel.c β€” 1.9 KB view raw
memcchr_body.c β€” 1.8 KB view raw
memcchr_instr.c β€” 4.2 KB view raw
instr_driver.c β€” 4.5 KB view raw
guard_harness.c β€” 4.6 KB view raw
memcchr_fixed.c β€” 1.5 KB view raw
fixed_body.c β€” 1.4 KB view raw
memcchr_fixed_instr.c β€” 3.4 KB view raw
fixed_driver.c β€” 2.6 KB view raw
fix.diff β€” 1.3 KB view raw
build.log β€” 690 B view raw
run.log β€” 1.8 KB view raw
run.2.log β€” 1.8 KB view raw
journal.txt β€” 134 B view raw
env.txt β€” 2.4 KB view raw
logs/guard.out β€” 179 B view raw
logs/instr.out β€” 566 B view raw
logs/fixed.out β€” 216 B view raw
logs/guardfix.out β€” 179 B view raw
logs/kernel_liveness.txt β€” 2.4 KB view raw
logs/scratch/memcchr.c.patched β€” 3.2 KB ↓ download
logs/scratch/memcchr.c.orig β€” 3.3 KB ↓ download
manifest.json β€” 1.7 KB view raw
verdict.json β€” 5.3 KB view raw

DF-5706 β€” sys/libkern/memcchr.c live longword over-read (proven benign) + hardening

What this pack proves (and what it does NOT claim)

sys/libkern/memcchr.c β€” unlike its sibling bcmp.c (DF-5700, latent) β€” is compiled into every DragonFlyBSD kernel (sys/conf/files:2212) and present in the shipped/running guest kernel (ffffffff809d5bf0 T memcchr, disassembly matches this C source; see logs/kernel_liveness.txt).

Its word-at-a-time scanner performs a live over-read:

  • backward: the first-word align-down load at memcchr.c:83-85 reads up to 7 bytes before begin;
  • forward: the word-walk load at memcchr.c:90-91 runs while lp < end, so the last granule can extend up to 7 bytes past begin + n.

Measured on the pristine source (rig, guest): 2,282,570 below-begin loads, 61,553 past-end loads, max excursion 7 B in both directions, across 2,608,384 differential cases (logs/instr.out).

Why it is benign (the claim of this finding: hardening, no impact)

  1. Page containment (proof + empirical). Every load is a naturally aligned sizeof(long) granule that contains at least one in-bounds byte of [begin, begin+n) (the first granule contains begin, the last contains end-1). Since 8 | 4096, a granule never straddles a page, so every load stays inside a page that holds valid buffer bytes β€” it cannot fault and cannot touch an unmapped page. The instrumented rig additionally asserts zero loads outside [align_down(begin), align_up(end)) (g_outside_granule == 0), and a 762,160-call guard sweep with PROT_NONE pages flanking the data page on both sides (offsets 0..4095 Γ— page-edge-critical lengths Γ— 5 content modes) recorded zero faults.
  2. Result filtering. Over-read bytes never escape the function: mismatches found at p >= end are rejected by the done: filter at memcchr.c:114. The rig observed the filter fire 10,948 times and return NULL every time (0 wrong returns across all sweeps).
  3. No disclosure. The loaded words only steer control flow; no byte value past end is ever returned or copied out.
  4. Consumers are bounded. All four call sites live in sys/vfs/ext2fs/ext2_alloc.c (:1358, :1362 inode-bitmap scan of ext2_nodealloccg; :1519, :1523 block-bitmap scan of ext2_mapsearch). Their lengths are bounded to [1, bsize] by mount-time validation: e2fs_fpg == e2fs_bpg == bsize*8 enforced at ext2_vfsops.c:558-572, e2fs_ipg ∈ [ipb, bsize*8] at :582-587 (also excluding the div-by-zero at ext2_alloc.c:1352), and ext2_mapsearch's bpref is normalized to the cg by its only caller ext2_alloccg (ext2_alloc.c:1029-1032). Buffers are bsize-byte buffer-cache bread blocks, so the ≀7-byte same-page over-read stays inside the mapped buffer-cache page.

Also documented (same family as DF-5700's dead branch): the goto done; at memcchr.c:102 is unreachable β€” entering the body requires *lp != word, which guarantees one of the 8 TESTBYTEs jumps first. Harmless; kept as belt and braces.

This finding does NOT share DF-5701's truncation shape: memcchr's length parameter is size_t end-to-end (memcchr.c:60), and no consumer can feed a negative or > bsize length given the mount checks above.

How to reproduce

Run on the host (pack copied anywhere) with the QEMU guest up, or directly inside the guest:

# host: ship the pack in (once)
tar czf df5706.tgz -C findings/poc DF-5706
cat df5706.tgz | ssh -F dfbsd-qemu/config dfbsd-maxx \
    'rm -rf /tmp/rig5706 && mkdir -p /tmp/rig5706 && tar xzf - -C /tmp/rig5706'
dfbsd-qemu/vm.sh run_user 'cd /tmp/rig5706/DF-5706 && sh ./build.sh && sh ./run.sh'

Expected (see run.log, stable across run.2.log):

guard sweep: 762160 calls (NULL=317387 ptr=444773), wrong returns=0
RESULT: PASS - no load escaped the valid page ...
exhaustive differential : 608384 cases, 0 failures
randomized differential : 2000000 cases, 0 failures
  loads below begin      : 2282570 (max 7 B before begin)
  loads past end         : 61553 (max 7 B past end)
  done-filter rejections : 10948 (...)
  loads outside [align_down(begin), align_up(end)) : 0 (MUST be 0)
RESULT: OVERREAD-CONFIRMED (live, <=7B both directions, granule-contained, result-filtered); semantics exact vs reference
fixed differential: 2608384 cases, 0 failures
fixed loads: below-start=0 past-end=0 in-range=11402250 outside-granule=0 ...
RESULT: FIX-PASS - zero loads outside [begin,begin+n), semantics exact
RESULT: PASS - no load escaped the valid page ...   (guard sweep on FIXED build)

Rig construction

  • extract.sh + build.sh compile the verbatim kernel body (sys/libkern/memcchr.c:36-117, byte-identical β€” memcchr_body.c, re-verified by diff; guest /usr/src md5 a200de772658559da4a1217004b8469d == repo) with shim/prelude.h replacing only the four kernel-only includes at :30-34.
  • memcchr_instr.c is the same algorithm with every load site wrapped (LDC/LDB + NOTE); zero differential failures over 2.6M cases proves the instrumentation did not change behavior.
  • guard_harness.c flanks one usable page with PROT_NONE guards on both sides and sweeps every begin-offset Γ— page-edge-critical lengths Γ— content modes (incl. the killer mode: canaries β‰  c everywhere outside [begin,end)).
  • fix.diff (validated, never applied to sys/): bytewise head alignment, full-word loop only while (lp + 1) <= end, bytewise tail β€” zero loads outside [begin, begin+n), semantics identical.

Files

  • build.sh, run.sh, extract.sh, shim/prelude.h β€” rig
  • memcchr_kernel.c (pristine verbatim body, generated), memcchr_body.c
  • memcchr_instr.c, instr_driver.c β€” measurement + differential
  • guard_harness.c β€” containment sweep (used for pristine AND fixed)
  • memcchr_fixed.c (generated: fix.diff applied), memcchr_fixed_instr.c, fixed_driver.c β€” fix validation
  • fix.diff β€” the fix (git-apply-able against the repo root)
  • build.log, run.log, run.2.log, logs/*.out, journal.txt, logs/kernel_liveness.txt, logs/scratch/ (patched + orig kernel file)
  • env.txt β€” guest uname, cc, kernel symbol proof
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-5706 VERDICT β€” memcchr.c live, page-contained longword over-read (Info/hardening)

Status: reproduced (the over-read property, measured and bounded). Impact: none.

Context (pass 2, GLM 5.3)

sys/libkern/memcchr.c was re-audited with the primitives-family checklist that produced DF-5700/5701 on the sibling sys/libkern/bcmp.c: (a) longword walk / over-read containment, (b) length arithmetic (int vs size_t truncation), (c) return semantics, (d) consumer census.

(0) Liveness β€” unlike bcmp.c, memcchr is LIVE

  • sys/conf/files:2212 β€” libkern/memcchr.c standard (bcmp.c has no entry).
  • Shipped guest kernel: nm /boot/kernel/kernel β†’ ffffffff809d5bf0 T memcchr; build object memcchr.o β†’ T memcchr.
  • Disassembly of the running kernel's symbol (logs/kernel_liveness.txt) shows exactly this C implementation: 8-byte pattern replication (shl $0x8/$0x10/$0x20), n==0 early return (test %rdx,%rdx; je), align-down first-word load (and $0xfffffffffffffff8, cmp %rcx,(%r9)).
  • Only consumer family: ext2 bitmaps β€” sys/vfs/ext2fs/ext2_alloc.c:1358,1362 (ext2_nodealloccg, inode bitmap) and :1519,1523 (ext2_mapsearch, block bitmap); both look for the first non-0xff byte (a free bit). ext2fs ships as /boot/kernel/ext2fs.ko; with the default vfs.usermount=0 mounting an image requires root (context for exposure).

(a) The longword walk β€” over-read exists, is live, and is provably benign

Loads: first-word align-down *lp++ at memcchr.c:83-85; word-walk *lp at memcchr.c:90-91 with loop guard lp < end (:90), so the last granule may extend up to sizeof(long)-1 bytes past begin+n when end is unaligned; the first granule may start up to 7 bytes before begin.

Containment proof: every load is a naturally aligned granule containing β‰₯1 in-bounds byte (first granule contains begin; last contains end-1); 8 | 4096 β‡’ granules never straddle pages β‡’ each load lies fully inside a page holding valid buffer bytes. It cannot fault; it cannot reach an unmapped page.

Empirical (guest rig, pristine verbatim body, md5-identical to repo): * instrumented measurement (logs/instr.out): 2,282,570 loads below begin (max 7 B), 61,553 loads past end (max 7 B), 11,404,517 fully in-range, 0 loads outside [align_down(begin), align_up(end)); * guard sweep (logs/guard.out): 762,160 calls, single usable page flanked by PROT_NONE guards on both sides, every begin-offset 0..4095 Γ— small + page-edge-critical lengths (end landing at page offset 4096-k, k=0..9) Γ— 5 content modes β†’ zero faults.

No disclosure: mismatches located at p >= end are rejected by the done: filter (memcchr.c:114); the rig observed 10,948 filter activations, every one correctly returning NULL (0 wrong returns). Loaded over-read bytes never leave the function. (A ≀1-bit timing channel on whether the ≀7 bytes past the buffer equal c is theoretically present β€” same as every strlen-style scanner β€” but for the ext2 consumers those bytes are adjacent bytes of the same buffer-cache block; sub-threshold.)

Conclusion: DF-5700's over-read shape is shared but LIVE here β€” still not a vulnerability. Filed as Info (hardening) with the containment proof.

(b) Length arithmetic β€” DF-5701's shape NOT shared

memcchr(const void *begin, int c, size_t n) (memcchr.c:60) β€” size_t end-to-end inside the function; end = begin + n (:84) cannot wrap for any caller-reachable n. Consumers pass int len but every value is bounded to [1, bsize] by mount-time validation:

  • e2fs_bpg == e2fs_fpg, both nonzero, and bpg == bsize*8 exactly (ext2_vfsops.c:556-572) β‡’ len = howmany(fpg, NBBY) - start = bsize - start ∈ [1, bsize] (ext2_alloc.c:1518); start = dtogd(fs,bpref)/NBBY is bounded because ext2_alloccg (the only caller of mapsearch) forces dtog(fs,bpref) == cg else bpref = 0 (ext2_alloc.c:1029-1030) and converts group-relative (:1032).
  • e2fs_ipg ∈ [e2fs_ipb, bsize*8] (ext2_vfsops.c:582-587) β‡’ inode-bitmap len = howmany(ipg - ipref, NBBY) ≀ bsize (ext2_alloc.c:1357); wrap calls use len = start + 1 ≀ ceil(ipg/8) (:1360-1362, :1521-1523).
  • ipg β‰₯ ipb β‰₯ 1 also excludes the division by zero at ext2_alloc.c:1352 (ipref %= fs->e2fs_ipg).

A crafted ext2 image cannot reach memcchr with an out-of-range length: the superblock is validated at mount, before any bitmap scan.

(c) Return semantics β€” correct

Returns pointer to the first byte β‰  c within [begin, begin+n), or NULL. The byte loops deliberately test into the granule past end (memcchr.c:86 bound is lp, not end) but the p < end filter at :114 guarantees the return domain. Verified by 2,608,384 differential cases vs a naive reference: 0 disagreements (608,384 exhaustive: offsets 0..31 Γ— lengths 0..96 Γ— every mismatch position Γ— 4 c-values with below-begin/past-end canaries; + 2,000,000 randomized). n == 0 returns before any load (:75-76) β€” rig-verified. Also: the goto done; at :102 is unreachable (body entered only when *lp != word, which forces an earlier TESTBYTE jump) β€” dead branch, same family note as DF-5700; harmless.

Fix validation (rig-level, per the established family pattern)

fix.diff replaces the align-down head with bytewise alignment and runs the word loop only while the whole granule is in range ((lp + 1) <= end), plus a bytewise tail β€” zero loads outside [begin, begin+n) in any direction. Applied via patch -p1 to a scratch copy of the pristine kernel file inside the build (never to sys/):

  • fixed instrumented: below-start=0, past-end=0, outside-granule=0; differential 2,608,384 cases, 0 failures (logs/fixed.out);
  • fixed guard sweep: 762,160 calls, PASS (logs/guardfix.out).

No kernel rebuild was performed: the finding is Info/hardening with zero demonstrated impact (nothing to observe in-kernel); the rig compiles the exact kernel source bytes (memcchr_body.c byte-identical to sys/libkern/memcchr.c:36-117, re-diffed; guest /usr/src md5 matches the repo md5 a200de772658559da4a1217004b8469d), which is the same validation level DF-5700 used for this family.

Negative results (classes hunted and killed)

  • OOB write: none β€” function only reads; returns pointers, never writes.
  • Uninitialized p at done: impossible β€” done is reachable only via TESTBYTE after p is assigned (:86, :92).
  • Pointer overflow begin + n: unreachable n (≀ bsize at every call site).
  • Consumer OOB from crafted ext2 images: blocked at mount (ext2_vfsops.c:556-587); div-by-zero (ext2_alloc.c:1352) impossible.
  • Info leak: over-read bytes never returned (filter at :114; measured).
  • ~LONGPTR_MASK int-promotion on 64-bit: (uintptr_t)begin & ~7UL-safe (int βˆ’8 converts to the same mask); #if LONG_BIT guards 32/64 only.
  • Refcount/locks/privilege: N/A (pure leaf function over caller buffers).

Guest left clean: rig confined to /tmp/rig5706 (removed after artifact retrieval); no kernel changes, no panics, guest still up.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied cleanly (patch -p1, hunk at :74) to a scratch copy of the pristine kernel file inside the rig and validated at rig level: instrumented fixed build shows below-start=0, past-end=0, outside-granule=0 over 2,608,384 differential cases (0 failures); both-side guard sweep passes unchanged. No kernel rebuild performed: the finding is Info/hardening with zero demonstrated impact, so there is no in-kernel behavior to observe; the rig compiles the exact kernel source bytes (md5-matched), matching the validation level used for this family in DF-5700.

logs/fixed.out (RESULT: FIX-PASS, 0 out-of-range loads), logs/guardfix.out (RESULT: PASS), logs/scratch/memcchr.c.patched, fix.diff, build.log
↓ fix.diffper-fix-DF-5706

Confirmed kernel references

Detail

Evidence (decisive lines)

logs/instr.out (over-read CONFIRMED: max 7B below/past, 0 outside-granule loads, 10948 filter rejections, differential 0 failures); logs/guard.out (RESULT: PASS, 762160 guarded calls); logs/fixed.out + logs/guardfix.out (fix validation: 0 out-of-range loads, 0 differential failures); logs/kernel_liveness.txt (nm + objdump of shipped kernel's memcchr); build.log/run.log/run.2.log; memcchr_body.c byte-identical to sys/libkern/memcchr.c:36-117

PoC changes

Seed plan (userland differential + guard pages, established rig pattern) required three rig repairs before the authoritative run: (1) guard_harness.c second mprotect call was missing its length argument; (2) instrumented copies needed an explicit LONGPTR_MASK define (the pristine body defines it at :50 but the hand-written instrumented twins did not); (3) drivers needed for ptrdiff_t on DFly gcc8. The pristine kernel body itself was extracted byte-identical (sed 36,117p; re-diffed) with only the four kernel includes (:30-34) replaced by shim/prelude.h; guest /usr/src source md5-matched the repo.

Verified recommended fix

Eliminate the over-read: bytewise head alignment, full-word loop only while (lp+1) <= end, bytewise tail (fix.diff; validated at rig level: zero out-of-range loads, semantics unchanged).

Verdict

The DF-5700-class longword over-read of sys/libkern/memcchr.c is real AND live - unlike bcmp.c this file is compiled into every kernel (sys/conf/files:2212) and the shipped guest kernel's memcchr (0xffffffff809d5bf0, disassembly matches this C source) is this implementation; its only consumers are the ext2 bitmap scans (ext2_alloc.c:1358/1362/1519/1523). Measured on the pristine, byte-identical source: the first-word align-down load (memcchr.c:83-85) reads up to 7 bytes below begin and the word-walk load (memcchr.c:90-91) up to 7 bytes past begin+n (2,282,570 / 61,553 occurrences over 2.6M differential cases). The over-read is provably benign: every load is a naturally-aligned granule containing >=1 in-bounds byte, 8|4096, so no load can straddle a page (instrumented invariant g_outside_granule==0; 762,160-call both-side PROT_NONE guard sweep with page-edge-critical lengths: zero faults), and mismatches found in over-read bytes are rejected by the done-filter at memcchr.c:114 (10,948 observed activations, all correctly returning NULL) so nothing leaks. DF-5701's truncation shape is NOT shared: n is size_t end-to-end (memcchr.c:60) and all consumer lengths are mount-bounded to [1,bsize] (ext2_vfsops.c:556-572 fpg==bpg==bsize8; :582-587 ipg<=bsize8; ext2_mapsearch's bpref normalized by ext2_alloccg:1029-1032). Return semantics exact over 2,608,384 differential cases (0 disagreements). Secondary note: the goto done at :102 is unreachable dead code. Hardening finding only - no exploitable path exists.