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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2575 Β· 12 files
FileTypeDescriptionSize
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
README.md readme reproduce instructions
↓ download 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) in ip_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):

  1. add_rule_dispatch: rule->refcnt = 1 (chain holds one reference).
  2. ip_fw3_dummynet_io: atomic_add_int(&rule->refcnt, 1) + pkt->dn_unref_priv = ip_fw3_unref_dn_priv.
  3. ip_fw3_delete_rule / flush_rule_dispatch: refcount-aware free (kfree only when refcnt reaches 0).
  4. ip_fw3_unref_dn_priv callback: 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.

VERDICT.md verdict full analysis: mechanism, privilege gate, fix, before/after
↓ download raw

DF-2575 β€” VERDICT

Verdict: REPRODUCED β€” kernel panic (UAF); fix VALIDATED.

Mechanism (trigger β†’ primitive β†’ effect)

Trigger

  1. Load ipfw3 + ipfw3_basic + dummynet3 (the ipfw3-native dummynet that registers the pipe action opcode with ipfw3).
  2. Set net.inet.ip.fw3.one_pass=0 β€” REQUIRED: makes re-injected dummynet packets re-traverse the rule chain, dereferencing args.rule.
  3. Configure pipe 1 with multi-second delay so packets SIT in the queue holding dn_priv = &rule.
  4. 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.
  5. Flood pings to 127.0.0.1 β†’ packets accumulate in pipe 1's queue, each tagged with dn_priv = pointer to rule 100.
  6. Delete rule 100 while packets are queued β†’ delete_rule_dispatch runs on all CPUs β†’ kfree(rule, M_IPFW3) with dangling dn_priv.
  7. 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):

  1. ip_fw3.h: add uint32_t refcnt to struct ip_fw, filling the existing 4-byte alignment padding between timestamp and sibling. sizeof(struct ip_fw) is unchanged (60 bytes) β€” no ABI impact.

  2. ip_fw3.c add_rule_dispatch: rule->refcnt = 1 (the chain holds the initial reference).

  3. ip_fw3.c ip_fw3_dummynet_io: take a reference before storing the pointer: atomic_add_int(&fwa->rule->refcnt, 1) and set pkt->dn_unref_priv = ip_fw3_unref_dn_priv.

  4. ip_fw3.c ip_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).

  5. ip_fw3.c ip_fw3_delete_rule / flush_rule_dispatch: refcount-aware free β€” decrement the chain reference; only kfree when refcnt reaches 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (unpatched baseline kernel) + fixed ipfw3_fixed.ko KLD module loaded (refcnt patch). Validated at module level since ipfw3 is a KLD.

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.