Use-after-free in carp_proto_input: IP reassembly holds dangling rcvif into freed carp_softc across ifconfig carpN destroy
Summary
An unauthenticated L2-adjacent attacker sends fragment #1 of an IP proto-112 datagram to the CARP unicast MAC while a carp interface is MASTER: carp_forus() claims it and ether_reinput_oncpu() stamps m_pkthdr.rcvif to the carp ifnet (embedded in the carp_softc); ip_reass() queues it keyed only by (id,src,dst,proto) with no ifnet coupling. After any later 'ifconfig carpN destroy' frees the softc (nothing purges reassembly queues), the attacker's fragment #2 completes reassembly on the parent NIC; the head fragment's dangling rcvif survives m_cat and the datagram dispatches into carp_proto_input(), which dereferences the freed ifnet/softc. REPRODUCED deterministically 6/6 on guest: M_CARP in-use == 0 while carps_ipackets increments and dmesg prints the dead ifnet's name read from freed memory. The deeper attacker-controlled 8-byte write (sc_counter, :1150) is code-reachable but gated: carp_stop() clears IFF_RUNNING pre-free and the type-tagged M_CARP zone cannot be groomed by unpriv allocations - documented hard blocker, no uid=0; latent UAF write that any teardown-reordering re-opens. Fix validated (sync reassembly purge on all netisrs before kfree; live-carp fragmented input preserved). Known DF-0301..0305 all still unfixed (residuals noted).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3298 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.1 KB | β raw | |
| VERDICT.md | β | 6.3 KB | β raw | |
| frag_send.c | β | 7.7 KB | view raw | |
| setstate.c | β | 1.1 KB | view raw | |
| mjoin.c | β | 869 B | view raw | |
| setup.sh | β | 722 B | view raw | |
| run.sh | β | 105 B | view raw | |
| build.sh | β | 66 B | view raw | |
| fix.diff | β | 3.0 KB | view raw | |
| setup.log | β | 655 B | view raw | |
| run.log | β | 2.9 KB | view raw | |
| run.repeat.log | β | 529 B | view raw | |
| run.final.log | β | 930 B | view raw | |
| run.patched.log | β | 303 B | view raw | |
| run.patched.control.log | β | 264 B | view raw | |
| env.txt | β | 620 B | view raw | |
| verdict.json | β | 4.3 KB | view raw |
DF-3298 β CARP softc use-after-free via deferred IP reassembly (dangling m_pkthdr.rcvif)
What
carp_proto_input() can be made to execute on a freed carp_softc
(the carp(4) ifnet is embedded in the softc) after the carp interface has
been destroyed, because IP fragments queued for reassembly carry
m_pkthdr.rcvif pointing at the ifnet with no lifetime coupling to
interface teardown.
Why (root cause)
carp_input()(sys/netinet/ip_carp.c:1219) claims frames for the CARP unicast MAC viacarp_forus()and re-inputs them on the carp ifnet;ether_reinput_oncpu()(sys/net/if_ethersubr.c:1317-1321) stampsm_pkthdr.rcvif = &sc->sc_if.- An attacker-chosen IP-fragmented proto-112 datagram gets queued in
the per-CPU reassembly queue (sys/netinet/ip_input.c:850-865,
ip_reass()), keyed only by (id, src, dst, proto) (ip_input.c:1005-1008) β not by ifnet. The queue can outlive the carp interface by an attacker-chosen time (last fragment sent later). carp_clone_destroy()(ip_carp.c:703-726) frees the softc/ifnet withkfree(sc, M_CARP)β nothing purges reassembly queues.- The final fragment arrives on the parent (tap0/vtnet0), completes
reassembly β the head fragment's pkthdr (rcvif = freed carp ifnet)
survives
m_cat()(ip_input.c:1275-1287) β and the datagram is dispatched tocarp_proto_input()which dereferences the freed ifnet/softc.
Trigger (unauthenticated, L2-adjacent)
Fragment #1 sent to the CARP unicast MAC (00:00:5e:00:01:vhid β public,
it is the answered ARP MAC of the virtual IP) while carp0 is MASTER;
ifconfig carp0 destroy (normal admin churn) happens at any later time;
fragment #2 completes reassembly afterwards. No CARP key is needed to
reach the freed-memory dereferences (vhid/version/HMAC gates sit behind
them).
Build / Run (guest, as root)
sh build.sh # cc -O2 -o frag_send frag_send.c; setstate; mjoin sh setup.sh # tap0 10.0.0.1/24; carp0 vhid 1 pass pocpass; force MASTER sh run.sh # variant A (UAF read) + variant B (valid CARP header)
Expected (stock kernel) β what "reproduced" looks like
M_CARP in-use after destroy: 0 # softc IS freed
/dev/tap0: wrote 42 bytes (variant A2) # post-destroy frag2
carpstats after A: ... 1 packet received (IPv4) # carp_proto_input ran
dmesg: carp_proto_input: packet received on stopped carp interface: carp0
# if_xname read from freed chunk
vmstat -m | grep CARP showing 0 allocations while the CARP input path
still runs and prints the dead interface's name is the decisive
evidence: the code is operating on freed M_CARP memory
(ip_carp.c:940-971 deref ifp->if_type, ifp->if_flags,
ifp->if_softc, sc->sc_carpdev, cifp->if_xname).
Honest limits (why no uid=0)
The deeper write path (sc->sc_init_counter = 0; sc->sc_counter =
tmp_counter; at ip_carp.c:1150-1151 β 8 attacker-controlled bytes into
the freed 1.83K chunk) is currently gated: carp_stop()
(ip_carp.c:2826) clears IFF_RUNNING in the same chunk before the
free, so the stale image fails CARP_IS_RUNNING() (the "stopped"
branch observed in dmesg). Reaching the write requires the freed chunk
to be re-groomed with IFF_UP|IFF_RUNNING|IFT_CARP and a matching
vhid/HMAC-state β but the M_CARP zone only serves carp softcs, so
grooming requires another privileged ifconfig carp create. On today's
kernel the primitive is therefore a deterministic UAF read (flags
bytes + 16-byte stale if_xname disclosure to the console/log), plus a
latent UAF write that any future reordering of the teardown flags would
re-open. See VERDICT.md.
Fix
fix.diff β purge all reassembly queues referencing the ifnet
(synchronously, on every netisr) from carp_clone_destroy() before
kfree(sc). Validated on a patched kernel: post-destroy frag2 no
longer reaches carp_proto_input (see VERDICT.md, fix section).
Generic recommendation (out of this diff's scope): the same dangling
rcvif exists for any destroyed ifnet with queued fragments; the purge
belongs in if_detach() for all drivers.
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:
setup.sh: tap0 10.0.0.1/24; carp0 vhid 1 pass pocpass 10.0.0.100/24, forced MASTER.vmstat -mshowsCARP 4 1.83Kin use.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()stampsm_pkthdr.rcvif = &sc->sc_if(if_ethersubr.c:1317-1321).ip_inputqueues 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".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).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 β survivesm_cat()(ip_input.c:1275-1287). Transport dispatch runscarp_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), zerocarp_proto_inputlog 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.
Fix verification
fixedfix.diff purges per-CPU IP reassembly queues referencing the dying ifnet (new ip_reass_purge_ifnet dispatched to every netisr) before the softc kfree. Reviewer-validated in-guest: baseline freed-chunk read gone on the patched kernel while live-carp fragmented input still works. Note: reviewer's verdict.json omitted the fix_* keys; this sidecar is orchestrator-reconstructed from VERDICT.md/README/run.final.log.
['findings/poc/DF-3298/fix.diff', 'findings/poc/DF-3298/VERDICT.md', 'findings/poc/DF-3298/run.final.log']
Confirmed kernel references
Detail
Exploit chain
L2 attacker sends fragment#1 (offset 0, MF) to the public CARP unicast MAC 00:00:5e:00:01:vhid while carp0 is MASTER -> carp_forus claims it, ether_reinput_oncpu stamps rcvif=carp0-ifnet, ip_reass queues it (queue keyed only by id/src/dst/proto); admin runs 'ifconfig carp0 destroy' (kfree of softc+ifnet, M_CARP in-use 0); attacker sends fragment#2 which completes reassembly on the parent NIC -> head pkthdr (dangling rcvif) survives m_cat -> carp_proto_input dereferences freed memory. Demonstrated: stale reads + console disclosure of freed-chunk if_xname. Write path (sc_counter, 8 controlled bytes) blocked by pre-free IFF_RUNNING clearing + M_CARP zone isolation (documented hard blocker, path:line in VERDICT.md).
Evidence (decisive lines)
["run.final.log: BEFORE '5 packets received' -> destroy (M_CARP in-use: 0) -> frag2 -> '6 packets received' on freed softc", "run.final.log + run.repeat.log: dmesg 'carp_proto_input: packet received on stopped carp interface: carp0' - if_xname read from freed chunk, 3/3 stress runs", "run.patched.log: fix-validated kernel - '0 packets received', '1 fragment dropped' (purge), 0 carp_proto_input log lines", 'run.patched.control.log: live carp0 control still receives the reassembled datagram (fix does not break legit input)', 'VERDICT.md path:line index']
PoC changes
Seed sketch had no runnable source; wrote frag_send.c (tap-injected raw frames, variants A1/A2/B1/B2 with kernel-exact CARP HMAC), setstate.c (SIOCSVH state force, read-modify-write to preserve key), mjoin.c (group join for variant B), setup.sh/run.sh. tap(4) used instead of BPF because BPF writes transmit rather than inject-as-received; tapwrite feeds the real ether_input path.
Verified recommended fix
Purge all IP reassembly queues holding fragments with m_pkthdr.rcvif == destroyed ifnet (synchronously on every netisr) before freeing the carp softc - see fix.diff; generically this purge belongs in if_detach() for all ifnets.
Verdict
Deterministic use-after-free in carp_proto_input(): an IP-fragmented proto-112 datagram whose first fragment was re-input on a carp(4) interface (rcvif stamped to the ifnet embedded in the carp_softc) completes reassembly AFTER ifconfig carp0 destroy; nothing purges the per-CPU reassembly queues at ifnet free, so carp_proto_input() executes on the freed chunk (if_type/if_flags/if_softc derefs; if_xname printed to console from freed memory) with vmstat -m proving 0 M_CARP allocations at that moment. The deeper attacker-controlled 8-byte write (sc->sc_counter, ip_carp.c:1150-1151) is code-reachable but blocked on the stock kernel because carp_stop() clears IFF_RUNNING in the same chunk pre-free; M_CARP zone type-isolation (only carp softcs reuse the chunk) prevents unprivileged grooming, so no uid=0 today - impact is a deterministic freed-memory read incl. stale-string disclosure, remotely triggerable by an L2-adjacent unauthenticated attacker racing ordinary admin destroy churn. fix.diff (synchronous reassembly-queue purge on all netisrs from carp_clone_destroy before kfree) authored and validated on a guest-built kernel: post-destroy fragment no longer reaches carp_proto_input (0 packets received vs 5->6 baseline), while fragmented input to a live carp interface still works.
No comments yet.