# 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.
