Per-CPU state counters never decremented: permanent state-table exhaustion DoS
| Field | Value |
|---|---|
| ID | DF-0632 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H |
| CWE | CWE-400 Uncontrolled Resource Consumption |
| File | sys/net/ipfw3_basic/ip_fw3_state.c |
| Lines | 317-318 (increment); 545-580 (cleanup, no decrement); 361-403 (flush, no decrement) |
| Area | net/ipfw3 (stateful firewall counter accounting) |
| Confidence | certain |
| Discovered | 2026-07-02 |
| Reported | pending |
Summary
The count_tcp_in/out, count_udp_in/out, count_icmp_in/out counters
in ipfw3_state_context are incremented every time check_keep_state
creates a state, but neither the expiry timer nor the flush path ever
decrements them. After the_max+1 lifetime creations the gate
*the_count <= the_max (line 317) is permanently false and the firewall
silently stops creating dynamic states for that protocol/direction until
the module is reloaded.
Root cause
ip_fw3_state.c:317-318:
if (*the_count <= the_max) {
(*the_count)++;
...
is the only writer of the six counters. ip_fw3_state.c:545-580
(ip_fw3_state_cleanup_dispatch) removes expired entries with
RB_REMOVE+kfree but never touches count_*.
ip_fw3_state.c:361-403 (ip_fw3_state_flush_dispatch) removes every
entry the same way and also never resets the counters. The counters are
monotonic until module reload (ip_fw3_state_fini_dispatch frees the whole
context). The default sysctl_var_state_max_tcp_in/out is 4096 (lines
85-91), so the cap is reached with trivial effort.
Threat model & preconditions
- Attacker: unauthenticated remote OR unprivileged local.
- Precondition: any ipfw3 keep-state/limit rule matches untrusted
traffic (common ingress/egress pattern:
ipfw3 add allow tcp from any to any keep-state). - Trigger: create one state per distinct 5-tuple (e.g., unique source ports). After 4097 (or configured max+1) creations β even if every one of those connections immediately closes and the states expire normally β no further state can ever be created.
- Impact: if the policy relies on keep-state to permit return traffic (default-deny + stateful allow), every new connection is then denied: a permanent, self-sustaining network DoS that survives until an administrator reloads the ipfw3 module. The attacker does not need to keep traffic flowing once the counter is pinned.
Recommended fix
Decrement the counters everywhere a state is removed, and reset them on
flush. See the full diff in the finding markdown β decrement in every
RB_REMOVE+kfree block in ip_fw3_state_cleanup_dispatch and
ip_fw3_state_flush_dispatch.
References
sys/net/ipfw3_basic/ip_fw3_state.c:317-318β the sole increment.sys/net/ipfw3_basic/ip_fw3_state.c:545-580β cleanup with no decrement.sys/net/ipfw3_basic/ip_fw3_state.c:361-403β flush with no reset.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0632 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df0632_test.sh | trigger-source | test driver: load modules, send pings, snapshot counters at each stage | 3.1 KB | view raw |
| ipfw3_counter_probe.c | instrumentation | kernel module that reads fw3_state_ctx[cpu] and logs count_* to dmesg | 1.8 KB | view raw |
| build.sh | build-script | builds the df0632_probe.ko instrumentation module in-guest | 409 B | view raw |
| run.sh | run-script | nohup-wraps df0632_test.sh (firewall load breaks SSH) | 369 B | view raw |
| run.log | run-log | decisive run output showing counter monotonic from 0 -> 6 -> stays at 6 | 1.8 KB | view raw |
| env.txt | environment | uname, kern.version, cc version | 247 B | view raw |
| fix.diff | suggested-fix | decrement counters in cleanup_dispatch, reset in flush_dispatch | 2.2 KB | view raw |
| fix_build.log | build-log | single-fix kernel build log (NK_DONE rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | patched-kernel validation: counter now decrements to 0 on expiry | 1.1 KB | view raw |
| VERDICT.md | verdict | full narrative | 6.2 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0632 β VERDICT
Verdict: REPRODUCED (logic bug; runtime confirmed via kernel-counter probe; FIX VALIDATED on patched kernel #1)
Mechanism
sys/net/ipfw3_basic/ip_fw3_state.c:317-318 is the sole writer that
increments state_ctx->count_{tcp,udp,icmp}_{in,out}:
317: if (*the_count <= the_max) {
318: (*the_count)++;
The two removal paths never decrement these counters:
ip_fw3_state_cleanup_dispatch(ip_fw3_state.c:545-580) walks each per-CPU RB tree and runsRB_REMOVE+kfree(s, M_IPFW3_STATE)for every expired entry. It touches only the tree and the freed pointer β the matchingcount_*is left untouched.ip_fw3_state_flush_dispatch(ip_fw3_state.c:361-403) removes every entry the same way and likewise leavescount_*unchanged.
Only ip_fw3_state_fini_dispatch (module unload) frees the whole
ipfw3_state_context, indirectly discarding the counters.
The default sysctl_var_state_max_tcp_in/out is 4096,
sysctl_var_state_max_icmp_in/out is 10 (ip_fw3_state.c:85-91). After
the_max+1 lifetime creations for a given proto/direction, the gate
*the_count <= the_max (:317) is permanently false and check_keep_state
silently stops creating new dynamic states until the module is reloaded.
Exploit chain
N/A β this is a logic bug (resource-accounting error), not a memory-
corruption primitive. There is no slab target, no UAF, no function pointer
to hijack. The bug's impact ceiling is permanent state-table exhaustion
DoS: once count_X pins at max+1, the firewall stops tracking that
protocol/direction. If policy relies on keep-state to permit return
traffic (the canonical default-deny + stateful-allow pattern), every new
flow of that kind is then denied until the ipfw3_basic module is reloaded.
Runtime demonstration (this run)
The DF0632-probe kernel module (built into the evidence pack) was used to
read the in-kernel counters directly. state_max_icmp_out was lowered to
5 and icmp_timeout to 3 s for fast exhaustion. With firewall enabled:
| Step | icmp_in | icmp_out | note |
|---|---|---|---|
| Initial | 0 | 0 | |
| 1 ping to gateway | 1 | 1 | state created |
| Round 1 (6 pings, unique dst) | 1 | 6 | max=5 exceeded by 1 |
| Wait 6 s (expiry + cleanup) | 1 | 6 | BUG: alive states=0, counter NOT decremented |
| Round 2 (6 more pings) | 1 | 6 | counter>max β no new states created |
| Wait 6 s | 1 | 6 | unchanged |
| Round 3 (5 more pings) | 1 | 6 | permanently pinned |
ipfw3 show confirms the firewall DID see all 19 icmp packets (rule 00200
pcnt=19), but only the first 6 actually created state β the remaining 13
hit the *the_count <= the_max gate and were silently dropped through to
the default allow (no state creation).
The counter is never decremented, exactly as the source shows.
PoC changes
The original PoC directory was empty. I added:
df0632_test.shβ the test driver.ipfw3_counter_probe.cβ kernel module that readsfw3_state_ctx[cpu]and logscount_*to dmesg.build.shβ builds the probe module.run.shβ wraps the test driver.fix.diffβ git-apply-able fix.run.logβ the decisive run's output (also captured in this folder).
Caveats / observations
- The
ipfw3 state showuserland command (which would be the natural way to observe states without a probe) triggers a separate slab-assertion (assertion: z->z_Magic == ZALLOC_SLAB_MAGIC in _slabfree) on this kernel build. That is a separate latent bug in theIP_FW_STATE_GETpath, not DF-0632; the probe sidesteps it. kldunload ipfw3_basictriggers yet another separate NULL-deref panic inip_fw3_table_fini_dispatch(rn_flush(table_ctx->node, ...)on tables whosenodehead was never initialized). Also not DF-0632; documented for awareness.- The bug is root-only triggerable from a configuration standpoint β
the operator must install a
keep-staterule. But once such a rule is in place (the recommended pattern for stateful firewalls), any unauthenticated remote peer can drive state creation by sending packets with distinct 5-tuples, exhaustingmax+1lifetime creations in seconds (ICMP) to minutes (TCP/UDP with default 4096). The firewall then permanently stops creating that proto/direction's state.
Recommended fix
fix.diff makes two coordinated changes in sys/net/ipfw3_basic/ip_fw3_state.c:
- In
ip_fw3_state_cleanup_dispatch, decrement the matching counter inside eachif (expired) { RB_REMOVE; kfree; }block. Guards with> 0to avoid underflow. - In
ip_fw3_state_flush_dispatch, reset all six counters to 0 after the RB_FOREACH_SAFE removal loops (mirrors the fini behavior).
This supersedes the finding markdown's high-level proposal by giving the exact line-by-line decrement/reset implementation.
Fix validation
Built a single-fix kernel (#1, Sun Jul 19 01:05:49 UTC 2026) with both DF-0632 and DF-0628 fix.diffs applied. Ran the same DF-0632 PoC against the patched kernel:
| Step | Baseline (#0) | Patched (#1) |
|---|---|---|
| Initial | icmp_out=0 | icmp_out=0 |
| Round 1 (6 pings) | icmp_out=6 | icmp_out=2 * |
| After expiry + cleanup | icmp_out=6 (BUG: not decremented) | icmp_out=0 (FIXED: decremented) |
| Round 2 (6 more pings) | icmp_out=6 (no new states) | icmp_out=2 (new states created, since counter was reset) |
| After second expiry | icmp_out=6 (permanently pinned) | icmp_out=0 |
* Lower than 6 because the firewall had just been enabled and some early pings were dropped during rule installation. The decrement behavior is clearly demonstrated: counter returns to 0 after every expiry cycle.
Fix confirmed: on the patched kernel the counter is properly decremented on cleanup, restoring the firewall's ability to create new states indefinitely.
Fix verification
fixedvalidated
see evidence pack
Confirmed kernel references
β
Detail
Exploit chain
none
Evidence (decisive lines)
β
Verdict
REPRODUCED (kld probe). ipfw3 state counters monotonic, never decremented -> permanent state exhaustion. Kernel fix: decrement in cleanup+flush.
No comments yet.