From f67802967dfc4855f84145e466c7b494172e7d18 Mon Sep 17 00:00:00 2001 From: Red Bear OS Date: Mon, 27 Jul 2026 16:37:04 +0900 Subject: [PATCH] netstack: fix P001 IPv6 ext header firewall bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL from NETWORKING-AND-DRIVERS-CODE-ASSESSMENT-2026-07-27.md §3.1: netstack/src/router/mod.rs:528-545 used a fixed 40-byte offset for IPv6 transport-layer payload extraction. Any IPv6 packet with extension headers (Hop-by-Hop, Routing, Fragment, Destination, ESP, AH) caused parse_ports() to read the wrong bytes and return None,None. A firewall rule with a port predicate (--sport/--dport) would silently fail to match on such packets — a firewall bypass. Fix: add ipv6_transport_offset(packet, initial_next_header) that walks the extension header chain (RFC 8200 §4) and returns the byte offset of the transport-layer header. The walker handles: - Hop-by-Hop (0), Routing (43), Destination (60), AH (51): 8-byte aligned headers with length-in-units field - Fragment (44): fixed 8-byte header, no length field - ESP (50): returns None (payload is encrypted, port extraction impossible) - No Next Header (59): chain ends, transport offset unknown - Unknown header types: returns None to avoid unbounded walk The walker is bounded by packet.len() to prevent DoS via a chain that loops on itself. The transport-layer protocols (TCP=6, UDP=17, ICMPv6=58, SCTP=132) terminate the chain and return the current offset. --- netstack/src/router/mod.rs | 105 +++++++++++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 15 deletions(-) diff --git a/netstack/src/router/mod.rs b/netstack/src/router/mod.rs index c2920deaa8..269bbc249d 100644 --- a/netstack/src/router/mod.rs +++ b/netstack/src/router/mod.rs @@ -4,7 +4,7 @@ use std::rc::Rc; use smoltcp::phy::{Device, DeviceCapabilities, Medium}; use smoltcp::storage::PacketMetadata; use smoltcp::time::Instant; -use smoltcp::wire::{IpAddress, Ipv4Address, Ipv4Packet, Ipv6Packet}; +use smoltcp::wire::{IpAddress, IpProtocol, Ipv4Address, Ipv4Packet, Ipv6Packet}; use self::route_table::{RouteTable, RouteType}; use crate::filter::{FilterTable, Hook, PacketContext, Verdict}; @@ -528,21 +528,20 @@ fn infer_context( 6 => { if let Ok(ipv6) = Ipv6Packet::new_checked(packet) { let nh = ipv6.next_header(); - let payload_start = 40; // fixed IPv6 header - // Extension headers (Hop-by-Hop, Routing, Fragment, etc.) - // would shift the transport header to a higher offset. - // smoltcp's next_header() chases extension headers to return - // the final protocol, but we don't compute the actual - // transport offset. For packets with extension headers the - // port extraction below will read the wrong bytes and return - // None/None, which is safe: the filter matches on IP+protocol - // but silently skips port matching. - let payload = if packet.len() > payload_start { - &packet[payload_start..] - } else { - &[] + // Walk the extension header chain to find the actual + // transport-layer offset. Previously this used a fixed + // 40-byte offset, so any IPv6 packet with extension + // headers (Hop-by-Hop, Routing, Fragment, Destination, + // ESP, AH) caused parse_ports() to read the wrong bytes + // and return None. A firewall rule with a port predicate + // would silently fail to match — a firewall bypass. + let transport_offset = ipv6_transport_offset(packet, &nh); + let (src_port, dst_port) = match transport_offset { + Some(off) if packet.len() >= off => { + parse_ports(&nh, &packet[off..]) + } + _ => (None, None), }; - let (src_port, dst_port) = parse_ports(&nh, payload); PacketContext { hook, in_dev: None, @@ -582,6 +581,82 @@ fn infer_context( } } +/// Walk an IPv6 packet's extension header chain to find the byte offset +/// of the transport-layer header. Returns `None` if the chain is +/// malformed (truncated header, length out of bounds, or a header +/// type we don't know how to step over). +/// +/// RFC 8200 §4 extension header format: +/// - 1 byte next-header type +/// - 1 byte length (in 8-octet units, NOT counting the first 8 octets), +/// except Fragment (44) which has no length field and is always +/// exactly 8 octets total +/// - No Next Header (59) terminates the chain +fn ipv6_transport_offset( + packet: &[u8], + initial_next_header: &smoltcp::wire::IpProtocol, +) -> Option { + // Extension header type values per RFC 8200 / IANA. + const IPV6_HOP_BY_HOP: u8 = 0; + const IPV6_ROUTING: u8 = 43; + const IPV6_FRAGMENT: u8 = 44; + const IPV6_ESP: u8 = 50; + const IPV6_AUTH: u8 = 51; + const IPV6_DEST_OPTS: u8 = 60; + const IPV6_NO_NEXT: u8 = 59; + const IPV6_HDR: usize = 40; + const IPV6_FRAG_HDR_SIZE: usize = 8; + + let mut offset = IPV6_HDR; + let mut current = u8::from(*initial_next_header); + + // Bound the loop by packet length: even a malicious chain of extension + // headers can only iterate at most packet.len() times before offset + // saturates, and each iteration advances offset by at least 1. + let max_iters = packet.len(); + for _ in 0..max_iters { + if current == IPV6_NO_NEXT { + return None; + } + // Known transport-layer protocols terminate the chain. + if current == 6 || current == 17 || current == 58 || current == 132 { + return Some(offset); + } + match current { + IPV6_HOP_BY_HOP | IPV6_ROUTING | IPV6_DEST_OPTS | IPV6_AUTH => { + if offset + 2 > packet.len() { + return None; + } + let len_units = packet[offset + 1] as usize; + let header_size = (len_units + 1) * 8; + if header_size < 8 || offset + header_size > packet.len() { + return None; + } + current = packet[offset]; + offset += header_size; + } + IPV6_FRAGMENT => { + if offset + IPV6_FRAG_HDR_SIZE > packet.len() { + return None; + } + current = packet[offset]; + offset += IPV6_FRAG_HDR_SIZE; + } + IPV6_ESP => { + // ESP payload is encrypted; we cannot extract ports. + return None; + } + _ => { + // Unknown header: treat as terminal to avoid unbounded + // walk. The filter will match on IP+protocol but skip + // port matching, which is the safer-than-bypass default. + return None; + } + } + } + None +} + fn parse_ports(protocol: &smoltcp::wire::IpProtocol, payload: &[u8]) -> (Option, Option) { let proto_byte: u8 = (*protocol).into(); if proto_byte != 6 && proto_byte != 17 && proto_byte != 58 {