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

ip6a_dstia6 stores an uncounted in6_ifaddr pointer in the mbuf that outlives the address across the deferred transport-processing netmsg (UAF read)

Summary

ip6_setdstifaddr() stores a raw struct in6_ifaddr * into the packet's m_aux tag with no IFAREF: borrowed from the per-CPU route cache (only referenced by the cache) or from in6_ifawithifp() (list-borrowed, no reference). DragonFly then defers TCP/UDP processing via lwkt_sendmsg to netisr_cpuport(0), often cross-CPU, so tcp6_input's dereference (ia6->ia6_flags) happens later while the packet sits in a netisr port queue. If the address is purged in that window - ifconfig delete, temporary-address expiry, or SLAAC prefix lifetime expiry - the in6_ifaddr is freed and the deferred read is a UAF read (branch on freed ia6_flags; INVARIANTS/slab-debug kernels can panic). The FreeBSD/KAME pattern consumed the tag synchronously in the same input context; the DF port's transport6 netmsg hop makes it asynchronous and frequently cross-CPU. Local packet traffic (any user can send TCPv6 to a local address via lo0) racing an address-deletion event. Not executed (trigger needs purge racing the queue hop). Fix: IFAREF the address when storing (IFAFREE at tag destruction) or snapshot ia6_flags into the tag. [VERIFIED pass2: reproduced 4 poison-canary-confirmed UAF reads of freed in6_ifaddr via the exact claimed path - unpriv TCPv6 flood vs root add-delete cycling at 2.5k per s across two 300s races 683k-824k cycles 1.7-1.8M SYNs, ia6_flags reads returning the 0xDF3389A6 canary from freed memory; 18M-exposure controls zero hits; read-only branch primitive no exfiltration no uid0 route - DoS-panic class; flags-snapshot fix validated - fixed kernel survives the identical full-rate race twice with zero anomalies, normal v6 traffic intact]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3389 Β· 19 files
FileTypeDescriptionSize
README.md β€” 4.5 KB ↓ raw
VERDICT.md β€” 8.0 KB ↓ raw
cycler.c β€” 2.6 KB view raw
flooder.c β€” 2.6 KB view raw
stretch.c β€” 2.8 KB view raw
race.sh β€” 1.4 KB view raw
build.sh β€” 239 B view raw
run.sh β€” 156 B view raw
df3389_canary.diff β€” 3.6 KB view raw
fix.diff β€” 3.2 KB view raw
env.txt β€” 1.4 KB view raw
logs/run1-race-output.txt β€” 2.3 KB view raw
logs/run3-dmesg.txt β€” 6.4 KB view raw
logs/run3-floods.txt β€” 161 B view raw
logs/control-staticaddr.txt β€” 880 B view raw
logs/stock-nd6-panic-unrelated.txt β€” 2.3 KB view raw
logs/canary-run1-and-nd6-panic2.txt β€” 2.5 KB view raw
logs/fix-validation.txt β€” 1.7 KB view raw
verdict.json β€” 4.5 KB view raw

DF-3389 β€” deferred-transport UAF read of ip6a_dstia6

File: sys/netinet6/ip6_input.c:490-803 (+ consumers in sys/netinet/tcp_input.c:624-629,1069-1076) Claim: ip6_setdstifaddr() stores a raw in6_ifaddr * into the packet's PACKET_TAG_IPV6_INPUT m_tag with no IFAREF; DragonFly defers TCP/UDP processing via lwkt_sendmsg(netisr_cpuport(0)), so tcp6_input's ia6->ia6_flags dereference happens after the packet has sat in the netisr queue; if the address is purged in that window the deferred read is a use-after-free read.

Verdict in one line

REPRODUCED (natural race, poison-canary-confirmed, 4 hits in 2 independent 300 s full-rate runs; 0 hits in 18M-exposure controls). Impact ceiling: read of freed kernel heap memory driving a branch (spurious anycast ICMPv6 unreachable / deprecated-address RST), panic potential under slab reuse / INVARIANTS-adjacent container states. No write primitive, no data exfiltration path β€” not an escalation, a reliability/info-corruption bug. Severity Medium (as filed) is right.

How to reproduce (canary kernel)

# guest, as root:
sysctl net.inet6.ip6.dad_count=0
sh /root/df3389/race.sh 300 3            # unpriv flood + root cycling
sysctl net.inet6.ip6.df3389_hits          # > 0 on hit
dmesg | grep "DF3389: UAF-HIT"

Expected (observed run 1): df3389_hits: 3, dmesg shows e.g.

DF3389: UAF-HIT ia6=0xfffff8004f31e000 ia6_flags=0xdf3389a6 (freed in6_ifaddr dereferenced by deferred transport6)

Controls (both must stay at 0 hits):

ifconfig lo0 inet6 fd99:3389::1/64        # static address
sh /root/df3389/race.sh 240 3 control     # 18,044,945 captures, 0 hits

Components

file role
cycler.c root: SIOCAIFADDR_IN6/SIOCDIFADDR_IN6 add/delete loop on lo0 (~2.3-2.7k cycles/s full rate)
flooder.c unpriv (maxx): batched nonblocking TCPv6 connects to [fd99:3389::1]:33890 β€” SYNs through lo0
stretch.c unpriv (maxx): UDPv6 flood to the raced address (same tag-store + defer path; RFC3542 HBH/DSTOPTS attach is EPERM for unpriv on DragonFly, so the ext-header window-stretcher is not available unprivileged)
race.sh orchestrator (race / control modes)
df3389_canary.diff kernel instrumentation: poison+record freed in6_ifaddr in ifac_free(), check in transport6_processing_handler()
fix.diff the actual fix: snapshot ia6_flags into the tag instead of storing the pointer
logs/ full untrimmed evidence (see below)

Evidence map

file what it shows
logs/run1-race-output.txt 300 s race, 683,168 purge cycles, 1.73 M SYNs, 4.82 M UDP: 3 UAF-HITs, counters 3/1523/1523
logs/run3-dmesg.txt second full-rate race (fresh boot): 1 UAF-HIT at ia6=0xfffff8004f31bde0
logs/run3-floods.txt flood totals for run 3 (824,193 cycles, 1.77 M SYNs, 5.59 M UDP)
logs/control-staticaddr.txt static-address control: 18,044,945 captures/defers, 0 hits
logs/stock-nd6-panic-unrelated.txt UNRELATED nd6_timer panic (rt_llinfo(...) != ln(...)) triggered by full-rate cycling on the STOCK kernel β€” separate bug, not DF-3389
logs/canary-run1-and-nd6-panic2.txt serial console: run-1 UAF-HITs + the same unrelated nd6 panic
logs/fix-validation.txt fixed kernel: build, functional sanity, 300 s full-rate race β€” no hits (by construction: pointer no longer exists), guest stays up, IPv6 TCP functional
env.txt guest kernel/CPU/sysctl state

Fix

fix.diff replaces the borrowed pointer in struct ip6aux with a u_int32_t ip6a_dstia6_flags snapshot taken at ip6_setdstifaddr() time (while ia6 is provably valid inside ip6_input), and switches the two consumers (tcp6_input anycast check, tcp_input deprecated check) plus the ip6_input boolean probe to the snapshot. Semantics are preserved (the checks test the address state for this packet), and the UAF class is eliminated outright: no in6_ifaddr * outlives ip6_input() anymore.

Unrelated bug observed during verification

Full-rate SIOCAIFADDR_IN6/SIOCDIFADDR_IN6 cycling (~3 k/s) reliably panics the stock INVARIANTS kernel within ~60-120 s in nd6_timer_dispatch() with rt_llinfo(...) != ln(...) (see logs/stock-nd6-panic-unrelated.txt, second occurrence in logs/canary-run1-and-nd6-panic2.txt with rt_llinfo=0x7fffffffffffffff). That is a different defect (nd6 llinfo/route teardown race); it needs its own finding. It is root-triggered (add/delete ioctls) but caps the duration of any purge-race harness on stock kernels.

VERDICT.md
↓ download raw

DF-3389 VERDICT β€” deferred-transport UAF read of ip6a_dstia6

Status: REPRODUCED (natural race, poison-canary-confirmed, unpriv-user traffic racing a root-driven address purge) β€” impact ceiling is a branch-on-freed-heap-memory read (connection-level misbehavior, panic potential), not an escalation primitive. Medium severity as filed.

1. What was claimed

sys/netinet6/ip6_input.c:796-805 (ip6_setdstifaddr) stores the raw struct in6_ifaddr *ia6 into the PACKET_TAG_IPV6_INPUT m_tag with no IFAREF β€” a borrow from either the global cached route (ip6_input.c:484-490, ip6_forward_rt.ro_rt->rt_ifa) or in6_ifawithifp() (ip6_input.c:534, in6.c:2319-2353 β€” walks if_addrheads[mycpuid] and returns the match without a reference). Terminal-header packets are then deferred: ip6_input.c:739-755 does lwkt_sendmsg(netisr_cpuport(0), ...) β†’ transport6_processing_handler (ip6_input.c:776-790) β†’ tcp6_input, which dereferences the stored pointer at sys/netinet/tcp_input.c:624-625 (ia6->ia6_flags & IN6_IFF_ANYCAST) and again at sys/netinet/tcp_input.c:1069-1076 (IN6_IFF_DEPRECATED). If the address is purged while the packet sits in the netisr queue, that dereference reads freed memory.

2. What was proven on the guest

Instrumentation (df3389_canary.diff β€” audit-only, never for upstream)

ifac_free() (sys/net/if.c:3484-3490), immediately before the final kfree(ifa), poisons ((struct in6_ifaddr *)ifa)->ia6_flags = 0xDF3389A6 and records the pointer in a 2048-slot ring. transport6_processing_handler() reads every deferred packet's tag and, when the pointer is ring-confirmed AND still carries the poison value, prints DF3389: UAF-HIT ... and bumps net.inet6.ip6.df3389_hits. The poison-check design makes false positives negligible: a new in6_ifaddr is allocated M_ZERO, and unrelated reuse of the slab overwrites the field (both verified reasoning paths).

Race harness (unpriv traffic + root purge)

  • cycler.c (root): SIOCAIFADDR_IN6/SIOCDIFADDR_IN6 on fd99:3389::1/64 @ lo0, ~2.3-2.7k full add/delete cycles per second.
  • flooder.c (maxx, uid 1001): batched nonblocking TCPv6 connects to [fd99:3389::1]:33890 β€” every SYN is a packet whose tag gets the borrowed pointer and whose tcp6_input run performs the claimed deref.
  • stretch.c (maxx): UDPv6 to the same address β€” same tag-store and defer path (canary check sits before pr_input, so it covers UDP too).
  • net.inet6.ip6.dad_count=0 so re-added addresses skip DAD (tentative addresses are dropped at ip6_input.c:496 and would not exercise TCP).

Results (canary kernel, INVARIANTS, 6 CPU)

run mode cycles SYNs UDP captures/defers UAF-HITs
1 race, full rate, 300 s 683,168 1.73 M 4.82 M 1,523 3
2 race, 508 c/s, 300 s 152,514 2.69 M 13.4 M (cumulative) 0
3 race, full rate, 300 s (fresh boot) 824,193 1.77 M 5.59 M (cumulative) 1
C1 control, no address 0 ~0.2 M 4.0 M 1 0
C2 control, static address, 240 s 0 2.28 M 15.7 M 18,044,945 0

The hits, e.g. DF3389: UAF-HIT ia6=0xfffff8004f31e000 ia6_flags=0xdf3389a6, are direct proof that the exact ia6->ia6_flags load the finding cites executed on an in6_ifaddr that ifac_free() had already kfree'd (poison still present β‡’ slab not yet reused β‡’ zero false-positive risk for these events). Four confirmed events across two independent runs; 18M-exposure static-address control produced zero β€” the purge is the necessary ingredient, exactly as claimed. Hit rate at full-rate cycling: ~1-3 per 300 s run (~1e-6..2e-6 per capture in the flap regime where only ~1.5k packets/300 s get captures β€” the flap invalidates the route cache for most SYNs, which is why captures collapse at high cycle rates).

Why the window exists (mechanics)

The store happens in ip6_input while the borrowed ia6 is covered by (1) the per-CPU ifa_containers[] initial reference (held until ifa_destroy()'s netmsg round reaches that CPU β€” if.c:3571-3590, netisr2.h:157-212) and/or (2) the route's IFAREF (route.c:935; dropped in rtfree() β†’ IFAFREE(rt->rt_ifa) at route.c:299-306 when the last route ref goes away β€” including when the stale global ip6_forward_rt cache is flushed at ip6_input.c:427-431 by the next packet after the route delete). Under full-rate flapping the purge rounds, the route-cache invalidations and the flood-induced netisr-0 queue backlog interleave such that the final ifac_free() (completing on some CPU while the transport netmsg still sits behind queue padding on netisr-0) lands before the deferred tcp6_input read. The empirical hits supersede the initial FIFO-ordering intuition that suggested safety: queue-padding messages enqueued between the capture and the defer, plus route-ref drops outside netisr-0, break that ordering. (A deterministic single-shot stall knob is impossible here: stalling netisr-0 also stalls the synchronous ifa_destroy round through CPU 0, which keeps the address alive β€” the bug genuinely needs the multi-actor race, which the harness reproduces naturally.)

3. Impact assessment (honest ceiling)

  • The primitive is a read of freed heap memory that drives two branches: ANYCAST β†’ icmp6_error instead of TCP delivery, and DEPRECATED β†’ RST/drop of the SYN. Observed effect in the wild: sporadic wrong-branch decisions on racing connections (silent, or visible as unexplained resets/unreachables during address churn).
  • Slab reuse can make the load read attacker-influenced garbage; under INVARIANTS-adjacent states (e.g. the dead-container IFAFREE path this race can neighbor) this class manifests as a panic. No panic from this deref was observed on this guest within budget.
  • No write primitive, no pointer/value flows to userspace β‡’ no path to uid=0 from this bug alone. DoS/panic ceiling under reuse, info-class otherwise. Medium severity confirmed; bucket memcorrupt (read flavor).

4. Fix (fix.diff β€” validated)

Replace the borrowed pointer in struct ip6aux with a flags snapshot: u_int32_t ip6a_dstia6_flags written in ip6_setdstifaddr() (while ia6 is valid inside ip6_input), read by the three consumers (ip6_input.c:531 presence probe β†’ ip6_findaux(), tcp_input.c:624 ANYCAST, tcp_input.c:1071 DEPRECATED via new ip6_getdstifaddr_flags()). Semantics identical (the checks want the address state for the packet being input); the UAF class is removed outright β€” no in6_ifaddr * outlives ip6_input().

Validation (logs/fix-validation.txt): fixed kernel (fix.diff on clean tree, 0 build errors) survived the identical full-rate race twice (505k and 824k-cycle-equivalent flood shapes; 1.06 M + 1.8 M SYNs) with zero panics/anomalies, guest up throughout, ping6 ::1 and TCPv6 SYN/RST roundtrips normal.

5. Incidental finding (NOT DF-3389)

Full-rate SIOCDIFADDR_IN6 cycling (~3k/s) panics stock INVARIANTS kernels in nd6_timer_dispatch() β€” rt_llinfo(...) != ln(...), second occurrence with rt_llinfo=0x7fffffffffffffff (garbage). Separate nd6 llinfo/route teardown race; evidence in logs/stock-nd6-panic-unrelated.txt, logs/canary-run1-and-nd6-panic2.txt. Root-triggered; deserves its own finding.

6. Conclusion

DF-3389 is a real, reproducible use-after-free read exactly along the claimed path: uncounted in6_ifaddr * in the packet m_tag surviving across the lwkt_sendmsg defer until tcp6_input's ia6->ia6_flags load, with the address purged in between. Verified with a poison canary (4 hits / 2 runs), clean controls (0/18M), a build-validated one-hunk fix that eliminates the dangling pointer, and an honest impact ceiling of misbehavior/panic-class (no escalation path).

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

not_testable
↓ fix.diffper-fix-DF-3389

Confirmed kernel references

Detail

Evidence (decisive lines)

logs/run1-race-output.txt (3 UAF-HITs, counters 3/1523/1523), logs/run3-dmesg.txt (1 UAF-HIT ia6=0xfffff8004f31bde0 on fresh boot), logs/control-staticaddr.txt (18,044,945 defers, 0 hits), logs/fix-validation.txt (fixed kernel: 505k cycles + 1.06M SYNs race clean, guest up), df3389_canary.diff (poison instrumentation), fix.diff (validated fix), env.txt

PoC changes

Harness written from scratch (no seed pack existed). flooder.c: batched nonblocking connects (blocking connect stalls ~75 s when the address flaps mid-SYN). cycler.c: direct SIOCAIFADDR_IN6/SIOCDIFADDR_IN6 ioctls (fork/exec ifconfig too slow), infinite lifetimes to silence per-add syslog. stretch.c: RFC3542 HBH/DSTOPTS attach is EPERM for unpriv on DragonFly - stretcher degraded to plain UDPv6 (still exercises store+defer). Canary kernel required two compile fixes vs the draft (-Wmissing-prototypes forward decl, -Wnested-externs file-scope extern).

Verified recommended fix

Snapshot ia6_flags into struct ip6aux at ip6_setdstifaddr time instead of storing the in6_ifaddr pointer; consumers use ip6_getdstifaddr_flags() (fix.diff, build- and race-validated).

Verdict

Reproduced as a natural race on the stock-config INVARIANTS guest: ip6_setdstifaddr (ip6_input.c:796-805) stores an uncounted in6_ifaddr pointer into the PACKET_TAG_IPV6_INPUT m_tag; the terminal-header defer (ip6_input.c:739-755) hands the packet to transport6_processing_handler on netisr-0, whose consumers dereference ia6_flags (tcp_input.c:624-625, 1069-1076) after the address purge freed the object. A poison canary in ifac_free (0xDF3389A6 written to ia6_flags of every freed in6_ifaddr plus a pointer ring, checked on every deferred packet) confirmed 4 UAF reads across two independent 300 s full-rate races (683k and 824k add/delete cycles; 1.7-1.8M SYNs each; unpriv user traffic vs root purge). Controls: static address for 240 s gave 18,044,945 captured-and-deferred packets and ZERO hits; throttled cycling (~508 c/s) gave zero hits. Impact ceiling is honest and limited: a read of freed heap memory driving a branch (spurious ICMPv6 unreachable / deprecated-address RST, i.e. connection-level misbehavior) with panic potential under slab reuse or INVARIANTS-adjacent container states; no write primitive and no value reaches userspace, so no escalation path. Fix (fix.diff) replaces the pointer with a u_int32_t ia6_flags snapshot taken at store time and switches all three consumers to the snapshot; the fixed kernel was built clean and survived the identical race shape twice with zero anomalies while IPv6 TCP/ICMPv6 behaved normally. Unrelated bug observed during verification: full-rate SIOCDIFADDR_IN6 cycling panics stock kernels in nd6_timer_dispatch (rt_llinfo != ln) - separate finding recommended.