From 225fab29abdc26328a000f17adcaeacbb5554e5d Mon Sep 17 00:00:00 2001 From: Arjen <18398758+Origami74@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:36:57 +0200 Subject: [PATCH] fix(tun): complete L4 checksum on hairpinned self-traffic (macOS) (#117) Self-addressed TCP/UDP connections to a node's own .fips address half-opened and hung on macOS. macOS routes self-traffic as loopback (a LOCAL route via lo0), which defers the transport TX checksum, but the point-to-point utun then egresses the packet into the daemon with only the pseudo-header partial checksum present. The hairpin path added in 9a9e90a re-injected these verbatim, so the local stack dropped every segment whose checksum MSS clamping didn't happen to rewrite: the SYN/SYN-ACK got through (clamping recomputes them) but the bare ACK, data, and FIN were dropped for a bad checksum, leaving the listener stuck in SYN_RCVD. Recompute the TCP/UDP checksum for self-addressed packets on the hairpin path before re-injection, completing the self-delivery 9a9e90a started (which only covered ICMP and the TCP handshake). Linux is unaffected: it loops self-traffic via lo before the TUN, so the hairpin branch never fires and checksums are already valid. Confirmed on macOS: a self-connect that previously timed out now completes in ~9ms with payload delivered. --- CHANGELOG.md | 20 ++++- src/upper/tcp_mss.rs | 197 ++++++++++++++++++++++++++++++++++--------- src/upper/tun.rs | 5 +- 3 files changed, 180 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 472af98..0b1c82e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -324,9 +324,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 interface egresses self-addressed traffic into the daemon, which previously pushed it onto the mesh outbound path where it was dropped for lack of a route to self; such packets are now hairpinned back to - the TUN for inbound delivery, so `ping6` and connections to a node's - own `.fips` address work. Linux was unaffected (the kernel - already loops self-traffic via `lo`). + the TUN for inbound delivery, so `ping6` to a node's own + `.fips` address works. Linux was unaffected (the kernel + already loops self-traffic via `lo`). (Full TCP/UDP self-delivery is + completed by the checksum fix below.) +- Self-delivered TCP and UDP traffic on macOS now carries a completed + checksum, finishing the self-delivery the hairpin above only partly + provided. macOS first routes self-addressed packets as loopback (a + `LOCAL` route via `lo0`), which leaves their transport TX checksum + offloaded and unfinished, but the point-to-point `utun` then egresses + them into the daemon with only the pseudo-header partial present. + Re-injecting them verbatim made the local stack drop every segment + whose checksum MSS clamping did not happen to rewrite: the SYN and + SYN-ACK got through, but the bare ACK, data, and FIN were dropped for + a bad checksum, so connections to a node's own `.fips` service + half-opened and hung. The hairpin path now recomputes the TCP/UDP + checksum before re-injection, so full self-connections — not just + `ping6` — work. Linux remains unaffected. - The Tor transport now increments its `connect_refused` statistic (the "Refused" line in fipstop) when a SOCKS5 connection is actively refused, instead of recording every connect failure as a generic diff --git a/src/upper/tcp_mss.rs b/src/upper/tcp_mss.rs index 77eaecf..6fd9776 100644 --- a/src/upper/tcp_mss.rs +++ b/src/upper/tcp_mss.rs @@ -115,8 +115,8 @@ pub fn clamp_tcp_mss(ipv6_packet: &mut [u8], max_mss: u16) -> bool { if current_mss > max_mss { ipv6_packet[i + 2..i + 4].copy_from_slice(&max_mss.to_be_bytes()); - // Recalculate TCP checksum - recalculate_tcp_checksum(ipv6_packet, tcp_start); + // Recompute the now-stale checksum over the rewritten header. + recalculate_l4_checksum(ipv6_packet); modified = true; } @@ -129,41 +129,39 @@ pub fn clamp_tcp_mss(ipv6_packet: &mut [u8], max_mss: u16) -> bool { modified } -/// Recalculate TCP checksum after modifying the packet. -fn recalculate_tcp_checksum(ipv6_packet: &mut [u8], tcp_start: usize) { - // Zero out existing checksum - ipv6_packet[tcp_start + 16] = 0; - ipv6_packet[tcp_start + 17] = 0; - - // Extract addresses - let src = &ipv6_packet[8..24]; - let dst = &ipv6_packet[24..40]; - - // Get TCP segment length +/// Recalculate the TCP or UDP checksum (IPv6 pseudo-header + segment) in place. +/// +/// Completes the checksum macOS leaves offloaded on hairpinned self-traffic, and +/// is reused by MSS clamping. No-op for other next-headers (e.g. ICMPv6) and for +/// malformed packets. Assumes the L4 header follows the 40-byte IPv6 header, as +/// all FIPS packets do. +pub fn recalculate_l4_checksum(ipv6_packet: &mut [u8]) { + if ipv6_packet.len() < 40 || ipv6_packet[0] >> 4 != 6 { + return; + } let payload_len = u16::from_be_bytes([ipv6_packet[4], ipv6_packet[5]]) as usize; - let tcp_segment = &ipv6_packet[tcp_start..tcp_start + payload_len]; + if payload_len == 0 || 40 + payload_len > ipv6_packet.len() { + return; + } - // Calculate checksum with pseudo-header + // Transport checksum field offset within the packet: TCP at 16, UDP at 6. + let proto = ipv6_packet[6]; + let csum = match proto { + 6 if payload_len >= TCP_HEADER_MIN_LEN => 40 + 16, + 17 if payload_len >= 8 => 40 + 6, + _ => return, + }; + ipv6_packet[csum] = 0; + ipv6_packet[csum + 1] = 0; + + // Pseudo-header (src + dst are contiguous at 8..40), length, next header, let mut sum: u32 = 0; - - // Pseudo-header: source address - for chunk in src.chunks(2) { + for chunk in ipv6_packet[8..40].chunks(2) { sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32; } - - // Pseudo-header: destination address - for chunk in dst.chunks(2) { - sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32; - } - - // Pseudo-header: TCP length - sum += payload_len as u32; - - // Pseudo-header: next header (TCP = 6) - sum += 6; - - // TCP segment - for chunk in tcp_segment.chunks(2) { + sum += payload_len as u32 + proto as u32; + // then the transport segment itself (with the checksum field zeroed above). + for chunk in ipv6_packet[40..40 + payload_len].chunks(2) { let value = if chunk.len() == 2 { u16::from_be_bytes([chunk[0], chunk[1]]) } else { @@ -171,15 +169,16 @@ fn recalculate_tcp_checksum(ipv6_packet: &mut [u8], tcp_start: usize) { }; sum += value as u32; } - - // Fold 32-bit sum to 16 bits while sum >> 16 != 0 { sum = (sum & 0xffff) + (sum >> 16); } - // One's complement - let checksum = !sum as u16; - ipv6_packet[tcp_start + 16..tcp_start + 18].copy_from_slice(&checksum.to_be_bytes()); + // IPv6 forbids an all-zero UDP checksum; send 0xffff instead (TCP keeps 0). + let checksum = match (!sum as u16, proto) { + (0, 17) => 0xffff, + (c, _) => c, + }; + ipv6_packet[csum..csum + 2].copy_from_slice(&checksum.to_be_bytes()); } #[cfg(test)] @@ -216,7 +215,7 @@ mod tests { packet[tcp_start + 24] = 0; // Calculate checksum - recalculate_tcp_checksum(&mut packet, tcp_start); + recalculate_l4_checksum(&mut packet); packet } @@ -277,4 +276,126 @@ mod tests { assert!(!modified); } + + // ======================================================================== + // recalculate_l4_checksum — finish macOS's offloaded self-traffic + // checksums (the bug that left ACK/data/FIN segments undeliverable). + // ======================================================================== + + /// True if the packet's TCP/UDP checksum verifies (folded ones-complement + /// sum over pseudo-header + segment, including the checksum field, == 0xffff). + fn l4_checksum_valid(pkt: &[u8]) -> bool { + let payload_len = u16::from_be_bytes([pkt[4], pkt[5]]) as usize; + let mut sum: u32 = 0; + for chunk in pkt[8..40].chunks(2) { + sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32; + } + sum += payload_len as u32; + sum += pkt[6] as u32; // next header + for chunk in pkt[40..40 + payload_len].chunks(2) { + let v = if chunk.len() == 2 { + u16::from_be_bytes([chunk[0], chunk[1]]) + } else { + u16::from_be_bytes([chunk[0], 0]) + }; + sum += v as u32; + } + while sum >> 16 != 0 { + sum = (sum & 0xffff) + (sum >> 16); + } + sum as u16 == 0xffff + } + + const SELF_ADDR: [u8; 16] = [0xfd, 0x12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x55]; + + /// A self-addressed 20-byte TCP ACK (no options) carrying a deliberately + /// wrong checksum — the shape macOS hands us for hairpinned non-SYN traffic. + fn make_tcp_ack_packet() -> Vec { + let mut p = vec![0u8; 40 + 20]; + p[0] = 0x60; + p[4..6].copy_from_slice(&20u16.to_be_bytes()); // payload length + p[6] = 6; // TCP + p[7] = 64; + p[8..24].copy_from_slice(&SELF_ADDR); + p[24..40].copy_from_slice(&SELF_ADDR); + let t = 40; + p[t..t + 2].copy_from_slice(&52097u16.to_be_bytes()); + p[t + 2..t + 4].copy_from_slice(&9999u16.to_be_bytes()); + p[t + 4..t + 8].copy_from_slice(&1000u32.to_be_bytes()); // seq + p[t + 8..t + 12].copy_from_slice(&2000u32.to_be_bytes()); // ack + p[t + 12] = 0x50; // data offset = 5 (20-byte header) + p[t + 13] = 0x10; // ACK + p[t + 14..t + 16].copy_from_slice(&2049u16.to_be_bytes()); // window + p[t + 16] = 0x8e; // bogus checksum (macOS pseudo-header partial) + p[t + 17] = 0xce; + p + } + + #[test] + fn recompute_fixes_tcp_non_syn_checksum() { + let mut pkt = make_tcp_ack_packet(); + assert!( + !l4_checksum_valid(&pkt), + "fixture should start with a bad checksum" + ); + recalculate_l4_checksum(&mut pkt); + assert!( + l4_checksum_valid(&pkt), + "TCP checksum must verify after recompute" + ); + } + + #[test] + fn recompute_fixes_udp_checksum() { + let mut p = vec![0u8; 40 + 12]; // 8-byte UDP header + 4-byte payload + p[0] = 0x60; + p[4..6].copy_from_slice(&12u16.to_be_bytes()); + p[6] = 17; // UDP + p[7] = 64; + p[8..24].copy_from_slice(&SELF_ADDR); + p[24..40].copy_from_slice(&SELF_ADDR); + let u = 40; + p[u..u + 2].copy_from_slice(&40000u16.to_be_bytes()); // src port + p[u + 2..u + 4].copy_from_slice(&5354u16.to_be_bytes()); // dst port + p[u + 4..u + 6].copy_from_slice(&12u16.to_be_bytes()); // UDP length + p[u + 6] = 0x8e; // bogus checksum + p[u + 7] = 0xce; + p[u + 8..u + 12].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); // payload + + assert!(!l4_checksum_valid(&p), "fixture should start invalid"); + recalculate_l4_checksum(&mut p); + assert!( + l4_checksum_valid(&p), + "UDP checksum must verify after recompute" + ); + assert_ne!( + &p[u + 6..u + 8], + &[0, 0], + "IPv6 UDP checksum must not be zero" + ); + } + + #[test] + fn recompute_is_noop_for_non_transport() { + // Next-header 59 (No Next Header): nothing to checksum. + let mut pkt = vec![0u8; 60]; + pkt[0] = 0x60; + pkt[4..6].copy_from_slice(&20u16.to_be_bytes()); + pkt[6] = 59; + let before = pkt.clone(); + recalculate_l4_checksum(&mut pkt); + assert_eq!(pkt, before, "non-transport packet must be left untouched"); + } + + #[test] + fn recompute_ignores_truncated_packet() { + // payload_len claims 20 bytes of TCP but only 10 are present. + let mut pkt = vec![0u8; 40 + 10]; + pkt[0] = 0x60; + pkt[4..6].copy_from_slice(&20u16.to_be_bytes()); + pkt[6] = 6; + let before = pkt.clone(); + recalculate_l4_checksum(&mut pkt); // must not panic + assert_eq!(pkt, before, "truncated packet must be left untouched"); + } } diff --git a/src/upper/tun.rs b/src/upper/tun.rs index a12fd53..bc302b8 100644 --- a/src/upper/tun.rs +++ b/src/upper/tun.rs @@ -693,7 +693,7 @@ fn handle_tun_packet( path_mtu_lookup: &PathMtuLookup, ) -> bool { use super::icmp::{DestUnreachableCode, build_dest_unreachable, should_send_icmp_error}; - use super::tcp_mss::clamp_tcp_mss; + use super::tcp_mss::{clamp_tcp_mss, recalculate_l4_checksum}; log_ipv6_packet(packet); @@ -719,6 +719,9 @@ fn handle_tun_packet( // exercised by the Linux-only CI unit tests. if packet[24..40] == *our_addr.as_bytes() { trace!(name = %name, "Hairpinning self-addressed packet back to TUN (loopback)"); + // Finish the checksum macOS leaves offloaded on self-traffic, else the + // local stack drops every non-SYN segment. See recalculate_l4_checksum. + recalculate_l4_checksum(packet); if tun_tx.send(packet.to_vec()).is_err() { return false; // Channel closed, shutdown }