kvsscanf %*Nc suppress short-input branch leaves inr stale β conversions read past the input string's NUL (cross-object kernel heap disclosure into caller buffers)
| Field | Value |
|---|---|
| ID | DF-2882 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:L |
| CWE | CWE-125 / CWE-200 |
| File | sys/kern/subr_scanf.c |
| Lines | 292-298 (sinks :308, :324, :375) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | kernleak |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
When a suppressed %Nc requests more characters than remain, the CT_CHAR
suppress branch advances inp to the NUL terminator but never sets inr=0.
The engine's invariant inr>0 β *inp is a valid unread char is broken:
a subsequent %s copies the NUL then keeps copying adjacent kernel memory
into the caller's buffer; %c bcopy-reads width bytes starting at the
terminator; negated %[ walks likewise; the return value lies
(nassigned=1 on exhausted input). Proven in-kernel on the stock kernel
with the kernel's own ksscanf: canary bytes past the terminator copied;
'Q' bytes from beyond an exact-size kmalloc(32) β out of the adjacent
LIVE heap chunk β copied into the caller buffer; a live kernel pointer
plus heap bytes leaked via the %c path. Distinct from DF-0111 (caller
width-vs-remaining) β here the engine corrupts its own accounting.
No in-tree %Nc caller β latent for the base system, live for the
exported KPI.
Proof of contest
VERIFIED (findings/poc/DF-2882/ KLD harness): E2 heap-grooms
200Γ32-byte chunks so an exact-size kmalloc(32) input lands directly
before a live 'Q' chunk; ksscanf(in, "%32c%s", out) β out =
NUL + 'Q's from beyond the allocation, ret=1; E3's %16c sink leaked a
live kernel pointer (0xfffff8008d941680). Negative control %8c clean.
Fix (inr = 0; in the short-input branch) validated on kernel #1:
ret=0, caller buffer untouched, matching libc.
Recommended fix
Validated fix.diff in findings/poc/DF-2882/.
Timeline
- 2026-09-02 Discovered during pass-2 audit of subr_scanf.c (GLM 5.3); in-kernel heap disclosure reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2882 Β· 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 | β | 4.3 KB | β raw | |
| verdict.json | β | 5.7 KB | view raw |
DF-2882 VERDICT β REPRODUCED (impact: leak)
Root cause (path:line)
sys/kern/subr_scanf.c:289-306, CT_CHAR suppress branch:
if (flags & SUPPRESS) {
size_t sum = 0;
for (;;) {
if ((n = inr) < width) { /* :292 short input */
sum += n;
width -= n;
inp += n; /* :295 inp -> NUL terminator */
/* inr NOT zeroed! */
if (sum == 0)
goto input_failure;
break;
} ...
inp advances by n = inr (to the terminator) while inr keeps its
old value. Every consumer of the invariant "inr > 0 β *inp is a
valid unread char" then operates on phantom input:
- guard at :258 (
if (inr <= 0) goto input_failure) passes; %sloop at :375-382 accepts the NUL (!isspace('\0')), copies it, advancesinppast the terminator and keeps copying until whitespace orinr/width exhausts β up toinr-1bytes past the string end;%cat :308bcopyswidthbytes starting at the terminator;- negated
%[^...]at :324 matches the NUL (tab[0]=1) and walks.
The alternative branch (inr -= width; inp += width) is balanced;
CCL/STRING/INT loops all decrement inr in lockstep with inp++.
Only this branch loses the count β and only when width > inr
(positive control E6, width == inr, is clean).
How it was proven
- Verbatim userland replica (guest libc as control):
%*16c%son an 8-char string β engineret=1,out = 00 'C' 'A' 'N' 'A' 'R' 'Y' '!' 00; libcret=0. - In-kernel, kernel's own ksscanf (stock INVARIANTS kernel #0,
KLD harness, run.log):
- E1: canary bytes after the NUL copied into caller buffer.
- E2:
kmalloc(32)exact-size input, groomed neighbor atin+32(delta199=32):out = 00 51Γ15β 'Q' bytes from beyond the allocation, i.e. out of the adjacent live heap object. Cross-object kernel heap disclosure. - E3: the walk crossed slab metadata:out = 00 80 16 94 8d 00 f8 ff ff 51 ...β a live kernel pointer (0xfffff8008d941680) plus heap bytes copied into the caller buffer. Second load leaked 0xfffff8004f102660 (run.2.log) β two distinct pointers, consistent with slab freelist/metadata adjacency. - E6 negative control (%*8c, N == strlen): clean. - Fix validation (kernel #1 built in-guest with fix.diff hunk 2):
E1/E2/E3 β
ret=0,outuntouched (sentinel 0xee intact) β the walk is gone; behavior now equals libc.
Impact ceiling
- Kernel memory disclosure: bytes past the end of the input string (same allocation = semantic break; end of allocation = adjacent kernel heap objects, slab metadata, kernel pointers) copied into the caller's destination buffer β buffers that callers overwhelmingly copy back to userland or embed in further output.
- Potential panic/DoS if the walk crosses into an unmapped page
(walk length β€ phantom
inrβ€ strlen). - Return-value/nassigned corruption (conversion "succeeds" on exhausted input).
Why Medium (not High)
No in-tree caller currently emits %*Nc with N>1 (21 call sites
surveyed: radeon_vce.c:107,122; ttm_page_alloc.c:167; ttm_memory.c:122;
amdgpu_gfx.c:126; vinumio.c:711 [uses %d/%[a-z]/%[s] β different,
balanced paths]; if_ath.c:576 [%c width 1 β balanced]; if_mxge.c:625;
smc_sysctl.c:589; channel.c:1078; feeder_eq.c:512; if_ethersubr.c:1690;
pci.c:3657,3661; autoconf.c:239,263,287; kern_uuid.c:394;
vfs_conf.c:431; vkernel64 autoconf.c:316,340,364). The format string
is kernel-supplied, so an unprivileged user cannot trigger it through
any shipping code path. It is a live defect of the exported KPI that
any KLD or future caller invokes at its peril β filed Medium in the
kernleak bucket so the one-line fix lands.
Exploit chain
None in-tree (no %*Nc caller). For a hypothetical caller with
user-controlled input: %*Nc + %s on an exact-size heap string
β adjacent-object kernel heap bytes (incl. pointers) copied into the
caller buffer β returned to user. No write primitive: all writes stay
inside caller-supplied destination buffers.
verdict.json summary
status=reproduced, reproduced=1, impact=leak, confidence=certain, fix_status=fixed (kernel #1, in-guest nativekernel, baseline reproduced / patched clean).
Fix verification
fixedfix.diff hunk 2 (inr = 0 in the CT_CHAR suppress short-input branch) built into kernel #1 via in-guest make nativekernel + installkernel; the identical dfpoc.ko re-run shows E1/E2/E3 returning 0 with the caller buffer untouched (0xee sentinels intact) and E6 unchanged -- no byte past the terminator is consulted, behavior now identical to libc; baseline (stock kernel #0) reproduced the cross-object disclosure in the same session.
["run_patched.log: 'dfpoc: E2 in=... delta199=32 ret=0' + 'out:ee ee ee ...' (groom still adjacent, nothing copied)", "run.log (baseline, kernel #0): same experiment copied 15 'Q' bytes from beyond the allocation", 'fix.diff hunk 2']
Confirmed kernel references
Detail
Exploit chain
no in-tree trigger exists (kernel-supplied format strings; no %Nc caller). For any caller with user-influenced input: exact-size heap input string + %(len+1)c + %s copies up to len-1 bytes past the allocation (adjacent kernel heap objects, slab freelist pointers -- observed 0xfffff8008d941680) into the caller's destination buffer, which such callers typically copy back to userland; walk can also fault into an unmapped page (panic). No write primitive: writes stay inside caller-supplied buffers.
Evidence (decisive lines)
["run.log: 'dfpoc: E1 %*16c%s ... ret=1' + 'out:00 43 41 4e 41 52 59 21 00' -- NUL + 7 canary bytes from beyond the terminator", "run.log: 'dfpoc: E2 in=0xfffff8008d942e60 ... live v[199]=0xfffff8008d942e80 delta199=32 ret=1' + 'out:00 51 51 ...' -- 'Q' bytes from beyond the 32-byte allocation (adjacent live chunk): CROSS-OBJECT HEAP DISCLOSURE", "run.log: 'dfpoc: E3 ... out:00 80 16 94 8d 00 f8 ff ff 51 ...' -- live kernel pointer 0xfffff8008d941680 leaked into caller buffer", 'run.2.log: second module load, E2 reproduces (delta199=32), E3 leaks 0xfffff8004f102660', 'run_patched.log: kernel #1 with fix.diff hunk 2 -- E1/E2/E3 ret=0, out untouched (0xee sentinels intact), equal to libc', 'VERDICT.md: full path:line chain and caller survey']
PoC changes
authored fresh (no seed): KLD harness dfpoc.ko drives the kernel's own ksscanf/kvsscanf; heap groom reuses the DF-2862-proven technique (200x32-byte M_TEMP chunks filled 'Q', evens freed LIFO so the input kmalloc lands directly before a live 'Q' chunk). E5 initially used ksscanf directly but cc's __scanflike(2,3) annotation rejects '%f' at compile time (itself confirming %f is not a supported kernel conversion) -- rerouted through an unannotated kvsscanf shim.
Verified recommended fix
zero the remaining-count in the short-input branch: add 'inr = 0;' after 'inp += n;' at sys/kern/subr_scanf.c:295 (fix.diff hunk 2)
Verdict
kvsscanf's CT_CHAR suppress short-input branch (sys/kern/subr_scanf.c:292-298) advances inp by n=inr without zeroing inr, so after a %Nc with N greater than the remaining input the engine believes inr characters are still unread while inp sits on the string's NUL terminator; every subsequent conversion then violates the read bound: %s (subr_scanf.c:375-382) copies the NUL and keeps copying adjacent kernel memory into the caller buffer, %c (:308) bcopy-reads width bytes starting at the terminator, negated %[ (:324) walks likewise. Proven in-kernel on the stock INVARIANTS kernel with the kernel's own ksscanf: canary bytes past the terminator copied (E1); 'Q' bytes from beyond an exact-size kmalloc(32) input -- i.e. out of the adjacent LIVE heap chunk at in+32 -- copied into the caller buffer (E2, delta199=32); a live kernel pointer 0xfffff8008d941680 plus heap bytes leaked on the %c path (E3), fresh pointer again on a second load (run.2.log). Negative control %8c (N==strlen) clean. No in-tree caller uses %Nc with N>1 today (21 sites surveyed; vinumio.c:711 uses the balanced %d/%[...] paths, if_ath.c:576 uses width-1 %c), so there is no unprivileged path in the shipping base system -- the defect is live for the exported KPI (any KLD or future in-tree caller) and was filed Medium/kernleak for that reason.
No comments yet.