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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2882 Β· 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 β€” 4.3 KB ↓ raw
verdict.json β€” 5.7 KB view raw
VERDICT.md
↓ download 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;
  • %s loop at :375-382 accepts the NUL (!isspace('\0')), copies it, advances inp past the terminator and keeps copying until whitespace or inr/width exhausts β€” up to inr-1 bytes past the string end;
  • %c at :308 bcopys width bytes 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

  1. Verbatim userland replica (guest libc as control): %*16c%s on an 8-char string β†’ engine ret=1, out = 00 'C' 'A' 'N' 'A' 'R' 'Y' '!' 00; libc ret=0.
  2. 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 at in+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.
  3. Fix validation (kernel #1 built in-guest with fix.diff hunk 2): E1/E2/E3 β†’ ret=0, out untouched (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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ 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

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.