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

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() at if_tap.c:426
  • tapread() at if_tap.c:870
  • tapwrite() at if_tap.c:980
  • tapifioctl() at if_tap.c:538/738 (via ifconfig)
  • tapifstart() on the next TX
  • tapifstop()

β€” 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=1 and 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 in tapopen() at if_tap.c:323-327: with the default net.link.tap.user_open=0, opening requires caps_priv_check(SYSCAP_RESTRICTEDROOT) (root); with user_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) with ti.type != IFT_ETHER. Concrete impact: 1. The offending fd cannot be closed (tapclose blocks at if_tap.c:426), so the process hangs in uninterruptible state on exit. 2. ifconfig tapN from root hangs (tapifioctl serialize). 3. Any RX/TX on the interface hangs (tapifstart/tapifinput path). 4. Module unload fails forever (taprefcnt never decrements to 0 because tapclose is 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.

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.c TAPSIFINFO early 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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0585 Β· 15 files
FileTypeDescriptionSize
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
README.md readme build/run/expected + root-cause + reachability notes
↓ download 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/tap clone node is created 0600 UID_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 requires caps_priv_check(SYSCAP_RESTRICTEDROOT) unless net.link.tap.user_open=1. Even with that sysctl set, the devfs node remains 0600 root:wheel, so an unprivileged user still gets EACCES (confirmed: maxx uid 1001 not in wheel β†’ Permission denied).
  • Net: this is a root/wheel-reachable local DoS. The CVSS PR:L in 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-byte struct tapinfo; fork-dance so the harness doesn't hang).
  • build.sh β€” cc -Wall -O2 -o leak_tap_lock leak_tap_lock.c.
  • run.sh β€” loads if_tap, runs the trigger, then a fully-detached ifconfig corroboration.
  • 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.

VERDICT.md verdict REPRODUCED + FIX VALIDATED: mechanism, proof, impact, reachability, build nuance (tap is modular), before/after table
↓ download raw

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)

  1. open("/dev/tap", O_RDWR) β†’ clone creates tap0.
  2. ioctl(fd, TAPSIFINFO, &ti) with ti.type = 0xFF (β‰  IFT_ETHER=6) β†’ hits the early return at line 745 β†’ serializer orphaned.
  3. 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/tap clone node is 0600 root:wheel (if_tap.c:183); tapopen() (if_tap.c:323) requires caps_priv_check(SYSCAP_RESTRICTEDROOT) unless net.link.tap.user_open=1, and even then the devfs node stays 0600.
  • Unprivileged maxx (uid 1001, not in wheel) gets EACCES β€” 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

  1. The seeded leak_tap_lock.c had a wrong struct tapinfo (16-byte layout). The real struct tapinfo (sys/net/tap/if_tap.h:46) is 8 bytes ({int baudrate; short mtu; u_char type; u_char dummy;}). Because TAPSIFINFO is _IOW('t', 91, struct tapinfo), the ioctl number is derived from sizeof(struct tapinfo); the wrong struct produced the wrong ioctl number β†’ kernel switch fell through to default: ENOTTY, never reaching the buggy path. Fixed by vendoring the correct 8-byte struct.
  2. Reworked the proof into a fork-dance (parent drops its ref first so the child's close() is the final close that runs tapclose), so the harness never hangs on the wedge.
  3. 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 with waitpid(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.

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 (sha256 bfe90971…, 263008 B)
  • /boot/kernel/if_tap.ko.orig ← baseline module (sha256 8e1a6da3…, 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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 ===
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 16:46:56 UTC 2026

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.