sbuf_drain() does not validate the drain callback's return value β a drain returning 0 yields a silent, unbounded, content-controlled linear kernel-heap overflow on production kernels
| Field | Value |
|---|---|
| ID | DF-2845 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-20 Improper Input Validation (missing contract check) |
| File | sys/kern/subr_sbuf.c |
| Lines | 324-351 (KASSERT :336-337) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
sbuf_drain() takes the drain callback's return value on faith: the only
guard against a callback returning 0 ("consumed nothing, no error") or a
value greater than s_len is a KASSERT, compiled out of production
kernels. With a drain returning 0, sbuf_put_byte() falls through to
s->s_buf[s->s_len++] = c; while SBUF_FREESPACE(s) β€ 0 β it writes
past the end of the heap allocation, once per byte, for as long as the
caller keeps appending, and neither s_error nor the return of
sbuf_cat()/sbuf_printf() ever signals a problem. With a drain returning
len > s_len, s->s_len -= len goes negative and the following memmove
uses a huge size_t.
Threat model & preconditions
No in-tree drain misbehaves (sbuf_sysctl_drain returns len or -error) and installing one requires root (KLD/custom kernel) β no unprivileged uid0 route today. This is a hardening gap whose failure mode is full kernel heap corruption for any future or third-party drain regression: content-controlled linear overflow.
Proof of contest
VERIFIED on a purpose-built no-INVARIANTS DragonFly kernel (compiled-in
trigger via debug.df2845 sysctl): sbuf_cat rc=0 s_error=0 s_len=255
s_size=32 + "OVERFLOW CONFIRMED: 224 byte(s) written past the end of
the 32-byte heap allocation". On the INVARIANTS kernel the same trigger
panics Bad drain amount 0 for sbuf (panic.txt). fix.diff (runtime
check: len==0 || len>s_len β s_error=EDOOFUS) validated in-guest:
patched no-INVARIANTS kernel stays in-bounds (s_len=31) and propagates
rc=β1/EDOOFUS.
Recommended fix
See findings/poc/DF-2845/fix.diff (validated).
Timeline
- 2026-09-02 Discovered during pass-2 audit of subr_sbuf.c (GLM 5.3); no-INVARIANTS overflow reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2845 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df2845_test.c | β | 2.0 KB | view raw | |
| baddrain.c | β | 2.6 KB | view raw | |
| Makefile | β | 56 B | β download | |
| build.sh | β | 313 B | view raw | |
| run.sh | β | 274 B | view raw | |
| build.log | β | 2.4 KB | view raw | |
| run.log | β | 12.7 KB | view raw | |
| run.2.log | β | 424 B | view raw | |
| panic.txt | β | 793 B | view raw | |
| env.txt | β | 186 B | view raw | |
| fix.diff | β | 569 B | view raw | |
| verdict.json | β | 4.7 KB | view raw | |
| manifest.json | β | 1.2 KB | view raw |
Fix verification
fixedPatched kernel: identical trigger leaves s_len=31/s_size=32 (in-bounds), sets s_error=EDOOFUS(88), sbuf_cat returns -1, zero OOB bytes. Baseline (same config, unpatched) overflowed by 224 bytes silently. Bad behavior gone.
["run.2.log: 'DF2845: [4] sbuf_cat rc=-1 s_error=88 s_len=31 s_size=32 ... BLOCKED'", 'fix.diff: runtime validation replacing KASSERT at sys/kern/subr_sbuf.c:336']
Confirmed kernel references
Detail
Exploit chain
Trigger chain (root-gated): kldload (or compile in) a drain callback that returns 0 on a sbuf used for formatting -> every appended byte past s_size-1 lands beyond the heap allocation with attacker-influenced content, no error signaled. No unprivileged in-tree path to a misbehaving drain exists, so no uid0 chain; ceiling is kernel-heap corruption primitive behind a root-loaded/future-buggy drain.
Evidence (decisive lines)
["run.log: 'DF2845: [4] sbuf_cat rc=0 s_error=0 s_len=255 s_size=32' + 'OVERFLOW CONFIRMED: 224 byte(s) written past the end of the 32-byte heap allocation' (unpatched no-INVARIANTS kernel; 224 drain calls each returning 0)", "panic.txt: 'panic: Bad drain amount 0 for sbuf 0xfffff80117a49380' with stack sbuf_drain<-sbuf_put_byte<-sbuf_cat<-df2845_sysctl (unpatched INVARIANTS kernel)", "run.2.log: 'DF2845: [4] sbuf_cat rc=-1 s_error=88 s_len=31 s_size=32' + 'BLOCKED' (patched no-INVARIANTS kernel, fix.diff applied)", 'build.log: three in-guest nativekernel builds (no-INVARIANTS baseline, patched, INVARIANTS) and the KLD variant build']
PoC changes
Original sketch planned a KLD trigger; kldload of any freshly-built module crashes this snapshot's prebuilt kernel (GCC 8.3 toolchain vs Jul-2026 kernel; minimal hello-world KLD crashes identically at strcmp+0x10 in the loader before MOD_LOAD). Same test body was compiled into custom kernels via sys/conf/files + debug.df2845_trigger sysctl (df2845_test.c).
Verified recommended fix
Replace the sbuf_drain() KASSERT with a runtime check: if (len == 0 || len > s->s_len) { s->s_error = EDOOFUS; return (s->s_error); }
Verdict
sbuf_drain() (sys/kern/subr_sbuf.c:324-351) validates the drain callback's return only with a KASSERT (:336-337) that is compiled out of production kernels. Demonstrated on a purpose-built no-INVARIANTS DragonFly kernel: a drain returning 0 ('consumed nothing, no error') let sbuf_put_byte() write 224 bytes past a 32-byte FIXEDLEN sbuf allocation (s_len=255 vs s_size=32) while sbuf_cat() returned 0 with s_error=0 - a silent, unbounded, content-controlled linear kernel-heap overflow. On an INVARIANTS kernel the same trigger panics 'Bad drain amount 0 for sbuf' with stack sbuf_cat->sbuf_put_byte->sbuf_drain. No in-tree drain misbehaves (sbuf_sysctl_drain returns len or -error) and installing one requires root (KLD/custom kernel), so there is no unprivileged uid0 route today - this is a hardening gap whose failure mode is full kernel heap corruption for any future or 3rd-party drain regression. fix.diff adds a runtime check (len==0 || len>s_len -> s_error=EDOOFUS) and was validated in-guest: identical trigger on the patched no-INVARIANTS kernel stays in-bounds (s_len=31) and propagates rc=-1/EDOOFUS.
No comments yet.