pf_get_sport() infinite loop (netisr hang) when the NAT pool port range includes port 0 and all candidate ports are occupied
Summary
The downward port-search loop 'for (tmp = cut - 1; tmp >= low; --(tmp))' uses an unsigned 16-bit counter with no wrap guard. pfctl accepts a NAT pool range containing port 0 ('port 0:65535'; NAT defaults only substitute when BOTH ends are 0), so low==0 reaches this branch; when tmp counts down past 0 it wraps to 0xffff and the condition is always true - once every port for the tuple is occupied the netisr thread loops forever, wedging that CPU's network stack. Requires root-configured port range containing 0 AND ~65536 states on one tuple (needs set limit states raised; DF-3268's missing enforcement makes it achievable) - admin-config-dependent remote hang/DoS. Not guest-verified. Fix: clamp low to 1 after the range swap (the ICMP path already does). [VERIFIED pass2: u_int16_t underflow source-certain but full pool occupancy not achievable on rig lo0 ceiling 10.7K states; fix compile-validated]
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3266 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| fillsyn.c | β | 4.3 KB | view raw | |
| README.md | β | 625 B | β raw | |
| VERDICT.md | β | 2.2 KB | β raw | |
| build.sh | β | 175 B | view raw | |
| run.sh | β | 166 B | view raw | |
| manifest.json | β | 679 B | view raw | |
| verdict.json | β | 2.1 KB | view raw | |
| fix.diff | β | 549 B | view raw | |
| env.txt | β | 272 B | view raw |
DF-3266 -- verification pack
Finding: pf_get_sport downward loop underflows when the pool range includes port 0
Status: not_reproduced / impact denial_of_service / confidence likely
What was run
guest: cc -O -o fillsyn fillsyn.c (raw SYN injector with valid checksums)
How to reproduce
guest: pfctl -f conf (port 0:65535 nat on lo0), fillsyn 127.0.0.1 9 fill, then trigger
Expected
rule accepted; full saturation (and thus the unterminating loop) not reached on this rig
See VERDICT.md for the full narrative and verdict.json for the persisted machine verdict. Artifacts: fillsyn.c, run.log.
DF-3266 verification
Kernel: DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64
Bottom line
Half verified, half refuted-in-practice: pfctl ACCEPTS a NAT pool whose port range includes 0 (nat ... port 0:65535 loads verbatim), and the downward loop for (tmp = cut - 1; tmp >= low; --tmp) with u_int16_t tmp provably underflows to 0xffff at low==0 (condition always true) - pf.c:3033. But saturating all candidate ports was not achievable on this rig: an external-destination fill wedges the QEMU slirp backend (65K pending host connects) before saturation, and a loopback fill collapses to ~10.7K concurrent states (local RSTs tear the half-open states down; a listener variant did not change the ceiling) while 194 million state-table searches were billed at 3.4M/s during the fill. The trigger connect therefore always found a free port (or was EPERM-blocked by pf) and never entered the unterminating loop. Admin-config-dependent DoS; fix (clamp low to 1, as the ICMP path already does) compile-validated.
Kernel references
- sys/net/pf/pf.c:3033-3050
- sys/net/pf/pf.c:3010-3012
Exploit chain
(none: availability/correctness finding; no privilege chain)
Fix
clamp low to 1 after the range swap
See fix.diff in this directory (git-apply-able against the read-only sys/ tree; applied only inside the disposable verification guest).
Reproduction
build: guest: cc -O -o fillsyn fillsyn.c (raw SYN injector with valid checksums)
run: guest: pfctl -f conf (port 0:65535 nat on lo0), fillsyn 127.0.0.1 9 fill, then trigger
expected: rule accepted; full saturation (and thus the unterminating loop) not reached on this rig
Notes on the PoC (what changed vs the finding sketch)
v1 used deliberately broken TCP checksums, which pf drops with EPERM from the raw sender; v2 computes correct checksums; destination moved inside the guest after the slirp wedge
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_testableConfirmed kernel references
- sys/net/pf/pf.c:3033-3050
- sys/net/pf/pf.c:3010-3012
Detail
Evidence (decisive lines)
run.log x2: rule accepted with port 0:65535; fill runs, state ceilings, searches 194030438 at 3464829.2/s, trigger outcomes
PoC changes
v1 used deliberately broken TCP checksums, which pf drops with EPERM from the raw sender; v2 computes correct checksums; destination moved inside the guest after the slirp wedge
Verified recommended fix
clamp low to 1 after the range swap
Verdict
Half verified, half refuted-in-practice: pfctl ACCEPTS a NAT pool whose port range includes 0 (nat ... port 0:65535 loads verbatim), and the downward loop for (tmp = cut - 1; tmp >= low; --tmp) with u_int16_t tmp provably underflows to 0xffff at low==0 (condition always true) - pf.c:3033. But saturating all candidate ports was not achievable on this rig: an external-destination fill wedges the QEMU slirp backend (65K pending host connects) before saturation, and a loopback fill collapses to ~10.7K concurrent states (local RSTs tear the half-open states down; a listener variant did not change the ceiling) while 194 million state-table searches were billed at 3.4M/s during the fill. The trigger connect therefore always found a free port (or was EPERM-blocked by pf) and never entered the unterminating loop. Admin-config-dependent DoS; fix (clamp low to 1, as the ICMP path already does) compile-validated.
No comments yet.