DIOCCHANGEADDR removal of a pool's last address NULL-derefs the kernel (pfioctl + pf_map_addr), deterministic panic
Summary
pfioctl's DIOCCHANGEADDR handler frees the selected pf_pooladdr on PF_CHANGE_REMOVE with no emptiness check, then unconditionally does pool->cur = TAILQ_FIRST(&pool->list) (NULL once empty) and PF_ACPY(&pool->counter, &pool->cur->addr.v.a.addr, af) - pf_addrcpy reads at VA 0. With pca->af=AF_UNSPEC the copy is a no-op: the ioctl succeeds and leaves a live nat/rdr/binat/route-to rule with rpool.cur == NULL; the next matching packet NULL-derefs in pf_map_addr. The >=1-address invariant enforced at rule creation is breakable after commit (this was the hot lead flagged by pf.c pass-2, cross-ref DF-3267). VERIFIED both variants on stock guest: 'Fatal trap 12 ... fault VA = 0x0 / Stopped at pf_addrcpy+0x11' and datapath variant 'Stopped at pf_map_addr+0x76'. Fix validated in-guest (refuse last-address removal EBUSY + NULL guard): identical trigger returns 'Device busy', non-last removals still succeed. Root-gated (/dev/pf 0600, FWRITE) - local DoS by an authorized pf administrator; primitive is a fixed-VA-0 read into kernel-owned memory, unexploitable beyond DoS.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3296 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger_changeaddr.c | β | 5.2 KB | view raw | |
| fixcheck.c | β | 2.7 KB | view raw | |
| build.sh | β | 137 B | view raw | |
| run.sh | β | 167 B | view raw | |
| build.log | β | 974 B | view raw | |
| run.log | β | 1.7 KB | view raw | |
| panic.txt | β | 1.4 KB | view raw | |
| env.txt | β | 784 B | view raw | |
| fix.diff | β | 913 B | view raw | |
| README.md | β | 2.3 KB | β raw | |
| VERDICT.md | β | 4.4 KB | β raw | |
| manifest.json | β | 917 B | view raw | |
| verdict.json | β | 4.0 KB | view raw |
DF-3296 β pf DIOCCHANGEADDR: removing a pool's LAST address NULL-derefs the kernel
What
pfioctl() DIOCCHANGEADDR with action = PF_CHANGE_REMOVE frees the
selected struct pf_pooladdr and then unconditionally does
(pf_ioctl.c:2330-2332):
pool->cur = TAILQ_FIRST(&pool->list); /* NULL when pool now empty */
PF_ACPY(&pool->counter, &pool->cur->addr.v.a.addr, pca->af);
PF_ACPY -> pf_addrcpy() (pf.c:411) reads src->addr32[0] at virtual
address 0 -> fatal page fault. With pca->af = AF_UNSPEC the copy is a
no-op, the ioctl returns success, and the owning rule is left with
rpool.cur == NULL β the next packet matching that nat/rdr/binat/route-to
rule NULL-derefs in pf_map_addr() (pf.c:2693/2712).
Reachability
- /dev/pf is created
0600 root:wheel(pf_ioctl.c:3360) and DIOCCHANGEADDR is a write ioctl (not in the FREAD allow-list, pf_ioctl.c:1047-1098) -> requires root (or a devfs rule granting write). - Deterministic: one ioctl sequence, no race, 100% reproducible.
Reproduce
kldload pf.ko # if not loaded cc -O2 -o trigger_changeaddr trigger_changeaddr.c ./trigger_changeaddr # ioctl variant: panic in pf_addrcpy # ./trigger_changeaddr datapath enable # packet variant: panic in pf_map_addr
Expected
ioctl variant (baseline, stock pf.ko):
Fatal trap 12: page fault while in kernel mode fault virtual address = 0x0 fault code = supervisor read data, page not present Stopped at pf_addrcpy+0x11: movl (%rsi),%eax
packet variant (baseline): Stopped at pf_map_addr+0x76: movzbl 0x28(%rbx),%ecx
Fix validation
fix.diff: refuse removing the last pool address (EBUSY) + defensive
NULL guard around the PF_ACPY. Validated by rebuilding only the pf
kernel module in the guest:
cd /usr/src && patch -p0 < fix.diff cd /usr/src/sys/net/pf && make obj && make kldload /usr/obj/usr/src/sys/net/pf/pf.ko ./fixcheck # remove non-last: OK; remove last: "Device busy", no panic ./trigger_changeaddr # "ioctl returned Device busy (no panic?)"
Note for the datapath variant re-run: leave DIOCSTART out or bind the
test NAT rule to lo0 β an unconstrained nat -> 10.9.9.9 rule with pf
enabled NATs the ssh session itself and black-holes guest networking
(kernel stays up; this is a harness artifact, not the bug).
DF-3296 VERDICT β reproduced (deterministic kernel NULL-deref panic; DoS class)
Result
status: reproduced, impact: panic (deterministic local kernel panic;
write-access to /dev/pf required, i.e. root on a stock system β /dev/pf is
crw------- root:wheel, pf_ioctl.c:3360).
How it was run (baseline, stock pf.ko, guest #0 X86_64_GENERIC)
kldload pf.ko; buildtrigger_changeaddr.c(plaincc, structs from the installed<net/pf/pfvar.h>, sizes verified identical to kernel: pfioc_pooladdr=1136, pf_pooladdr=88).- The trigger performs, all on one
/dev/pffd (O_RDWR): DIOCBEGINADDRS -> DIOCADDADDR(10.9.9.9/32) -> DIOCXBEGIN(NAT) -> DIOCADDRULE(onenatrule, pool_ticket matched) -> DIOCXCOMMIT -> DIOCGETRULES (fetch ACTIVE ticket) -> DIOCCHANGEADDR(action=PF_CHANGE_REMOVE, nr=0, r_num=0, r_action=PF_NAT, af=AF_INET). - Kernel panicked before the ioctl returned (ssh died, vm status down):
Fatal user address access from kernel mode from trigger_changead at ffffffff82607d31 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x0 fault code = supervisor read data, page not present Stopped at pf_addrcpy+0x11: movl (%rsi),%eax
%rsi = &pool->cur->addr.v.a.addr with pool->cur == NULL β the exact
predicted sink pf_ioctl.c:2330-2332 -> pf_addrcpy (pf.c:411-426).
- Variant 2 (same binary,
datapath enable): removal withaf=AF_UNSPEC(pf_addrcpy copies nothing -> no ioctl panic; rule left with an empty pool), then DIOCSTART + one UDP packet to 127.0.0.1:65001 matching the NAT rule:
Stopped at pf_map_addr+0x76: movzbl 0x28(%rbx),%ecx
rbx == rpool->cur == NULL (pf.c:2693/2712). Two independent panic
sites from one root cause β the invariant "a pool-using rule always
has >=1 address" (enforced at rule creation, pf_ioctl.c:1262-1266
and 1500-1506) is breakable after commit via DIOCCHANGEADDR.
Root cause
- pf_ioctl.c:2313-2318: PF_CHANGE_REMOVE frees the last pooladdr with no emptiness check.
- pf_ioctl.c:2330-2332: unconditional
pool->cur = TAILQ_FIRST(...)followed by an uncheckedpool->cur->addr...dereference. - pf.c:2693/2712: packet path also assumes rpool->cur != NULL.
Exploitability ceiling (why DoS, not more)
The primitive is a read from a fixed virtual address 0 (offsetof(struct
pf_pooladdr, addr) == 0; v.a.addr at offset 0 of pf_addr_wrap) into the
kernel-owned pool->counter β 4 bytes (AF_INET) or 16 (AF_INET6). The
faulting address is not attacker-influenced (always NULL+0) and nothing is
copied to userland; a NULL page mapping is not attainable for unprivileged
users on DragonFly (no MAP_FIXED at 0; vm.mmap_min_addr equivalent
enforced by sys/sysref/mmap checks). Both panic sites are identical
fixed-NULL reads. Conclusion: unexploitable beyond
panic/denial-of-service. No uid=0 chain is possible from this bug; pushed
to the honest ceiling (two deterministic panic variants + fix validation).
Fix (validated)
fix.diff:
1. refuse PF_CHANGE_REMOVE of the last pool address (EBUSY) β
restores the creation-time invariant for live rules;
2. defensive if (pool->cur != NULL) around the PF_ACPY.
Validation (same guest, module-only rebuild):
- baseline reproduced first (panic captured above, panic.txt);
- cd /usr/src && patch -p0 < fix.diff && cd sys/net/pf && make obj && make;
- kldload patched pf.ko;
- ./trigger_changeaddr -> ioctl returned Device busy (no panic?) β
identical trigger, panic GONE;
- ./fixcheck (pool with 2 addresses): remove #1 (non-last) succeeds
(fix does not over-block), remove #2 (last) -> Device busy, no panic;
- datapath variant post-fix: removal refused (EBUSY) so pf_map_addr
never sees an empty pool. (Caveat recorded in README: re-running with
DIOCSTART + an unconstrained nat rule NATs the ssh flow itself β
harness artifact, kernel stays alive.)
fix_status: fixed.
Files
trigger_changeaddr.c (both variants), fixcheck.c (fix validation), build.sh / run.sh, build.log, run.log (patched), panic.txt (both panics), fix.diff, env.txt, manifest.json, verdict.json.
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
fixedPatched 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.
run.log RUN 3; fixcheck output; build.log patched-module link line
Confirmed kernel references
Detail
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 (decisive lines)
["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']
PoC changes
Authored from scratch (no seed): raw-ioctl sequence mirroring pfctl -f (DIOCBEGINADDRS/ADDADDR/XBEGIN/ADDRULE/XCOMMIT/GETRULES/CHANGEADDR) using the guest's installed
Verified 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.
Verdict
DIOCCHANGEADDR PF_CHANGE_REMOVE of a pool's 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.
No comments yet.