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)
PoC verification
Evidence pack
findings/poc/DF-3389 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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_IN6onfd99: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 whosetcp6_inputrun performs the claimed deref.stretch.c(maxx): UDPv6 to the same address β same tag-store and defer path (canary check sits beforepr_input, so it covers UDP too).net.inet6.ip6.dad_count=0so re-added addresses skip DAD (tentative addresses are dropped atip6_input.c:496and 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_errorinstead 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
IFAFREEpath 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=0from 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_testableConfirmed kernel references
- sys/netinet6/ip6_input.c:490
- sys/netinet6/ip6_input.c:534-536
- sys/netinet6/ip6_input.c:739-755
- sys/netinet6/ip6_input.c:776-790
- sys/netinet6/ip6_input.c:796-805
- sys/netinet6/ip6_var.h:271
- sys/netinet/tcp_input.c:624-629
- sys/netinet/tcp_input.c:1069-1076
- sys/netinet6/in6.c:2304-2353
- sys/netinet6/in6.c:1290-1337
- sys/net/if.c:3466-3491
- sys/net/if.c:3520-3590
- sys/net/route.c:285-309
- sys/net/netisr2.h:84-128
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.
No comments yet.