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:
Red Bear OS
2026-07-27 16:37:04 +09:00
parent 51ae15674a
commit f67802967d
+90 -15
View File
@@ -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 {