DF-3296 / verdict.json
{ "finding_id": "DF-3296", "status": "reproduced", "reproduced": 1, "impact": "panic", "confidence": "certain", "verdict": "DIOCCHANGEADDR PF_CHANGE_REMOVE of a pools last address NULL-derefs the kernel: pf_ioctl.c:2330 sets pool->cur=TAILQ_FIRST(empty list)=NULL and :2331 immediately reads &pool->cur->addr.v.a.addr at VA 0 via pf_addrcpy. Reproduced deterministically on the stock pf.ko (Fatal trap 12, fault VA 0x0, Stopped at pf_addrcpy+0x11, current process = trigger). Second variant (af=AF_UNSPEC leaves the rule with an empty pool; one matching packet) panics in pf_map_addr+0x76 (pf.c:2693/2712) - the same broken invariant from the packet path. Primitive is a fixed-VA-0 4/16-byte kernel READ into kernel-owned pool->counter: no attacker-controlled address, nothing copied to userland, hence unexploitable beyond a deterministic local DoS (requires write access to /dev/pf, root on stock - 0600 root:wheel). fix.diff (refuse emptying a pool with EBUSY + NULL guard) validated by in-guest module rebuild: identical trigger returns Device busy, non-last removals still succeed (fixcheck), no panic.", "exploit_chain": "root(/dev/pf O_RDWR) -> DIOCBEGINADDRS+DIOCADDADDR(1 addr) -> DIOCXBEGIN/DIOCADDRULE(nat rule)+DIOCXCOMMIT -> DIOCGETRULES(active ticket) -> DIOCCHANGEADDR(PF_CHANGE_REMOVE, nr=0) -> pool->cur==NULL -> pf_addrcpy reads VA 0 -> Fatal trap 12, all CPUs stopped, system dead. Ceiling: denial of service (fixed-address NULL read; not weaponizable to code execution).", "evidence": [ "panic.txt: Fatal trap 12 ... fault virtual address = 0x0 ... Stopped at pf_addrcpy+0x11: movl (%rsi),%eax (ioctl variant) and Stopped at pf_map_addr+0x76: movzbl 0x28(%rbx),%ecx (datapath variant)", "run.log: baseline run 1 & 2 (panic) vs run 3 on patched pf.ko (ioctl returned Device busy (no panic?), fixcheck remove#1 OK / remove#2 EBUSY)", "VERDICT.md: full narrative incl. exploitability analysis and fix-validation steps" ], "kernel_refs": [ "sys/net/pf/pf_ioctl.c:2313", "sys/net/pf/pf_ioctl.c:2330", "sys/net/pf/pf_ioctl.c:2331", "sys/net/pf/pf.c:411", "sys/net/pf/pf.c:2693", "sys/net/pf/pf.c:2712", "sys/net/pf/pf_ioctl.c:1262" ], "poc_changes": "Authored from scratch (no seed): raw-ioctl sequence mirroring pfctl -f (DIOCBEGINADDRS/ADDADDR/XBEGIN/ADDRULE/XCOMMIT/GETRULES/CHANGEADDR) using the guests installed <net/pf/pfvar.h> for exact struct/ioctl parity; two variants (ioctl-path panic, packet-path panic) plus fixcheck.c for non-over-blocking fix validation.", "attempts": 3, "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", "runtime_sec": 2700, "guest_dirty": 0, "build_cmd": "cc -O2 -o trigger_changeaddr trigger_changeaddr.c && cc -O2 -o fixcheck fixcheck.c", "run_cmd": "kldload pf.ko; ./trigger_changeaddr", "code_hash": "9abf81026ee1471fd52e17148f457364b4941c6ca85d9677fd9e7d67c9d04bab", "notes": "Fix validated via module-only rebuild (cd /usr/src && patch -p0 < fix.diff; cd sys/net/pf && make obj && make; kldload pf.ko). Post-fix datapath re-run black-holed ssh because the wildcard nat rule NATs the session itself once pf is enabled - harness artifact, kernel alive (documented in README/VERDICT). Guest reset to clean snapshot afterwards.", "recommended_fix": "Refuse DIOCCHANGEADDR PF_CHANGE_REMOVE when it would empty the pool (EBUSY), and NULL-guard the pool->cur PF_ACPY at pf_ioctl.c:2330-2332.", "fix_status": "fixed", "fix_kernel_uname": "DragonFly dfbsd 6.5-DEVELOPMENT #0 + rebuilt pf.ko module (/usr/obj/usr/src/sys/net/pf/pf.ko, 350392 bytes, module sha differs from stock; kernel unchanged)", "fix_baseline_reproduced": 1, "fix_patched_reproduced": 0, "fix_verdict": "Patched module refuses last-address removal with EBUSY; identical trigger no longer panics; non-last removals still succeed; the empty-pool state that fed pf_map_addr can no longer be created.", "fix_evidence": "run.log RUN 3; fixcheck output; build.log patched-module link line" } |