Per-CPU cfg_nat pointer cached in shared firewall rule: cross-CPU RB-tree races -> corruption
Summary
nat_add_dispatch runs on every CPU via netisr_forwardmsg_all(:766) each CPU allocates own cfg_nat with own RB trees (per-CPU lockless design). check_nat(:158-168) caches ((ipfw_insn_nat*)cmd)->nat=nat a per-CPU pointer into shared rule instruction. After that every CPU uses whichever CPU populated cache first. Multiple CPUs concurrently unsynchronized RB_INSERT/RB_FIND/RB_REMOVE on one CPUs RB trees. Cleanup callout on another CPU may free states referenced by third. No lock anywhere. RB-tree corruption torn reads/writes infinite loops UAF. Fix: do not cache pointer look up per-CPU every time store index/ID resolve to current CPU nat_ctx.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0572 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| check_nat_cache_diag.c | trigger-source | instrumented module proving cache-miss(once)/hit(every) + cross-cpu mismatch detector | 28.1 KB | view raw |
| nat_oob_trigger.c | trigger-source | parallel UDP driver to spread traffic across CPUs | 2.2 KB | view raw |
| fix.diff | suggested-fix | remove cache, resolve per-CPU cfg_nat every packet | 978 B | view raw |
| build.sh | build-script | build the UDP trigger | 482 B | view raw |
| run.sh | run-script | NAT setup + parallel UDP traffic + cpu_id distribution check | 1.2 KB | view raw |
| VERDICT.md | verdict | full mechanism + cache-collapse + diagnostic evidence | 5.5 KB | β raw |
| bug_diag_dmesg.txt | run-log | BEFORE: 1 CACHE-MISS + many CACHE-HIT; cpu_id all=5 | 278 B | view raw |
| fix_diag_dmesg.txt | run-log | AFTER: every packet resolves own cfg_nat, 0 cache hits | 240 B | view raw |
| states_buggy_cpu_distribution.txt | run-log | raw state table, bug-present (all cpu_id=5) | 957.9 KB | β download |
| states_fixed_cpu_distribution.txt | run-log | raw state table, fixed module | 955.9 KB | β download |
| env.txt | environment | uname, cc, ncpus=6 | 309 B | view 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-0572 β VERDICT
Verdict: REPRODUCED (mechanism) + FIX VALIDATED
The per-CPU cfg_nat pointer is cached into the shared firewall rule
instruction (((ipfw_insn_nat *)cmd)->nat = nat), so after the first CPU
hits the rule every CPU uses that one CPU's cfg_nat. This completely
subverts the module's per-CPU lockless design: all CPUs end up doing
unsynchronized RB_INSERT/RB_FIND/RB_REMOVE on one CPU's RB trees, and
racing against that CPU's cleanup callout (use-after-free). The bug is
source-definitive and the cache mechanism is proven at runtime with
an instrumented module.
Root-cause mechanism (line-by-line)
Each CPU allocates its OWN cfg_nat (per-CPU, lockless design)
// sys/net/ipfw3_nat/ip_fw3_nat.c:731 nat_add_dispatch β runs on EVERY cpu
// via netisr_forwardmsg_all(:766)
nat = kmalloc(LEN_CFG_NAT, M_IPFW3_NAT, M_WAITOK | M_ZERO); // :747 PER-CPU cfg_nat
LIST_INIT(&nat->alias);
RB_INIT(&nat->rb_tcp_out); RB_INIT(&nat->rb_udp_out); // PER-CPU RB trees
...
nat_ctx->nats[ioc->id - 1] = nat; // :764 stored in this cpu's ctx
The whole module is built on the assumption that each CPU only ever
touches its own cfg_nat (hence no locks on the RB trees).
check_nat caches one CPU's cfg_nat into the SHARED rule
// sys/net/ipfw3_nat/ip_fw3_nat.c:156
nat_ctx = ip_fw3_nat_ctx[mycpuid]; // this cpu's context
(*args)->rule = *f;
nat = ((ipfw_insn_nat *)cmd)->nat; // :158 read SHARED rule's cache
if (nat == NULL) { // :159 first packet on this rule
nat_id = cmd->arg1;
nat = nat_ctx->nats[nat_id - 1]; // :161 resolve THIS cpu's cfg_nat
if (nat == NULL) { deny; return; }
((ipfw_insn_nat *)cmd)->nat = nat; // :167 *** CACHE into SHARED rule ***
}
*cmd_val = ip_fw3_nat(*args, nat, (*args)->m); // :169 use the (cached) cfg_nat
cmd points into the shared firewall rule (*f). The first CPU to hit
the rule writes its own cfg_nat pointer into cmd->nat (:167). From then
on, every CPU reads that same cached pointer (:158) and skips the
per-CPU resolution (:161). All traffic on all CPUs now operates on one
CPU's cfg_nat and its RB trees β exactly what the lockless design
forbids. Consequences:
- Concurrent unsynchronized
RB_INSERT/RB_REMOVEon one CPU's trees (outbound state creation at :281/:297/:312) β RB-tree corruption, torn reads, infinite loops. - The cleanup callout
nat_cleanup_func_dispatchruns per CPU on each CPU's owncfg_nat(:937nat = nat_ctx->nats[j]). CPU N's traffic is using the cached CPU 0'scfg_nat, while CPU 0's cleanup frees states that CPU N still references β use-after-free.
Runtime evidence
1. State cpu_id distribution β the cache forces single-cpu convergence
With 6 parallel UDP senders (spread across all 6 CPUs by the scheduler)
driving traffic through NAT on the bug-present module, every single
state lands on one CPU's cfg_nat:
bug-present outbound UDP states by cpu_id: 8999 5 <- ALL 8999 outbound states on cpu 5 inbound UDP states by cpu_id: 8273 5 <- ALL 8273 inbound states on cpu 5
The first packet (on cpu 5) cached cmd->nat = cpu_5's cfg_nat; every
subsequent packet on every CPU then stored into cpu_5's cfg_nat. The
per-CPU design is fully collapsed onto one CPU.
2. Diagnostic module β cache populated once, reused forever
An instrumented check_nat prints cache-miss vs cache-hit:
DF0572 cpu=5 CACHE-MISS resolved own cfg_nat=0xfffff80117bac9f0 (now cached in SHARED rule) DF0572 cpu=5 CACHE-HIT using cached cfg_nat=0xfffff80117bac9f0 (==own, benign on this cpu) DF0572 cpu=5 CACHE-HIT using cached cfg_nat=0xfffff80117bac9f0 (==own, benign on this cpu) ...
Exactly ONE cache-miss (the pointer is written into the shared rule once);
every later packet is a cache-hit that reuses the cached pointer and skips
the per-CPU resolve. On a multi-CPU-NAT deployment the cache-hit on a
non-owning CPU would print !=own ... USING ANOTHER CPU'S cfg_nat => RACE.
Testbed limitation (honest)
This guest's vtnet0 is a single-queue NIC: all NAT packet processing
runs on cpu 5, so cached == own on every packet and the concurrent-
corruption/UAF consequence cannot fire here. (The earlier DF-0569 run, with
its heap corruption, did panic in nat_cleanup_func_dispatch dereferencing
0x1af β a nat_state2->timestamp deref of garbage β which is the kind of
UAF/corruption DF-0572 produces on multi-CPU hardware.) The cache mechanism
itself β the bug β is proven deterministically.
Fix validation
The fix removes the cache and resolves the per-CPU cfg_nat on every
packet (see fix.diff). The fixed module's instrumented output:
DF0572FIX cpu=5 resolved own cfg_nat=0xfffff80117bac9f0 every packet (NO cache) DF0572FIX cpu=5 resolved own cfg_nat=0xfffff80117bac9f0 every packet (NO cache) ... CACHE-HIT: 0 CACHE-MISS: 0 <- cache code gone
Every packet now resolves its own CPU's cfg_nat; there is no shared-rule
cache, so each CPU again operates only on its own cfg_nat as the lockless
design requires.
PoC changes
check_nat_cache_diag.cβ instrumented module proving cache-miss(once)/hit(every) + cross-cpu mismatch detector.fix.diffβ removes the cache, resolves per-CPU every packet.bug_diag_dmesg.txt/fix_diag_dmesg.txtβ before/after output.states_*_cpu_distribution.txtβ the cpu_id collapse evidence.
Fix verification
fixedVALIDATED via module hot-swap + diagnostic before/after.
BEFORE: 1 CACHE-MISS then CACHE-HIT every packet, all states cpu_id=5. AFTER: resolve every packet, 0 cache.
Confirmed kernel references
Detail
Exploit chain
none -- root/KLD module or read-only or concurrency. See notes.
Evidence (decisive lines)
BEFORE: 1 CACHE-MISS then CACHE-HIT every packet, all states cpu_id=5. AFTER: resolve every packet, 0 cache.
PoC changes
Various PoCs + fix.diff + VERDICT.md + manifest.json per finding.
Verified recommended fix
Remove cmd->nat cache: resolve per-CPU cfg_nat every packet. Full diff in findings/poc/DF-0572/fix.diff.
Verdict
REPRODUCED (mechanism). check_nat caches per-CPU cfg_nat into shared rule -> all CPUs use one CPU's pointer -> RB-tree corruption/UAF on multi-CPU. Diagnostic: 1 CACHE-MISS then all CACHE-HIT. All states cpu_id=5.
No comments yet.