# DF-3298 VERDICT — CARP softc use-after-free via deferred IP reassembly

**Status: REPRODUCED (deterministic, 6/6 runs incl. 3-run stress block)**
**Impact: UAF read on freed kernel memory (stale `if_type`/`if_flags`/
`if_softc` derefs + `if_xname` printed to console from the freed chunk);
attacker-controlled 8-byte write (`sc->sc_counter`, ip_carp.c:1150-1151)
is *reachable in code* but blocked on the current stock kernel by an
incidental pre-free mutation — see "Why not uid=0".**
**Fix: authored + build-validated on the guest (bad behavior gone).**

## How it reproduces (baseline, stock kernel `#0 Jul 2 2026`)

See `run.log` / `run.final.log` / `run.repeat.log`:

1. `setup.sh`: tap0 10.0.0.1/24; carp0 vhid 1 pass pocpass 10.0.0.100/24,
   forced MASTER. `vmstat -m` shows `CARP 4 1.83K` in use.
2. `frag_send /dev/tap0 A1` — IP proto-112 fragment #1 (offset 0, MF,
   payload = 40 bytes: CARP header + pad), **dst MAC 00:00:5e:00:01:01**
   (the public CARP vhid-1 MAC). `carp_forus()` (ip_carp.c:1688-1705)
   claims it, `carp_input()` (ip_carp.c:1229-1233) re-inputs it on
   carp0, `ether_reinput_oncpu()` stamps `m_pkthdr.rcvif = &sc->sc_if`
   (if_ethersubr.c:1317-1321). `ip_input` queues it for reassembly
   (ip_input.c:850-865; dst 10.0.0.1 is "ours" -> `goto ours`). The
   tap0 original is consumed, so there is no duplicate-offset dedup
   that would replace the head. `netstat -s -p ip`: "1 fragment
   received".
3. `ifconfig carp0 destroy` — `carp_clone_destroy()` (ip_carp.c:703-726)
   kfree()s the softc. **`vmstat -m`: CARP in-use 0** (all M_CARP memory
   freed, including the ifnet embedded in the softc).
4. `frag_send /dev/tap0 A2` — fragment #2 (offset 5, no MF), same
   (id, src, dst, proto). Arrives on tap0, `ip_reass()` matches the
   queue **ignoring ifnet** (ip_input.c:1005-1008), completes, and the
   head fragment's pkthdr — `rcvif` = the freed carp0 ifnet — survives
   `m_cat()` (ip_input.c:1275-1287). Transport dispatch runs
   `carp_proto_input()` (in_proto.c:317-320).

Observed on the stock kernel (`run.final.log`):

```
BEFORE            5 packets received (IPv4)
frag1 ... destroy (M_CARP in-use: 0) ... frag2
AFTER             6 packets received (IPv4)     <- ran on freed softc
dmesg: carp_proto_input: packet received on stopped carp interface: carp0
```

That dmesg line is emitted at ip_carp.c:956-961 after dereferencing
`ifp->if_type` (fread, == IFT_CARP passed on stale bytes),
`CARP_IS_RUNNING(ifp)` (fread, failed on stale flags) and then
printing `ifp->if_xname` — **read out of freed M_CARP memory**. 3/3
stress runs identical (`run.repeat.log`).

Variant B (`run.log`, dst 224.0.0.18, group joined on tap0 by
`mjoin`, fully valid CARP header incl. checksum and HMAC under
"pocpass") reached the same branch — see blocker below.

## Why not uid=0 (honest exploitation ceiling)

The deep path (`carp_proto_input_c`: vhid compare -> version ->
`carp_hmac_verify` reading `sc_sha1`/`sc_pad` from the freed chunk —
which are NOT wiped at free, DF-0304 — then `sc->sc_init_counter = 0`
and `sc->sc_counter = attacker-chosen 8 bytes` at ip_carp.c:1150-1151)
is gated by `CARP_IS_RUNNING(ifp)` at ip_carp.c:956. `carp_stop()`
(ip_carp.c:2826) clears IFF_RUNNING *in the same chunk* during
destroy, before the free — so the stale image always fails the RUNNING
check (the observed "stopped" branch). Reaching the write requires the
freed 1.83K M_CARP chunk to be re-groomed with IFF_UP|IFF_RUNNING|
IFT_CARP and attacker-chosen HMAC state; but the M_CARP kmalloc zone
only serves carp softcs, so grooming requires another privileged
`ifconfig carp create` (cross-instance reclaim yields at most
cross-instance confusion on a *live* new softc, not a freed-chunk
write). Conclusion: on this kernel the demonstrated primitive is a
deterministic freed-memory read (incl. a 16-byte stale-string
disclosure to console) plus a latent UAF-write that any future
reordering of the teardown sequence re-opens. Not escalated to uid=0;
no panic either (branch is a clean drop).

Threat model: steps 2 and 4 are unauthenticated L2-adjacent packet
sends; step 3 is ordinary admin churn — the attacker fully controls
the inter-fragment timing (deterministic, ms-scale window, repeatable
at will), so reliability of the *read* is 100%.

## Fix validation (mandatory for memcorrupt class)

`fix.diff` = synchronous purge of every reassembly queue holding
fragments whose `m_pkthdr.rcvif == ifp`, dispatched on all netisrs
from `carp_clone_destroy()` before `kfree(sc)`.

- Baseline (stock kernel #0 Jul 2): frag2-after-destroy →
  ipackets 5→6 + "stopped carp interface: carp0" from freed chunk.
- Patched kernel (built in-guest `#1 Sep 10 08:43:37`, `make
  nativekernel` + installkernel + reboot):
  - `run.patched.log`: frag1 queued ("1 fragment received"), destroy
    (M_CARP 0), frag2 → **"0 packets received"**, "1 fragment dropped
    (dup or out of space)" (the purge freed the stale queue; frag2
    starts a fresh incomplete queue), **zero** `carp_proto_input` log
    lines.
  - `run.patched.control.log`: with carp0 alive (no destroy),
    frag1+frag2 → reassembly completes → "1 packet received" +
    "non-mcast packet on interface: carp0" on the LIVE ifnet — the fix
    does not interfere with legitimate fragmented input; carp
    advertisements continue ("7 packets sent").

**fix_status: fixed** (baseline reproduced / patched: bad behavior
gone, correct behavior preserved).

Guest reset to clean source snapshot after validation.

## Path:line index

- ip_carp.c:1219-1233 carp_input / forus reinput (rcvif stamped)
- ip_carp.c:703-726 carp_clone_destroy → kfree(sc) with no reassembly purge
- ip_carp.c:928-1028 carp_proto_input deref chain on rcvif
- ip_carp.c:1107-1151 carp_proto_input_c (writes at 1150-1151)
- ip_input.c:850-865 fragment → ip_reass
- ip_input.c:1004-1012 queue keyed w/o ifnet
- ip_input.c:1275-1287 head pkthdr survives m_cat
- if_ethersubr.c:1304-1333 ether_reinput_oncpu rewrites rcvif
- in_proto.c:317-320 IPPROTO_CARP → carp_proto_input

## 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.
