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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5702 Β· 19 files
FileTypeDescriptionSize
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-compilable k_strtoq.c/k_strtouq.c plus 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 β€” harness
  • build.sh, run.sh β€” exact commands
  • build_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 sources
  • k_strtoq_fix.c, k_strtouq_fix.c β€” fix-only variants
  • fix.diff β€” git-apply-able one-guard fix (apply-tested)
  • env.txt β€” guest uname/compiler/sysctl
  • guest_status.txt β€” build/run exit codes
VERDICT.md
↓ download raw

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-77 and sys/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 with any == 0, so strtoq.c:126-127 (strtouq.c:104-105) stores nptr β€” the legitimate leading 0 digit 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 the 0).
  • Userland counterpart guards it: lib/libc/stdlib/_strtol.h:83-91 and _strtoul.h:86-94 (&& isxdigit_l((unsigned char)s[1], locale)) β€” same guard exists in NetBSD/OpenBSD/FreeBSD modern strtol implementations.

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-628 and sys/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 libc strtoll) accepts as 0 is rejected EINVAL by the kernel parser (eptr == s + *off guard).
  • 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 passes endptr = NULL to 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 == 0 is always converted to 8/10 at strtoq.c:78-79 / strtouq.c:76-77 before qbase is 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.
  • strtouq negative acceptance (checklist d): '-' accepted, value negated modulo 2^64 (strtouq.c:62-64,102-103) β€” C99 7.20.1.4p5-conformant and identical to libc strtoull (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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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.
↓ fix.diffper-fix-DF-5702

Confirmed kernel references

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.