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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3298 Β· 17 files
FileTypeDescriptionSize
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)

  1. carp_input() (sys/netinet/ip_carp.c:1219) claims frames for the CARP unicast MAC via carp_forus() and re-inputs them on the carp ifnet; ether_reinput_oncpu() (sys/net/if_ethersubr.c:1317-1321) stamps m_pkthdr.rcvif = &sc->sc_if.
  2. 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).
  3. carp_clone_destroy() (ip_carp.c:703-726) frees the softc/ifnet with kfree(sc, M_CARP) β€” nothing purges reassembly queues.
  4. 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 to carp_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.

VERDICT.md
↓ download raw

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1 (in-guest rebuild with fix.diff, per VERDICT.md 'Fix validation' + 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.