config_pipe accepts negative bandwidth: NULL-deref panic (TAILQ_LAST on empty delay line) or permanent netisr wedge in the drain loop
Summary
config_pipe() stores ioc_pipe->bandwidth verbatim with no validation. With bandwidth < 0 on a pipe carrying a WF2Q queue, p->numbytes (int) goes negative and the tick computation is skipped ('if (p->bandwidth > 0)'), so t stays 0; the pipe is reinserted into wfq_ready_heap with key == curr_time. If delay == 0 the delay line is empty when the event fires and the code dereferences TAILQ_LAST() == NULL ('->output_time += t') - write fault at NULL+0x30, panic. If delay > 0 the drain loop extracts and reinserts the same pipe forever - dummynet() never returns, the netisr thread spins at 100%, network stack permanently dead with no crash (worse than a panic: no dump, needs power cycle). VERIFIED on stock guest 2/2 (delay=0: 'Fatal trap 12, fault VA 0x30, Stopped at ready_event_wfq+0x274'; delay=50: ssh dead instantly, no panic for 40+s, network unrecoverable). Config requires a raw IP socket (SYSCAP_NONET_RAW - root); once the bad pipe exists the TRIGGER is a single packet matched by an ipfw 'queue' rule - the triggering packet needs no privilege. No uid=0 (fixed fault address, crash/wedge primitive). Fix validated (reject bandwidth<0 + NULL guard on TAILQ_LAST): both variants return EINVAL, positive-control pipe works.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3350 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.2 KB | β raw | |
| VERDICT.md | β | 4.8 KB | β raw | |
| df3350.c | β | 3.5 KB | view raw | |
| build.sh | β | 116 B | view raw | |
| run.sh | β | 275 B | view raw | |
| build.log | β | 846 B | view raw | |
| run.log | β | 1.3 KB | view raw | |
| run.2.log | β | 1.4 KB | view raw | |
| run.3.log | β | 1.0 KB | view raw | |
| panic.txt | β | 659 B | view raw | |
| env.txt | β | 555 B | view raw | |
| fix.diff | β | 1.0 KB | view raw | |
| code_hash.txt | β | 65 B | view raw |
DF-3350 β dummynet negative-bandwidth pipe: NULL-deref panic / netisr wedge
What
sys/net/dummynet/ip_dummynet.c:config_pipe() (line 1516) stores the
user-supplied ioc_pipe->bandwidth into the kernel pipe with no
validation. A negative bandwidth is accepted and drives the WF2Q
scheduler into two deterministic failure modes:
-
delay == 0 variant β kernel panic (NULL deref)
ready_event_wfq()line 623:TAILQ_LAST(&p->p_queue, dn_pkt_queue)->output_time += t;Withbandwidth < 0,p->numbytesgoes negative andtstays 0 (thet = (bandwidth-1-numbytes)/bandwidthcomputation at :621-622 is guarded byif (p->bandwidth > 0)). When the wfq event fires on the next 1 ms tick, the delay line is already empty (delay 0 packets are redispatched synchronously bytransmit_event()at the end of the first invocation), soTAILQ_LAST()returns NULL and the kernel takes a write page fault atNULL + offsetof(dn_pkt, output_time)= 0x30. Panic, 100% reproducible on the first packet. -
delay > 0 variant β permanent netisr wedge (hard DoS) The packet is still in the delay line, so
TAILQ_LAST()is legal, but the pipe is reinserted intowfq_ready_heapwithkey == curr_time(t == 0), so the drain loopwhile (h->elements > 0 && DN_KEY_LEQ(h->p[0].key, curr_time))(dummynet(), :678) extracts and reinserts the same pipe forever.dummynet()never returns: the netisr thread onip_dn_cpu(cpu0) spins at 100%, all packet processing on that CPU stops, the network stack dies with no crash and no recovery short of a hard reset.
Reachability / threat
Configuring a pipe requires setsockopt(IPPROTO_IP, IP_DUMMYNET_CONFIGURE)
on a raw IP socket β root (or SYSCAP_NONET_RAW). There is no kernel-side
priv_check in ip_dn_sockopt() (only a securelevel >= 3 guard), and no
userland tool can be relied on to validate (the PoC bypasses dnctl
entirely). Once the bad pipe exists, any user's traffic matched by an
ipfw queue rule triggers the fault β the triggering packet needs no
privilege.
Reproduce (on the audit QEMU guest)
# host
cat findings/poc/DF-3350/df3350.c | ssh -F dfbsd-qemu/config dfbsd 'cat > /tmp/df3350.c'
dfbsd-qemu/vm.sh run_root 'cc -O -Wall -o /tmp/df3350 /tmp/df3350.c'
dfbsd-qemu/vm.sh run_root 'kldload ipfw && \
ipfw add 1 allow ip from any to any via vtnet0 && \
ipfw add 100 queue 100 ip from any to any via lo0 && \
kldload dummynet'
# panic variant (delay=0):
dfbsd-qemu/vm.sh run_root '/tmp/df3350 0' # => Fatal trap 12 in ready_event_wfq
# wedge variant (delay=50):
dfbsd-qemu/vm.sh run_root '/tmp/df3350 50' # => ssh dies, guest never answers again
Expected (baseline, unfixed): Fatal trap 12 ... fault virtual address = 0x30,
Stopped at ready_event_wfq+0x274: addq %rdx,0x30(%rax) (see panic.txt);
for the wedge variant: qemu alive, console idle, network dead (run.2.log).
Expected (patched with fix.diff): config pipe 1: Invalid argument β the
setsockopt is rejected, no panic, no wedge.
Fix
fix.diff β reject bandwidth < 0 in config_pipe() plus a defensive
NULL guard on the TAILQ_LAST() deref. Validated: kernel rebuilt with the
fix, PoC cleanly rejected (see VERDICT.md).
DF-3350 VERDICT
status: reproduced | impact: panic (+ permanent network wedge) | confidence: certain fix_status: fixed (validated on rebuilt guest kernel)
Root cause (line-accurate)
sys/net/dummynet/ip_dummynet.c:1516 β config_pipe():
x->bandwidth = ioc_pipe->bandwidth; /* no validation; negative accepted */
bandwidth comes verbatim from the IP_DUMMYNET_CONFIGURE setsockopt
(struct dn_ioc_pipe, copied by soopt_to_kbuf() in
sys/net/dummynet/ip_dummynet_glue.c:517). Zero ("no limit") is legal;
negative is nonsense and every consumer assumes numbytes < 0 β
bandwidth > 0:
ip_dummynet.c:618-623 β ready_event_wfq():
if (p->numbytes < 0) { /* comment claims: implies bandwidth>0 */
dn_key t = 0;
if (p->bandwidth > 0) /* false for bw<0 -> t stays 0 */
t = (p->bandwidth - 1 - p->numbytes) / p->bandwidth;
TAILQ_LAST(&p->p_queue, dn_pkt_queue)->output_time += t; /* NULL deref */
With bandwidth = -1, the first packet for a WF2Q queue enters the drain
loop with numbytes = 0 >= 0, moves to the delay line, drives numbytes
negative, and schedules the pipe in wfq_ready_heap with key ==
curr_time (t = 0).
- delay == 0:
transmit_event()(called synchronously at :638 because the delay line was empty) redispatches the packet immediately β the delay line is empty again. On the next 1 ms tick,dummynet()(:678) fires the wfq event,p->numbytesstays negative,t == 0, andTAILQ_LAST()returns NULL on the empty queue β write fault at 0x30. - delay > 0: the packet is still in the delay line, so the deref is
legal, but the pipe is reinserted with
key == curr_time; the drainwhile (h->elements > 0 && DN_KEY_LEQ(h->p[0].key, curr_time))at :678 extracts and reinserts the same pipe forever βdummynet()never returns, the netisr thread onip_dn_cpu(cpu0) spins at 100%, and the network stack is dead with no crash (unrecoverable without hard reset).
How it was reproduced (guest: X86_64_GENERIC stock INVARIANTS kernel)
Setup: kldload ipfw && ipfw add 1 allow ... via vtnet0 && ipfw add 100
queue 100 ip from any to any via lo0 && kldload dummynet (the audit-target
module, not dummynet3 β verified by kldstat). /tmp/df3350 0 then
configures pipe 1 (bw=-1, delay=0) and WF2Q queue 100 (parent=1,
weight=1) via raw setsockopt and sends one UDP packet to 127.0.0.1:9.
Result (serial console, panic.txt):
Fatal trap 12: page fault while in kernel mode cpuid = 0; lapic id = 0 fault virtual address = 0x30 fault code = supervisor write data, page not present current process = Idle <- netisr drain thread Stopped at ready_event_wfq+0x274: addq %rdx,0x30(%rax)
0x30 == offsetof(struct dn_pkt, output_time); %rax == NULL
(TAILQ_LAST of empty queue); %rdx == t == 0. Exactly the analyzed
instruction. 100% deterministic (2/2 runs).
Wedge variant (run.2.log): /tmp/df3350 50 β ssh died instantly; qemu
still running; console idle at login: with no panic for 40+ s;
vm.sh status down (network dead). Deterministic (1/1 run; variant not
repeated because each run costs a guest reset).
Exploit chain / uid=0?
None. The fault address is fixed (NULL+0x30) with a fixed addend (t=0 or a small controlled tick count) β a clean crash/wedge primitive, not a controllable write. Primary impact: local kernel DoS; the wedge variant is worse than a panic (no crash dump, survives watchdog-less configs, requires power cycle). Trigger packet itself needs no privilege once the (root-made) pipe exists.
Fix validation
fix.diff = reject bandwidth < 0 in config_pipe() (:1487-1492) +
defensive NULL guard on the TAILQ_LAST deref (:620-627).
Guest rebuilt with the fix (make nativekernel KERNCONF=X86_64_GENERIC,
RC=0; make installkernel; uname #1 Fri Sep 11 06:08:07 2026):
* /tmp/df3350 0 β config pipe 1: Invalid argument, no panic
* /tmp/df3350 50 β config pipe 1: Invalid argument, no wedge
* positive control bw=1000000 β pipe+queue configure, packet traverses
WF2Q path, guest alive.
Baseline vs patched: panic/wedge present β gone, functionality retained.
Not re-reported (known six)
DF-0373..DF-0378 cover the delete-constant, both config_red divisors
(lines 1346-1350 incl. max_th), red_drops lookup_step, negative qsize,
config_red error-path kfree, SET_TICKS overflow. This finding is the
missing-bandwidth-validation family β distinct root cause and lines.
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
fixedfix.diff applied in guest /usr/src; nativekernel build RC=0; installkernel; reboot. Baseline: panic (delay=0) / wedge (delay=50). Patched: both variants rejected with 'config pipe 1: Invalid argument' (EINVAL), no panic, no wedge; positive control bw=1Mbit/s pipe+queue configures and passes a packet through the same WF2Q path. Bad behavior gone, functionality retained.
['run.3.log (patched-kernel PoC runs + positive control)', 'build.log (patch/compile/install transcript)', 'fix.diff (git-apply-able against sys/net/dummynet/ip_dummynet.c)']
Confirmed kernel references
Detail
Exploit chain
root: raw setsockopt IP_DUMMYNET_CONFIGURE pipe{bw=-1} + queue{parent} ; unpriv trigger packet via lo0 ipfw 'queue' rule -> dummynet_io WF2Q path -> ready_event_wfq -> (delay=0) NULL+0x30 write fault -> panic | (delay>0) wfq_ready_heap self-loop -> netisr wedge. No uid=0: fixed-address fault, crash/wedge primitive only.
Evidence (decisive lines)
["panic.txt: 'Fatal trap 12 ... fault virtual address = 0x30 ... Stopped at ready_event_wfq+0x274: addq %rdx,0x30(%rax)' (current process = Idle = netisr drain thread)", 'run.log: baseline panic variant transcript (setup + trigger + ssh death)', 'run.2.log: wedge variant -- qemu alive, console idle, no panic, network dead 40+s', "run.3.log: fix validation -- both variants => 'config pipe 1: Invalid argument', positive control bw=1Mbit works, guest alive", 'VERDICT.md: full line-accurate root cause + fix-validation narrative']
PoC changes
PoC authored fresh for this finding (none of the pass-1 findings covered bandwidth): verbatim ABI structs copied from ip_dummynet.h so no kernel headers needed in guest; raw setsockopt instead of dnctl (dnctl would validate); trigger is one UDP packet to 127.0.0.1 matched by an ipfw O_QUEUE rule so is_pipe=0 forces the WF2Q path.
Verified recommended fix
Reject bandwidth < 0 in config_pipe() and guard the TAILQ_LAST deref in ready_event_wfq() against an empty delay line (see fix.diff).
Verdict
config_pipe() stores the user's ioc_pipe->bandwidth with no validation (ip_dummynet.c:1516). bandwidth=-1 on a pipe carrying a WF2Q queue drives ready_event_wfq() into t==0 (the tick computation at :621 is guarded by bandwidth>0) and then either a NULL-deref write at :623 (TAILQ_LAST on the empty delay line, delay==0 variant: Fatal trap 12, fault VA 0x30, Stopped at ready_event_wfq+0x274 addq %rdx,0x30(%rax), reproduced 2/2) or an infinite extract/reinsert loop in the dummynet() drain at :678 (delay>0 variant: netisr cpu0 spins forever, network permanently dead, no crash -- reproduced 1/1). Fix (reject bandwidth<0 + NULL guard on TAILQ_LAST) validated on a rebuilt guest kernel: PoC now returns EINVAL for both variants, positive-control bw=1Mbit pipe still works.
No comments yet.