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)
PoC verification
Evidence pack
findings/poc/DF-3264 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 allsynflood_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-flightpf_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) callspf_send_tcp(), which releases pf_token aroundip_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 tosrc->*after the send).struct pf_statehas 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.
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)
- The packet path runs pf with pf_token held shared
(pf_ioctl.c:3205/3224/3246/3262
lwkt_gettoken_shared(&pf_token)). pf_send_tcp()releases the token aroundip_output()β pf.c:2254-2256 (eh == NULLbranch), 2274-2276 (route-to/reply-to branch), 2289-2291 (IPv6 branch) β then re-acquires it.- Callers on the state path keep using
struct pf_stateafter 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/dstat pf.c:4974-4984; * create-state synproxy: pf.c:4294-4298 send β pf_test tail usess->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 tosrc->*AFTER the send; *pf_unlink_state()RST: pf.c:1489-1497 (purge-thread reachable). struct pf_statehas 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_threadpf.c:1262-1333, once per second per cpu) can acquire the token exclusively, findpf_state_expires(cur) <= time_secondand unlink+free the state in the same sweep (pf.c:1589-1597).- 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 frompf_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_testablebaseline not reproduced, so fix validation not possible in this run; fix.diff is code-reviewed only
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.
No comments yet.