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

pf_state use-after-free: pf_send_tcp() drops pf_token mid-state-operation, letting the purge thread free the in-flight state

Summary

The packet path processes states holding pf_token only shared. pf_send_tcp() releases that token around ip_output() (:2254-2256/:2274-2276/:2289-2291). Its state-path callers continue using the un-refcounted struct pf_state after the send: the bad-state RST path writes src->seqlo/seqhi/max_win AFTER the send (:4738-4746), the SYNPROXY retransmit/completion paths send then write 12+ src/dst fields and lockmgr the embedded lk (:4963-4986/:5066-5067), the create-state synproxy path feeds pf_test's tail which reads/writes s->tag/nat_rule.ptr/packets/bytes (:6712-6812). In the token-drop window the pf_purge_thread can acquire the token exclusively, find the just-expired state, and unlink+free it in the same sweep - the netisr then resumes on freed M_PFSTATEPL memory. Detector-kernel racing ~1h (loopback+virtio egress, continuous steady-state, 1.5M-iteration DIOCKILLSTATES hammer): 0 hits - the guest's 3-30us ip_output windows give ~0.05-1% overlap per state free and the purge thread is token-starved exactly when window coverage is high; not_reproduced, mechanism code-certain (VERDICT.md traces every step). On hardware with ms-scale egress stalls (tx backpressure, ARP, congestion) the window is 2-3 orders wider. Escalation route if triggered: the ~12 post-send writes land in a freed ~250-byte chunk recycled as new pf_state objects (attacker-groomable) -> live-state key/rule-pointer corruption -> potential code-exec class; at minimum remote-triggerable panic. Fix: try-lock-free-defer in pf_free_state + hold s->lk across the synproxy sends (fix.diff in pack); long-term stop dropping pf_token in pf_send_tcp.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3264 Β· 8 files
FileTypeDescriptionSize
synproxy.conf β€” 360 B ↓ download
synflood_uaf.c β€” 4.7 KB view raw
race.sh β€” 753 B view raw
run.log β€” 1.5 KB view raw
env.txt β€” 207 B view raw
fix.diff β€” 4.3 KB view raw
README.md β€” 2.3 KB ↓ raw
VERDICT.md β€” 5.3 KB ↓ raw

DF-3264 β€” pf_state use-after-free: pf_send_tcp drops pf_token mid-state-operation

Files

  • synproxy.conf β€” rule set: pass quick inet proto tcp from any to 127.0.0.2 port 8080 synproxy state + pass all
  • synflood_uaf.c β€” creates N=512 synproxy states, waits to just before their PFTM_TCP_FIRST_PACKET (120 s) expiry, then retransmits all SYNs at high rate so the pf_purge_thread sweep at +120 s collides with in-flight pf_send_tcp() ip_output token-drop windows.
  • race.sh β€” multi-round driver (6 rounds).

Mechanism (sys/net/pf/pf.c)

  • Packet path: pf_check_in/out() hold pf_token shared (pf_ioctl.c:3205/3224).
  • pf_test_state_tcp() SYNPROXY retransmit path (pf.c:4913-4924) calls pf_send_tcp(), which releases pf_token around ip_output() (pf.c:2254-2256, 2274-2276, 2289-2291).
  • In that window pf_purge_thread (blocked on the token) acquires it exclusively, finds the just-expired state (pf_state_expires() <= time_second), and unlinks+frees it (pf.c:1589-1597).
  • ip_output() returns β†’ netisr keeps using the freed state: FAIL(PF_SYNPROXY_DROP) β†’ lockmgr(&(*state)->lk, LK_RELEASE) (pf.c:5066-5067), then pf_test's tail: s->state_flags, s->tag, s->nat_rule.ptr, s->packets[], s->bytes[] (pf.c:6712-6812). Same exposure in the create-state synproxy send (pf.c:4294 β†’ pf_test tail) and the bad-state RST path (pf.c:4738-4746: three writes to src->* after the send).
  • struct pf_state has no reference count (pfvar.h:810-859); lifetime is governed solely by the pf_token + purge timeouts.

Run (stock kernel)

sh race.sh β€” 6 rounds Γ— 512 states; state churn verified (state count dropped back each round exactly when the +120 s purge freed the expired batch mid-flood). No visible crash on the stock kernel (a hit does not guarantee an observable fault: the freed 256-byte slab chunk is typically recycled as another pf_state and the stale writes land in valid-looking fields β€” silent corruption).

Run (instrumented kernel)

apply_detector.py marks the state currently inside the pf_send_tcp token-drop window (per-cpu pf_dbg_inflight[], checked from pf_free_state()); a RACE-HIT: freeing in-flight state ... message proves the purge freed a state the packet path is still going to touch after ip_output() returns. See run.log / dmesg.txt in this pack.

VERDICT.md
↓ download raw

DF-3264 VERDICT β€” not_reproduced on the QEMU guest (mechanism code-proven)

Result

status: not_reproduced, impact: none (demonstrated) / panic-to-UAF (by construction), confidence: likely for the vulnerability mechanism, which is certain from the code; what could not be demonstrated inside the budget is the timing overlap on this guest.

Mechanism (all path:line verified against sys/net/pf/pf.c @ 8faf1f65)

  1. The packet path runs pf with pf_token held shared (pf_ioctl.c:3205/3224/3246/3262 lwkt_gettoken_shared(&pf_token)).
  2. pf_send_tcp() releases the token around ip_output() β€” pf.c:2254-2256 (eh == NULL branch), 2274-2276 (route-to/reply-to branch), 2289-2291 (IPv6 branch) β€” then re-acquires it.
  3. Callers on the state path keep using struct pf_state after the send: * SYNPROXY retransmit: pf.c:4918-4924 send, then FAIL β†’ lockmgr(&(*state)->lk, LK_RELEASE) pf.c:5066-5067; * SYNPROXY completion: pf.c:4963-4973 two sends, then twelve+ read/writes of (*state)->src/dst at pf.c:4974-4984; * create-state synproxy: pf.c:4294-4298 send β†’ pf_test tail uses s->state_flags, s->tag, s->nat_rule.ptr, s->packets[], s->bytes[] (pf.c:6712-6812); * bad-state RST: pf.c:4738-4746 β€” three writes to src->* AFTER the send; * pf_unlink_state() RST: pf.c:1489-1497 (purge-thread reachable).
  4. struct pf_state has no reference count (pfvar.h:810-859); the ONLY things keeping it alive are (a) the shared pf_token and (b) its timeout. During the token-drop window neither holds: the purge thread (pf_purge_thread pf.c:1262-1333, once per second per cpu) can acquire the token exclusively, find pf_state_expires(cur) <= time_second and unlink+free the state in the same sweep (pf.c:1589-1597).
  5. Result: the netisr thread resumes after ip_output() and writes read/write-freed ~250-byte kmalloc(M_PFSTATEPL) memory. Slab reuse with attacker-influenced content (new pf_states are being allocated continuously) makes this a corrupting UAF write primitive; at minimum it is a crash.

Verification attempts (guest: DragonFly 6.5-DEVELOPMENT #0/#1, 6 vCPU)

  • Instrumented kernel: built a detector kernel marking the state currently inside the pf_send_tcp token-drop window (per-cpu pf_dbg_inflight[]), checked from pf_free_state(); a hit proves free-during-window (the exact race precondition). Module verified loaded (strings pf.ko | grep RACE-HIT), state machinery verified live (512-state synproxy waves expiring at +120 s, state-table churn observed: inserts/removals counting up).
  • Campaigns (all against the detector kernel): 1. 6 race rounds Γ— 512 synproxy states (create β†’ wait 117 s β†’ retransmit flood across the +120 s expiry sweep). 2. + 4 parallel retransmit flooders (loopback egress). 3. 12 continuous flooders β€” purge thread became token-starved (state churn stopped; removals frozen) β†’ dropped to 2 flooders. 4. 2 flooders with SYN|ACK egress via the virtio NIC (10.0.2.0/24 spoofed sources) to widen the ip_output window. 5. DIOCKILLSTATES tight-loop ioctl hammer (1.5M+ iterations, 20M lifetime state removals) β€” accelerates unlink (pf_ioctl.c pfioctl DIOCKILLSTATES β†’ pf_unlink_state) but the free stays deferred to the purge thread, so this did not close the chain.
  • 0 RACE-HITs in ~1 h of steady-state racing (~5-10 state frees/s continuously crossing thousands of in-flight send windows).

Why it did not reproduce here (honest assessment)

The overlap probability per free is P β‰ˆ (per-state marked time fraction) = retransmit_rate Γ— ip_output_duration. On QEMU, loopback egress is ~3 Β΅s and virtio ~20-30 Β΅s, with a ~300 Β΅s per-send cycle β†’ P β‰ˆ 0.05-1 % per free; with ~5-10 frees/s the expected hit time is minutes-to-tens-of-minutes if the purge wins tokens during windows, but under continuous multi-sender load the purge thread itself becomes token-starved (observed), collapsing the very regime that has high window coverage. On real hardware (ms-scale tx stalls, ARP waits, congested links β€” i.e. every production firewall), the window is 2-3 orders of magnitude larger and the race is correspondingly more real.

Exploit chain (not executed)

synproxy/keep-state rule (default in most pf deployments) + SYN retransmits timed at state expiry β†’ purge frees in-flight state β†’ netisr's post-send writes corrupt a recycled pf_state slab object (attacker can groom with new states) β†’ state-key/rule-pointer corruption β†’ potential code-execution class impact; at minimum remote-triggerable panic.

Fix

fix.diff in this pack (three hunks: try-lock-and-defer in pf_free_state; state lock held across the create-state synproxy send; state lock held across pf_test's SYNPROXY_DROP tail). Not validated on guest (baseline not reproduced); recommended upstream direction: stop dropping pf_token inside pf_send_tcp (queue generated packets instead).

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

not_testable
baseline no→ patch + rebuild →patched clean

baseline not reproduced, so fix validation not possible in this run; fix.diff is code-reviewed only

↓ fix.diffper-fix-DF-3264

Confirmed kernel references

Detail

Exploit chain

synproxy/keep-state rule (pfctl default) + attacker SYN retransmits timed at the state's PFTM_TCP_FIRST_PACKET expiry: purge frees the in-flight state during pf_send_tcp's ip_output; the netisr resumes and writes ~12 fields of the freed ~250-byte kmalloc(M_PFSTATEPL) object (attacker-groomable: new states recycle the chunk); corrupting a live state's key/rule pointers is an escalation path, at minimum a remote-triggerable panic.

Evidence (decisive lines)

['VERDICT.md: full mechanism with path:line and campaign-by-campaign results', 'run.log: 5 campaigns, purge starvation observation, detector design', "fix.diff: three-hunk mitigation (try-lock-and-defer in pf_free_state; state lock across create-state synproxy send; state lock across pf_test's SYNPROXY_DROP tail)"]

PoC changes

evolved from a simple retransmit flood to: continuous-flood steady-state variant, virtio-egress variant (wider windows), DIOCKILLSTATES ioctl hammer, and a purpose-built detector kernel marking states inside the token-drop window

Verified recommended fix

Do not free states that are inside a pf_send_tcp token-drop window: try-lock s->lk in pf_free_state and defer the free to the next purge sweep; hold s->lk across every state-path pf_send_tcp caller's post-send uses

Verdict

Use-after-free window in the pf state machine is code-certain: the packet path holds pf_token only shared (pf_ioctl.c:3205/3224), pf_send_tcp() drops that token around ip_output() (pf.c:2254-2256/2274-2276/2289-2291), its callers keep using the un-refcounted struct pf_state afterwards (pf.c:4738-4746, 4918-4924, 4963-4986, 4294-4298 + pf_test tail 6712-6812, lockmgr 5066-5067), and the purge thread can acquire the token exclusively in that window and unlink+free the just-expired state (pf.c:1589-1597). A detector kernel (per-cpu mark of the state inside the token-drop window, checked in pf_free_state) was built and ~1 h of racing was run (synproxy state waves expiring at +120 s under retransmit floods on loopback and virtio egress, plus a 1.5M-iteration DIOCKILLSTATES hammer): 0 hits. On this guest the ip_output window is 3-30 us against ~300 us send cycles (overlap odds ~0.05-1% per state free) and the purge thread becomes token-starved exactly when window coverage is high, so the overlap could not be demonstrated; on real hardware with ms-scale egress stalls (tx-queue backpressure, ARP, congested links) the window is 2-3 orders of magnitude larger and the race is correspondingly practical. Mechanism certain, practical trigger not demonstrated here.