ip_fw3_dummynet_io caches rule pointer in dn_pkt->dn_priv with no refcount rule deletion during pipe transit causes UAF read+write on reinject
Summary
When packet matches dummynet pipe/queue rule ip_fw3_dummynet_io stores raw unrefcounted pointer to matching rule in dummynet tag dn_priv=fwa->rule. dn_unref_priv left NULL. ip_fw3_delete_rule kfrees rule slab without sweeping dummynet tags. When packet emerges from pipe re-checked by ip_fw3_check_in/out args.rule read back from dn_priv as dangling pointer. ip_fw3_chk derefs args->rule->next_rule and on done path executes f->pcnt++ f->bcnt+=ip_len f->timestamp=time_second UAF write to freed M_IPFW3 slab. Gated by onepass==0 and flushing==0. delete_rule_dispatch forwarded to every CPU each kfrees own copy. Reinject sequenced through same per-CPU netisr. With M_IPFW3 slab grooming freed slot refilled controlled data arbitrary kernel write.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2575 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| poc.c | trigger-source | documentation of the bug + privilege analysis | 2.6 KB | view raw |
| poc.sh | trigger-source | trigger script: pipe config + ICMP flood + rule delete + UAF | 2.4 KB | view raw |
| run.sh | readme | setup wrapper: module load + sysctl | 753 B | view raw |
| build.sh | readme | no-op build (shell-based PoC) | 274 B | view raw |
| fix.diff | suggested-fix | refcount the rule: add refcnt field + ref/unref in dummynet path | 2.3 KB | view raw |
| run.log | run-log | baseline (unpatched) panic: trap 9 in ip_fw3_chk+0x100 | 2.3 KB | view raw |
| fix_build.log | build-log | fixed ipfw3.ko module compilation (MODULE_BUILD_RC=0) | 7.2 KB | view raw |
| fix_run.log | run-log | fixed module: 3/3 runs no panic, guest stays up | 1.1 KB | view raw |
| panic.txt | panic-signature | Fatal trap 9 GP fault at ip_fw3_chk+0x100: movzbl 0x16(%rax),%ecx | 2.0 KB | view raw |
| env.txt | environment | uname, cc version, sysctls, kldstat | 792 B | view raw |
| VERDICT.md | verdict | full analysis: mechanism, privilege gate, fix, before/after | 6.3 KB | β raw |
| README.md | readme | reproduce instructions | 4.2 KB | β raw |
DF-2575 β ipfw3 dummynet dn_priv use-after-free
Severity: High
File: sys/net/ipfw3/ip_fw3.c:630 (ip_fw3_dummynet_io)
Verdict: REPRODUCED β kernel panic (UAF); fix VALIDATED.
The bug
When a packet matches a dummynet pipe/queue rule, ip_fw3_dummynet_io
stores a raw, unrefcounted pointer to the matching struct ip_fw rule
into the dummynet packet tag:
/* sys/net/ipfw3/ip_fw3.c:630 */
pkt->dn_priv = fwa->rule; /* raw pointer β no refcount taken */
/* dn_unref_priv left NULL (struct bzero'd at line 604) */
ip_fw3_delete_rule() (line 757) unlinks and kfrees the rule slab
(M_IPFW3) on every CPU via delete_rule_dispatch() without sweeping
dummynet tags that still hold the dangling dn_priv pointer.
When the dummynet pipe delay expires and re-injects the queued packet,
ip_fw3_check_in/out() (lines 1191 / 1266) loads the stale pointer:
args.rule = ((struct dn_pkt *)m_tag_data(mtag))->dn_priv; /* UAF load */
When net.inet.ip.fw3.one_pass == 0 (ip_fw3.c:428), ip_fw3_chk()
dereferences it:
f = args->rule->next_rule; /* UAF DEREF β freed M_IPFW3 slab */
Under INVARIANTS slab poisoning (debug.use_weird_array=1), the freed
chunk is filled with 0xdeadc0de β next_rule becomes a non-canonical
address β f = 0xdeadc0de... β the rule-scan loop dereferences f β
general protection fault (trap 9) in ip_fw3_chk+0x100.
Even without poisoning (default use_weird_array=0), the UAF is silently
exploitable: the freed slab retains stale-but-plausible rule data, and
the "+++ ipfw: ouch!" messages in the boot log prove the freed memory is
being traversed as a live rule chain (silent data corruption).
Privilege gate (root-only)
The entire path is root-only:
- kldload ipfw3 / ipfw3_basic / dummynet3 β root
- sysctl net.inet.ip.fw3.one_pass β root (CTLFLAG_RW)
- ipfw3 add / ipfw3 delete (raw-socket setsockopt(IPPROTO_IP, IP_FW_X))
β rip_ctloutput β caps_priv_check(SYSCAP_NONET_RAW) β root
So this is a rootβkernel memory-corruption bug. Rootβkernel is
game-over by definition (root can kldload arbitrary code), so uid=0
escalation from an unprivileged user is not possible on this path.
Impact = root-triggerable kernel panic / DoS, plus a defence-in-depth
hardening gap (the dn_unref_priv callback should be used β the classic
ipfw ip_fw2.c already does this correctly).
Reproduce
Build
./build.sh # shell-only PoC, no compilation needed
Setup (as root on the guest)
sysctl -w net.filters_default_to_accept=1 # BEFORE loading ipfw3 kldload ipfw3 kldload ipfw3_basic kldload dummynet3 sysctl -w debug.use_weird_array=1 # INVARIANTS slab poisoning
Trigger (as root)
sh /root/poc/DF-2575/poc.sh 100 3000 500
The trigger:
1. Configures pipe 1 with 3000 ms delay (packets sit in queue).
2. Sets one_pass=0 (REQUIRED for the UAF deref path).
3. Adds rule 100: pipe 1 icmp from 127.0.0.1 to 127.0.0.1 (lo0 only β
does NOT affect ssh on vtnet0).
4. Floods pings to 127.0.0.1 β packets accumulate in pipe 1 tagged with
dn_priv = &rule_100.
5. Deletes rule 100 while packets are queued β kfree(rule) with dangling
dn_priv.
6. After 3 s delay, dummynet re-injects β UAF deref β panic.
Expected
- Unpatched
6.5-DEVELOPMENT #0: Fatal trap 9 (GP fault) inip_fw3_chk+0x100: movzbl 0x16(%rax),%ecx. - Patched (fix.diff / ipfw3_fixed.ko): no panic; script exits cleanly; guest stays up. Validated 3 consecutive runs.
Fix
See fix.diff. The fix adds a uint32_t refcnt field to struct ip_fw
(filling existing alignment padding β no ABI change) and implements the
same refcounting pattern as the classic ipfw (sys/net/ipfw/ip_fw2.c):
add_rule_dispatch:rule->refcnt = 1(chain holds one reference).ip_fw3_dummynet_io:atomic_add_int(&rule->refcnt, 1)+pkt->dn_unref_priv = ip_fw3_unref_dn_priv.ip_fw3_delete_rule/flush_rule_dispatch: refcount-aware free (kfree only whenrefcntreaches 0).ip_fw3_unref_dn_privcallback: called by dummynet when a tagged packet is freed/re-dispatched β decrements refcnt β kfree if last ref.
See VERDICT.md for the full analysis and fix_run.log for the
before/after contrast.
DF-2575 β VERDICT
Verdict: REPRODUCED β kernel panic (UAF); fix VALIDATED.
Mechanism (trigger β primitive β effect)
Trigger
- Load
ipfw3+ipfw3_basic+dummynet3(the ipfw3-native dummynet that registers thepipeaction opcode with ipfw3). - Set
net.inet.ip.fw3.one_pass=0β REQUIRED: makes re-injected dummynet packets re-traverse the rule chain, dereferencingargs.rule. - Configure pipe 1 with multi-second delay so packets SIT in the queue
holding
dn_priv = &rule. - Add a pipe rule scoped to ICMP on lo0 (safe: doesn't affect ssh on
vtnet0):
ipfw3 add 100 pipe 1 icmp from 127.0.0.1 to 127.0.0.1. - Flood pings to 127.0.0.1 β packets accumulate in pipe 1's queue, each
tagged with
dn_priv = pointer to rule 100. - Delete rule 100 while packets are queued β
delete_rule_dispatchruns on all CPUs βkfree(rule, M_IPFW3)with danglingdn_priv. - After the pipe delay expires, dummynet re-injects the queued packets.
Primitive: use-after-free (read + write on freed M_IPFW3 slab)
Store site β sys/net/ipfw3/ip_fw3.c:630:
pkt->dn_priv = fwa->rule; /* raw pointer, no refcount, dn_unref_priv=NULL */
Free site β sys/net/ipfw3/ip_fw3.c:765 (ip_fw3_delete_rule):
kfree(rule, M_IPFW3); /* no sweep of in-flight dummynet tags */
Deref site β sys/net/ipfw3/ip_fw3.c:435 (ip_fw3_chk, gated by
one_pass==0 && flushing==0):
f = args->rule->next_rule; /* UAF: args->rule loaded from dangling dn_priv */
if (f == NULL)
f = lookup_next_rule(args->rule); /* also derefs freed rule */
Then the rule-scan loop uses f β reads f->rulenum, f->cmd_len, etc.
On the "done" path: f->pcnt++; f->bcnt += ip_len; f->timestamp = time_second;
β UAF write to the freed slab (if the scan reaches a matching rule).
Effect
Under INVARIANTS slab poisoning (debug.use_weird_array=1), the freed
M_IPFW3 chunk is filled with 0xdeadc0de. The re-injected packet's
args.rule->next_rule reads 0xdeadc0dedeadc0de (non-canonical address)
β the scan loop dereferences it β general protection fault (trap 9).
Panic signature (from dfbsd-qemu/boot.log):
Fatal trap 9: general protection fault while in kernel mode Stopped at ip_fw3_chk+0x100: movzbl 0x16(%rax),%ecx
movzbl 0x16(%rax),%ecx reads offset 0x16 (=22) from rax = the poisoned
rule pointer. Offset 22 in struct ip_fw is the set field.
Even without poisoning (default use_weird_array=0), the UAF is
present and silently corrupts: the freed slab retains stale rule data,
and "+++ ipfw: ouch!, skip past end of rules, denying packet" messages in
the boot log prove the freed memory is being traversed as a live rule
chain.
Exploit chain
This is a root-only memory corruption. The entire trigger path
requires root:
- kldload β root
- sysctl net.inet.ip.fw3.one_pass β root
- ipfw3 add/delete β raw socket β caps_priv_check(SYSCAP_NONET_RAW) β root
Per the audit's bright-line rule, rootβkernel is game-over by definition
(root can kldload arbitrary kernel code). Therefore uid=0 escalation
from an unprivileged user is NOT possible on this path. This is a valid
hard blocker for the escalation chain.
The realistic impact ceiling is:
- Root-triggerable kernel panic / DoS (confirmed).
- Silent heap corruption (the freed M_IPFW3 slab is read as a live
rule chain even without poisoning β a root attacker could groom the slab
to shape the stale data and achieve arbitrary read/write, though root
already has kldload for that).
- Defence-in-depth gap: the dummynet dn_unref_priv callback mechanism
exists precisely for this purpose and is used correctly by the classic
ipfw (sys/net/ipfw/ip_fw2.c:4448-4450); ipfw3 simply omitted it.
PoC changes
Authored the PoC from scratch (the PoC dir was empty):
- poc.c β documentation of the bug and privilege analysis.
- poc.sh β the trigger script (pipe config + one_pass=0 + ICMP flood +
rule delete + wait for re-injection UAF).
- run.sh β setup (module load + sysctl) wrapper.
- build.sh β no-op (shell-based PoC).
Key design decisions:
- Scoped to ICMP on lo0 (from 127.0.0.1 to 127.0.0.1) so the pipe
rule doesn't capture ssh traffic on vtnet0.
- debug.use_weird_array=1 to make the UAF crash deterministically
(poisons freed slab with 0xdeadc0de β non-canonical deref β GP fault).
Without this, the UAF is silent (reads stale-but-plausible data).
- one_pass=0 is REQUIRED β with the default one_pass=1, the
re-injected packet returns IP_FW_PASS before dereferencing
args.rule, so no UAF occurs.
Fix (fix.diff)
The fix implements the same refcounting pattern used by the classic ipfw
(sys/net/ipfw/ip_fw2.c):
-
ip_fw3.h: adduint32_t refcnttostruct ip_fw, filling the existing 4-byte alignment padding betweentimestampandsibling.sizeof(struct ip_fw)is unchanged (60 bytes) β no ABI impact. -
ip_fw3.cadd_rule_dispatch:rule->refcnt = 1(the chain holds the initial reference). -
ip_fw3.cip_fw3_dummynet_io: take a reference before storing the pointer:atomic_add_int(&fwa->rule->refcnt, 1)and setpkt->dn_unref_priv = ip_fw3_unref_dn_priv. -
ip_fw3.cip_fw3_unref_dn_priv(new static function): the dummynet unref callback βatomic_fetchadd_int(&rule->refcnt, -1); if it was the last reference,kfree(rule, M_IPFW3). -
ip_fw3.cip_fw3_delete_rule/flush_rule_dispatch: refcount-aware free β decrement the chain reference; onlykfreewhenrefcntreaches 0 (i.e., no in-flight dummynet packets hold a reference).
The fix compiles cleanly as an ipfw3 KLD module (-Werror) and was
validated by loading the fixed ipfw3.ko on the unpatched #0 kernel and
running the same PoC that panics the unpatched module: 3 consecutive
runs, no panic, guest stays up.
Before/after contrast
| Kernel / module | PoC result |
|---|---|
| #0 unpatched + stock ipfw3 | Fatal trap 9 in ip_fw3_chk+0x100 (UAF β GP fault) |
| #0 unpatched + fixed ipfw3 | No panic; script exits 0; guest up (3/3 runs) |
Fix verification
fixedVALIDATED: PoC (pipe 1 / 3s delay / ICMP flood / rule delete / re-injection) PANICS on unpatched stock ipfw3.ko (Fatal trap 9 in ip_fw3_chk+0x100) and does NOT panic on fixed ipfw3_fixed.ko (3 consecutive runs, guest up, no 'ouch' messages, clean dmesg). Validated by building the ipfw3 KLD module with refcnt patch (compiles cleanly -Werror) and loading on running #0 kernel β correct validation level since ipfw3 is modular. Before: trap 9 GP fault. After: FIX_RUN_RC=0, uptime confirmed.
baseline (stock ipfw3.ko): Fatal trap 9 general protection fault / Stopped at ip_fw3_chk+0x100: movzbl 0x16(%rax),%ecx / db> (guest dead). patched (ipfw3_fixed.ko): === survived: no panic === / FIX_RUN_RC=0 / uptime confirmed / dmesg ipfw3 initialized default to accept (no warnings). 3/3 runs survived on fixed module.
Confirmed kernel references
Detail
Exploit chain
Memory-corruption (UAF on freed M_IPFW3 rule slab): read+write on freed heap via the dangling dn_priv pointer in dummynet-tagged packets. BLOCKED for uid0 by valid hard blocker: root-only reachability. Trigger path requires kldload (root), sysctl (root), and ipfw3 add/delete via setsockopt on SOCK_RAW requiring caps_priv_check(SYSCAP_NONET_RAW)=root. Unprivileged user cannot enter this path. Root->kernel game-over by definition. Classic ipfw (sys/net/ipfw/ip_fw2.c:4448-4450) already solves this correctly with ipfw_ref_rule/ipfw_unref_rule refcounting β ipfw3 simply omitted the dn_unref_priv callback. No escalation chain file needed.
Evidence (decisive lines)
BASELINE (unpatched #0 + stock ipfw3.ko, debug.use_weird_array=1, one_pass=0): pipe 1 queued 51 ICMP packets, rule 100 deleted while queued, after 3s re-injection -> ssh dies. Serial log: '+++ ipfw: ouch!, skip past end of rules, denying packet' (x16, silent UAF reads) then 'Fatal trap 9: general protection fault / Stopped at ip_fw3_chk+0x100: movzbl 0x16(%rax),%ecx / db>'. FIXED (ipfw3_fixed.ko with refcnt): same PoC 3/3 runs survived, FIX_RUN_RC=0, guest uptime confirmed, no 'ouch' messages, dmesg clean.
PoC changes
Authored entire PoC from scratch (dir empty). poc.c = bug documentation + privilege analysis. poc.sh = trigger: pipe 1 with 3s delay, one_pass=0 (REQUIRED for UAF deref path), rule 100 ICMP on lo0, flood pings to 127.0.0.1 to fill pipe queue with dn_priv-tagged packets, delete rule (kfree with dangling dn_priv), wait for dummynet re-injection UAF. run.sh = module-load setup. Key discovery: debug.use_weird_array=1 makes the UAF crash deterministically (default 0 = silent stale-data read); dummynet3 (not classic dummynet) required to register pipe action opcode with ipfw3.
Verified recommended fix
Add refcounting to struct ip_fw (same pattern as classic ipfw ip_fw2.c:4448-4450): (1) add uint32_t refcnt to struct ip_fw in ip_fw3.h filling existing 4-byte alignment padding between timestamp and sibling (sizeof unchanged = no ABI impact); (2) init refcnt=1 in add_rule_dispatch; (3) in ip_fw3_dummynet_io take a ref (atomic_add_int) and set pkt->dn_unref_priv = ip_fw3_unref_dn_priv; (4) add ip_fw3_unref_dn_priv callback that atomic-decs and kfrees on last ref; (5) make ip_fw3_delete_rule and flush_rule_dispatch refcount-aware (kfree only when refcnt==0). Matches proven classic ipfw implementation.
Verdict
REPRODUCED. The bug is real: ip_fw3_dummynet_io (sys/net/ipfw3/ip_fw3.c:630) stores a raw unrefcounted pointer to the matching rule in pkt->dn_priv with dn_unref_priv left NULL. ip_fw3_delete_rule (line 765) kfrees the rule without sweeping in-flight dummynet tags. When the pipe delay expires and dummynet re-injects, ip_fw3_check_in (line 1191) loads args.rule = dn_priv (dangling), and with one_pass=0 ip_fw3_chk (line 435) dereferences it: f = args->rule->next_rule. Under INVARIANTS slab poisoning (debug.use_weird_array=1) the freed M_IPFW3 chunk is 0xdeadc0de-poisoned -> non-canonical deref -> Fatal trap 9 (GP fault) at ip_fw3_chk+0x100: movzbl 0x16(%rax),%ecx. Even without poisoning the UAF is silently exploitable (freed slab retains stale rule data, proven by '+++ ipfw: ouch!' messages). Entire path root-only.
No comments yet.