TAPSIFINFO leaks the ifnet serializer on type mismatch (local DoS / kernel wedge)
| Field | Value |
|---|---|
| ID | DF-0585 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-667 Improper Locking (lock acquired but not released on error path) |
| File | sys/net/tap/if_tap.c |
| Lines | 738-832 |
| Area | net |
| Confidence | certain |
| Discovered | 2026-07-01 |
| Reported | pending |
Summary
tapioctl() acquires the per-ifnet serializer with ifnet_serialize_all() at
the top of the function and releases it only on the single fall-through exit
path. The TAPSIFINFO case has an early return (EPROTOTYPE) when the
caller-supplied type does not match the interface type, which returns while the
serializer is still held. That orphaned lock then wedges every subsequent
operation on the interface β including close() of the very fd used to trigger
it β turning a single ioctl into a permanent local denial of service.
Root cause
In tapioctl() (sys/net/tap/if_tap.c:726) the serializer is acquired
unconditionally at if_tap.c:738 (ifnet_serialize_all(ifp);) and released
only at if_tap.c:832 (ifnet_deserialize_all(ifp);) on the normal return
path. The TAPSIFINFO handler at if_tap.c:742-748 is:
case TAPSIFINFO:
tapp = (struct tapinfo *)data;
if (ifp->if_type != tapp->type)
return (EPROTOTYPE); /* <-- line 745: leaks the serializer */
ifp->if_mtu = tapp->mtu;
ifp->if_baudrate = tapp->baudrate;
break;
ifp->if_type for a tap device is IFT_ETHER (set in tapcreate()).
tapp->type is an attacker-controlled u_char taken verbatim from the ioctl
argument (struct tapinfo, sys/net/tap/if_tap.h:46-51). Any value !=
IFT_ETHER drives the early return, leaving the ifnet serializer acquired
with no matching release. The serializer is the lwkt serializer installed by
ether_ifattach(); it is not auto-released across the syscall return
boundary. Once orphaned, the next ifnet_serialize_all() on this ifp β which
happens in:
tapclose()atif_tap.c:426tapread()atif_tap.c:870tapwrite()atif_tap.c:980tapifioctl()atif_tap.c:538/738(viaifconfig)tapifstart()on the next TXtapifstop()
β blocks indefinitely. There is exactly one such early return inside the
serialized section of tapioctl(); tapifioctl() (the net-iface path) has no
unbalanced returns, and all other early returns in tapread/tapwrite occur
either before serialization or after an explicit deserialize.
Threat model & preconditions
- Attacker position: any process holding an open tap fd (root by default;
any user if
net.link.tap.user_open=1and node perms allow). - Privileges gained or impact: permanent local denial of service β no integrity or confidentiality impact.
- Required config or capabilities: open fd on
/dev/tapN. The fd-open privilege gate is intapopen()atif_tap.c:323-327: with the defaultnet.link.tap.user_open=0, opening requirescaps_priv_check(SYSCAP_RESTRICTEDROOT)(root); withuser_open=1(or on a node chmod'd world-writable) an unprivileged user can open it. In practice tap fds are routinely delegated to lower-privilege processes β qemu/bhyve VM processes, VPN daemons (OpenVPN, WireGuard userspace), jails/containers with a passed tap fd β and any of those compromised or malicious processes can wedge the kernel. - Reachability: single
ioctl(fd, TAPSIFINFO, &ti)withti.type != IFT_ETHER. Concrete impact: 1. The offending fd cannot be closed (tapcloseblocks atif_tap.c:426), so the process hangs in uninterruptible state on exit. 2.ifconfig tapNfrom root hangs (tapifioctlserialize). 3. Any RX/TX on the interface hangs (tapifstart/tapifinputpath). 4. Module unload fails forever (taprefcntnever decrements to 0 becausetapcloseis stuck). On a multi-process system the wedge can cascade as more threads touch the interface. Recovery requires a reboot.
Proof of concept
PoC source: findings/poc/DF-0585/leak_tap_lock.c
Build & run
# on a DragonFlyBSD host/guest with the tap module loaded cc -I/sys/net/tap -o leak_tap_lock findings/poc/DF-0585/leak_tap_lock.c ./leak_tap_lock /dev/tap0
(If the kernel include path is awkward, vendor struct tapinfo and the
TAPSIFINFO _IOW('t',91,struct tapinfo) definition into the source to avoid
the kernel header dependency.)
Expected output
serializer orphaned; close() will now wedge
The binary prints the "close() will now wedge" line, then never prints the
final "unreachable: fd closed" line β it hangs in tapclose. A separate shell
running ifconfig tap0 or cat /dev/tap0 also hangs, proving the interface
serializer is orphaned. ps -axl | grep leak_tap_lock shows the process stuck
in tapcls/ifser. Recovery requires a reboot.
Impact
Permanent local denial of service on any system that exposes a tap fd to a
process that can be compromised or that is itself malicious. Default kernels
require root to open /dev/tapN, but the common deployment pattern (VM
processes, VPN daemons, jails) deliberately lowers that bar. A single buggy or
malicious ioctl wedges the kernel: the offending process can never exit, the
interface becomes unusable, and the module cannot unload.
Recommended fix
Do not return from inside the serialized section; route all error exits
through the single ifnet_deserialize_all() at the bottom, exactly like every
other case in tapioctl() and like tapifioctl() does. Convert the early
return into the standard error = β¦; break; pattern so control falls
through to ifnet_deserialize_all(ifp) before returning. Callers still see
EPROTOTYPE; behavior is otherwise unchanged.
--- a/sys/net/tap/if_tap.c
+++ b/sys/net/tap/if_tap.c
@@ -741,8 +741,9 @@ tapioctl(struct dev_ioctl_args *ap)
switch (ap->a_cmd) {
case TAPSIFINFO:
tapp = (struct tapinfo *)data;
- if (ifp->if_type != tapp->type)
- return (EPROTOTYPE);
+ if (ifp->if_type != tapp->type) {
+ error = EPROTOTYPE;
+ break;
+ }
ifp->if_mtu = tapp->mtu;
ifp->if_baudrate = tapp->baudrate;
break;
References
- FreeBSD rS366310 (2020-10-02) β same pattern (
if_tap.cTAPSIFINFOearly return vs serializer), historically addressed by routing all error exits through the single deserialize. - DragonFlyBSD
ifnet_serialize_all(9)/ lwkt serializer semantics: the serializer is a recursive-spinning token that is not released across syscall return; orphaning it deadlocks every subsequent acquire.
Timeline
- 2026-07-01 Discovered during automated file-by-file audit of
sys/net/tap/if_tap.c. - 2026-07-01 PoC source staged under
findings/poc/DF-0585/; awaiting upstream report.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0585 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| leak_tap_lock.c | trigger-source | minimal TAPSIFINFO serializer-orphan trigger; vendored correct 8-byte struct tapinfo; fork-dance + waitpid(WNOHANG) detection so the PoC prints honest opposite verdicts on buggy vs fixed kernels | 5.5 KB | view raw |
| build.sh | build-script | cc -Wall -O2 -o leak_tap_lock leak_tap_lock.c | 214 B | view raw |
| run.sh | run-script | loads if_tap, runs trigger, then fully-detached ifconfig corroboration | 1.6 KB | view raw |
| build.log | build-log | final userland build (one harmless unused-var warning) | 270 B | view raw |
| run.log | run-log | BASELINE #0 run: TAPSIFINFO EPROTOTYPE + child D1/slize wedge + ifconfig hang | 1.5 KB | view raw |
| fix_build.log | build-log | full nativekernel build of single-fix kernel+module (rc=0, ~4min, no errors) | 5.6 MB | β download |
| fix_run.log | run-log | FIX-VALIDATION: patched #1 + new if_tap.ko -> child EXITED (Z), ifconfig returns 0s; before/after contrast table | 3.6 KB | view raw |
| baseline_proof.txt | run-log | first baseline capture: child 908 D3 + ifconfig 959 D1/slize | 1.1 KB | view raw |
| corroborate.log | run-log | prior-session detached ifconfig tap0 wedge corroboration | 34 B | view raw |
| env.txt | environment | uname (#0 + #1), cc 8.3, if_tap.ko hashes (baseline 8e1a6da3 / patched bfe90971), kldstat, /dev/tap perms, sysctls, maxx not in wheel | 1.4 KB | view raw |
| VERDICT.md | verdict | REPRODUCED + FIX VALIDATED: mechanism, proof, impact, reachability, build nuance (tap is modular), before/after table | 6.3 KB | β raw |
| fix.diff | suggested-fix | release serializer on TAPSIFINFO early-return (ifnet_deserialize_all before return EPROTOTYPE); applied to /usr/src, built, booted, validated | 418 B | view raw |
| README.md | readme | build/run/expected + root-cause + reachability notes | 4.6 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 |
DF-0585 β PoC evidence pack
Reproduces the TAPSIFINFO ifnet-serializer orphan in sys/net/tap/if_tap.c
(tapioctl). A confirmed local DoS that permanently wedges a tap(4)
interface, requiring a reboot to recover.
Root cause (confirmed in source)
tapioctl() at sys/net/tap/if_tap.c:726 acquires the interface serializer
with ifnet_serialize_all(ifp) at line 738 and releases it only at
line 832 (ifnet_deserialize_all(ifp)), after the switch. The
TAPSIFINFO case (line 742) early-returns EPROTOTYPE at line 745 when
tapp->type != ifp->if_type, without releasing the serializer. The lock is
orphaned for the lifetime of the interface. Every later
ifnet_serialize_all(ifp) then blocks forever β including tapclose() at
line 426, so even closing the file descriptor hangs.
Reachability / privilege (verified)
/dev/tapclone node is created0600UID_ROOT/GID_WHEEL(if_tap.c:183-185,if_tap.c:377-378), so opening it requires root or wheel membership at the devfs layer.tapopen()(if_tap.c:323-327) additionally requirescaps_priv_check(SYSCAP_RESTRICTEDROOT)unlessnet.link.tap.user_open=1. Even with that sysctl set, the devfs node remains0600 root:wheel, so an unprivileged user still getsEACCES(confirmed:maxxuid 1001 not in wheel βPermission denied).- Net: this is a root/wheel-reachable local DoS. The CVSS
PR:Lin the finding is generous; effective privilege is High (or wheel membership). The defect is still real and worth fixing: a privileged network configuration tool (commonly root, e.g. a VPN/bridge/jail setup helper) can trivially wedge the interface and force a reboot.
Files
leak_tap_lock.cβ minimal trigger (vendored correct 8-bytestruct tapinfo; fork-dance so the harness doesn't hang).build.shβcc -Wall -O2 -o leak_tap_lock leak_tap_lock.c.run.shβ loadsif_tap, runs the trigger, then a fully-detachedifconfigcorroboration.build.log/run.log/corroborate.logβ full logs.env.txtβ guest environment.VERDICT.mdβ full narrative.fix.diffβ git-apply-able one-line fix.manifest.jsonβ artifact catalog.
Build & run (as root on the DragonFly guest)
./build.sh ./run.sh # loads if_tap if needed, triggers, corroborates
Note on tap being a module: in X86_64_GENERIC, tap(4) is a KLD module
(if_tap.ko), not a static kernel device. The bug and the fix live in
if_tap.ko. When validating fix.diff, install BOTH the rebuilt kernel AND
the rebuilt /usr/obj/.../net/tap/if_tap.ko to /boot/kernel/if_tap.ko
(otherwise kldload if_tap loads the old unpatched module and the wedge
reproduces even on a rebuilt kernel).
Expected result (bug present)
[*] TAPSIFINFO returned -1: errno=41 (Protocol wrong type for socket) [EPROTOTYPE=41] [*] tapioctl() early-returned at if_tap.c:745 WITHOUT releasing [*] the ifnet serializer acquired at if_tap.c:738 -> LOCK ORPHANED [+] ===================================== PROOF ===== [+] child pid <N> still running 3s into its close() call [+] -> tapclose() is wedged at ifnet_serialize_all() (if_tap.c:426) [+] -> DF-0585 REPRODUCED: interface permanently wedged
On a FIXED kernel (with fix.diff applied to if_tap.ko) the same
binary instead reports the honest negation β the child exits and the
serializer is released:
[+] child pid <N> EXITED after close() (status=0x0) [+] -> tapclose() completed; serializer released [+] -> DF-0585 NOT reproduced: no wedge (FIXED kernel)
The detection uses waitpid(WNOHANG) (not kill(pid,0), which cannot
distinguish a wedged child in D-sleep from an exited zombie).
and the detached corroboration:
BG_PID=<N> CORROB_IFCONFIG_WEDGED # ifconfig tap0 still in D-sleep after 6s
After this the tap0 interface (and the stuck kernel thread) are unusable.
Recovery requires a reboot (vm.sh reset). This is a destructive hang,
not a panic β the guest does not drop to DDB.
How the proof works
The trigger opens /dev/tap (clone β tap0), issues TAPSIFINFO with
type=0xFF (β IFT_ETHER=6) to hit the buggy early-return at line 745, then
forks. The parent drops its file reference first; the child's close() is
therefore the final reference, so it runs tapclose() β
ifnet_serialize_all(ifp) at line 426, which blocks forever on the orphaned
serializer. The parent observes the child is still alive 3 s later and reports
PROOF, then exits (leaving the child wedged in the kernel). A fully-detached
ifconfig tap0 corroborates that the whole interface (not just this fd) is
wedged.
DF-0585 β VERDICT
Verdict: REPRODUCED (local DoS β interface wedge, reboot required) β FIX VALIDATED on single-fix kernel
The bug, confirmed in source
tapioctl() in sys/net/tap/if_tap.c:
726: static int
727: tapioctl(struct dev_ioctl_args *ap)
728: {
...
738: ifnet_serialize_all(ifp); <-- acquire interface serializer
739: error = 0;
740:
741: switch (ap->a_cmd) {
742: case TAPSIFINFO:
743: tapp = (struct tapinfo *)data;
744: if (ifp->if_type != tapp->type)
745: return (EPROTOTYPE); <-- BUG: early return, lock NOT released
746: ifp->if_mtu = tapp->mtu;
...
830: }
831:
832: ifnet_deserialize_all(ifp); <-- the ONLY release point
833: return (error);
The serializer acquired at line 738 is released at exactly one place,
line 832. The TAPSIFINFO early-return at line 745 bypasses it,
orphaning the lock for the lifetime of the interface. The same serializer is
re-acquired in tapclose() at line 426, so once the orphan exists the
close of the fd β and every other op that serializes the interface β blocks
forever.
Trigger & proof (re-confirmed on this run, unpatched #0)
open("/dev/tap", O_RDWR)β clone createstap0.ioctl(fd, TAPSIFINFO, &ti)withti.type = 0xFF(βIFT_ETHER=6) β hits the early return at line 745 β serializer orphaned.- Fork; parent drops its fd reference; the child's
close()is the final reference βtapclose()βifnet_serialize_all()at line 426 β blocks forever.
Decisive kernel-side evidence (unpatched #0 + original if_tap.ko):
[+] child pid 858 still running 3s into its close() call
[+] -> DF-0585 REPRODUCED: interface permanently wedged
PID STAT WCHAN COMM
858 D1 slize leak_tap_lock <- child wedged in uninterruptible sleep
on the orphaned ifnet serializer
An independent detached ifconfig tap0 (no shared fd) also wedges in
D-sleep on wchan=slize β proving the entire interface is orphaned, not
just the triggering fd. Recovery requires a reboot.
Privilege / reachability (verified)
/dev/tapclone node is0600 root:wheel(if_tap.c:183);tapopen()(if_tap.c:323) requirescaps_priv_check(SYSCAP_RESTRICTEDROOT)unlessnet.link.tap.user_open=1, and even then the devfs node stays0600.- Unprivileged
maxx(uid 1001, not in wheel) getsEACCESβ confirmed. - Net: root/wheel-reachable local DoS. Realistic threat model: a privileged network-config helper (VPN/bridge/jail setup, a qemu/bhyve VM process given a tap fd) that is buggy or compromised can wedge the kernel.
Impact
- Class: CWE-667 (lock orphan) β permanent local denial of service.
- Effect: the tap(4) interface becomes unusable; the open fd cannot be
closed;
ifconfig, RX/TX, module-unload all block forever. Reboot required. - No panic / no memory corruption β no exploit chain beyond DoS.
Impact is
dos.
PoC changes from the seeded version
- The seeded
leak_tap_lock.chad a wrongstruct tapinfo(16-byte layout). The realstruct tapinfo(sys/net/tap/if_tap.h:46) is 8 bytes ({int baudrate; short mtu; u_char type; u_char dummy;}). BecauseTAPSIFINFOis_IOW('t', 91, struct tapinfo), the ioctl number is derived fromsizeof(struct tapinfo); the wrong struct produced the wrong ioctl number β kernelswitchfell through todefault: ENOTTY, never reaching the buggy path. Fixed by vendoring the correct 8-byte struct. - Reworked the proof into a fork-dance (parent drops its ref first so the
child's
close()is the final close that runstapclose), so the harness never hangs on the wedge. - This run: fixed the wedge detection. The original
kill(pid, 0)check cannot distinguish a genuinely-wedged child (D-sleep) from a child that exited and is now a zombie βkill(pid,0)succeeds for both, so the PoC falsely printed PROOF on the FIXED kernel. Replaced it withwaitpid(pid, &st, WNOHANG): a reaped child β close() returned β serializer released β FIXED; a still-running child after the probe window β wedged β BUG. The PoC now prints honest, opposite verdicts on the two kernels.
Recommended fix β VALIDATED on a built+booted single-fix kernel
Release the serializer on the early-return path. One-line change at
if_tap.c:744-747 (see fix.diff):
if (ifp->if_type != tapp->type) {
ifnet_deserialize_all(ifp);
return (EPROTOTYPE);
}
Fix-validation result: FIXED (clean before/after)
Critical build nuance discovered: in X86_64_GENERIC, tap(4) is a KLD
module, not a static kernel device. The bug and the fix live in if_tap.ko,
not in the kernel binary. The first fix-validation rebuilt the kernel but
left the original Jun-29 if_tap.ko on disk; kldload if_tap then loaded the
unpatched module and the wedge reproduced even on the #1 kernel. The
correct validation installs both the rebuilt kernel AND the rebuilt
if_tap.ko:
/boot/kernel/if_tap.koβ patched module (sha256bfe90971β¦, 263008 B)/boot/kernel/if_tap.ko.origβ baseline module (sha2568e1a6da3β¦, 262320 B)
| Probe | Baseline #0 + orig if_tap.ko |
Patched #1 + new if_tap.ko |
|---|---|---|
| child state 3s into close() | D1 wchan=slize (WEDGED) |
Z (EXITED) |
independent ifconfig tap0 |
hangs >120s in D-sleep (slize) |
returns 0s, rc=1 (clean teardown) |
| PoC verdict | REPRODUCED (wedge) | NOT reproduced (no wedge) |
Built kernel: DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 16:46:56 UTC 2026
(make -j6 nativekernel KERNCONF=X86_64_GENERIC, rc=0; full log fix_build.log).
Confirmed deterministic across 3 runs on the patched kernel. The fix supersedes
the finding markdown's error=β¦; break; proposal (functionally equivalent β
both route through the single deserialize β but the applied ifnet_deserialize_all
+ return is more localized and matches the immediate-acquire/immediate-release
style of the surrounding error paths).
Fix verification
fixedVALIDATED. On the unpatched #0 baseline + original if_tap.ko the PoC wedges (child D1/slize; independent ifconfig tap0 hangs >120s). On the single-fix #1 kernel + rebuilt if_tap.ko the wedge is GONE (child EXITS, state Z; ifconfig tap0 returns 0s, rc=1). Build rc=0. CRITICAL NUANCE: tap(4) is a KLD MODULE in X86_64_GENERIC, not a static kernel device β the bug and fix live in if_tap.ko. The first fix-validation rebuilt only the kernel and the wedge still reproduced (old if_tap.ko loaded); installing BOTH the rebuilt kernel and the rebuilt /usr/obj/.../net/tap/if_tap.ko to /boot/kernel/if_tap.ko closes the bug. fix closes the bug.
baseline #0+orig_mod: child 858 D1 wchan=slize (wedged); ifconfig tap0 hung >120s D-sleep (slize) | patched #1+new_mod(sha256 bfe90971): child 999/958 EXITED (Z); ifconfig tap0 rc=1 elapsed=0s | nativekernel build: === NK_DONE rc=0 ===
Confirmed kernel references
Detail
Exploit chain
Local DoS / kernel wedge via TAPSIFINFO ifnet-serializer orphan on type mismatch. Reachable by root/wheel (/dev/tap is 0600 root:wheel; tapopen needs SYSCAP_RESTRICTEDROOT unless net.link.tap.user_open=1, which still leaves the devfs node 0600 β unprivileged maxx gets EACCES). No memory corruption β no escalation chain; impact ceiling is permanent interface wedge (CWE-667).
Evidence (decisive lines)
BASELINE #0 + orig if_tap.ko (8e1a6da3): [+] child pid 858 still running 3s into its close() call / 858 D1 slize leak_tap_lock / independent `ifconfig tap0` hung >120s in D-sleep (slize). PATCHED #1 + new if_tap.ko (bfe90971): [+] child pid 999 EXITED after close() (status=0x0) / 958 Z - leak_tap_lock / ifconfig tap0 rc=1 elapsed=0s. Build: === NK_DONE rc=0 === (Thu Jul 2 16:50:15 UTC 2026).
PoC changes
Improved leak_tap_lock.c wedge detection: replaced kill(pid,0) (which succeeds for both a wedged D-state child AND an exited zombie, giving a false-positive PROOF on the fixed kernel) with waitpid(pid,&st,WNOHANG) β a reaped child means close() returned => serializer released => FIXED; a still-running child after the probe window means wedged => BUG. The PoC now prints honest opposite verdicts on the two kernels. Vendored the correct 8-byte struct tapinfo (was wrong-sized, producing the wrong TAPSIFINFO ioctl number). Updated README.md/VERDICT.md/env.txt with the build nuance (tap is a KLD module).
Verified recommended fix
In sys/net/tap/if_tap.c at the TAPSIFINFO type-mismatch branch (line 744-745), release the serializer before returning: wrap the early return as if (ifp->if_type != tapp->type) { ifnet_deserialize_all(ifp); return (EPROTOTYPE); }. This supersedes the finding markdown's error=EPROTOTYPE; break; proposal (functionally equivalent β both route through the single deserialize at line 832 β but the applied form is more localized and matches the immediate-acquire/immediate-release style of the surrounding code). Validated: built + booted, wedge gone. Full git-apply-able diff in findings/poc/DF-0585/fix.diff (git apply --check = OK).
Verdict
REPRODUCED + FIX VALIDATED. tapioctl() (sys/net/tap/if_tap.c) acquires the ifnet serializer at line 738 and releases it ONLY at line 832; the TAPSIFINFO early return (EPROTOTYPE) at line 745 (when tapp->type != ifp->if_type) bypasses that release, orphaning the serializer for the lifetime of the interface. Confirmed on unpatched #0 + original if_tap.ko: after the trigger the forked child wedges in tapclose()->ifnet_serialize_all() at line 426 (state D1, wchan=slize) and an INDEPENDENT detached ifconfig tap0 also wedges in D-sleep on slize for >120s, proving the entire interface is orphaned (reboot required). The one-line fix (ifnet_deserialize_all(ifp) before return EPROTOTYPE) was applied to /usr/src, built with make -j6 nativekernel (rc=0), and installed as BOTH the rebuilt kernel AND the rebuilt if_tap.ko module; on the patched #1 kernel the child EXITS (state Z) and ifconfig tap0 returns in 0s β the wedge is gone. Deterministic across 3 patched runs.
No comments yet.