kvsscanf 'x'-pushback under-advance at exact end-of-input: stale byte re-consumed (parse divergence from userland scanf, double-counted nread)
| Field | Value |
|---|---|
| ID | DF-2883 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N |
| CWE | CWE-681 / CWE-838 |
| File | sys/kern/subr_scanf.c |
| Lines | 487-490, 505-510 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | base:kern |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
The CT_INT accept path leaves inp un-advanced when --inr hits 0, while the [sign]0x fixup pushback unconditionally does inp--/inr++. For an input ending exactly in "0x" under %i/%x/%p, the pushback lands inp one byte stale β on the already-consumed '0' β with inr=1 and zero chars truly remaining; unlike the sign-pushback (which returns), execution continues, so the next directive re-reads the consumed byte and nread double-counts it. In-kernel: ksscanf("0x","%i%c%n") yields ch='0' where libc yields ch='x'; stable across loads. Memory-safe (stale inp β₯ str start) β pure correctness divergence, hit by base-system %i/%x parsers (kern_uuid.c:394, if_ethersubr.c:1690, autoconf.c:263, pci.c:3657) on inputs truncated at '0x'.
Proof of contest
VERIFIED (findings/poc/DF-2883/, E4): ch='0' (libc: ch='x') on
stock; ch='x' (libc-identical) on patched kernel #1. Fix: always
advance inp on accept (inp++; if (--inr <= 0) break;) so both
pushbacks become exact inverses β all consumers re-audited under the
new invariant.
Timeline
- 2026-09-02 Discovered during pass-2 audit of subr_scanf.c (GLM 5.3); in-kernel divergence reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2883 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| poc_dfscanf.c | β | 6.4 KB | view raw | |
| Makefile | β | 56 B | β download | |
| build.sh | β | 167 B | view raw | |
| run.sh | β | 219 B | view raw | |
| build.log | β | 835 B | view raw | |
| run.log | β | 1.1 KB | view raw | |
| run.2.log | β | 1.1 KB | view raw | |
| run_patched.log | β | 935 B | view raw | |
| env.txt | β | 530 B | view raw | |
| fix.diff | β | 578 B | view raw | |
| VERDICT.md | β | 2.2 KB | β raw | |
| verdict.json | β | 3.5 KB | view raw |
DF-2883 VERDICT β REPRODUCED (impact: none β correctness divergence)
Root cause
Two lines interact:
sys/kern/subr_scanf.c:487-490β accept path leavesinppointing AT the last consumed character when--inr == 0(no advance).sys/kern/subr_scanf.c:505-510β the[sign]0xfixup pushback doesinp--; inr++unconditionally.
Net effect when the numeric conversion ends exactly at end-of-input
with buf ending in 'x'/'X' (input ends in "0x"/"0X"): inp
moves one byte BACK past the consumed '0', inr becomes 1 with zero
characters truly remaining, and β unlike the NDIGITS sign pushback at
:498-502, which returns immediately β the loop continues, so all
subsequent fmt directives parse the stale byte.
Proof
Stock kernel #0 (run.log, E4):
dfpoc: E4 '0x' %i%c%n: ret=2 v=0 ch='0' nread=2 (libc: ch='x')
Guest libc control (userland verbatim replica): sscanf("0x","%i%c%n")
β ch='x'; engine replica β ch='0'. Same divergence for "7 0x"
(%*d %i%c%n: engine ch='0', libc ch='x'). Re-run stable
(run.2.log).
Why memory-safe / Low
The stale state is inr = 1, inp = position of the consumed '0'
which is β₯ the string start and β€ the last real character: every
subsequent read stays inside [str, NUL]. No OOB read or write is
possible from this defect (the OOB-capable stale state is DF-2882's
suppress branch, a different root cause). Impact is wrong parse
results/nread for any caller whose input ends in "0x" under
%i/%x/%p-style formats β a silent divergence from userland
semantics that base-system parsers (kern_uuid.c:394, if_ethersubr.c:1690,
autoconf.c:263, pci.c:3657) can hit with well-formed-but-truncated
input. Low severity, certain confidence.
Fix validation
Kernel #1 (in-guest make nativekernel, fix.diff hunk 3):
E4 ... ch='x' (libc: ch='x') β pushback exact, libc-identical
(run_patched.log). New invariant "inr==0 β inp at NUL" re-audited
against every inp consumer (:134-137, :151-157, :258, :265-272,
CCL :324-348, STRING :365-382, INT :402-491) β all reads are guarded
by inr > 0; no behavioral regressions in the other experiments
(E1/E2/E3/E5/E6 outcomes are those of their own fixes/controls).
Fix verification
fixedfix.diff hunk 3 (always advance inp in the CT_INT ok: path) validated on kernel #1: E4 returns ch='x', identical to libc, and the accept-path invariant was re-audited against every inp consumer (all inr>0-guarded) with no regressions in the other experiments.
['run_patched.log: "dfpoc: E4 \'0x\' %i%c%n: ret=2 v=0 ch=\'x\' nread=2 (libc: ch=\'x\')"', "run.log (baseline kernel #0): ch='0'", 'fix.diff hunk 3']
Confirmed kernel references
Detail
Exploit chain
none (no memory-unsafety): wrong values delivered to callers, wrong match/failure decisions, wrong %n counts
Evidence (decisive lines)
['run.log: "dfpoc: E4 \'0x\' %i%c%n: ret=2 v=0 ch=\'0\' nread=2 (libc: ch=\'x\')" + RESULT DIVERGENCE REPRODUCED', 'run.2.log: identical on second load', 'run_patched.log: kernel #1 with fix.diff hunk 3 -- "E4 ... ch=\'x\' (libc: ch=\'x\')"', 'VERDICT.md: root-cause trace at :487-490 / :505-510 and invariant re-audit']
PoC changes
authored fresh (no seed): experiment E4 of the shared dfpoc.ko KLD harness (same module as DF-2882's pack)
Verified recommended fix
always advance inp on accept so inr==0 consistently means 'inp at the NUL' and both pushbacks are exact inverses: replace :487-490 with 'inp++; if (--inr <= 0) break;' (fix.diff hunk 3)
Verdict
kvsscanf's CT_INT accept path (sys/kern/subr_scanf.c:487-490) does not advance inp when --inr hits 0, while the x/X pushback (:505-510) unconditionally does inp--/inr++; for an input ending exactly in '0x' under %i/%x/%p the pushback therefore lands inp one byte stale (on the already-consumed '0') with inr=1 and zero characters truly remaining, and execution continues: the next directive re-reads the consumed byte and nread double-counts it. Proven in-kernel on the stock kernel (E4: ksscanf("0x","%i%c%n") yields ch='0' where guest libc yields ch='x'; same for '7 0x'), stable across two module loads. All reads stay within [str, NUL] so the defect is memory-safe -- pure parse divergence from userland scanf semantics, hit by base-system %i/%x parsers (kern_uuid.c:394, if_ethersubr.c:1690, autoconf.c:263, pci.c:3657) on inputs truncated at '0x'. Low severity.
No comments yet.