netstack: fix P001 IPv6 ext header firewall bypass
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.
This commit is contained in:
+90
-15
@@ -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<usize> {
|
||||
// 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<u16>, Option<u16>) {
|
||||
let proto_byte: u8 = (*protocol).into();
|
||||
if proto_byte != 6 && proto_byte != 17 && proto_byte != 58 {
|
||||
|
||||
Reference in New Issue
Block a user