DF-3456 / verdict.json
{ "finding_id": "DF-3456", "status": "reproduced", "reproduced": 1, "impact": "pf-policy-bypass", "confidence": "certain", "verdict": "pf_anchor_stack[64] (pf.c:148) is a single global anchor-walk stack; the pf pfil hooks hold pf_token only SHARED (pf_ioctl.c:3199+), and DragonFly shared tokens allow concurrent holders across CPUs, so simultaneous pf rule walks tear each others frames. Demonstrated on a 6-vCPU guest with an anchor sandwich ruleset (block rules between two nested anchors): 117 (run 6) and 1 (run 8) UDP datagrams that pf block drops inside the anchor were delivered to an unprivileged receiver while the identical control block in the main ruleset dropped 100%; per-rule evaluation counters prove ~20,791 (run 6) and ~8,700 (run 8) walks silently skipped the block rule (counter gap), versus a flat/zero gap on lo-only controls and on the fixed kernel. Silent intermittent firewall rule bypass under concurrent load on any anchor-using pf ruleset; root configures pf once, unprivileged traffic triggers it.", "exploit_chain": "root loads an anchor-based pf ruleset (standard pfctl practice) and enables pf -> unprivileged users send concurrent UDP flows (lo + vtnet-bound DNS queries) -> two CPUs evaluate anchor rules simultaneously -> pf_step_into_anchor/pf_step_out_of_anchor write/read the same global frames -> victim walk resumes the rule list past the block rules (TAILQ_NEXT(other walks anchor rule)) -> block verdict lost, packet delivered. No memory-safety fault observed (all frame fields remain valid kernel pointers); impact is filtering integrity, not corruption of arbitrary memory.", "evidence": [ "run.6.log: recv port 5555: 117 datagrams (BYPASS! should be 0) with control port 5556 at 0", "run.6.log counter forensics: first decoy 1,649,386 evals vs block rule 1,628,593 evals = 20,793 skipped walks; inner2 first decoy 1,628,558", "run8_supplement.log: second reproduction, 1 delivered datagram, counter gap +8,659 in one run", "run.9_fixed.log + fixed-run-2: patched kernel, 2.88M walks, 0 delivered, counters flat (2,876,466/2,876,506/2,876,509)", "VERDICT.md: full mechanism, pfctl workaround notes, control experiment attribution" ], "kernel_refs": [ "sys/net/pf/pf.c:143-148", "sys/net/pf/pf.c:2501-2533", "sys/net/pf/pf.c:2535-2570", "sys/net/pf/pf.c:3978-3986", "sys/net/pf/pf_ioctl.c:3199-3263", "sys/kern/lwkt_token.c:726-785" ], "poc_changes": "No orchestrator seed existed; PoC designed from scratch and iterated six times: v1 ruleset misordered (pf last-match-wins, caught by control port); v2 flat anchor - concurrent walks into the SAME anchor rule write identical frames (benign); v3 revealed pfctl/kernel nested-anchor path doubling (walked anchor is spam/spam/inner1, 0 evals at the natural path); v4 empty inline anchors are optimized away by pfctl; v5 populated the actually-walked anchors via pfctl -a spam/spam/innerN -f - lo-only output walks did not tear (3.25M walks, perfectly equal counters); v6 added vtnet0-bound DNS query workers as the concurrent walk population and no state - reproduced immediately (117 bypasses). Also worked around: include <signal> -> <signal.h>, wait() prototype, ssh backgrounding of receivers, receiver port reuse between runs.", "attempts": 8, "guest_uname": "DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (6 vCPU, stock INVARIANTS kernel for baseline; rebuilt in-guest for fix validation)", "runtime_sec": 5400, "guest_dirty": 0, "build_cmd": "cc -O2 -o /tmp/df3456/ab /tmp/df3456/anchor_bypass.c && cc -O2 -o /tmp/df3456/dns /tmp/df3456/dns_flood.c", "run_cmd": "sh setup_pf.sh (root); ./ab recv 5555 110 & ./ab recv 5556 110 & ; ./ab flood 6 90 & ./dns 24 90 400 (all as unpriv user)", "code_hash": "8dafbb249ced50fc9304d554bebf4e4c171c846041e99fec3392986f54c27ac2 (anchor_bypass.c); de30a1b0b4e0a1917f7f35bdcd739a0a9c6caa02df2028f28301849e28c703d4 (dns_flood.c)", "notes": "Auxiliary observations (documented, not filed as kernel findings): (1) pfctl inline nested anchors resolve to doubled kernel paths (spam/spam/inner1) - rules written the natural pfctl way in nested inline anchors are silently unreachable; (2) an unrelated 20k-rule single-anchor ruleset flooded by ONE sender wedged the entire guest (no panic, sshd unresponsive) - not chased, distinct from this finding. Bypass delivery rate is stochastic (117 vs 1 per 90s run) but the counter-proven tear rate was 0.4-1.3% of walks in every reproducing run; lo-only control showed ~0.01%. Config-vs-walker lifetime is properly serialized by exclusive-vs-shared pf_token - the defect is strictly walker-vs-walker.", "recommended_fix": "Remove the global pf_anchor_stack; give each walker (pf_match_translation, pf_test_rule, pf_test_fragment) a caller-local struct pf_anchor_stackframe astack[64] passed through pf_step_into_anchor/pf_step_out_of_anchor (see fix.diff); per-CPU arrays are NOT sufficient since a netisr thread can preempt a user-thread walk on the same CPU.", "fix_status": "fixed", "fix_kernel_uname": "DragonFly 6.5-DEVELOPMENT #0 (in-guest rebuild of X86_64_GENERIC with fix.diff applied to /usr/src/sys/net/pf/pf.c; /boot/kernel/kernel dated Sep 13 04:52, md5 667631b7029907145cc7240125e8b228; booted 04:56)", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_verdict": "Rebuilt the kernel in-guest (make nativekernel, 4299 compile units, completed 04:49:31) with fix.diff (caller-local anchor walk stacks), installed and rebooted. Re-ran the exact reproducer twice (90s floods, 2.88M and 2.8M walks - more walks than the reproducing baseline runs): zero datagrams delivered on the blocked port in both runs and per-rule evaluation counters perfectly flat (no block-skip gap), versus 117+1 delivered and 20,791+8,700 skipped walks on the unpatched kernel. The bypass is gone with the fix.", "fix_evidence": "run.9_fixed.log (fixed run 1: 0 deliveries, counters 2,876,466 vs 2,876,506 vs 2,876,509); fixed run 2 transcript in VERDICT.md (0 deliveries, block evals >= decoy evals); kbuild_tail.txt (kernel build completion); fix.diff" } |