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)
PoC verification
Evidence pack
findings/poc/DF-5692 Β· 20 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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)
- Consumers:
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β allkfnmatch(pattern, ifp->if_xname, 0). - Pattern side root-gated: raw-socket attach
caps_priv_check(SYSCAP_NONET_RAW)(sys/netinet/raw_ip.c:473, IPv6:sys/netinet6/raw_ip6.c:530). - String side kernel-bounded:
if_xname[IFNAMSIZ], IFNAMSIZ=16 (sys/net/if_var.h:326,sys/net/if.h:54-56) β max 15 chars. - Class D needs a successful libc match at recursion depth 20, which consumes β₯19 string chars (one literal between consecutive collapsed stars, fnmatch.c:84-87,111-118) β impossible with a 15-char ifname.
- Classes A-C need
\, high bytes, or[//-with-PATHNAME in the pattern; consumer flags are always 0 (no PATHNAME) and ifname globs are ASCII β only a misconfigured root rule could express them.
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).
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-OTHERsamples in log). - MAXNEST probe: divergence begins exactly at 20 stars (
[maxnest] stars=20 kernel=1 libc=0 <== DIVERGE) β fnmatch.c:51MAXNEST 20, :62-63 earlyreturn (FNM_NOMATCH), :112 recursion. - Signed ranges:
[a-\xff]vs\x80and\xfe: kernel NOMATCH, libc match ([signed]lines) β fnmatch.c:170/215, plain signedcharcomparisons.
Why the impact ceiling is "none" for the kernel
- Only three in-kernel callers exist (rg over
sys/):iface_match()atsys/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 passflags=0(so FNM_PATHNAME never set β class C impossible), pattern from a firewall rule, stringifp->if_xname. - 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. if_xnameischar[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.- 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 bystring != 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
fixedfix.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'.
Confirmed kernel references
- sys/libkern/fnmatch.c:51
- sys/libkern/fnmatch.c:62-63
- sys/libkern/fnmatch.c:112
- sys/libkern/fnmatch.c:140-146
- sys/libkern/fnmatch.c:170
- sys/libkern/fnmatch.c:198-199
- sys/libkern/fnmatch.c:215
- 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
- sys/netinet/raw_ip.c:473
- sys/netinet6/raw_ip6.c:530
- sys/net/if_var.h:326
- sys/net/if.h:54-56
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).
No comments yet.