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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2883 Β· 12 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download raw

DF-2883 VERDICT β€” REPRODUCED (impact: none β€” correctness divergence)

Root cause

Two lines interact:

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 21:17:17 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.