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

sbuf_uionew truncates ssize_t uio_resid to int length then uiomove()s full original size into undersized buffer

Summary

sbuf_uionew constructs backing buffer with sbuf_new(s NULL uio->uio_resid+1 0) where uio_resid is ssize_t(64-bit) but length is int. For uio_resid whose low 32 bits of (resid+1) are small (e.g. 0x100000000 -> length=1) allocated s_buf far smaller than uio_resid. Next statement does uiomove(s->s_buf uio->uio_resid uio) with untruncated 64-bit resid writing gigabytes into tiny kernel buffer heap overflow. s_size also set to s_size-1 in ssize_t while buffer int-sized. Today sbuf_uionew has ZERO in-tree callers latent until wired. [VERIFIED pass2: reproduced with surgical precision - uio_resid 0x100000000 truncates length to 1, kmalloc 1-byte chunk in the shared 8-byte zone, uiomove copies all 512 committed attacker bytes past it before faulting on a PROT_NONE tail, 63 adjacent canary chunks corrupted with byte-exact address-offset-verified pattern 3 of 3 runs guest stable, EFAULT return masks the corruption; zero in-tree callers so latent KPI landmine with impact none today; resid-range guard fix-validated - EINVAL untouched uio, control byte-identical; FreeBSD carries the identical latent code]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2553 Β· 17 files
FileTypeDescriptionSize
kern_df2553.c β€” 12.3 KB view raw
df2553_trigger.c β€” 2.5 KB view raw
build.sh β€” 210 B view raw
run.sh β€” 187 B view raw
fix_validate.sh β€” 2.2 KB view raw
fix.diff β€” 714 B view raw
run.mode2.log β€” 353 B view raw
run.mode0.log β€” 684 B view raw
run.mode1.log β€” 2.3 KB view raw
run.2.mode1.log β€” 1.8 KB view raw
run.stress3.log β€” 5.1 KB view raw
run.1.pre-fix-scan.log β€” 2.1 KB view raw
fix_run.log β€” 1.9 KB view raw
fix_build.log β€” 5.7 MB ↓ download
build.snapshot-stock-kernel.log β€” 449.2 KB ↓ download
env.txt β€” 472 B view raw
VERDICT.md β€” 7.7 KB ↓ raw
VERDICT.md
↓ download raw

DF-2553 VERDICT -- sbuf_uionew int truncation + full-resid uiomove

STATUS : reproduced (primitive proven on live kernel; LATENT KPI -- zero in-tree callers today, so no user-reachable path on a stock kernel) IMPACT : none today (see honest assessment below); primitive strength = arbitrary-length attacker-content kernel heap overflow if any future caller feeds a user uio into sbuf_uionew CONF. : certain

ROOT CAUSE (verified line-by-line on the running kernel)

sys/kern/subr_sbuf.c:246

s = sbuf_new(s, NULL, uio->uio_resid + 1, 0);

sbuf_new() (subr_sbuf.c:210) takes int length; uio->uio_resid is ssize_t. uio_resid = 0x100000000 -> the (resid + 1) argument is 0x100000001, truncated to int = 1 -> sbuf_newbuf() (subr_sbuf.c:183) sets s_size = 1 and SBMALLOC(1) (subr_sbuf.c:197) = kmalloc(1, M_SBUF) -> 8-byte chunk of the per-CPU slab zone class 0 (kern_slaballoc.c zoneindex: sizes 0-127 -> 8-byte chunks).

subr_sbuf.c:251

*error = uiomove(s->s_buf, uio->uio_resid, uio);

uiomove() (kern/kern_subr.c:96) takes size_t n -- NO truncation: n = 0x100000000. For UIO_WRITE + UIO_USERSPACE it runs copyin(iov->iov_base, cp, min(iov_len, n)) per iov (kern_subr.c:117-156), so a fully-committed first iov of any size <= n is copied in its entirety into the 1-byte allocation before any fault on a later iov.

Additional latent edge: resid in [0x7fffffff, 0x100000000) makes (int)(resid+1) <= 0 -> KASSERT(length >= 0) at subr_sbuf.c:213 PANICS an INVARIANTS kernel (this guest config has INVARIANTS).

WHAT THE RIG PROVED (baseline kernel, verbatim subr_sbuf.c sha256 854fe3f4bf6e5b10d09e503c24304dbc432ffad83a3d67c7c8f54699741ac44e)


  1. TRUNCATION AT ALLOCATION - mode 2: kmalloc(0x100000001) = NULL (a faithful 4GiB+1 allocation is impossible on the 4 GB guest) while the truncated (int)1 kmalloc succeeds with usable size 8 (run.mode2.log). - mode 1: sbuf_uionew did NOT return ENOMEM (which a faithful kmalloc(0x100000001) would have produced) -- it proceeded to uiomove and returned EFAULT, i.e. sbuf_new SUCCEEDED: the allocation could only have been the truncated 1-byte one. - mode 0: success path prints s_size = (int)(resid+1) = 257 for resid = 256, directly observing the length relation.

  2. COUNT MISMATCH + OVERFLOW WITH ATTACKER BYTES (run.mode1.log, run.stress3.log: 3/3 consecutive runs, each 63/63 canaries) - uio after the call: resid 0x100000000 -> 0xfffffe00, iov0.len 512 -> 0: uiomove consumed EXACTLY the 512 committed attacker bytes, then faulted on the PROT_NONE iov1 -> *error = EFAULT. - 8192 one-byte canaries sprayed in the same slab zone; the sbuf chunk placement was made deterministic (LIFO-verified hole); after the call 63 adjacent canaries (the 512 bytes behind the 1-byte chunk) were corrupted, and EVERY one of the 63 matched the user pattern "DF2553PWN" at its exact address-derived cyclic offset (full-8-byte-pattern-match=63). That is a byte-exact, ordered, linear copy of userspace memory into kernel heap past a 1-byte allocation. - The guest kept running cleanly after every run: the corruption is surgical and deterministic (it landed only on canaries), which is exactly the property an exploit wants -- not a random crash. - Overflow length is attacker-chosen (iov0 size up to the committed user mapping; the count param allows up to 0x100000000) and content is attacker-chosen. On this non-SMAP/non-KASLR snapshot guest, corruption of the shared 8-byte slab zone (including freelist-eligible chunks) is the classic route toward uid=0.

  3. CONTROL - mode 0: resid 256 -> success, s_size 257, s_len 256, data == user bytes, zero canary corruption; identical before and after the overflow runs (allocator healthy).

HONEST ASSESSMENT (why impact is "none" today)

rg over the whole sys/ tree: sbuf_uionew has ZERO callers -- only the prototype (sys/sys/sbuf.h:94) and the definition (sys/kern/subr_sbuf.c:238). It is an exported KPI ("string buffers" API) awaiting users, and the harness had to fabricate the uio because no stock kernel path supplies one. Therefore NO local or remote attacker can reach this on a stock DragonFly kernel today -- not even root (no plumbing exists). Severity is latent: Medium as filed.

Why it still matters: - The same code is exported KPI; any future caller that passes a uio built from user input (the exact pattern sbuf_uionew was designed for) instantly turns this into an arbitrary-length heap overflow -- the rig shows content, length, and placement are all attacker controlled and the failure is masked as EFAULT. - FreeBSD carries the identical latent bug (subr_sbuf.c rev 255805 ancestry); the fix applies there too. - The [0x7fffffff, 0x100000000) band panics INVARIANTS kernels via the negative-length KASSERT if a caller appears.

FIX VALIDATION (A/B on the same guest)

fix.diff (in-pack, git apply -p1) rejects resid < 0 or > 0x7ffffffe with EINVAL before any allocation.

baseline kernel : mode 1 -> EFAULT after 512-byte smash, 63/63 canaries corrupted; mode 0 -> s_size 257 data OK patched kernel : mode 1 -> EINVAL, uio UNTOUCHED (0 bytes copied, resid still 0x100000000), corrupted=0; mode 0 -> byte-identical success output (s_size=257 s_len=256 data=44 46 32 35 35 33 50 57)

See fix_run.log (patched rerun) vs run.stress3.log (baseline) and fix_build.log (full untrimmed fixed-kernel build).

RIG FIDELITY NOTES

  • Runtime kldload GPFs on this guest, so the harness is compiled into the stock config X86_64_GENERIC (only additions: kern/kern_df2553.c
  • one conf/files line; subr_sbuf.c untouched, sha-gated).
  • The trigger sysctl is root-only and the trigger program is run as root: the finding is about the KPI landmine, not a stock attack path.
  • The trigger is pinned to CPU 0 (usched_set) because the slab allocator is per-CPU; canary grooming exploits LIFO freelists (kern_slaballoc.c _kfree: chunk pushed on z_LChunks head).
  • run.2.mode1.log keeps one intermediate run where the hole landed on the zone fill frontier (no canaries behind): 512 bytes were still copied (uio mutation proves it) but no canary was hit. Harness v3 selects the lowest-address canary of the frontier zone; all runs since reproduce 63/63.
  • My baseline nativekernel build log was wiped by the mandatory vm.sh reset with-src before the fix build; fix_build.log contains the full untrimmed compile of the same harness (plus the 12-line fix), and build.snapshot-stock-kernel.log is the stock kernel build log present on the snapshot.

CONCLUSION

The bug is real and its primitive is as strong as claimed (arbitrary length, arbitrary content, deterministic placement, error-masked). It is latent: zero callers today. The one-line-range guard in fix.diff kills it with no behavior change for legitimate sizes (validated A/B). Recommended upstream: apply the guard, and consider changing the KPI to take ssize_t*/return the needed length so future callers cannot silently truncate.

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

A/B validated on the same guest: baseline kernel reproduces the overflow (3/3 runs, 63/63 canaries); kernel built with only fix.diff applied returns EINVAL for resid 0x100000000 with the uio untouched (0 bytes copied, 0 corrupted) and produces byte-identical output for the small-resid control (s_size=257, s_len=256, data intact).

fix_run.log (patched rerun of the exact PoC) vs run.stress3.log (baseline); fix_build.log (full untrimmed fixed-kernel build).
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Mon Oct 19 23:14:32 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

Rig-proven chain: fabricated uio (resid 0x100000000; iov0 = 512 committed pattern bytes; iov1 = faulting tail) -> sbuf_new int truncation to length 1 -> kmalloc(1) 8-byte slab chunk placed deterministically behind sprayed canaries (LIFO-verified hole) -> uiomove copyin writes 512 user bytes past the chunk -> 63 adjacent kernel heap objects overwritten byte-exact with user pattern -> EFAULT masks everything, sbuf freed normally. Real-kernel chain: none exists today because sbuf_uionew has no callers; escalation to uid=0 is structurally impossible on a stock kernel and was not attempted for that reason. If a future caller passes user-derived uio_resid, the same primitive corrupts the shared small-chunk slab zone with attacker length and content on a non-SMAP non-KASLR guest - the standard grooming-to-LPE route.

Evidence (decisive lines)

run.stress3.log: three consecutive mode-1 runs each ending corrupted=63 full-8-byte-pattern-match=63 with the OVERFLOW REPRODUCED line, plus clean mode-0 after; run.mode1.log: single decisive run with per-canary hex dumps vs expected pattern bytes at address-derived offsets; run.mode2.log: faithful kmalloc(0x100000001)=NULL vs truncated usable=8; fix_run.log: patched kernel returns error 22 EINVAL with uio untouched (0 bytes copied, corrupted=0) and byte-identical mode-0 output; VERDICT.md: full narrative; kern_df2553.c/df2553_trigger.c: rig sources.

PoC changes

Rewrote the seeded sketch into a compiled-in kernel harness (runtime kldload GPFs on this guest): root-only sysctl kern.df2553_trigger drives the real sbuf_uionew with a fabricated 2-iov uio (iov0 = 512 committed attacker bytes, iov1 = 4GiB tail at a PROT_NONE page so uio_resid = 0x100000000 and the copy faults exactly after iov0); added 8192-canary heap grooming with LIFO probe verification in the 8-byte slab zone, an allocation-boundary negative control (mode 2), a small-resid control (mode 0), CPU pinning via usched_set(481), and an address-based cyclic-pattern verifier (index-based math was wrong because the zone UIndex wraps).

Verified recommended fix

Reject uio_resid < 0 or > 0x7ffffffe in sbuf_uionew with EINVAL before calling sbuf_new (see fix.diff); longer term make the KPI take a 64-bit length so future callers cannot silently truncate.

Verdict

Primitive REPRODUCED at full strength on the live stock-config kernel via a compiled-in harness: with uio_resid = 0x100000000, sbuf_new receives int length 1 (truncation), allocates a 1-byte kmalloc chunk in the shared 8-byte slab zone, then uiomove copies attacker bytes with count 0x100000000; a committed 512-byte iov0 lands byte-exact into 63 adjacent kernel heap canaries (3/3 runs, full-8-byte-pattern-match=63) before the PROT_NONE iov1 faults, so sbuf_uionew returns a clean EFAULT and frees the sbuf, masking the corruption. Faithful 4GiB+1 allocation is impossible on the guest (mode 2), proving the allocation only succeeded via truncation; small-resid control is byte-perfect. LATENT: zero in-tree callers of sbuf_uionew (verified across sys/), so nothing a local, remote, or even root user does on a stock kernel reaches this code today - impact none NOW; the moment any caller wires a user uio into this exported KPI it becomes an arbitrary-length, attacker-content heap overflow (potential LPE), and resid in [0x7fffffff,0x100000000) panics INVARIANTS kernels via the negative-length KASSERT.