From 90a2c6499feb0085a6384f3a8c8d19cddaddc4d5 Mon Sep 17 00:00:00 2001 From: Loic Lambiel Date: Fri, 12 Jun 2026 00:00:00 +0000 Subject: [PATCH] tcp/ipv6: re-resolve route after binding ephemeral source port in tcp_v6_connect() tcp_v6_connect() resolves the route while fl6 is still incomplete: the source address has not been selected yet (fl6.saddr == np->saddr == ::, the lookup fills it in afterwards) and the ephemeral source port has not been bound (fl6.fl6_sport == 0, inet6_hash_connect() assigns it later). The resulting dst is cached on the socket via ip6_dst_store() and, unlike tcp_v4_connect() (which re-resolves via ip_route_newports()), is never refreshed. The IPv6 multipath hash uses these fields: with net.ipv6.fib_multipath_hash_policy=1 the ECMP next hop depends on the source port, and with policy=0 it depends on the source address (and flow label). Either way the next hop is selected from the incomplete tuple (saddr ::, sport 0), which in general differs from the one the connection's real 5-tuple hashes to. This stays hidden while the cached dst remains valid, but cached IPv6 dst entries are revalidated against the FIB node cookie: when the dst is invalidated (e.g. by unrelated routing churn in the same table) the socket re-resolves the route -- now with the real source address and bound source port (inet6_csk_route_socket() uses np->saddr and inet->inet_sport) -- and the established flow silently moves to a different ECMP next hop mid-connection. This is benign for ordinary unicast multipath, where all next hops reach the same destination, but it breaks configurations where per-next-hop state matters -- e.g. an anycast destination that each next hop NATs to a different backend keeping per-path connection state: the connection migrates to a next hop that has no state for it and is reset. Reproducible with both fib_multipath_hash_policy values; present on current mainline. Fix it the same way IPv4 does: once the ephemeral source port has been bound, re-resolve the route. By this point the source address has already been selected and written back into fl6.saddr, so re-resolving with the bound source port pins the connection to the correct ECMP path for its whole lifetime under either hash policy. The lookup is only repeated when the source port actually changed (i.e. it was not already bound before connect()). The behaviour is observable with bpftrace by comparing the next hop chosen by fib6_select_path() with the gateway of the dst transmitted on in ip6_finish_output2(): before this change, under FIB churn, a flow whose selection is stable on one next hop is seen transmitting on a *different* next hop until its dst is invalidated, then jumping to the selected one and resetting. Fixes: 51ebd3181572 ("ipv6: add support of equal cost multipath (ECMP)") Signed-off-by: Loic Lambiel --- net/ipv6/tcp_ipv6.c | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/net/ipv6/tcp_ipv6.c b/net/ipv6/tcp_ipv6.c index 952bf49efbc6ad..7a6bce3865e0f6 100644 --- a/net/ipv6/tcp_ipv6.c +++ b/net/ipv6/tcp_ipv6.c @@ -315,6 +315,28 @@ static int tcp_v6_connect(struct sock *sk, struct sockaddr *uaddr, sk_set_txhash(sk); + /* The route was resolved before fl6 was complete: the source address + * was unselected (::) and the source port unbound (0). Both are now + * final -- the earlier lookup selected the source (written back to + * fl6.saddr) and inet6_hash_connect() just bound the port. The IPv6 + * multipath hash uses the source port (policy=1) or the source + * address (policy=0), so the next hop picked from the incomplete + * tuple may differ from the connection's real one, and the flow would + * migrate on dst invalidation. Re-resolve with the bound port -- as + * tcp_v4_connect() does via ip_route_newports() -- to pin the flow to + * the correct path. Only repeat the lookup if the port changed. + */ + if (fl6.fl6_sport != inet->inet_sport) { + fl6.fl6_sport = inet->inet_sport; + security_sk_classify_flow(sk, flowi6_to_flowi_common(&fl6)); + dst = ip6_dst_lookup_flow(net, sk, &fl6, final_p); + if (IS_ERR(dst)) { + err = PTR_ERR(dst); + goto late_failure; + } + ip6_dst_store(sk, dst, NULL, NULL); + } + if (likely(!tp->repair)) { union tcp_seq_and_ts_off st;