mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 21:42:05 +00:00
407 lines
16 KiB
Rust
407 lines
16 KiB
Rust
//! The decrypted stream, written out as the packets it never was.
|
|
//!
|
|
//! TLS is terminated in this process, which is what lets a protected service occupy no
|
|
//! port beyond the one it already answered on — and it means the plaintext is never a
|
|
//! packet on any interface, so there is nothing for a capture tool to see. Everything an
|
|
//! operator wants to do with the decrypted traffic (read it in Wireshark, keep a pcap of
|
|
//! a round, grep a flow after the fact) would have gone with the ports.
|
|
//!
|
|
//! So the engine writes it out itself. Each connection's plaintext is framed as the TCP
|
|
//! stream it was before encryption and sent to a dummy interface, where tcpdump and
|
|
//! Wireshark read it like anything else. One interface for the whole instance: every TLS
|
|
//! service's plaintext arrives there and nothing else does.
|
|
//!
|
|
//! **These are reconstructed packets.** The bytes are real — they are exactly what the
|
|
//! filters saw and exactly what was forwarded — but the framing around them is made up
|
|
//! here, because the framing that actually crossed the wire was encrypted. Sequence
|
|
//! numbers start at zero, there are no retransmissions, and the segmentation is this
|
|
//! engine's read sizes rather than the peer's. What that buys is a stream Wireshark can
|
|
//! follow; what it costs is that this is not evidence of what was on the wire, and the
|
|
//! documentation says so.
|
|
//!
|
|
//! **A QUIC stream is reconstructed the same way, one TCP stream each.** A QUIC stream
|
|
//! is ordered, reliable and starts at zero, which is precisely what a [`Tap`] rebuilds —
|
|
//! but every stream of one connection shares the connection's four-tuple, so writing
|
|
//! them out as they are would interleave a hundred of them into a single conversation
|
|
//! that no tool could take apart. So each is given a **synthetic client port** by
|
|
//! [`Tap::open_stream`], and that is one more piece of invented framing on top of the
|
|
//! invented framing this whole file is: the port identifies a stream and was never a
|
|
//! port anybody bound. It has to be said wherever a capture from here is offered, next
|
|
//! to the sentence about sequence numbers, because an operator reading a port back out
|
|
//! of Wireshark will otherwise go looking for a socket that does not exist.
|
|
//!
|
|
//! Failure is always silent and always local. If the interface is not there, or the
|
|
//! socket cannot be opened, or a send fails, the traffic goes on exactly as it would
|
|
//! have: a capture aid must never be able to interrupt a service it is only watching.
|
|
|
|
use std::ffi::CString;
|
|
use std::net::{IpAddr, SocketAddr};
|
|
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
/// The interface the decrypted traffic is written to. A fixed name on purpose: it is
|
|
/// something an operator types into a capture tool.
|
|
pub const DEVICE: &str = "firegex0";
|
|
|
|
/// How much payload goes in one segment. Under the dummy device's 1500-byte MTU with
|
|
/// room for the largest headers this writes (Ethernet + IPv6 + TCP).
|
|
const SEGMENT: usize = 1400;
|
|
|
|
const ETH_HEADER: usize = 14;
|
|
const IPV4_HEADER: usize = 20;
|
|
const IPV6_HEADER: usize = 40;
|
|
const TCP_HEADER: usize = 20;
|
|
|
|
const FIN: u8 = 0x01;
|
|
const SYN: u8 = 0x02;
|
|
const PSH: u8 = 0x08;
|
|
const ACK: u8 = 0x10;
|
|
|
|
/// A raw socket bound to the capture interface, shared by every connection.
|
|
pub struct Capture {
|
|
fd: libc::c_int,
|
|
ifindex: libc::c_int,
|
|
}
|
|
|
|
// The fd is used only for `sendto`, which is thread-safe on a packet socket.
|
|
unsafe impl Send for Capture {}
|
|
unsafe impl Sync for Capture {}
|
|
|
|
impl Drop for Capture {
|
|
fn drop(&mut self) {
|
|
unsafe { libc::close(self.fd) };
|
|
}
|
|
}
|
|
|
|
impl Capture {
|
|
/// Open the capture interface, or `None` if there is nothing to write to.
|
|
///
|
|
/// `None` is the ordinary case, not an error: the interface exists only while some
|
|
/// TLS service is running, and a host that cannot have one still runs services.
|
|
pub fn open() -> Option<Arc<Capture>> {
|
|
let name = CString::new(DEVICE).ok()?;
|
|
let ifindex = unsafe { libc::if_nametoindex(name.as_ptr()) };
|
|
if ifindex == 0 {
|
|
return None;
|
|
}
|
|
// `ETH_P_ALL` in the socket's protocol would also make it *receive* every frame
|
|
// on every interface, which is a firehose nobody reads. Zero sends only.
|
|
let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW | libc::SOCK_CLOEXEC, 0) };
|
|
if fd < 0 {
|
|
return None;
|
|
}
|
|
Some(Arc::new(Capture { fd, ifindex: ifindex as libc::c_int }))
|
|
}
|
|
|
|
fn send(&self, frame: &[u8]) {
|
|
let mut addr: libc::sockaddr_ll = unsafe { std::mem::zeroed() };
|
|
addr.sll_family = libc::AF_PACKET as u16;
|
|
addr.sll_ifindex = self.ifindex;
|
|
addr.sll_halen = 6;
|
|
unsafe {
|
|
// The return value is deliberately ignored: a capture that cannot keep up,
|
|
// or an interface that went away, must not become an error on the path of
|
|
// the traffic it is watching.
|
|
libc::sendto(
|
|
self.fd,
|
|
frame.as_ptr() as *const libc::c_void,
|
|
frame.len(),
|
|
libc::MSG_DONTWAIT,
|
|
&addr as *const libc::sockaddr_ll as *const libc::sockaddr,
|
|
std::mem::size_of::<libc::sockaddr_ll>() as libc::socklen_t,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One connection's reconstruction: the two ends, and where each direction has got to.
|
|
pub struct Tap {
|
|
capture: Arc<Capture>,
|
|
client: SocketAddr,
|
|
server: SocketAddr,
|
|
/// Next sequence number for client → service.
|
|
up: AtomicU32,
|
|
/// Next sequence number for service → client.
|
|
down: AtomicU32,
|
|
/// Whether the end of the stream has been written.
|
|
ended: AtomicBool,
|
|
}
|
|
|
|
/// A reconstruction always gets an end, whichever way its owner left.
|
|
///
|
|
/// The TCP path closes its tap where the connection is torn down, and could; the paths
|
|
/// that carry one stream of many have a dozen ways out — a refused request returns before
|
|
/// anything is joined — and a stream left without a FIN is one Wireshark holds open
|
|
/// waiting for bytes that are never coming. So the last holder emits it, and
|
|
/// [`Tap::closed`] stays callable where the end is known and worth saying explicitly.
|
|
impl Drop for Tap {
|
|
fn drop(&mut self) {
|
|
self.closed();
|
|
}
|
|
}
|
|
|
|
/// Hands out the ports that tell one multiplexed stream from another.
|
|
///
|
|
/// A counter and not a hash of the stream id: ids restart at zero on every QUIC
|
|
/// connection, so deriving the port from one would drop two clients' first streams into
|
|
/// the same conversation. Cycling the ephemeral range instead gives the same answer a
|
|
/// NAT gives, and reuse only becomes possible after 16384 live streams — at which point
|
|
/// a new SYN is what separates them, exactly as it does for a real port that came round
|
|
/// again.
|
|
static NEXT_STREAM_PORT: AtomicU16 = AtomicU16::new(0);
|
|
|
|
const EPHEMERAL_BASE: u16 = 49152;
|
|
const EPHEMERAL_SPAN: u16 = 65535 - EPHEMERAL_BASE + 1;
|
|
|
|
fn stream_port() -> u16 {
|
|
EPHEMERAL_BASE + NEXT_STREAM_PORT.fetch_add(1, Ordering::Relaxed) % EPHEMERAL_SPAN
|
|
}
|
|
|
|
impl Tap {
|
|
/// Start a reconstruction for one stream of a multiplexed connection.
|
|
///
|
|
/// The client's address keeps its IP and loses its port, because the port is the only
|
|
/// field left that can tell two streams of one connection apart: the service's end is
|
|
/// the service's and changing it would misattribute the traffic, and the client's IP
|
|
/// is what an operator filters on. What comes back is therefore a conversation
|
|
/// between a real host and a real service on a port that never existed.
|
|
pub fn open_stream(
|
|
capture: Option<Arc<Capture>>,
|
|
client: SocketAddr,
|
|
server: SocketAddr,
|
|
) -> Option<Arc<Tap>> {
|
|
let mut client = client;
|
|
client.set_port(stream_port());
|
|
Tap::open(capture, client, server)
|
|
}
|
|
|
|
/// Start a reconstruction, and emit the handshake that opens it.
|
|
///
|
|
/// The handshake is not decoration: without a SYN, Wireshark has no beginning for
|
|
/// the stream and cannot reassemble it, so the payloads show up as unrelated
|
|
/// segments of a conversation it never saw start.
|
|
pub fn open(capture: Option<Arc<Capture>>, client: SocketAddr, server: SocketAddr) -> Option<Arc<Tap>> {
|
|
// Mixed families would need a translation nobody asked for; in practice both
|
|
// ends of a connection the engine terminates are the same family.
|
|
if (client.is_ipv4()) != (server.is_ipv4()) {
|
|
return None;
|
|
}
|
|
let tap = Arc::new(Tap {
|
|
capture: capture?,
|
|
client,
|
|
server,
|
|
up: AtomicU32::new(0),
|
|
down: AtomicU32::new(0),
|
|
ended: AtomicBool::new(false),
|
|
});
|
|
// Interleaved with the increments, not emitted and then counted: each of these
|
|
// reads the two counters as they stand, so a SYN-ACK sent before the client's
|
|
// SYN has been counted acknowledges nothing and reads as a broken handshake in
|
|
// any tool that checks. SYN consumes a sequence number; so does SYN-ACK.
|
|
tap.emit(true, SYN, &[]);
|
|
tap.up.fetch_add(1, Ordering::Relaxed);
|
|
tap.emit(false, SYN | ACK, &[]);
|
|
tap.down.fetch_add(1, Ordering::Relaxed);
|
|
tap.emit(true, ACK, &[]);
|
|
Some(tap)
|
|
}
|
|
|
|
/// One chunk of plaintext, in the direction it was forwarded.
|
|
///
|
|
/// What is written here is what was *forwarded*, after any rewriting: a capture that
|
|
/// showed the bytes a filter replaced would disagree with what the two ends actually
|
|
/// received, which is the one thing it is for.
|
|
pub fn wrote(&self, from_client: bool, data: &[u8]) {
|
|
for chunk in data.chunks(SEGMENT) {
|
|
self.emit(from_client, PSH | ACK, chunk);
|
|
let counter = if from_client { &self.up } else { &self.down };
|
|
counter.fetch_add(chunk.len() as u32, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// Close the reconstruction, so the stream has an end as well as a beginning.
|
|
///
|
|
/// Idempotent: [`Drop`] calls it too, and a stream that ended twice would read as one
|
|
/// that was closed and then closed again.
|
|
pub fn closed(&self) {
|
|
if self.ended.swap(true, Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
self.emit(true, FIN | ACK, &[]);
|
|
self.emit(false, FIN | ACK, &[]);
|
|
}
|
|
|
|
fn emit(&self, from_client: bool, flags: u8, payload: &[u8]) {
|
|
let (src, dst) = if from_client {
|
|
(self.client, self.server)
|
|
} else {
|
|
(self.server, self.client)
|
|
};
|
|
let (seq, ack) = if from_client {
|
|
(self.up.load(Ordering::Relaxed), self.down.load(Ordering::Relaxed))
|
|
} else {
|
|
(self.down.load(Ordering::Relaxed), self.up.load(Ordering::Relaxed))
|
|
};
|
|
let frame = match (src.ip(), dst.ip()) {
|
|
(IpAddr::V4(s), IpAddr::V4(d)) => {
|
|
build_v4(s, d, src.port(), dst.port(), seq, ack, flags, payload)
|
|
}
|
|
(IpAddr::V6(s), IpAddr::V6(d)) => {
|
|
build_v6(s, d, src.port(), dst.port(), seq, ack, flags, payload)
|
|
}
|
|
_ => return,
|
|
};
|
|
self.capture.send(&frame);
|
|
}
|
|
}
|
|
|
|
/// Ethernet header. The addresses are locally administered and made up: a dummy device
|
|
/// has no peer to address, and a capture tool only needs the ethertype to know what
|
|
/// follows.
|
|
fn ethernet(out: &mut Vec<u8>, ethertype: u16) {
|
|
out.extend_from_slice(&[0x02, 0, 0, 0, 0, 0x02]); // destination
|
|
out.extend_from_slice(&[0x02, 0, 0, 0, 0, 0x01]); // source
|
|
out.extend_from_slice(ðertype.to_be_bytes());
|
|
}
|
|
|
|
fn ones_complement(sum: u32) -> u16 {
|
|
let mut sum = sum;
|
|
while sum >> 16 != 0 {
|
|
sum = (sum & 0xffff) + (sum >> 16);
|
|
}
|
|
!(sum as u16)
|
|
}
|
|
|
|
fn sum_bytes(bytes: &[u8]) -> u32 {
|
|
let mut sum = 0u32;
|
|
let mut i = 0;
|
|
while i + 1 < bytes.len() {
|
|
sum += u16::from_be_bytes([bytes[i], bytes[i + 1]]) as u32;
|
|
i += 2;
|
|
}
|
|
if i < bytes.len() {
|
|
sum += (bytes[i] as u32) << 8;
|
|
}
|
|
sum
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn tcp_header(
|
|
src_port: u16,
|
|
dst_port: u16,
|
|
seq: u32,
|
|
ack: u32,
|
|
flags: u8,
|
|
payload: &[u8],
|
|
pseudo: u32,
|
|
) -> Vec<u8> {
|
|
let mut tcp = Vec::with_capacity(TCP_HEADER);
|
|
tcp.extend_from_slice(&src_port.to_be_bytes());
|
|
tcp.extend_from_slice(&dst_port.to_be_bytes());
|
|
tcp.extend_from_slice(&seq.to_be_bytes());
|
|
tcp.extend_from_slice(&ack.to_be_bytes());
|
|
tcp.push(5 << 4); // data offset: five 32-bit words, no options
|
|
tcp.push(flags);
|
|
tcp.extend_from_slice(&65535u16.to_be_bytes()); // window
|
|
tcp.extend_from_slice(&[0, 0]); // checksum, filled in below
|
|
tcp.extend_from_slice(&[0, 0]); // urgent pointer
|
|
let checksum = ones_complement(pseudo + sum_bytes(&tcp) + sum_bytes(payload));
|
|
tcp[16..18].copy_from_slice(&checksum.to_be_bytes());
|
|
tcp
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_v4(
|
|
src: std::net::Ipv4Addr,
|
|
dst: std::net::Ipv4Addr,
|
|
src_port: u16,
|
|
dst_port: u16,
|
|
seq: u32,
|
|
ack: u32,
|
|
flags: u8,
|
|
payload: &[u8],
|
|
) -> Vec<u8> {
|
|
let tcp_len = TCP_HEADER + payload.len();
|
|
let pseudo = sum_bytes(&src.octets())
|
|
+ sum_bytes(&dst.octets())
|
|
+ libc::IPPROTO_TCP as u32
|
|
+ tcp_len as u32;
|
|
let tcp = tcp_header(src_port, dst_port, seq, ack, flags, payload, pseudo);
|
|
|
|
let mut out = Vec::with_capacity(ETH_HEADER + IPV4_HEADER + tcp_len);
|
|
ethernet(&mut out, 0x0800);
|
|
let start = out.len();
|
|
out.push(0x45); // version 4, five-word header
|
|
out.push(0); // dscp/ecn
|
|
out.extend_from_slice(&((IPV4_HEADER + tcp_len) as u16).to_be_bytes());
|
|
out.extend_from_slice(&[0, 0]); // identification
|
|
out.extend_from_slice(&0x4000u16.to_be_bytes()); // don't fragment
|
|
out.push(64); // ttl
|
|
out.push(libc::IPPROTO_TCP as u8);
|
|
out.extend_from_slice(&[0, 0]); // checksum, filled in below
|
|
out.extend_from_slice(&src.octets());
|
|
out.extend_from_slice(&dst.octets());
|
|
let checksum = ones_complement(sum_bytes(&out[start..]));
|
|
out[start + 10..start + 12].copy_from_slice(&checksum.to_be_bytes());
|
|
|
|
out.extend_from_slice(&tcp);
|
|
out.extend_from_slice(payload);
|
|
out
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn build_v6(
|
|
src: std::net::Ipv6Addr,
|
|
dst: std::net::Ipv6Addr,
|
|
src_port: u16,
|
|
dst_port: u16,
|
|
seq: u32,
|
|
ack: u32,
|
|
flags: u8,
|
|
payload: &[u8],
|
|
) -> Vec<u8> {
|
|
let tcp_len = TCP_HEADER + payload.len();
|
|
let pseudo = sum_bytes(&src.octets())
|
|
+ sum_bytes(&dst.octets())
|
|
+ libc::IPPROTO_TCP as u32
|
|
+ tcp_len as u32;
|
|
let tcp = tcp_header(src_port, dst_port, seq, ack, flags, payload, pseudo);
|
|
|
|
let mut out = Vec::with_capacity(ETH_HEADER + IPV6_HEADER + tcp_len);
|
|
ethernet(&mut out, 0x86DD);
|
|
out.extend_from_slice(&0x6000_0000u32.to_be_bytes()); // version 6, no traffic class
|
|
out.extend_from_slice(&(tcp_len as u16).to_be_bytes()); // payload length
|
|
out.push(libc::IPPROTO_TCP as u8); // next header
|
|
out.push(64); // hop limit
|
|
out.extend_from_slice(&src.octets());
|
|
out.extend_from_slice(&dst.octets());
|
|
out.extend_from_slice(&tcp);
|
|
out.extend_from_slice(payload);
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The one property that makes multiplexed streams separable in a capture.
|
|
///
|
|
/// Two streams of one connection differ in nothing a capture tool looks at — same
|
|
/// addresses, same service port — so if this stopped handing out distinct ports they
|
|
/// would arrive as one interleaved conversation, which is worse than not capturing
|
|
/// them at all: it reads as a single stream whose bytes make no sense.
|
|
#[test]
|
|
fn every_stream_gets_a_port_of_its_own() {
|
|
let mut seen = std::collections::HashSet::new();
|
|
for _ in 0..EPHEMERAL_SPAN as usize {
|
|
let port = stream_port();
|
|
assert!(port >= EPHEMERAL_BASE, "{port} is not an ephemeral port");
|
|
assert!(seen.insert(port), "{port} came round before the range was spent");
|
|
}
|
|
// And then it does come round, rather than running out: a long-lived engine has
|
|
// more streams than there are ports, and a new SYN is what separates the reuse —
|
|
// exactly as it does for a real port the kernel hands out again.
|
|
assert!(!seen.insert(stream_port()));
|
|
}
|
|
}
|