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

led_parse() leaks the sbuf on every empty parsed spec (and on sbuf_finish error): *sb = NULL without sbuf_delete()

Summary

The empty/error branch clobbers the caller's pointer instead of freeing (the default: case right above DOES delete). VERIFIED LIVE against the unmodified /usr/src led.c via KLD: 20000 empty-spec writes -> sbuf 0->39.1K allocs/1.53MB (userland write path) and 78.1K/3.05MB (kernel led_set path); valid-spec control flat; one-line fix validated flat on both paths under the patched module. 64B/call permanent; root:wheel 0600 nodes + the exported led_set API. Inherited from FreeBSD.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4958 Β· 13 files
FileTypeDescriptionSize
trig_leak.c β€” 2.7 KB view raw
wr.c β€” 1.0 KB view raw
Makefile β€” 92 B ↓ download
Makefile.fixed β€” 91 B ↓ download
led_fixed.c β€” 8.1 KB view raw
build.sh β€” 98 B view raw
run.sh β€” 90 B view raw
build.log β€” 1.2 KB view raw
run.log β€” 1.1 KB view raw
env.txt β€” 188 B view raw
fix.diff β€” 283 B view raw
VERDICT.md β€” 4.5 KB ↓ raw
verdict.json β€” 3.7 KB view raw
VERDICT.md
↓ download raw

DF-4958 VERDICT β€” led_parse() sbuf leak on empty spec

Status: REPRODUCED (live on guest, both trigger paths, causality control included). Fix VALIDATED live (module-level).

Root cause (source trace)

  • led_write (sys/dev/misc/led/led.c:222-253) and led_set (led.c:255-278) both call led_parse(s, &sb, &state).
  • led_parse (led.c:114-208) allocates *sb = sbuf_new_auto() (led.c:128). For spec strings starting with 's', it appends only chars passing the filter at led.c:169-174. For "s" alone or "s" + chars the filter drops (e.g. "sz", since 'z' > 'u' fails every filter clause), nothing is appended.
  • led.c:202-206:
    error = sbuf_finish(*sb);
    if (error != 0 || sbuf_len(*sb) == 0) {
        *sb = NULL;     /* <-- sbuf deleted NOWHERE: leak */
        return (error);     /* error == 0 for the empty case */
    }

The caller's cleanup if (sb != NULL) sbuf_delete(sb) (led.c:250-251, 275-276) now sees NULL, and the sbuf allocated at led.c:128 β€” sizeof(struct sbuf) (48 B) plus the initial 16-byte auto-extend buffer (subr_sbuf.c:53-54 SBMALLOC/M_SBUF; subr_sbuf.c:79 SBUF_MINEXTENDSIZE=16; subr_sbuf.c:137) β€” is orphaned: 64 bytes of M_SBUF leaked per call, two kmallocs, zero frees. - The default: case of the same switch DOES sbuf_delete(*sb) (led.c:198-200), confirming the omission at 202-206 is an oversight. - Both leaks (empty-spec and sbuf_finish-error) are one root cause: the *sb = NULL branch frees nothing. - Inherited verbatim from FreeBSD's dev/led/led.c (same branch, no sbuf_delete as of FreeBSD head today) β€” equivalent upstream, worth an upstream report too.

Threat model / reachability

  • /dev/led/<name> nodes are created 0600 root:wheel (led.c:305-306); in-tree creators: acpi_asus(4) (bled/dled/gled/mled/tled/wled), acpi_thinkpad(4) (led/thinklight). On such hardware any root process can loop write(fd, "sz", 2) β†’ unbounded kernel heap exhaustion (memory-pressure DoS; kmem is finite).
  • Kernel-side: the exported led_set(name, cmd) API (led.h) hits the identical path β€” any kernel/module caller passing a spec that parses empty (e.g. "s") leaks. (No in-tree led_set callers today, so the write path is the realistic trigger; severity stays Low because it requires root.)

Verification on the guest (DragonFly 6.5-DEVELOPMENT, cc 8.3)

trig.ko compiles the unmodified tree file (SRCS=led.c, .PATH=/usr/src/sys/dev/misc/led) + a trigger module. Measured with vmstat -m | grep -w sbuf (columns: InUse, MemUse):

step InUse MemUse
baseline 0 0
+20000 Γ— write("/dev/led/leaktest", "sz", 2) (cdev path, stock) 39.1K 1.53M
+20000 Γ— led_set("leaktest","s") via sysctl (kernel API, stock) 78.1K 3.05M
+20000 Γ— write("sA") β€” VALID spec control 78.1K (flat) 3.05M (flat)
swap module to trig_fixed.ko, +20000 Γ— write("sz") 78.1K (flat) 3.05M (flat)
+20000 Γ— led_set on fixed module 78.1K (flat) 3.05M (flat)
  • 20000 leaked calls Γ— 2 allocations = ~39.1K InUse β€” exactly as predicted; MemUse 1.53M β‰ˆ 20000 Γ— 64 B + allocator overhead/rounding.
  • The control (valid "sA" spec) allocates and frees in steady state (Requests counter +39.1K, InUse flat) β€” proves the leak is caused by the empty-spec branch, not by write(2) traffic itself.
  • The leaked allocations remain InUse after kldunload (leaked is leaked).

Fix

fix.diff (one line): sbuf_delete(*sb); before *sb = NULL; at led.c:204 β€” mirrors the default: case at led.c:199. Validated live: trig_fixed.ko = same tree led.c + this one line; both trigger paths re-run β†’ zero growth (InUse/MemUse flat). Module-level validation is the deployment unit here: led.c ships as optional led (/boot/kernel/led.ko), and the fix changes only led.c.

Note on the interaction with the 's'-filter typo (DF-4959)

Because of the *s <= 'u' typo (led.c:171), control characters and bytes ≀ 'u' PASS the filter; the leak's write-path trigger must use a char > 'u' (e.g. "sz"). With FreeBSD-intended semantics (== 'u'), echo s > /dev/led/x (writing "s\n") would leak too. The two bugs are independent; both are inherited from FreeBSD.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff adds the missing sbuf_delete(sb) before sb = NULL at led.c:204 (mirroring the default: case at led.c:199). Validated live at module level -- the deployment unit, since led.c ships as the led.ko module: trig_fixed.ko (tree led.c + this one line, verified by grep in build.log) re-ran BOTH trigger paths (20000 writes + 20000 led_set) with ZERO sbuf growth (InUse/MemUse stayed 78.1K/3.05M), while the stock module leaked 64 B/call on identical input. No kernel rebuild needed for this file.

run.log last three sbuf snapshots (fixed PATH 1 and PATH 2 flat); build.log ('204: sbuf_delete(*sb);' in led_fixed.c); fix.diff
↓ fix.diffper-fix-DF-4958

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (vmstat -m sbuf rows: 0/0 -> 39.1K/1.53M -> 78.1K/3.05M, control flat, fixed module flat); VERDICT.md table; build.log (trig.ko compiles /usr/src/sys/dev/misc/led/led.c verbatim via SRCS=led.c; trig_fixed.ko = same + one-line fix, hunk at led.c:204); fix.diff

PoC changes

PoC authored fresh (no seed): KLD embedding the tree's led.c unchanged + sysctl trigger for the led_set API path + userland wr(1) for the real /dev/led write path + valid-spec control + fixed-module variant for fix validation. One iteration: first ciphysink-style include of sys/bus.h removed from the sibling pack (unrelated file); trig packs built clean first try.

Verified recommended fix

led_parse(): sbuf_delete(sb) before sb = NULL in the finish-error/empty branch (one line, see fix.diff).

Verdict

REPRODUCED live on the guest: led_parse() (sys/dev/misc/led/led.c:202-206) orphans the sbuf allocated at led.c:128 whenever the parsed spec comes back empty or sbuf_finish() errors -- the branch sets sb = NULL without sbuf_delete(), so both callers' cleanup (led.c:250-251, 275-276) sees NULL. 20000 userland write(fd,"sz",2) calls to a real /dev/led/leaktest node created by the tree's own led_create() moved vmstat -m sbuf from 0/0 to 39.1K allocs / 1.53MB in use (2 M_SBUF kmallocs and 64 bytes per call, exactly as predicted from subr_sbuf.c:53-54,79,137); 20000 kernel-side led_set(name,"s") calls doubled it to 78.1K/3.05MB; a valid-spec control ("sA") and the fix.diff-patched module both stayed perfectly flat. Unbounded kernel heap leak, root-only via the 0600 /dev/led/ write path (or any kernel led_set() API caller). Inherited verbatim from FreeBSD's dev/led/led.c (equivalent upstream).