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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3406 Β· 9 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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 numbers
  • fix.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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Sep 12 06:59:23 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.