Outbound-ICMP NAT heap OOB write: byte-swapped alias_port used as icmp_in[] index (distinct generator from DF-0569/0570)
Summary
ip_fw3_nat.c:310 computes s->alias_port=htons((uint32)src*dst % 64511) and :326 stores alias->icmp_in[s->alias_port]=s2 using the RAW byte-swapped value as index into icmp_in[64511] (last member of the 1.5MB cfg_alias). On LE, residues whose low byte is 0xFC..0xFF (~1.57% of flows, attacker-steerable by choosing src/dst) yield index 64512..65532 -> 8-byte kernel heap pointer written 8..8184 bytes past the allocation. REPRODUCED on guest: /dev/kmem shows the neighbouring cfg_alias's ip field replaced by a kernel pointer at exactly +16B past the flow cpu's alias end (all other cpus intact); instrumented module printed rawport=64512..64515 confirming the raw index. Root configures NAT once; afterwards any unprivileged local raw-socket user or internal host picks the wrapped residue, hence which of ~1012 OOB slots receives a kernel heap pointer - repeatable per new icmp_id. Silent cross-object heap corruption. Fix validated (host-order store + bounds-checked inbound lookup): neighbour intact, state in-bounds, NAT still translates.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3398 Β· 20 files| File | Type | Description | Size | |
|---|---|---|---|---|
| icmp_oob.c | β | 4.5 KB | view raw | |
| kcheck.c | β | 2.0 KB | view raw | |
| kslots.c | β | 585 B | view raw | |
| ktree.c | β | 549 B | view raw | |
| kpeek.c | β | 2.1 KB | view raw | |
| kscan.c | β | 1.6 KB | view raw | |
| kprobe.c | β | 624 B | view raw | |
| query_nat.c | β | 1.9 KB | view raw | |
| icmp_in.c | β | 2.3 KB | view raw | |
| proof.sh | β | 737 B | view raw | |
| proof2.sh | β | 1.2 KB | view raw | |
| run.log | β | 3.0 KB | view raw | |
| fix_run.log | β | 1.8 KB | view raw | |
| build.log | β | 541 B | view raw | |
| env.txt | β | 400 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| build.sh | β | 237 B | view raw | |
| run.sh | β | 256 B | view raw | |
| VERDICT.md | β | 4.6 KB | β raw | |
| verdict.json | β | 5.6 KB | view raw |
DF-3398 VERDICT
Status: reproduced (heap OOB write, cross-object corruption of a neighbouring kernel allocation; no panic on this guest).
Root cause (line-accurate)
sys/net/ipfw3_nat/ip_fw3_nat.c:310-311:s->alias_port = htons(s->src_addr * s->dst_addr % ALIAS_RANGE);β src/dst are host-order u32 (ip_fw3.c:413-414ntohl's into f_id); the product is computed in 32-bit wrapping C arithmetic.sys/net/ipfw3_nat/ip_fw3_nat.c:326:alias->icmp_in[s->alias_port] = s2;β raw (byte-swapped on LE) index, no ALIAS_BEGIN offset, no bounds check.sys/net/ipfw3_nat/ip_fw3_nat.h:133:icmp_in[ALIAS_RANGE=64511]is the final member ofstruct cfg_alias(24 B header: LIST_ENTRY next+le_prev, in_addr ip + pad; then tcp_in, udp_in, icmp_in) β total 1,548,288 B.
Index math: x β [0,64510] = wrapped residue; raw index = bswap16(x); OOB iff x's low byte β {0xFC..0xFF} (1012 residues β 1.57%); write lands at 8..8184 B past the allocation end. Attacker-steerable: choosing src/dst chooses x (PoC brute-forces dst; the 32-bit wrap keeps every residue reachable).
How it was proven (6-vCPU guest, stock INVARIANTS kernel #0)
- Rebuilt the stock module from pristine source; configured
ipfw3 nat 1 config ip 10.0.2.15+nat 1 icmp out via vtnet0. - Triggered with x = 0x00FC (raw index 64512). The stack processed the
flow on cpu0 (raw-IP dispatch); resolved cpu0's cfg_alias chain through
/dev/kmem (
ip_fw3_nat_ctxruntime symbol of the loaded module). - Baseline: qword at alias_end+16 =
0xfffff8004f1025c0β an M_IPFW3_NAT heap pointer β where the neighbouring cfg_alias'sipfield (10.0.2.15 = 0x0f02000a, intact on every other cpu at the same offset) used to be. That is thenat_state2*written byicmp_in[64512], 16 bytes past the allocation, corrupting a second kernel object. (run.log) - Diagnostic instrumented module (kprintf only):
rawport=64514,rawport=64515for x=0x2fc/0x3fc β the raw index the stock store uses is exactly the byte-swapped residue. (run.log) - Fix validation (
fix.diff: store at the host-order residueicmp_in[x], bounds-check + ntohs the inbound lookup): identical trigger on the fixed module leaves the neighbouripintact at +16 on all cpus and puts the state in-bounds βicmp_in[252] = 0xfffff8004f1025e0read immediately after the trigger. (fix_run.log) - ICMP NAT still translates after the fix (tcpdump showed the full outbound/request/reply/forwarded-translation quad with net.inet.ip.forwarding=1) β no functional regression.
Course corrections during verification (honesty notes)
- The first-generation oracle ("third packet" translations in tcpdump) was invalid: my initial arithmetic ignored the mod-2^32 wrap of the product, so the flows I sent had residues like 36656 whose byte-swapped indices are in-bounds; those translations exercised normal NAT plumbing, not the OOB. The kmem differential (steps 3-5 above) is the actual proof.
- The trigger's cpu pinning is irrelevant β DFly dispatches raw-IP output to the flow's hash cpu (cpu0 here); the OOB store follows the flow.
Impact
8-byte kernel heap pointer written at an attacker-chosen 8-byte-aligned
offset in an 8 KB window behind a 1.5 MB kmem allocation, repeatable at
will (new icmp_id β new state β new store), value = fresh nat_state2
allocation pointer. Demonstrated consequence: silent corruption of a
neighbouring cfg_alias (its alias ip β a NAT-correctness field β
clobbered). Depending on allocator state the neighbour is any kmem object;
spraying the ~1012 residues smears pointers across an 8 KB band. Root must
configure the NAT once; afterwards every internal host / unprivileged
local raw-socket user can drive it. Realistic ceiling on this guest:
memory corruption + DoS; a uid=0 chain was not pursued for this
NAT-only-reachable subsystem (the translation engine itself is
root-configured, which lowers privesc value vs. an open local primitive).
kernel_refs
- sys/net/ipfw3_nat/ip_fw3_nat.c:310 (htons'd wrapped residue)
- sys/net/ipfw3_nat/ip_fw3_nat.c:326 (raw index OOB store)
- sys/net/ipfw3_nat/ip_fw3_nat.h:133 (icmp_in[64511], last member)
- sys/net/ipfw3/ip_fw3.c:413-414 (f_id addrs are host-order)
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
fixedBaseline (pristine module) reproduces the OOB store: neighbouring cfg_alias ip field at +16 past the flow cpu's alias shows the nat_state2 pointer 0xfffff8004f1025c0 (other cpus intact at the same offset). Patched module (fix.diff), identical trigger: neighbour ip intact (0x0f02000a) on all cpus, state stored in-bounds (icmp_in[252] = 0xfffff8004f1025e0), ICMP NAT translation still functional (tcpdump quad with ip.forwarding=1). Corruption eliminated, no functional regression.
findings/poc/DF-3398/fix_run.log (all-cpu kcheck dump + immediate in-bounds slot read after trigger)
Confirmed kernel references
Detail
Exploit chain
root: kldload ipfw3_nat; ipfw3 nat 1 config ip
Evidence (decisive lines)
['findings/poc/DF-3398/run.log: baseline kcheck (cpu0 +16 = 0xfffff8004f1025c0 heap pointer where all neighbours show ip 0x0f02000a) + NATDBG rawport prints', 'findings/poc/DF-3398/fix_run.log: fixed module, all cpus +16 = intact 0x0f02000a, in-bounds icmp_in[252]=0xfffff8004f1025e0', 'findings/poc/DF-3398/fix.diff: verified one-hunk store fix + inbound lookup bounds check']
PoC changes
Seed sketch had no solver and no byte-order model. Rebuilt twice: (1) modular-inverse src solver assumed full-width product -- WRONG: C computes the product in 32-bit wrapping arithmetic, so the first-generation 'third packet' tcpdump oracle was invalid (flows hit in-bounds indices); (2) final: brute-force dst solver replicating the C wrap, /dev/kmem differential reader (kcheck) resolving ip_fw3_nat_ctx -> nats[0] -> alias from the loaded module's runtime symbol, plus a diagnostic instrumented module to print the raw index. Proof is the baseline-vs-fixed kmem differential at the exact predicted offset.
Verified recommended fix
Store the return state at icmp_in[(src*dst) % ALIAS_RANGE] (host-order, always < ALIAS_RANGE) instead of the raw htons'd s->alias_port, and bounds-check + ntohs the inbound icmp_id lookup (fix.diff, validated)
Verdict
Reproduced on the 6-vCPU stock INVARIANTS guest as a cross-object heap corruption: the outbound ICMP NAT store at ip_fw3_nat.c:326 uses s->alias_port (htons of the 32-bit-wrapped (src*dst)%%64511 residue) as the raw icmp_in[] index; for residues whose low byte is 0xFC..0xFF (1012 of 64511, ~1.57%%, attacker-steerable by picking src/dst) the index is 64512..65532, writing an 8-byte nat_state2 heap pointer 8..8184 bytes past the 1,548,288-byte cfg_alias allocation. /dev/kmem differential: baseline shows the neighbouring cfg_alias's ip field (10.0.2.15) replaced by 0xfffff8004f1025c0 (an M_IPFW3_NAT pointer) at exactly +16 bytes past the flow cpu's alias end, while all other cpus' neighbours are intact; an instrumented diagnostic module printed rawport=64512..64515 confirming the raw index. fix.diff (store at host-order residue + bounds/byte-order-checked inbound lookup) validated on a rebuilt kernel+module: neighbour ip intact, state stored in-bounds (icmp_in[252]=0xfffff8004f1025e0), NAT still translates. Impact field 'panic' reflects kernel heap corruption class; on this guest the write is silent corruption (no panic observed) -- the primitive is a repeatable, attacker-offset heap pointer store, not a one-shot crash. uid=0 not pursued: the subsystem is only reachable after root configures NAT, so the primitive's realistic value is remote-triggered corruption/DoS of NAT routers.
No comments yet.