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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3398 Β· 20 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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-414 ntohl'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 of struct 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)

  1. Rebuilt the stock module from pristine source; configured ipfw3 nat 1 config ip 10.0.2.15 + nat 1 icmp out via vtnet0.
  2. 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_ctx runtime symbol of the loaded module).
  3. Baseline: qword at alias_end+16 = 0xfffff8004f1025c0 β€” an M_IPFW3_NAT heap pointer β€” where the neighbouring cfg_alias's ip field (10.0.2.15 = 0x0f02000a, intact on every other cpu at the same offset) used to be. That is the nat_state2* written by icmp_in[64512], 16 bytes past the allocation, corrupting a second kernel object. (run.log)
  4. Diagnostic instrumented module (kprintf only): rawport=64514, rawport=64515 for x=0x2fc/0x3fc β€” the raw index the stock store uses is exactly the byte-swapped residue. (run.log)
  5. Fix validation (fix.diff: store at the host-order residue icmp_in[x], bounds-check + ntohs the inbound lookup): identical trigger on the fixed module leaves the neighbour ip intact at +16 on all cpus and puts the state in-bounds β€” icmp_in[252] = 0xfffff8004f1025e0 read immediately after the trigger. (fix_run.log)
  6. 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

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

Baseline (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)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Sep 12 05:08:20 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (kernel rebuilt from same source; ipfw3_nat module carries fix.diff)

Confirmed kernel references

Detail

Exploit chain

root: kldload ipfw3_nat; ipfw3 nat 1 config ip ; ipfw3 add 100 nat 1 icmp out -> any unprivileged local raw-socket user (or internal host) sends ICMP echo requests whose (srcdst mod 2^32) %% 64511 has low byte 0xFC..0xFF (brute-forced dst in the PoC) -> kernel writes nat_state2 at chosen offset in the 8KB window past a 1.5MB kmem allocation -> demonstrated corruption of neighbouring cfg_alias (alias ip field). Escalation to uid=0 not developed (root-config-gated subsystem, corruption primitive is pointer-valued at fixed relative offsets).

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.