Kernel strtoq/strtouq take the 0x/0X prefix without a following hex digit, violating the C99 strtol endptr contract and diverging from DragonFly's own userland libc on the same input
Summary
The 1990 Torek body consumes '0x' on (*s=='x') alone: when no hex digit follows, endptr=nptr discards the legitimate leading '0' and reports NO conversion where C99 mandates subject '0' with endptr past it - exactly what DFly's own libc does (the userland template gates on isxdigit(s[1]); the kernel copy was never updated). Proven on-guest: 43x9x2 differential vs guest libc, 28 endptr divergences exactly the '0x'+non-xdigit class, deterministic x3, ZERO value divergences (kernel saturation bit-identical and strictly fail-closed). Consumers: ng_parse EINVALs tokens libc validators accept; kgetenv_quad/hammer_str_to_tid/isp WWN likewise (root/loader-controlled). isxdigit-lookahead fix validated in-rig (0 residual divergences, 0 regressions). Memory safety clean; strtouq '-' acceptance C99-conformant; the ERANGE-absence is DF-2230's root (not re-filed).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5702 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 1.9 KB | β raw | |
| VERDICT.md | β | 7.9 KB | β raw | |
| diffharness.c | β | 6.0 KB | view raw | |
| kernel_strtoq_shim.h | β | 812 B | view raw | |
| build.sh | β | 311 B | view raw | |
| run.sh | β | 107 B | view raw | |
| build_full.log | β | 1.5 KB | view raw | |
| run.log | β | 14.5 KB | view raw | |
| run.1.log | β | 14.5 KB | view raw | |
| run2.log | β | 14.5 KB | view raw | |
| k_strtoq.c | β | 4.0 KB | view raw | |
| k_strtouq.c | β | 3.0 KB | view raw | |
| k_strtoq_fix.c | β | 4.1 KB | view raw | |
| k_strtouq_fix.c | β | 3.1 KB | view raw | |
| fix.diff | β | 871 B | view raw | |
| env.txt | β | 538 B | view raw | |
| guest_status.txt | β | 542 B | view raw | |
| manifest.json | β | 1.6 KB | view raw | |
| verdict.json | β | 5.1 KB | view raw |
DF-5702 β kernel strtoq/strtouq "0x" prefix consumed without hex-digit lookahead (C99 endptr contract violation; kernel/libc parser desync)
sys/libkern/strtoq.c:72-77 / sys/libkern/strtouq.c:70-75
Reproduce (on the DragonFly guest)
scp -F dfbsd-qemu/config kernel_strtoq_shim.h diffharness.c build.sh run.sh dfbsd-maxx:/tmp/df5702/
vm.sh run_user 'cd /tmp/df5702 && ./build.sh && ./run.sh'
- build: seds the untouched
/usr/src/sys/libkern/{strtoq,strtouq}.c(cksum verified against the repo) into userland-compilablek_strtoq.c/k_strtouq.cplus fix-only variants, compiles with guest cc 8.3. - run: 43-input Γ 9-base corpus Γ signed/unsigned, comparing kernel vs libc VALUE / endptr-offset / ERANGE.
Expected
SUMMARY: kernel-vs-libc VALUE divergences : 0 kernel-vs-libc ENDPTR divergences : 28 <- DF-5702 (all and only "0x"+non-xdigit, bases 0/16) invalid-base semantic diffs : 258 <- legacy BSD class, no kernel caller affected saturation w/o ERANGE (DF-2230 class) : 79 <- known DF-2230 root cause, not re-reported patched-kernel residual ENDPTR diffs : 0 <- fix validated patched-kernel VALUE regressions : 0 harness exit: 0
Deterministic: identical md5 across 3 runs (run.1.log / run.log / run2.log).
Impact: fail-closed semantic divergence only (kernel stricter than libc); no memory-safety impact, no value desync. Severity Low.
Artifacts
diffharness.c,kernel_strtoq_shim.hβ harnessbuild.sh,run.shβ exact commandsbuild_full.logβ full untrimmed build output (guest)run.log(run 1, via tee),run.1.log(copy),run2.log(guest-side run)k_strtoq.c,k_strtouq.cβ guest-sed copies of the kernel sourcesk_strtoq_fix.c,k_strtouq_fix.cβ fix-only variantsfix.diffβ git-apply-able one-guard fix (apply-tested)env.txtβ guest uname/compiler/sysctlguest_status.txtβ build/run exit codes
DF-5702 β VERDICT
File: sys/libkern/strtoq.c (129 LOC) with its duplicated-body sibling
sys/libkern/strtouq.c (107 LOC) β the kernel quad parser pair.
Finding: C99 strtol-family endptr/prefix contract violation: the kernel
strtoq/strtouq consume the 0x/0X prefix without confirming that a
hex digit follows, so inputs whose C99 subject sequence is exactly "0"
are reported as no conversion (*endptr = nptr, return 0) instead of
"parsed 0, *endptr at the x". This diverges from DragonFly's own userland
libc (strtoqβstrtoll/strtouqβstrtoull, built from
lib/libc/stdlib/_strtol.h:83-91 and _strtoul.h:86-94, both of which gate
the prefix on isxdigit(s[1])), producing kernel/userland parser desync on
the shared ASCII-integer surface.
Reproduced? YES (status: reproduced, impact class: none β semantic contract divergence)
Guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026, cc 8.3
[DragonFly]. The guest /usr/src/sys/libkern/{strtoq,strtouq}.c are
byte-identical to the audited repo tree (cksum 3699317468 4185 /
3988050194 3159 β see build_full.log).
Method (the established differential rig)
build.sh seds the untouched guest kernel sources into userland-compilable
form (only the four kernel #includes swapped for kernel_strtoq_shim.h,
function renamed k_strtoq/k_strtouq β no hand edits; full generated copies
in k_strtoq.c/k_strtouq.c), links them into diffharness against guest
libc, and drives a 43-input Γ 9-base corpus through both families, comparing
VALUE, endptr-nptr, and post-call errno. A second copy carrying only
the proposed isxdigit((unsigned char)s[1]) guard (k_strtoq_fix.c) proves
fix sufficiency in the same run.
Decisive output (run.log / run2.log, deterministic across 3 runs, identical md5)
ENDPTR-DIFF s="0x" b=0 kernel= 0 (off 0) libc= 0 (off 1)
ENDPTR-DIFF s="0xg" b=16 kernel= 0 (off 0) libc= 0 (off 1)
... (28 rows total: {"0x","0X","0xg","0XG","0x 1","0xz1","0xz"} Γ {base 0,16} Γ {signed,unsigned})
SUMMARY:
kernel-vs-libc VALUE divergences : 0 <- saturation values are bit-identical to libc
kernel-vs-libc ENDPTR divergences : 28 <- DF-5702, exactly the 0x-no-xdigit class
invalid-base semantic diffs : 258 <- legacy BSD (no base validation); no kernel caller passes base 1/37/-1
saturation w/o ERANGE (DF-2230 class) : 79 <- KNOWN, root cause filed as DF-2230 (not re-reported)
patched-kernel residual ENDPTR diffs : 0 <- fix validation PASSED
patched-kernel VALUE regressions : 0 <- fix changes no value on any valid base
Root cause (path:line)
sys/libkern/strtoq.c:72-77andsys/libkern/strtouq.c:70-75: prefix taken on(*s == 'x' || *s == 'X')alone; when no hex digit follows, the digit loop (strtoq.c:103-121) breaks withany == 0, so strtoq.c:126-127 (strtouq.c:104-105) storesnptrβ the legitimate leading0digit is swallowed by the failed prefix. C99 7.20.1.4p7: "if the subject sequence begins with a '0x' or '0X' but is not followed by hex digits, then the subject sequence is just '0'" (endptr just past the0).- Userland counterpart guards it:
lib/libc/stdlib/_strtol.h:83-91and_strtoul.h:86-94(&& isxdigit_l((unsigned char)s[1], locale)) β same guard exists in NetBSD/OpenBSD/FreeBSD modernstrtolimplementations.
Impact assessment (honest ceiling)
- No memory-safety impact. All reads terminate at the NUL (the
s[1]peek at strtoq.c:74 is in-bounds because it is gated on*s == 'x'|'X'); the only write is*endptr. Verified end-to-end over the corpus. - No value desync: kernel and libc produce bit-identical values for every input/base pair, including all saturation cases (the harness asserts this: VALUE divergences = 0). The divergence is strictly in the conversion/no-conversion decision, and the kernel is always the stricter (fail-closed) side: it never accepts or advances past what libc accepts.
- Concrete manifestations, all fail-closed:
sys/netgraph/netgraph/ng_parse.c:620-628andsys/netgraph7/netgraph/ng_parse.c:640-648(ng_int64_parse): a token like"0xz"that any C99-conforming parser (and thus any userland tool that pre-validated the same string with libcstrtoll) accepts as 0 is rejectedEINVALby the kernel parser (eptr == s + *offguard).sys/kern/kern_environment.c:432-470(kgetenv_quad), isp/hammer/dm callers: same fail-closed reject; inputs are loader/root-controlled.sys/kern/subr_scanf.c:186-234,391(%d/%i/%o/%u/%x/%p): insulated β scanf pre-scans with its own PFXOK/NDIGITS logic and passesendptr = NULLto the parser.- plist layer (DF-2230's file): both kernel and userland reject
<integer>0xZZ</integer>because_prop_object_internalize_find_tag(prop_object.c:390) requires the next char to be<β no accept/reject change there. - Net: a C-contract violation and kernel/userland behavioral desync with fail-closed direction only. Severity Low (hardening/interop), confidence certain (machine-proven differential).
Not re-reported (per pass-2 instructions)
- DF-2230 (silent saturation of plist integers; root cause at strtoq.c:122-123 / strtouq.c:100-101 β kernel variant cannot signal ERANGE): the harness counts the 79 ERANGE-ONLY rows and marks them as the known class. The kernel-errno aspect is not filed here.
Fix validation
fix.diff (git-apply-able against the read-only sys/ tree; apply-tested in
a scratch repo) adds exactly the isxdigit((unsigned char)s[1]) guard to
both files, mirroring _strtol.h:86/_strtoul.h:89. Compiling the patched
sources through the same rig yields 0 residual ENDPTR divergences and 0
value regressions on all valid bases (run.log SUMMARY lines 5-6). This is
a pure-function semantic fix; no kernel rebuild is required to validate it
(the compiled object is the kernel body unchanged except the guard), and the
bug class is not memory corruption, so the rebuild-reboot fix protocol is
not triggered. Guest left running, no kernel state modified.
Negative results (weight for the clean parts of this file)
- Memory safety: all string reads terminate at NUL (do-while isspace
strtoq.c:61-63;
s[1]peek gated strtoq.c:73-74; digit loop breaks at NUL before any further read, strtoq.c:103-111); only write is*endptr(strtoq.c:126-127). No OOB, no uninit, no truncation (all math is u_quad_t). - Arithmetic: cutoff/cutlim idiom verified correct for the signed pair including the exact β2^63 boundary (strtoq.c:99-102: cutoff = 2^63 when neg; "β9223372036854775808" accepted, "β9223372036854775809" saturates to QUAD_MIN β value-identical to libc) and for the unsigned pair (strtouq.c:78-80: UQUAD_MAX boundary exact).
- Divide-by-zero impossible:
base == 0is always converted to 8/10 at strtoq.c:78-79 / strtouq.c:76-77 beforeqbaseis used; negative or >36 bases break the digit loop immediately (c >= base, int compare β always true for negative base) before any division-dependent path can misbehave. strtouqnegative acceptance (checklist d): '-' accepted, value negated modulo 2^64 (strtouq.c:62-64,102-103) β C99 7.20.1.4p5-conformant and identical to libcstrtoull(harness: no divergence rows for "-1", "-18446744073709551615", etc. beyond the counted ERANGE class).- ctype parity: kernel macros (sys/sys/ctype.h:43-51) are semantically identical to userland C-locale ctype(3) for all 256 bytes.
- No errno surface at all in the kernel variant (systematic difference vs
libc ERANGE at
_strtol.h:139,149) β that is DF-2230's claimed root cause; not re-filed.
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 (git-apply-checked in a scratch repo) adds only the isxdigit((unsigned char)s[1]) guard to both files; recompiled through the identical rig, all 28 ENDPTR divergences vanish and no value changes on any valid base (SUMMARY: patched residual 0, regressions 0). Pure-function semantic fix - the compiled fix variant is byte-for-byte the kernel body except the guard - so no kernel rebuild was required for validation (non-memory-corruption class).
findings/poc/DF-5702/run.log SUMMARY lines: 'patched-kernel residual ENDPTR diffs : 0 (fix validation; must be 0)' and 'patched-kernel VALUE regressions : 0 (must be 0)'; build_full.log contains the unified diff of k_strtoq.c vs k_strtoq_fix.c / k_strtouq.c vs k_strtouq_fix.c showing the guard as the only change.
Confirmed kernel references
- sys/libkern/strtoq.c:72-77
- sys/libkern/strtoq.c:103-121
- sys/libkern/strtoq.c:126-127
- sys/libkern/strtouq.c:70-75
- sys/libkern/strtouq.c:104-105
- lib/libc/stdlib/_strtol.h:83-91
- lib/libc/stdlib/_strtoul.h:86-94
- sys/netgraph/netgraph/ng_parse.c:620-628
- sys/netgraph7/netgraph/ng_parse.c:640-648
- sys/kern/kern_environment.c:432-470
- sys/kern/subr_scanf.c:186-234
- sys/libprop/prop_number.c:518-540
Detail
Evidence (decisive lines)
findings/poc/DF-5702/run.log (and run2.log/run.1.log, identical md5): rows 'ENDPTR-DIFF s="0x" b=0 kernel=0 (off 0) libc=0 (off 1)' x28; SUMMARY block: VALUE 0 / ENDPTR 28 / invalid-base 258 / ERANGE-ONLY 79 (DF-2230 class, not re-reported) / patched residual 0 / patched regressions 0. build_full.log shows cksum-identical guest /usr/src kernel sources and the sed-generated k_*.c copies. fix.diff git-apply-checked.
PoC changes
No seed PoC existed. Built the differential rig from scratch: kernel_strtoq_shim.h (swaps the four kernel includes for userland equivalents; QUAD_ macros already provided by userland sys/limits.h, NULL via stddef.h), build.sh sed-generates userland-compilable k_strtoq.c/k_strtouq.c plus fix-only k__fix.c variants from the untouched guest /usr/src copies (cksum-verified against the repo), diffharness.c drives 43 inputs x 9 bases x {signed,unsigned} comparing kernel vs libc VALUE / endptr offset / ERANGE, with invalid-base rows classified separately so the DF-5702 signal is unambiguous.
Verified recommended fix
Add the same isxdigit((unsigned char)s[1]) guard the userland template has (_strtol.h:86 / _strtoul.h:89) to the 0x-prefix test in sys/libkern/strtoq.c:72-77 and sys/libkern/strtouq.c:70-75.
Verdict
Kernel strtoq/strtouq consume the 0x/0X prefix without verifying a hex digit follows (strtoq.c:72-77, strtouq.c:70-75), so inputs whose C99 subject sequence is exactly "0" (e.g. "0x", "0xg", "0xz") are reported as NO conversion (endptr=nptr, return 0) where C99 7.20.1.4p7 and DragonFly's own userland libc (strtoq==strtoll via _strtol.h:83-91, strtouq==strtoull via _strtoul.h:86-94, both gated on isxdigit(s[1])) parse 0 and advance endptr past the '0'. Proven on the DragonFly guest with a differential harness compiling the untouched guest kernel sources against guest libc: 28 ENDPTR divergences, exactly and only this class (bases 0/16, both families), deterministic across 3 runs. VALUE divergences are 0 across the whole corpus - the kernel is strictly fail-closed (never accepts or advances beyond libc), saturation values are bit-identical to libc, and there is no memory-safety impact (all reads NUL-terminated, only write is endptr). Net effect: kernel/userland parser desync on the shared ASCII-integer surface (ng_parse ng_int64_parse EINVAL vs libc-validated accept; kgetenv_quad; scanf %i insulated) - Low severity hardening/interop defect, machine-proven.
No comments yet.