tapwrite() leaks one mbuf per EFAULTing write: spare MGET allocated before loop-condition recheck is never freed
Summary
The tapwrite() copy loop MGETs the next chain mbuf whenever uio->uio_resid > 0 at the loop bottom - including the iteration whose uiomove just faulted - so a part-way EFAULT write exits with one unlinked spare mbuf that the error path never frees. A faulting uiomove never exhausts uio_resid, so every failed write leaks exactly one mbuf, permanently. MEASURED: 100,000 faulting writes grow the mbuf pool from 7 to 100,007 (50.5 MB, 36% of mb_map) with perfect linearity and no reclamation - ~293k more writes exhaust mb_map and stall all networking (kernel-wide DoS). Privileged (open /dev/tapN fd). No memory corruption. Fix validated (m_freem(m) on the error path): count stays at 7.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3406 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| tapleak.c | β | 2.3 KB | view raw | |
| build.sh | β | 82 B | view raw | |
| README.md | β | 669 B | β raw | |
| VERDICT.md | β | 3.3 KB | β raw | |
| build.log | β | 194 B | view raw | |
| run.log | β | 896 B | view raw | |
| fix_validation.log | β | 593 B | view raw | |
| fix.diff | β | 303 B | view raw | |
| verdict.json | β | 3.1 KB | view raw |
DF-3406 β tapwrite mbuf leak on EFAULT (one m_get per failed write)
Reproduce (guest, as root)
cc -O2 -o tapleak tapleak.c
netstat -m | head -3
./tapleak 100000
netstat -m | head -3 # stock: +100,000 data mbufs (~50 MB)
# fixed: unchanged
Creates a tap unit, opens it, and issues 100,000 write()s whose buffer crosses into a PROT_NONE page, so uiomove faults part-way (EFAULT) after at least one successful mbuf fill β the exact condition that leaks the spare MGET at if_tap.c:961.
Expected (stock kernel): mbuf count grows by exactly 1 per failed write and never returns. Fixed kernel: no growth.
DF-3406 β VERDICT
Status: reproduced (baseline, stock INVARIANTS kernel #0).
Fix: fixed (one-line m_freem(m) in the validated combined kernel).
Impact: deterministic kernel-memory (mbuf) exhaustion β privileged
local DoS; Low.
Root cause (path:line)
tapwrite() (sys/net/tap/if_tap.c:917) allocates the next chain
mbuf at the bottom of the copy loop before re-checking the loop
condition:
955: while (error == 0 && uio->uio_resid > 0) {
956: m->m_len = (int)szmin(mlen, uio->uio_resid);
957: error = uiomove(mtod(m, caddr_t), (size_t)m->m_len, uio);
958: *mp = m; /* linked into chain */
959: mp = &m->m_next;
960: if (uio->uio_resid > 0) {
961: MGET(m, M_WAITOK, MT_DATA); /* spare -- never
962: mlen = MLEN; linked on error */
963: }
964: }
965: if (error) {
966: IFNET_STAT_INC(ifp, ierrors, 1);
967: if (top)
968: m_freem(top); /* frees only the chain */
969: return (error); /* <-- spare m leaked */
970: }
uiomove faults leave uio_resid > 0 (only successfully-moved bytes
are deducted), so a part-way EFAULT write always exits the loop one
fresh MGET ahead of the chain β exactly one mbuf leaked per failed
write. The leaked mbufs are never reclaimed (the kernel holds the
only reference).
How it was reproduced (baseline numbers, stock kernel #0)
tapleak.c (run as root): create tap, open it, then 100,000
write(fd, buf, page+128) where buf is 2 pages with the second
PROT_NONE β every write faults part-way (EFAULT).
before: 7/146632 mbufs in use ... 263 data mbufs ... 515 Kbytes
after 100000 writes (ok=0 efault=100000):
100007/146632 mbufs ... 100263 data mbufs ... 50515 Kbytes
(36% of mb_map in use)
+1 more EFAULT write: 100008 (perfectly linear; persists over idle)
Exactly +1 leaked data mbuf per EFAULT write; permanent until
reboot. ~0.5 KB kernel memory per write β unbounded exhaustion
with ~293k writes to fill mb_map on this guest (network-wide DoS:
mbuf exhaustion blocks all networking).
Privilege bar: writing to /dev/tapN requires an open fd, which
requires root (0600 node + tapuopen=0 SYSCAP gate at if_tap.c:323).
Low severity for that reason; no memory corruption β pure leak.
Fix validation
fix.diff adds m_freem(m); to the error path (the spare is always
the unlinked m on that path: a faulting uiomove never exhausts
uio_resid, so the spare MGET always happened; a normal exit never
takes the path). Built into the same patched kernel #1 as DF-3405's
fix; re-ran the identical 100,000-EFAULT storm:
before: 7/146632 mbufs in use after 100000 EFAULT writes: 7/146632 mbufs in use <- zero growth
Artifacts
tapleak.cβ trigger (faulting write storm)build.sh/run.logβ build + baseline numbersfix.diffβ one-line validated fix
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
fixedfix.diff adds m_freem(m) for the spare mbuf on tapwrite()'s error path. Validated in the same patched kernel #1: the identical 100,000-EFAULT-write storm leaves netstat -m at exactly 7/146632 mbufs before and after (baseline stock kernel: 7 -> 100,007 = +1 leaked mbuf per failed write, 36% of mb_map, permanent). Zero growth post-fix; no functional regression.
['findings/poc/DF-3406/fix_validation.log', 'findings/poc/DF-3406/fix.diff']
Confirmed kernel references
Detail
Evidence (decisive lines)
['run.log: baseline netstat -m 7 mbufs -> 100007 after 100000 EFAULT writes (ok=0 efault=100000), 100263 data mbufs, 50515 Kbytes 36% of mb_map; +1 write -> 100008 (linear); count persists over idle', 'fix_validation.log: patched kernel #1, same storm, mbuf count stays 7/146632 before and after']
PoC changes
PoC written fresh; unit changed tap0->tap5 after the DF-3405 race wedged unit 0 on the patched kernel (same code path).
Verified recommended fix
Free the spare mbuf on the error path: add m_freem(m) next to m_freem(top) in tapwrite()'s error branch (see fix.diff).
Verdict
tapwrite() leaks exactly one m_get mbuf per EFAULTing write(): the copy loop MGETs the next chain mbuf before re-checking the loop condition, so a part-way uiomove fault (uio_resid still > 0) exits with one unlinked spare mbuf that the error path never frees (if_tap.c:955-969). Reproduced on the stock INVARIANTS kernel: 100,000 faulting writes grew the mbuf pool from 7 to 100,007 (100,263 data mbufs, 50.5 MB = 36% of mb_map), perfectly linear (+1/write), permanent until reboot; ~293k more writes would exhaust mb_map and stall all networking. Fixed by adding m_freem(m) on the error path (fix.diff); on the patched kernel the same 100,000-write storm leaves the count at exactly 7. Privilege bar: requires an open /dev/tapN fd (root:wheel 0600 + tapuopen=0 SYSCAP gate), so Low severity -- privileged local kernel-memory exhaustion.
No comments yet.