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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3296 Β· 13 files
FileTypeDescriptionSize
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).

VERDICT.md
↓ download raw

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)

  1. kldload pf.ko; build trigger_changeaddr.c (plain cc, structs from the installed <net/pf/pfvar.h>, sizes verified identical to kernel: pfioc_pooladdr=1136, pf_pooladdr=88).
  2. The trigger performs, all on one /dev/pf fd (O_RDWR): DIOCBEGINADDRS -> DIOCADDADDR(10.9.9.9/32) -> DIOCXBEGIN(NAT) -> DIOCADDRULE(one nat rule, 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).
  3. 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).

  1. Variant 2 (same binary, datapath enable): removal with af=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 unchecked pool->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

fixed
baseline reproduced→ patch + rebuild →patched clean

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.

run.log RUN 3; fixcheck output; build.log patched-module link line
↓ fix.diffDragonFly 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)

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 for exact struct/ioctl parity; two variants (ioctl-path panic, packet-path panic) plus fixcheck.c for non-over-blocking fix validation.

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.