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

kfnmatch() diverges from userland fnmatch(3) in four machine-demonstrated ways β€” MAXNEST>=20-star silent FNM_NOMATCH, trailing-backslash literal, signed-char bracket ranges, PATHNAME-unterminated-bracket hard fail

Summary

32.3M-case differential vs libc: 67,195 divergences in four classes - (A) trailing-backslash matches literally in kernel (66,505 cases), (B) bracket ranges compare signed chars so bytes >=0x80 are negative ([a-\xff] fails where libc matches, 651 cases), (C) PATHNAME-unterminated-bracket hard-fails vs libc literal fallback, (D) MAXNEST=20 makes >=20-star patterns silently FNM_NOMATCH. No unprivileged reachability (three ipfw consumers, root-glob patterns, if_xname capped at 15 chars which kills class D); residual risk is root-rule divergence and future consumers matching longer strings. Memory safety machine-proven clean (32.4M guard-page cases, 0 overreads; no stores exist in the matcher). unsigned-char fix validated in-rig (690->39 other-class divergences, targeted probes 4/4 parity); full parity needs the iterative libc algorithm. The devfs/jail consumer hint was refuted (rg).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5692 Β· 20 files
FileTypeDescriptionSize
README.md β€” 3.2 KB ↓ raw
VERDICT.md β€” 5.9 KB ↓ raw
verdict.json β€” 5.8 KB view raw
manifest.json β€” 1.6 KB view raw
shim.h β€” 827 B view raw
fnmatch_k.c β€” 6.1 KB view raw
fnmatch_k_fixed.c β€” 6.1 KB view raw
difftest.c β€” 6.4 KB view raw
guardfuzz.c β€” 4.4 KB view raw
build.sh β€” 98 B view raw
run.sh β€” 90 B view raw
fix.diff β€” 594 B view raw
difftest2.log β€” 2.7 KB view raw
difftest_fixed.log β€” 2.7 KB view raw
guardfuzz.log β€” 84 B view raw
guardfuzz_fixed.log β€” 84 B view raw
fix_build.log β€” 13 B view raw
fix_run.log β€” 2.7 KB view raw
fix_guard_run.log β€” 84 B view raw
env.txt β€” 238 B view raw

DF-5692 β€” kernel kfnmatch() semantic divergences vs userland fnmatch(3) (differential rig)

What this pack proves

Pass-2 audit of sys/libkern/fnmatch.c (225 LOC). The file is memory-safe (machine-proven, see below) and not reachable by unprivileged users (only consumers are ipfw/ipfw3/ip6fw iface_match() with root-installed rule patterns vs kernel-set if_xname strings, flags always 0). What the audit found is a set of semantic divergences between the kernel matcher and the DragonFly userland fnmatch(3) running on the same OS β€” demonstrated by a differential rig that compiles the kernel source verbatim in userland.

Divergence classes (baseline: 67,195 / 32,303,668 corpus cases)

class example kernel libc root cause
A. trailing \ (66,505 cases) pattern \ vs string\ match FNM_NOMATCH fnmatch.c:140-146 treats trailing backslash as literal (POSIX/glibc behavior); libc r254091 returns NOMATCH
B. signed-char ranges (651 cases) pattern [a-\xff] vs \x80 NOMATCH match fnmatch.c:170 char c, c2 + :215 c <= test && test <= c2 β€” bytes β‰₯ 0x80 are negative on signed-char archs (x86_64)
C. unterminated [ + / + FNM_PATHNAME (~39 cases) pattern [\/ vs /[ NOMATCH match fnmatch.c:198-199 returns RANGE_NOMATCH (hard fail) where libc falls back to literal [
D. MAXNEST depth cap pattern *aΓ—20 vs aΓ—22 NOMATCH match fnmatch.c:51,62-63 recursion cap: β‰₯20 star-globs silently fail

Kernel reachability (all four classes: NONE today)

Memory-safety negative result (machine-checked)

guardfuzz.c places pattern and string so the terminating NUL is the last readable byte before a PROT_NONE page: 32,445,104 cases, 0 overreads (baseline and fixed). The file also contains no writes at all β€” it cannot overflow/UAF/leak; worst-case runtime with the 15-char ifname bound is measured at ≀ 0.45 ms (*aΓ—10 failing match vs 16 chars).

Reproduce

On the DragonFly guest (source tree at /usr/src):

./build.sh     # compiles difftest + guardfuzz (baseline and fixed)
./run.sh       # runs the full rig, ~4 minutes

Key artifacts: difftest2.log (baseline differential), guardfuzz.log (overread proof), difftest_fixed.log (after fix.diff), fix.diff (signedness fix, validated: other-class divergences 690 β†’ 39).

VERDICT.md
↓ download raw

DF-5692 β€” VERDICT

Finding: kernel kfnmatch() diverges from userland fnmatch(3) semantics in four machine-demonstrated ways; no class is reachable through any current kernel consumer. Filed as Info (hardening/correctness; no demonstrated kernel impact).

Was it reproduced? Yes β€” as a differential, in a userland rig on the QEMU guest

The audited file sys/libkern/fnmatch.c was compiled verbatim (only the three kernel includes replaced by shim.h; byte-identity verified by diff against /usr/src/sys/libkern/fnmatch.c, md5 8ce762105543b95ae7df67bc6161239c) and differentially tested against DragonFly's own libc fnmatch (/usr/src/lib/libc/gen/fnmatch.c, md5 dea059df6117055afa3dc03ea45a46cd, FreeBSD r254091 multibyte implementation) on the guest (DragonFly 6.5-DEVELOPMENT, gcc 8.3).

Results (difftest2.log):

  • Exhaustive corpus 12-symbol alphabet, patterns len ≀3 Γ— strings len ≀3 Γ— 9 flag combos + dedicated bracket sweep + 300k random: 32,303,668 cases, 67,195 divergences β€” 66,505 trailing-backslash (class A), 690 other (classes B+C, DIVERGE-OTHER samples in log).
  • MAXNEST probe: divergence begins exactly at 20 stars ([maxnest] stars=20 kernel=1 libc=0 <== DIVERGE) β€” fnmatch.c:51 MAXNEST 20, :62-63 early return (FNM_NOMATCH), :112 recursion.
  • Signed ranges: [a-\xff] vs \x80 and \xfe: kernel NOMATCH, libc match ([signed] lines) β€” fnmatch.c:170/215, plain signed char comparisons.

Why the impact ceiling is "none" for the kernel

  1. Only three in-kernel callers exist (rg over sys/): iface_match() at sys/net/ipfw/ip_fw2.c:1355-1360, sys/net/ipfw3_basic/ip_fw3_basic.c:134-140, sys/net/ip6fw/ip6_fw.c:323-330. All pass flags=0 (so FNM_PATHNAME never set β‡’ class C impossible), pattern from a firewall rule, string ifp->if_xname.
  2. Installing a rule requires a raw IP socket: rip_attach β†’ caps_priv_check(SYSCAP_NONET_RAW) sys/netinet/raw_ip.c:473 (IPv6: sys/netinet6/raw_ip6.c:530) β‡’ pattern is root-set.
  3. if_xname is char[IFNAMSIZ], IFNAMSIZ=16 (sys/net/if_var.h:326, sys/net/if.h:54-56) β‡’ ≀15 chars. Class D (MAXNEST) requires a libc- matching pattern with 20 star-globs; a successful path at depth d consumes β‰₯ dβˆ’1 string characters (stars collapse, fnmatch.c:84-87, and β‰₯1 literal is consumed between consecutive recursion levels, :111-118), so β‰₯19 chars are needed β€” unattainable. Class B needs β‰₯0x80 bytes in a rule glob and class A a trailing backslash: root-authored garbage that simply yields a dead rule.
  4. Classes do enable silent firewall-rule failure: a rule written per fnmatch(3) semantics (as validated in userland/shell) can fail to match in the kernel. With ≀15-char ifnames that requires class A/B/C patterns β€” possible only by explicit root misconfiguration.

Memory safety (negative result, machine-checked)

guardfuzz.c copies each pattern/string so the NUL terminator is the final readable byte before a PROT_NONE page; every read past the terminator faults. 32,445,104 cases (exhaustive bracket-heavy corpus + 500k random with high bytes), 0 overreads β€” baseline (guardfuzz.log) and after the fix (guardfuzz_fixed.log). This machine-confirms the manual trace:

  • every pattern++ is preceded by an EOS check: fnmatch.c:66,84-87 (star collapse stops at EOS), 142-145 (trailing \ rewinds), rangematch:191,194-196 (escape+EOS), 204-206 (*(pattern+1) only read when *pattern=='-', in-bounds), 208-210 (escape+EOS), 220 (tail read may read the NUL itself; body's :195-196 EOS check catches it next iteration);
  • *(string-1) reads (:78,:91,:126) are guarded by string != stringstart;
  • index() (:99,:105) is NUL-bounded;
  • the function performs zero writes β€” overflow/UAF/double-free classes are structurally absent; no copyout β‡’ no info leak.

Runtime bound (negative result for ReDoS)

Worst-case failing match *aΓ—k + b vs 16 chars (IFNAMSIZ bound): ≀0.45 ms ([time-ifname-worst] stars=10 n=16 kernel: 0.000449 s). Work grows ~Γ—5.5 per +4 string chars ([time-long-worst]: n=20β†’4.3 ms, n=24β†’31 ms, n=28β†’165 ms): exponential in string length (β‰ˆC(n,k) chains), but the only kernel string is bounded at 15 chars β€” no livelock is constructible through current consumers. MAXNEST=20 additionally bounds recursion depth (21 small frames max) β‡’ no stack exhaustion.

Fix validation

fix.diff (3-line signedness fix: unsigned char c, c2; + two (unsigned char)test casts in rangematch) applied to the rig copy:

  • baseline difftest2.log: 67,195 divergences (66505 A + 690 B/C)
  • patched difftest_fixed.log: 66,544 divergences (66505 A + 39 C) β†’ class B (651 cases) eliminated; targeted [signed] probes 4/4 kernel==libc after the fix; classes A/C/D unaffected as expected (they are algorithm-level; full unification requires adopting the libc r254091 iterative/multibyte algorithm, out of scope for a 3-line patch).
  • guard harness on patched source: 0 overreads.

fix_status: fixed (for class B, which the diff targets); fix_baseline_reproduced=1, fix_patched_reproduced=0.

Bottom line

sys/libkern/fnmatch.c is memory-safe and unreachable by unprivileged input; the real defects are semantic (four divergence classes vs the OS's own libc, most notably the MAXNEST=20 silent-fail cap and signed-char ranges). No privilege escalation, leak, or DoS exists through any current consumer; the finding is filed as Info hardening with a validated minimal fix for the signedness class and a recommendation to unify with the libc algorithm.

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 (unsigned char c,c2 + two (unsigned char)test casts in rangematch) eliminates the signed-range divergence class in the rig: baseline 690 other-class divergences -> 39 after fix (all remaining are the separate unterminated-bracket/PATHNAME class), targeted [signed] probes agree with libc 4/4 afterwards, guard-page harness still 0 overreads on the patched source. Classes A/C/D are algorithm-level and require adopting the libc algorithm; documented in VERDICT.md, not addressed by this diff.

difftest_fixed.log: 'TOTAL divergences: 66544 / 32303668 cases (trailing-backslash class: 66505, other: 39)' and [signed] lines showing kernel=0 libc=0 for [a-\xff] cases; fix_guard_run.log: 0 overreads; compare difftest2.log baseline 'other: 690'.
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 x86_64 (rig-level validation: verbatim kernel source compiled userland; kernel rebuild not required for an Info/no-impact finding)

Confirmed kernel references

Detail

Evidence (decisive lines)

difftest2.log: TOTAL divergences 67195/32303668 (trailing-backslash 66505, other 690) and [maxnest] first divergence at 20 stars and [signed] kernel=1 libc=0 for [a-\xff] vs 0x80/0xfe; guardfuzz.log: 32445104 cases, 0 overreads; difftest_fixed.log: TOTAL 66544 (other 39) after fix.diff; [time-ifname-worst] stars=10 n=16 kernel: 0.000449 s; VERDICT.md for the full per-class trace with kernel citations.

PoC changes

No runnable seed existed; built the full rig: shim.h (kernel headers swapped for userland equivalents, flag values verified identical to sys/sys/libkern.h:177-186), fnmatch_k.c (verbatim kernel source, include-swap only, diff-verified), difftest.c (32.2M-case differential + bracket sweep + MAXNEST probe + signed-range probes + worst-case timing), guardfuzz.c (guard-page overread detector), fnmatch_k_fixed.c + fix.diff (signedness fix A/B). First calibration run hit the exponential path on a 40-char/26-star probe and was killed; probes rescaled to string length k+2.

Verified recommended fix

Apply the 3-line unsigned-char signedness fix to rangematch() (fix.diff); for full semantic parity with userland fnmatch(3) (trailing-backslash, PATHNAME-unterminated-bracket, MAXNEST>=20 silent NOMATCH), replace the recursive kernel matcher with the iterative algorithm from lib/libc/gen/fnmatch.c (byte-oriented variant).

Verdict

sys/libkern/fnmatch.c is memory-safe and unreachable by unprivileged input (only consumers are ipfw/ipfw3/ip6fw iface_match() with root-installed rule globs matched against kernel-set if_xname, flags=0, raw-socket creation priv-gated at raw_ip.c:473/raw_ip6.c:530). A differential rig compiling the kernel source verbatim (md5-verified against /usr/src) against DragonFly libc fnmatch on the guest found 67,195 divergences in 32,303,668 cases across four classes: (A) trailing backslash treated as literal where libc returns FNM_NOMATCH (66,505; fnmatch.c:140-146); (B) signed-char bracket ranges wrong for bytes >=0x80 (651; fnmatch.c:170,215); (C) unterminated '[' + '/' under FNM_PATHNAME hard-fails where libc falls back to literal (~39; fnmatch.c:198-199); (D) MAXNEST=20 recursion cap makes >=20-star-glob patterns silently FNM_NOMATCH (fnmatch.c:51,62-63,112). None is reachable through current consumers: flags are always 0 (kills C), ifname globs are root-authored ASCII (kills A/B), and if_xname is capped at 15 chars (IFNAMSIZ=16, if_var.h:326) while a successful depth-20 match needs >=19 string chars (kills D). Guard-page harness: 0 overreads in 32,445,104 cases (no write exists in the file; no leak surface). Worst-case runtime at the 16-byte ifname bound is 0.45 ms, so no ReDoS either. Impact ceiling: silent misbehavior of root-authored firewall globs only; filed as Info hardening. fix.diff (unsigned-char ranges) validated in-rig: class B eliminated (690->39 other-class divergences, targeted probes 4/4 kernel==libc after fix).