seq_puts() shim passes its data string as the ksnprintf format β kernel format-string trap with live %n write conversion (latent for ported code)
Summary
The shim maps Linux's data-only seq_puts onto ksnprintf(buf,size,str), putting DATA in the FORMAT slot while the kernel printf core keeps %n as a live write-through-pointer conversion and %s as an unbounded strlen: any dynamic string acquires a kernel format-string primitive. Proven live three ways - shipped drm.ko disassembly (mov %rsi,%rdx into the fmt register), a %d-in-data kmod leaking a never-passed vararg slot, and a %s-in-data rig that PANICKED the kernel ('Stopped at strlen'). Latent today (the whole seq_file graph is dead on DFly: CONFIG_DEBUG_FS undefined, no struct seq_file instantiated) but upstream drivers routinely seq_puts() EDID/connector-derived strings - a format-string trap for every future port. data-via-%s fix validated live (verbatim, guest alive); %n-removal in kvcprintf noted as defense-in-depth. DF-2224's call graph also proven dead.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5779 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| seqputs_rig.c | β | 2.2 KB | view raw | |
| seqputs_rigc.c | β | 1.5 KB | view raw | |
| seqputs_rigfix.c | β | 1.5 KB | view raw | |
| build.sh | β | 153 B | view raw | |
| run.sh | β | 199 B | view raw | |
| build.log | β | 2.6 KB | view raw | |
| run.log | β | 414 B | view raw | |
| run_c.log | β | 766 B | view raw | |
| fix_run.log | β | 250 B | view raw | |
| panic.txt | β | 1.6 KB | view raw | |
| disasm.txt | β | 1.5 KB | view raw | |
| env.txt | β | 373 B | view raw | |
| fix.diff | β | 704 B | view raw | |
| VERDICT.md | β | 4.6 KB | β raw | |
| README.md | β | 3.3 KB | β raw | |
| manifest.json | β | 1.2 KB | view raw | |
| verdict.json | β | 5.4 KB | view raw |
DF-5779 β seq_puts() shim passes its data string as the kernel printf FORMAT
What / where
- Shim unit under audit:
sys/dev/drm/linux_printf.c(seq_printf; known finding DF-2224 covers its va_list forwarding and is NOT re-reported). - The remaining shim in the same unit, declared in
sys/dev/drm/include/linux/seq_file.h:44:
#define seq_puts(m, str) ksnprintf((m)->buf, (m)->size, str)
str β which the Linux seq_puts() API defines as pure DATA (never
interpreted) β sits in ksnprintf's __printflike(3,4) format parameter.
Build
On the host, from this directory:
../../../dfbsd-qemu/vm.sh up # if down
./build.sh # transfers sources (base64 via run_root),
# builds seqputs_rig.ko + seqputs_rigc.ko in-guest
Manual equivalent (in guest /tmp/rig): make SYSDIR=/usr/src/sys WERROR=""
with KMOD=seqputs_rig SRCS=seqputs_rig.c (DragonFly bsd.kmod.mk).
Run
./run.sh
kldload /tmp/rig/seqputs_rig.koβ rigs A+B (safe): * A: controlled%nβseqputs_rig A: %n wrote 2(write conversion LIVE) * B:seq_puts(&m, "conn=DP-1 link=%d ok=100%%\n")βlink=0β%dinterpreted against a NEVER-passed varargkldload /tmp/rig/seqputs_rigc.koβ rig C: same macro, data with nine%sβ kernel panic (wild strlen), see panic.txt.
Expected output (baseline, vulnerable shim)
seqputs_rig: loaded seqputs_rig A: %n wrote 2 (2 == LIVE write conv) seqputs_rig B: seq_puts(m,data) produced: 'conn=DP-1 link=0 ok=100% ' seqputs_rig B: Linux contract would print the literal data string verbatim (no interpretation) seqputs_rigc: firing %s-walk on unset varargs... Fatal trap 12: page fault while in kernel mode fault virtual address = 0x1a640 ... Stopped at strlen: cmpb $0,(%rdi)
Fix validation
seqputs_rigfix.c compiles the FIXED shim (ksnprintf(buf, size, "%s", str))
and replays the same inputs:
rigfix B: 'conn=DP-1 link=%d ok=100%% ' <- verbatim, no interpretation rigfix C: 'm=%s s=%s x=%s y=%s z=%s w=%s v=%s u=%s t=%s ' <- the string that PANICKED baseline: now verbatim rigfix N: 'w=%d write=%n addr=%p ' <- %n/%d/%p in data stay literal rigfix: DONE verbatim, guest alive
Reachability (honest scope)
In-tree TODAY: every seq_puts/seq_printf call site is a debugfs show
callback, and DragonFly defines neither CONFIG_DEBUG_FS nor any debugfs
dispatch (sys/dev/drm/drm_internal.h:139-143 stub; drm_debugfs.c not in
sys/conf/files; no struct seq_file instantiation exists anywhere). All
in-tree seq_puts arguments are %-free literals. The bug is therefore
LATENT: it converts any future ported caller that passes a dynamic string
(upstream Linux drivers routinely pass EDID/connector-derived names β
attacker-influenced bytes from a malicious monitor) into a kernel
format-string primitive: %n = write-through-pointer (live, subr_prf.c:688),
%s = wild read (panic, proven), %d/%x/%p = kernel slot leak into the
seq buffer.
Severity Low (latent hardening / contract divergence). Proof is nonetheless complete: disassembly of the shipped drm.ko (disasm.txt) + live-kernel differential (run.log, run_c.log, panic.txt, fix_run.log).
DF-5779 VERDICT β reproduced (latent primitive, proven live)
Finding
seq_puts() in the DRM Linux-compat shim passes its DATA argument through
ksnprintf's FORMAT parameter:
sys/dev/drm/include/linux/seq_file.h:44#define seq_puts(m, str) ksnprintf((m)->buf, (m)->size, str)- Sole in-tree route for a runtime string into that slot:
sys/dev/drm/drm_print.c:125-133(__drm_puts_seq_file), selected viadrm_seq_file_printer().puts(sys/dev/drm/include/drm/drm_print.h:184-190) and fed bydrm_puts()(sys/dev/drm/drm_print.c:163-169).
Linux's seq_puts(m, s) never interprets s. DragonFly's kernel printf
core, however, supports conversions that make a data-as-format bug a memory
corruption primitive:
%nβ live write-through-pointer:sys/kern/subr_prf.c:688-701%sβ unboundedstrlenon the va_arg pointer:sys/kern/subr_prf.c:750-755
Verification (guest DragonFly 6.5-DEVELOPMENT, X86_64_GENERIC)
Guest /usr/src md5-verified identical to the audited tree for both
linux_printf.c and include/linux/seq_file.h.
-
Disassembly (shipped
/boot/kernel/drm.ko) β__drm_puts_seq_file:mov %rsi,%rdxplaces the caller'sstrin%rdx, the third parameter ofksnprintf(rdi=buf, rsi=size, rdx=fmt, ...)(disasm.txt). Contract divergence proven at machine-code level in the artifact users boot. -
Differential rig (live kernel) β
seqputs_rig.creplicates the struct + macro verbatim: * Rig A:ksnprintf(buf, size, "AA%nBB\n", &written)βseqputs_rig A: %n wrote 2β the%nwrite conversion is live in the running kernel. * Rig B:seq_puts(&m, "conn=DP-1 link=%d ok=100%%\n")(no format args β the seq_puts contract) β outputconn=DP-1 link=0 ok=100%: the%dconsumed a never-passed vararg slot and its value landed in the seq buffer. Under the Linux contract this string must print verbatim. -
Rig C (impact ceiling) β same macro, data containing nine
%s:Fatal trap 12: page fault while in kernel mode, fault virtual address 0x1a640 β¦ Stopped at strlen: cmpb $0,(%rdi)(panic.txt). Wild kernel read from an unset vararg slot; guest down (reset afterwards).
Fix validation
seqputs_rigfix.c compiles the fixed shim (ksnprintf(buf, size, "%s", str),
fix.diff) and replays the same inputs: rig B verbatim, the rig-C panic
string verbatim, %d/%n/%p inside data literal, guest alive, module
unloaded cleanly (fix_run.log). The fix is a header macro; semantic
validation via a consumer compiled against the exact fixed macro is
equivalent to a full drm.ko rebuild for this contract (a full rebuild would
recompile the identical expansion in every consumer).
baseline: interpretation + leak + panic. patched: verbatim, no panic. => fixed.
Honest reachability scope (why Low, not High)
CONFIG_DEBUG_FSis not defined anywhere in the DragonFly DRM build (sys/dev/drm/kconfig.hβ no match, verified in tree and in guest /usr/src).drm_debugfs.c/amdgpu_debugfs.care not insys/conf/files;drm_debugfs_initis the no-op inline (sys/dev/drm/drm_internal.h:139-143).- No
struct seq_fileis ever instantiated in the tree (grep for allocation: zero hits) βm->buf/m->sizenever get set by anything. - Every in-tree
seq_printf/seq_putsformat/argument today is a%-free or arg-matched literal (scanned all call sites; the only non-literalseq_putsargument isdrm_print.c:128itself).
So no in-tree caller currently executes the primitive: the defect is a latent format-string trap for ported code. Upstream Linux drivers pass dynamic strings to seq_puts (device/connector names, EDID-derived strings β bytes an attacker with a crafted monitor controls), and this shim silently turns that pattern into kernel %n/%s interpretation. The adjacent seq_printf va_list ABI issue is DF-2224 (known; not re-reported).
Files
- disasm.txt β shipped drm.ko disassembly (the
%rdxproof) - run.log β rigs A+B output (live
%n,%dslot leak) - run_c.log + panic.txt β rig C kernel panic
- fix_run.log β fixed-shim replay (verbatim, alive)
- build.log β full untrimmed guest builds (includes the honest first empty-module transfer failure)
- env.txt β guest uname/toolchain/module state
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
fixedFixed shim compiled into seqputs_rigfix.ko and replayed on the live guest: the exact rig-C string that panicked the baseline kernel now prints verbatim, %d/%n/%p inside data remain literal, guest stays up, module unloads cleanly (fix_run.log). The fix unit is a header macro; a consumer compiled against the exact fixed macro is semantically equivalent to rebuilding every drm kmod. Baseline bad behavior (interpretation, slot leak, wild-read panic) is GONE with the fix.
findings/poc/DF-5779/fix_run.log (rigfix B/C/N all verbatim, 'DONE verbatim, guest alive', UNLOADED_CLEAN); baseline in run.log/run_c.log/panic.txt; diff in fix.diff
Confirmed kernel references
Detail
Exploit chain
Simulated-caller chain (proven): dynamic string reaches seq_puts(m, s) -> ksnprintf(buf, size, s-as-fmt) -> kvcprintf interprets s: '%d/%x/%p' leak unset vararg slots into the seq buffer (rig B: link=0); '%n' writes retval through a wild pointer (conversion live, rig A: wrote 2 to &written); '%s' strlen()s a wild pointer -> kernel page fault + panic (rig C, 0x1a640). In-tree activation requires a future port passing attacker-influenced data (e.g. EDID strings) to seq_puts/drm_puts via drm_seq_file_printer; today that dispatch (debugfs) does not exist on DragonFly, so no unprivileged-user-to-kernel path is currently wired.
Evidence (decisive lines)
disasm.txt (mov %rsi,%rdx -> ksnprintf fmt slot in shipped drm.ko); run.log (rig A '%n wrote 2'; rig B 'link=0'); run_c.log + panic.txt (Fatal trap 12, Stopped at strlen, guest down); fix_run.log (fixed shim: same inputs verbatim, guest alive); env.txt (uname, md5 match of guest /usr/src vs audited tree, no CONFIG_DEBUG_FS)
PoC changes
New pass-2 finding (no seed sketch). Built the rig set from scratch; first transfer attempt produced empty object files (vm.sh run_root consumes ssh stdin for the command itself) β re-transferred via base64-in-command; widened rig C to nine %s so the va_arg walk leaves the register-save area and reaches stack slots, making the wild read deterministic instead of a lucky (null).
Verified recommended fix
Replace the seq_puts macro with an inline function that passes the data string through "%s": static inline void seq_puts(struct seq_file m, const char str) { ksnprintf(m->buf, m->size, "%s", str); } (fix.diff in this pack)
Verdict
The seq_puts() shim (sys/dev/drm/include/linux/seq_file.h:44) places its DATA argument in ksnprintf's format parameter. Proven three ways on the live guest: (1) shipped /boot/kernel/drm.ko disassembly of __drm_puts_seq_file shows mov %rsi,%rdx putting str into ksnprintf's fmt register; (2) a kmod replicating the exact macro shows %d inside data interpreted against a never-passed vararg (slot value leaked into the seq buffer) while the Linux contract requires verbatim output; (3) data containing %s walks unset vararg slots and strlen() faults on a wild pointer -> Fatal trap 12, guest down (panic.txt). The kernel printf core's %n is a live write-through-pointer conversion (subr_prf.c:688-701), demonstrated controlled (wrote 2). LATENT in-tree today: CONFIG_DEBUG_FS is undefined, drm_debugfs.c is not built, no struct seq_file is ever instantiated, and all current call sites pass literals β so the finding is filed Low (hardening/contract divergence), but any ported caller passing a dynamic string (EDID/connector-derived bytes, as upstream drivers do) silently acquires a kernel format-string primitive with %n write and %s wild-read. DF-2224 (seq_printf va_list forwarding) known and not re-reported.
No comments yet.