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

tunwrite leaks mbuf chain on unsupported address family (m_freem(m) vs m_freem(top))

Field Value
ID DF-0588
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:C/C:N/I:N/A:H
CWE CWE-401 Missing Release of Memory after Effective Lifetime
File sys/net/tun/if_tun.c
Lines 875-882, 952-954
Area net (tun/tap driver)
Confidence certain
Discovered 2026-07-02
Reported pending

Summary

In tunwrite, when TUN_IFHEAD mode is enabled and the user-supplied 4-byte address family is anything other than AF_INET/AF_INET6, the default case of the family switch calls m_freem(m) on the last mbuf of the chain instead of m_freem(top) on the head. Because m_freem() walks m_next starting from its argument and the trailing mbuf has m_next == NULL, only that single trailing mbuf is freed β€” the chain head top and every intermediate mbuf leak. The leak is unbounded and repeatable from a privileged caller, exhausting kernel mbuf memory system-wide (including across jail / chroot boundaries) until the kernel panics or wedges networking for all tenants.

Root cause

tunwrite builds an mbuf chain in the loop at sys/net/tun/if_tun.c:875-882:

873:    top = NULL;
874:    mp = ⊤
875:    while (error == 0 && uio->uio_resid > 0) {
876:        m->m_len = (int)szmin(MHLEN, uio->uio_resid);
877:        error = uiomove(mtod(m, caddr_t), (size_t)m->m_len, uio);
878:        *mp = m;
879:        mp = &m->m_next;
880:        if (uio->uio_resid > 0)
881:            MGET(m, M_WAITOK, MT_DATA);
882:    }

Each iteration links one mbuf via *mp = m and advances mp = &m->m_next. On the final iteration, after the last uiomove brings uio_resid to 0, the conditional MGET at line 880-881 is not taken, so the local variable m ends up pointing at the last mbuf linked into the chain (whose m_next is NULL). The chain head is top.

At line 941 the code switches on the parsed address family:

941:    switch (family) {
942:#ifdef INET
943:    case AF_INET:
944:        isr = NETISR_IP;
945:        break;
946:#endif
947:#ifdef INET6
948:    case AF_INET6:
949:        isr = NETISR_IPV6;
950:        break;
951:#endif
952:    default:
953:        m_freem(m);          /* BUG: should be top */
954:        return (EAFNOSUPPORT);
955:    }

The default case at :952-955 does m_freem(m). Per sys/kern/uipc_mbuf.c:1469, m_freem(m) walks m->m_next from its argument; since m is the tail mbuf (m_next == NULL), only that single trailing mbuf is freed. The chain head top and every intermediate mbuf are reachable only through the local top, which goes out of scope on return without being freed β€” a classic leak. The intended call is m_freem(top); the earlier error path at :883-888 correctly uses m_freem(top), confirming the convention.

With uio_resid up to 65539 bytes (TUNMRU + 4, see :861-867) and MHLEN ~200 bytes per mbuf, a single write leaks up to ~326 mbufs.

Threat model & preconditions

  • Attacker position: privileged local user. tunopen at sys/net/tun/if_tun.c:283 gates with caps_priv_check(SYSCAP_RESTRICTEDROOT) β€” per sys/sys/caps.h:123-132 this is always disabled in jails/chroots and amounts to genuine host root.
  • Privileges gained or impact: kernel memory exhaustion / DoS. Impact is system-scoped (S:C in CVSS): mbuf exhaustion in the kernel affects every process and every jail on the host, not just the attacker's context, so a single privileged misbehaving process can take down networking for the whole machine.
  • Required config or capabilities: host root with an open fd on /dev/tunN. Default kernel (INET compiled in).
  • Reachability: open("/dev/tunN", O_RDWR) β†’ ioctl(fd, TUNSIFHEAD, &one) (sys/net/tun/if_tun.c:716, enables TUN_IFHEAD) β†’ repeatedly write() a 4-byte family β‰  AF_INET/AF_INET6 (e.g. AF_UNSPEC=0, AF_IPX, etc.) followed by up to 65531 bytes of payload. Each write hits the default case and leaks ~326 mbufs.

Proof of concept

PoC source: findings/poc/DF-0588/tun_leak.c

Build & run

cc -O2 -o tun_leak findings/poc/DF-0588/tun_leak.c
./tun_leak            # as host root
# in another terminal:
netstat -m            # watch mbuf count climb monotonically
vmstat -z | grep mbuf

Expected output

netstat -m / vmstat -z mbuf count climbing monotonically (the freed tail mbuf is recycled but the ~325 earlier mbufs per write are not). After enough writes, the kernel reports mbuf-zone exhaustion and either panics or wedges networking for all tenants:

mbuf zone exhausted
panic: ...

Reproduces 100% of the time on a default-config DragonFly kernel.

Impact

  • Blast radius: any DragonFly system that exposes /dev/tun* to any privileged process (containers/jails on the host, VPN daemons running as root, etc.). The mbuf exhaustion is global and takes down networking for the entire host, not just the misbehaving tenant β€” including processes in other jails that share the host's network stack.
  • Severity rationale: Medium. Reliable and trivially triggerable by a privileged caller, system-scoped DoS impact; no code execution or info leak.
  • Reliability: 100% β€” the leak is on a straight-line code path with no timing dependency.

Free the chain head top in the default case, exactly as the earlier error path at line 885 does.

--- a/sys/net/tun/if_tun.c
+++ b/sys/net/tun/if_tun.c
@@ -950,7 +950,7 @@ tunwrite(struct dev_write_args *ap)
        break;
 #endif
    default:
-       m_freem(m);
+       m_freem(top);
        return (EAFNOSUPPORT);
    }

top is the chain head set at :873/:878 and is the correct argument. (Optional cleanup: also bump IFNET_STAT_INC(ifp, ierrors, 1) before returning for parity with the other drop paths at :886, but that is accounting cosmetics, not a security fix.)

References

  • FreeBSD if_tun.c uses m_freem(top) in the equivalent default case β€” the correct reference behavior.
  • sys/kern/uipc_mbuf.c:1469 (m_freem semantics β€” walks m_next from its argument).

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0588 Β· 19 files
FileTypeDescriptionSize
tun_leak.c trigger-source minimal tunwrite mbuf-chain-leak trigger; vendored TUNSIFINFO/TUNMRU; opens /dev/tun clone; configurable write count; ENOBUFS detection 3.6 KB view raw
build.sh build-script cc -Wall -O2 -o tun_leak tun_leak.c 188 B view raw
run.sh run-script measure-before/after harness (awk quoting for vm.sh) 930 B view raw
run_local.sh run-script phase-8 measure harness (self-contained, runs in /root on guest); reports leaked mbufs/write 655 B view raw
build.log build-log final successful PoC build (clean) 31 B view raw
run.log run-log 50-write demo on #0: 4 -> 13203 mbufs (~264/write) 1.1 KB view raw
run.2.log run-log 100-write continuation on #0: 13204 -> 39603 (unreclaimed) 1.0 KB view raw
run.stress.log run-log stress to exhaustion on #0: 39603 -> 76247, 12 memory denials 1.0 KB view raw
exhaustion.txt panic-signature boot.log 'Warning: objcache(mbuf) exhausted on cpu1!' 51 B view raw
baseline_repro.log run-log PHASE 8 BEFORE: clean #0 baseline repro, 7->13207 (50w) then 13207->39607 (100w), ~264/write, monotonic/unreclaimed 1.0 KB view raw
fix.diff suggested-fix m_freem(m) -> m_freem(top) at if_tun.c:953 (free chain head not tail); patch -p1 --forward: Hunk #1 succeeded at 950 224 B view raw
fix_build.log build-log PHASE 8 single-fix kernel build: make -j6 nativekernel KERNCONF=X86_64_GENERIC on patched /usr/src, rc=0, if_tun.c recompiled, kernel.stripped relinked 5.6 MB ↓ download
fix_run.log run-log PHASE 8 AFTER: same PoC on #1 patched kernel, 50/50/200 writes all keep mbufs flat at 7 (delta=0). Leak gone. 1.3 KB view raw
fix_env.txt environment PHASE 8 patched #1 kernel env: uname, kern.version (#1 06:56:05), cc 8.3, nmbclusters=33296, /dev/tun 0600 uucp:dialer, baseline 7 mbufs, live if_tun.c:953 = m_freem(top) 608 B view raw
env.txt environment original verification env (prior #1 build): uname, cc 8.3, kldstat (tun built-in), /dev/tun perms, nmbclusters, baseline mbufs 979 B view raw
VERDICT.md verdict REPRODUCED + PHASE 8 fix-validated: mechanism, measured leak table (#0), flat-mbuf table (#1), build-install-reboot notes 7.8 KB ↓ raw
README.md readme build/run/expected + root-cause + reachability + verifier notes 4.3 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme build/run/expected + root-cause + reachability + verifier notes
↓ download raw

DF-0588 β€” PoC evidence pack

Reproduces the mbuf-chain leak in tunwrite() at sys/net/tun/if_tun.c. A confirmed privileged local resource-exhaustion DoS that leaks ~263 mbufs per write and drives the kernel mbuf zone to exhaustion system-wide.

Root cause (confirmed in source)

tunwrite() builds an mbuf chain in the loop at sys/net/tun/if_tun.c:875-882:

869:  /* get a header mbuf */
870:  MGETHDR(m, M_WAITOK, MT_DATA);          /* m = chain head */
...
874:  mp = ⊤
875:  while (error == 0 && uio->uio_resid > 0) {
876:      m->m_len = (int)szmin(MHLEN, uio->uio_resid);
877:      error = uiomove(mtod(m, caddr_t), (size_t)m->m_len, uio);
878:      *mp = m;                              /* append m */
879:      mp = &m->m_next;
880:      if (uio->uio_resid > 0)
881:          MGET(m, M_WAITOK, MT_DATA);       /* get NEXT mbuf only if more data */
882:  }

On the last iteration uio->uio_resid reaches 0, so the MGET at line 881 is not taken and m is left pointing at the last (tail) mbuf of the chain, while top is the head. Then, when TUN_IFHEAD is set and the user-supplied 4-byte address family is anything other than AF_INET/AF_INET6, the default case at lines 952-955:

952:  default:
953:      m_freem(m);              /* BUG: frees only the tail mbuf */
954:      return (EAFNOSUPPORT);
955:  }

calls m_freem(m) β€” freeing only the trailing mbuf β€” and leaks the chain head top plus all intermediate mbufs (since m->m_next == NULL at the tail, m_freem(m) frees exactly one mbuf). The chain is reachable only from the local top, which goes out of scope on return, so the leak is permanent.

A 65539-byte write (max, TUNMRU+4) assembles ~264 mbufs, freeing 1 and leaking ~263 per write (measured).

Trigger

  1. open("/dev/tun", O_RDWR) (clone β†’ fresh tunN).
  2. ioctl(fd, TUNSIFHEAD, &one) β€” enable the IFHEAD address-family prefix.
  3. write(fd, buf, 65539) where the first 4 bytes are a family β‰  AF_INET(2)/ AF_INET6(28) (e.g. 0xffffffff). write() returns EAFNOSUPPORT after leaking ~263 mbufs.
  4. Loop. The mbuf count climbs monotonically and unreclaimed until the objcache is exhausted system-wide.

Reachability / privilege (verified)

  • /dev/tun clone node is 0600 UID_UUCP/GID_DIALER (if_tun.c:155-157).
  • tunopen() (if_tun.c:283) requires caps_priv_check(SYSCAP_RESTRICTEDROOT) with no user_open escape (unlike tap). So this is host-root-only.
  • maxx (uid 1001) cannot open /dev/tun β†’ matches CVSS PR:H.
  • Impact is S:C: the mbuf zone is kernel-global, so a privileged trigger exhausts mbufs for every tenant / network stack on the box.

Files

  • tun_leak.c β€” minimal reproducer (vendored TUNSIFINFO/TUNMRU; opens the clone /dev/tun; configurable write count).
  • build.sh / run.sh β€” build & measure-before/after harness.
  • build.log, run.log (50-write demo), run.2.log (100-write continuation, proves unreclaimed), run.stress.log (200-write exhaustion), exhaustion.txt (kernel objcache(mbuf) exhausted warning).
  • env.txt, VERDICT.md, fix.diff, manifest.json.

Build & run (as host root on the DragonFly guest)

./build.sh
./run.sh 50            # ~16k leaked mbufs, safe demo
./run.sh 1000000       # stress: drive to mbuf exhaustion (guest will wedge)

Expected result (bug present)

Controlled demo (50 writes):

[baseline] mbufs in use: 4
... tun_leak: 50 writes ...
[after]    mbufs in use: 13203
[delta]    leaked mbufs: 13199 over 50 writes (~263 mbufs/write)

Stress to exhaustion:

76247/72904 mbufs in use (current/max):     <-- over nominal max
12 requests for memory denied
boot.log: Warning: objcache(mbuf) exhausted on cpu1!

After exhaustion the guest's networking is degraded system-wide (new mbuf allocations denied). Recovery: vm.sh reset (reboot).

Notes for the per-PoC verifier (done)

  • tun is built into X86_64_GENERIC (no kldload needed; net.link.tun sysctl present, /dev/tun exists on clean boot).
  • The seeded PoC opened /dev/tun3 (non-existent) and #included <net/if_tun.h> (not shipped in guest /usr/include); both fixed.
  • The leak is straight-line, no race; reproduces 100 % of the time.
  • After the fix (m_freem(top)), the same workload keeps the mbuf count flat.
VERDICT.md verdict REPRODUCED + PHASE 8 fix-validated: mechanism, measured leak table (#0), flat-mbuf table (#1), build-install-reboot notes
↓ download raw

DF-0588 β€” VERDICT

Verdict: REPRODUCED (privileged local resource-exhaustion DoS, S:C)

The bug, confirmed in source

tunwrite() in sys/net/tun/if_tun.c assembles an mbuf chain whose head is top. The loop at lines 875-882 only allocates the next mbuf (line 881) when more data remains, so on the final iteration β€” when uio->uio_resid hits 0 β€” the MGET is not taken and the local m is left pointing at the last (tail) mbuf of the chain, not the head:

870:  MGETHDR(m, M_WAITOK, MT_DATA);          /* m = chain head initially */
874:  mp = &top;
875:  while (error == 0 && uio->uio_resid > 0) {
...
878:      *mp = m;                              /* append m to chain */
879:      mp = &m->m_next;
880:      if (uio->uio_resid > 0)
881:          MGET(m, M_WAITOK, MT_DATA);       /* NOT taken on last iter */
882:  }

After the loop: top = chain head; m = chain tail (m->m_next == NULL).

When TUN_IFHEAD is set and the user 4-byte family is not AF_INET/AF_INET6, the default case frees the wrong variable:

952:  default:
953:      m_freem(m);              /* BUG: frees only the tail (1 mbuf) */
954:      return (EAFNOSUPPORT);
955:  }

m_freem(m) walks m->m_next, which is NULL at the tail, so it frees exactly one mbuf. The chain head top and every intermediate mbuf are leaked. top is a local that goes out of scope on return, so the leak is permanent.

Trigger & proof

  1. open("/dev/tun", O_RDWR) β†’ clone creates a tun interface.
  2. ioctl(fd, TUNSIFHEAD, &one) β€” enable IFHEAD.
  3. write(fd, buf, 65539) with buf[0..3] = 0xffffffff (family β‰  AF_INET/6) β†’ EAFNOSUPPORT, ~263 mbufs leaked.
  4. Loop; watch netstat -m.

Measured (mbufs-in-use, netstat -m):

run writes before after Ξ” mbufs/write
run #1 50 4 13203 13199 ~264
run #2 100 13204 39603 26399 ~264
stress ~137 39603 76247 36644 ~267

Run #2 starting at 13204 (the leftover from run #1, not reclaimed) and climbing by the same ~264/write proves the leak is monotonic and unreclaimed β€” the kernel never frees the leaked chain heads.

Stress to exhaustion:

76247/72904 mbufs in use (current/max):     <- over the nominal max
12 requests for memory denied
boot.log: Warning: objcache(mbuf) exhausted on cpu1!

The objcache(mbuf) exhausted kernel warning + requests for memory denied confirm system-wide mbuf exhaustion: a privileged local user can drain the global mbuf zone and degrade networking for every tenant β€” the S:C impact in the CVSS vector.

Impact

  • Class: CWE-401 (resource leak / missing release) β†’ resource-exhaustion DoS.
  • Effect: each unsupported-family write leaks ~263 mbufs; ~280 such writes exhaust a default nmbclusters=16912 / 72904-mbuf system, after which new mbuf allocations are denied system-wide (objcache(mbuf) exhausted). The triggering write() itself eventually blocks in M_WAITOK once the zone is empty. Recovery requires a reboot.
  • Privilege: host-root only. tunopen() (if_tun.c:283) requires caps_priv_check(SYSCAP_RESTRICTEDROOT) with no escape; /dev/tun is 0600 uucp:dialer. Matches CVSS PR:H. (Unprivileged maxx gets EACCES.)
  • No memory corruption (no overflow/UAF) β€” no exploit chain beyond DoS. Impact is dos (system-scoped resource exhaustion).

PoC changes from the seeded version

The seeded tun_leak.c had two bugs that prevented it from working: 1. It opened /dev/tun3, which does not exist (tun uses the /dev/tun clone device). Fixed to open /dev/tun. 2. It #include <net/if_tun.h>, which is not shipped in the guest's /usr/include. Vendored TUNSIFINFO (_IOW('t', 96, int)) and TUNMRU (65535) directly.

Also added a configurable write count (default 50 for a safe demo), an ENOBUFS/ENOMEM detection branch for the stress-to-exhaustion case, and a run.sh that measures netstat -m before/after to quantify the leak per write.

Free the chain head top, not the tail m. One line at if_tun.c:953:

    default:
        m_freem(top);          /* was: m_freem(m) */
        return (EAFNOSUPPORT);

This frees the entire assembled chain. See fix.diff (git apply --check passes). After the fix, the same 50/100/200-write workload keeps the mbuf count flat.


PHASE 8 β€” Fix validation on a single-fix kernel (verified)

The m_freem(m) β†’ m_freem(top) fix was validated end-to-end by building a single-fix kernel from the audited /usr/src tree (warm obj), installing it, rebooting, and re-running the identical PoC workload.

Setup

  • Base guest: DragonFly 6.5-DEVELOPMENT #0 (unpatched audit-source kernel, with-src snapshot β€” full /usr/src + warm /usr/obj/usr/src/sys/X86_64_GENERIC).
  • Patch applied: ONLY findings/poc/DF-0588/fix.diff via cd /usr/src && patch -p1 --forward < /root/fix.diff β†’ Hunk #1 succeeded at 950. (verified: sed -n 952,954p shows m_freem(top)).
  • Build: make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ rc=0, >>> Kernel build for X86_64_GENERIC completed. Only if_tun.c recompiled (warm obj); full build log in fix_build.log.
  • Install: copied kernel.stripped β†’ /boot/kernel/kernel (the file the loader actually boots β€” /boot/kernel/kernel.stripped alone is not loaded), plus kernel.debug. (First boot attempt used only .stripped/.debug and silently kept the old #0; the loader boots the bare name kernel.)
  • Booted kernel: DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 06:56:05 UTC 2026 (build timestamp matches the rebuilt kernel.debug).

Before (unpatched #0) β€” baseline_repro.log

run writes before after Ξ” mbufs/write
A 50 7 13207 13200 264
B (cont) 100 13207 39607 26400 264

Leak is monotonic and unreclaimed (run B starts from run A's terminal count).

After (single-fix #1) β€” fix_run.log

run writes before after Ξ” mbufs/write
A 50 7 7 0 0
B 50 7 7 0 0
C 200 7 7 0 0

Mbuf count stays perfectly flat across 300 total writes. The leak is gone.

Classification

  • fix_status = fixed β€” baseline climbed at 264 mbufs/write; patched kernel leaks 0 across 50/50/200-write runs (identical workload, identical PoC binary).
  • fix_kernel_uname β€” DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 06:56:05 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64.
  • The fix is the exact one-line change in the finding markdown's ## Recommended fix; the runner's fix.diff matches the finding proposal (no supersede needed).
  • No regressions observed: patched kernel boots clean, networking normal, no boot.log warnings or panics during the test workload.

Notes for the build-install-reboot workflow (template for future findings)

  1. The loader boots /boot/kernel/kernel, NOT kernel.stripped. A single-fix install must overwrite /boot/kernel/kernel (copy kernel.stripped there).
  2. kern.version is the authoritative "did the new kernel boot" check β€” the #N suffix and build timestamp change with each link.
  3. Warm-obj nativekernel for a .c-only fix is fast (~3-4 min wall here despite a full module sweep); background it (&) and poll kill -0 $(cat nk.pid) + grep NK_DONE, because the backgrounded child holds the ssh channel open.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

FIXED. The single-fix kernel (m_freem(m) -> m_freem(top) at if_tun.c:953, only change) eliminates the leak: baseline #0 climbed 7 -> 13207 -> 39607 (~264 mbufs/write, monotonic); patched #1 stayed flat at 7 mbufs across 50+50+200 writes (delta=0 each). Same PoC binary, same workload, opposite result. fix.diff matches the finding markdown proposal; git apply --check and patch --dry-run both pass.

BEFORE (#0): [baseline] 7 -> [after 50w] 13207 (delta 13200, ~264/w); continuation [baseline] 13207 -> [after 100w] 39607 (delta 26400, ~264/w). AFTER (#1, kern.version 'DragonFly 6.5-DEVELOPMENT #1: Thu Jul  2 06:56:05 UTC 2026'): RUN A 50w 7->7 delta=0; RUN B 50w 7->7 delta=0; RUN C 200w 7->7 delta=0. Patched if_tun.c:953 live = m_freem(top). Build: make -j6 nativekernel rc=0, if_tun.o rebuilt 06:54, kernel.stripped relinked 06:56. boot.log clean (no panic/warning).
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 06:56:05 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

none - this is a pure resource-exhaustion DoS (CWE-401), not memory corruption. No overflow/UAF/type-confusion, so there is no primitive to chain to code execution or uid0. Impact ceiling is system-scoped mbuf-zone exhaustion: a privileged (host-root, tunopen requires SYSCAP_RESTRICTEDROOT) caller drains the global mbuf zone (~264 mbufs/write, ~280 writes to exhaust 72904-mbuf default) after which new mbuf allocations are denied system-wide (objcache(mbuf) exhausted), degrading networking for every tenant on the host. Recovery requires a reboot.

Evidence (decisive lines)

BEFORE (#0 unpatched, baseline_repro.log): 7 -> 13207 mbufs over 50 writes (~264/write); continuation 13207 -> 39607 over 100 more (monotonic/unreclaimed). AFTER (#1 single-fix kernel, fix_run.log): kern.version='DragonFly 6.5-DEVELOPMENT #1: Thu Jul  2 06:56:05 UTC 2026'; RUN A 50w: 7->7 delta=0; RUN B 50w: 7->7 delta=0; RUN C 200w: 7->7 delta=0. Boot log clean, no panics/warnings on either kernel. fix.diff: m_freem(m) -> m_freem(top) at if_tun.c:953; git apply --check + patch --dry-run both pass.

PoC changes

No changes to the trigger (tun_leak.c) or fix.diff - both were already correct from the prior runner. Added run_local.sh (self-contained measure harness that survives vm.sh's awk-quoting), baseline_repro.log (clean #0 BEFORE evidence), fix_build.log (full single-fix nativekernel build), fix_run.log (#1 AFTER evidence, 50/50/200-write flat-mbuf runs), fix_env.txt (#1 kernel env), and a PHASE 8 section in VERDICT.md. Updated manifest.json with fix_status/fix_baseline_reproduced/fix_patched_reproduced/fix_kernel_uname and the new artifacts.

Verified recommended fix

matches finding proposal. Change m_freem(m) -> m_freem(top) at sys/net/tun/if_tun.c:953 in the default case of the family switch, freeing the chain head assembled at :873/:878 instead of the tail m (whose m_next==NULL, so m_freem(m) freed exactly one mbuf and leaked the rest). Verified correct against the audited source (line 953 confirmed) and proven effective: the single-fix kernel keeps mbufs flat where the unpatched kernel leaks ~264/write. The full git-apply-able diff lives in findings/poc/DF-0588/fix.diff (git apply --check + patch --dry-run pass).

Verdict

REPRODUCED on the unpatched #0 audit-source kernel and FIX VALIDATED on a single-fix #1 kernel built from the same tree. Bug: in tunwrite() at sys/net/tun/if_tun.c:953, with TUN_IFHEAD set and a family != AF_INET/AF_INET6, the default case calls m_freem(m) on the chain TAIL (m->m_next==NULL after the loop at :875-882) instead of m_freem(top) on the head, leaking the entire chain head + intermediates. Measured on #0: mbufs climb 7 -> 13207 over 50 writes (~264 leaked/write) and monotonically 13207 -> 39607 over 100 more (unreclaimed), reproducible 100%. The one-line fix (m_freem(m) -> m_freem(top) at :953, exactly the finding proposal) was applied via patch -p1 (Hunk #1 succeeded at 950), a single-fix kernel was built (make -j6 nativekernel, rc=0, warm obj), installed, and booted as #1 06:56:05 UTC 2026. The identical PoC workload on #1 keeps mbufs perfectly flat (7 -> 7 across 50/50/200-write runs, delta=0). fix_status=fixed.