//! Black-hat adversarial tests for truncated in-range TLS ClientHello probes. //! These tests encode a strict anti-probing expectation: malformed TLS traffic //! should still be masked as a legitimate website response. use super::*; use crate::config::{UpstreamConfig, UpstreamType}; use crate::protocol::constants::MIN_TLS_CLIENT_HELLO_SIZE; use std::net::SocketAddr; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt, duplex}; use tokio::net::{TcpListener, TcpStream}; use tokio::time::sleep; fn in_range_probe_header() -> [u8; 5] { [ 0x16, 0x03, 0x03, ((MIN_TLS_CLIENT_HELLO_SIZE >> 8) & 0xff) as u8, (MIN_TLS_CLIENT_HELLO_SIZE & 0xff) as u8, ] } fn make_test_upstream_manager(stats: Arc) -> Arc { Arc::new(UpstreamManager::new( vec![UpstreamConfig { upstream_type: UpstreamType::Direct { interface: None, bind_addresses: None, }, weight: 1, enabled: true, scopes: String::new(), selected_scope: String::new(), }], 1, 1, 1, 1, false, stats, )) } fn truncated_in_range_record(actual_body_len: usize) -> Vec { let mut out = in_range_probe_header().to_vec(); out.extend(std::iter::repeat_n(0x41, actual_body_len)); out } async fn write_fragmented( writer: &mut W, bytes: &[u8], chunks: &[usize], delay_ms: u64, ) { let mut offset = 0usize; for &chunk in chunks { if offset >= bytes.len() { break; } let end = (offset + chunk).min(bytes.len()); writer.write_all(&bytes[offset..end]).await.unwrap(); offset = end; if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } } if offset < bytes.len() { writer.write_all(&bytes[offset..]).await.unwrap(); } } async fn run_blackhat_generic_fragmented_probe_should_mask( payload: Vec, chunks: &[usize], delay_ms: u64, backend_reply: Vec, ) { let mask_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let mask_addr = mask_listener.local_addr().unwrap(); let probe_header = in_range_probe_header(); let mask_accept_task = tokio::spawn({ let backend_reply = backend_reply.clone(); async move { let (mut stream, _) = mask_listener.accept().await.unwrap(); let mut got = [0u8; 5]; stream.read_exact(&mut got).await.unwrap(); assert_eq!(got, probe_header); stream.write_all(&backend_reply).await.unwrap(); } }); let mut cfg = ProxyConfig::default(); cfg.general.beobachten = false; cfg.timeouts.client_handshake = 1; cfg.censorship.mask = true; cfg.censorship.mask_unix_sock = None; cfg.censorship.mask_host = Some("127.0.0.1".to_string()); cfg.censorship.mask_port = mask_addr.port(); cfg.censorship.mask_proxy_protocol = 0; let config = Arc::new(cfg); let stats = Arc::new(Stats::new()); let upstream_manager = make_test_upstream_manager(stats.clone()); let replay_checker = Arc::new(ReplayChecker::new(128, Duration::from_secs(60))); let buffer_pool = Arc::new(BufferPool::new()); let rng = Arc::new(SecureRandom::new()); let route_runtime = Arc::new(RouteRuntimeController::new(RelayRouteMode::Direct)); let ip_tracker = Arc::new(UserIpTracker::new()); let beobachten = Arc::new(BeobachtenStore::new()); let (server_side, mut client_side) = duplex(4096); let peer: SocketAddr = "203.0.113.202:55002".parse().unwrap(); let handler = tokio::spawn(handle_client_stream( server_side, peer, config, stats, upstream_manager, replay_checker, buffer_pool, rng, None, route_runtime, None, ip_tracker, beobachten, false, )); write_fragmented(&mut client_side, &payload, chunks, delay_ms).await; client_side.shutdown().await.unwrap(); let mut observed = vec![0u8; backend_reply.len()]; tokio::time::timeout( Duration::from_secs(2), client_side.read_exact(&mut observed), ) .await .unwrap() .unwrap(); assert_eq!(observed, backend_reply); tokio::time::timeout(Duration::from_secs(2), mask_accept_task) .await .unwrap() .unwrap(); let _ = tokio::time::timeout(Duration::from_secs(2), handler) .await .unwrap() .unwrap(); } async fn run_blackhat_client_handler_fragmented_probe_should_mask( payload: Vec, chunks: &[usize], delay_ms: u64, backend_reply: Vec, ) { let mask_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let mask_addr = mask_listener.local_addr().unwrap(); let front_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let front_addr = front_listener.local_addr().unwrap(); let probe_header = in_range_probe_header(); let mask_accept_task = tokio::spawn({ let backend_reply = backend_reply.clone(); async move { let (mut stream, _) = mask_listener.accept().await.unwrap(); let mut got = [0u8; 5]; stream.read_exact(&mut got).await.unwrap(); assert_eq!(got, probe_header); stream.write_all(&backend_reply).await.unwrap(); } }); let mut cfg = ProxyConfig::default(); cfg.general.beobachten = false; cfg.timeouts.client_handshake = 1; cfg.censorship.mask = true; cfg.censorship.mask_unix_sock = None; cfg.censorship.mask_host = Some("127.0.0.1".to_string()); cfg.censorship.mask_port = mask_addr.port(); cfg.censorship.mask_proxy_protocol = 0; let config = Arc::new(cfg); let stats = Arc::new(Stats::new()); let upstream_manager = make_test_upstream_manager(stats.clone()); let replay_checker = Arc::new(ReplayChecker::new(128, Duration::from_secs(60))); let buffer_pool = Arc::new(BufferPool::new()); let rng = Arc::new(SecureRandom::new()); let route_runtime = Arc::new(RouteRuntimeController::new(RelayRouteMode::Direct)); let ip_tracker = Arc::new(UserIpTracker::new()); let beobachten = Arc::new(BeobachtenStore::new()); let server_task = { let config = config.clone(); let stats = stats.clone(); let upstream_manager = upstream_manager.clone(); let replay_checker = replay_checker.clone(); let buffer_pool = buffer_pool.clone(); let rng = rng.clone(); let route_runtime = route_runtime.clone(); let ip_tracker = ip_tracker.clone(); let beobachten = beobachten.clone(); tokio::spawn(async move { let (stream, peer) = front_listener.accept().await.unwrap(); let real_peer_report = Arc::new(std::sync::Mutex::new(None)); ClientHandler::new( stream, peer, config, stats, upstream_manager, replay_checker, buffer_pool, rng, None, route_runtime, None, ip_tracker, beobachten, false, real_peer_report, ) .run() .await }) }; let mut client = TcpStream::connect(front_addr).await.unwrap(); write_fragmented(&mut client, &payload, chunks, delay_ms).await; client.shutdown().await.unwrap(); let mut observed = vec![0u8; backend_reply.len()]; tokio::time::timeout(Duration::from_secs(2), client.read_exact(&mut observed)) .await .unwrap() .unwrap(); assert_eq!(observed, backend_reply); tokio::time::timeout(Duration::from_secs(2), mask_accept_task) .await .unwrap() .unwrap(); let _ = tokio::time::timeout(Duration::from_secs(2), server_task) .await .unwrap() .unwrap(); } #[tokio::test] async fn blackhat_truncated_in_range_clienthello_generic_stream_should_mask() { let mask_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let mask_addr = mask_listener.local_addr().unwrap(); let backend_reply = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(); let probe = in_range_probe_header(); let mask_accept_task = tokio::spawn({ let backend_reply = backend_reply.clone(); async move { let (mut stream, _) = mask_listener.accept().await.unwrap(); let mut got = [0u8; 5]; stream.read_exact(&mut got).await.unwrap(); assert_eq!(got, probe); stream.write_all(&backend_reply).await.unwrap(); } }); let mut cfg = ProxyConfig::default(); cfg.general.beobachten = false; cfg.timeouts.client_handshake = 1; cfg.censorship.mask = true; cfg.censorship.mask_unix_sock = None; cfg.censorship.mask_host = Some("127.0.0.1".to_string()); cfg.censorship.mask_port = mask_addr.port(); cfg.censorship.mask_proxy_protocol = 0; let config = Arc::new(cfg); let stats = Arc::new(Stats::new()); let upstream_manager = make_test_upstream_manager(stats.clone()); let replay_checker = Arc::new(ReplayChecker::new(128, Duration::from_secs(60))); let buffer_pool = Arc::new(BufferPool::new()); let rng = Arc::new(SecureRandom::new()); let route_runtime = Arc::new(RouteRuntimeController::new(RelayRouteMode::Direct)); let ip_tracker = Arc::new(UserIpTracker::new()); let beobachten = Arc::new(BeobachtenStore::new()); let (server_side, mut client_side) = duplex(4096); let peer: SocketAddr = "203.0.113.201:55001".parse().unwrap(); let handler = tokio::spawn(handle_client_stream( server_side, peer, config, stats, upstream_manager, replay_checker, buffer_pool, rng, None, route_runtime, None, ip_tracker, beobachten, false, )); client_side.write_all(&probe).await.unwrap(); client_side.shutdown().await.unwrap(); // Security expectation: even malformed in-range TLS should be masked. // This invariant must hold to avoid probe-distinguishable EOF/timeout behavior. let mut observed = vec![0u8; backend_reply.len()]; tokio::time::timeout( Duration::from_secs(2), client_side.read_exact(&mut observed), ) .await .unwrap() .unwrap(); assert_eq!(observed, backend_reply); tokio::time::timeout(Duration::from_secs(2), mask_accept_task) .await .unwrap() .unwrap(); let _ = tokio::time::timeout(Duration::from_secs(2), handler) .await .unwrap() .unwrap(); } #[tokio::test] async fn blackhat_truncated_in_range_clienthello_client_handler_should_mask() { let mask_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let mask_addr = mask_listener.local_addr().unwrap(); let front_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let front_addr = front_listener.local_addr().unwrap(); let backend_reply = b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n".to_vec(); let probe = in_range_probe_header(); let mask_accept_task = tokio::spawn({ let backend_reply = backend_reply.clone(); async move { let (mut stream, _) = mask_listener.accept().await.unwrap(); let mut got = [0u8; 5]; stream.read_exact(&mut got).await.unwrap(); assert_eq!(got, probe); stream.write_all(&backend_reply).await.unwrap(); } }); let mut cfg = ProxyConfig::default(); cfg.general.beobachten = false; cfg.timeouts.client_handshake = 1; cfg.censorship.mask = true; cfg.censorship.mask_unix_sock = None; cfg.censorship.mask_host = Some("127.0.0.1".to_string()); cfg.censorship.mask_port = mask_addr.port(); cfg.censorship.mask_proxy_protocol = 0; let config = Arc::new(cfg); let stats = Arc::new(Stats::new()); let upstream_manager = make_test_upstream_manager(stats.clone()); let replay_checker = Arc::new(ReplayChecker::new(128, Duration::from_secs(60))); let buffer_pool = Arc::new(BufferPool::new()); let rng = Arc::new(SecureRandom::new()); let route_runtime = Arc::new(RouteRuntimeController::new(RelayRouteMode::Direct)); let ip_tracker = Arc::new(UserIpTracker::new()); let beobachten = Arc::new(BeobachtenStore::new()); let server_task = { let config = config.clone(); let stats = stats.clone(); let upstream_manager = upstream_manager.clone(); let replay_checker = replay_checker.clone(); let buffer_pool = buffer_pool.clone(); let rng = rng.clone(); let route_runtime = route_runtime.clone(); let ip_tracker = ip_tracker.clone(); let beobachten = beobachten.clone(); tokio::spawn(async move { let (stream, peer) = front_listener.accept().await.unwrap(); let real_peer_report = Arc::new(std::sync::Mutex::new(None)); ClientHandler::new( stream, peer, config, stats, upstream_manager, replay_checker, buffer_pool, rng, None, route_runtime, None, ip_tracker, beobachten, false, real_peer_report, ) .run() .await }) }; let mut client = TcpStream::connect(front_addr).await.unwrap(); client.write_all(&probe).await.unwrap(); client.shutdown().await.unwrap(); // Security expectation: malformed in-range TLS should still be masked. let mut observed = vec![0u8; backend_reply.len()]; tokio::time::timeout(Duration::from_secs(2), client.read_exact(&mut observed)) .await .unwrap() .unwrap(); assert_eq!(observed, backend_reply); tokio::time::timeout(Duration::from_secs(2), mask_accept_task) .await .unwrap() .unwrap(); let _ = tokio::time::timeout(Duration::from_secs(2), server_task) .await .unwrap() .unwrap(); } #[tokio::test] async fn blackhat_generic_truncated_min_body_1_should_mask() { run_blackhat_generic_fragmented_probe_should_mask( truncated_in_range_record(1), &[6], 0, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_generic_truncated_min_body_8_should_mask() { run_blackhat_generic_fragmented_probe_should_mask( truncated_in_range_record(8), &[13], 0, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_generic_truncated_min_body_99_should_mask() { run_blackhat_generic_fragmented_probe_should_mask( truncated_in_range_record(MIN_TLS_CLIENT_HELLO_SIZE - 1), &[5, MIN_TLS_CLIENT_HELLO_SIZE - 1], 0, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_generic_fragmented_header_then_close_should_mask() { run_blackhat_generic_fragmented_probe_should_mask( truncated_in_range_record(0), &[1, 1, 1, 1, 1], 0, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_generic_fragmented_header_plus_partial_body_should_mask() { run_blackhat_generic_fragmented_probe_should_mask( truncated_in_range_record(5), &[1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 0, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_generic_slowloris_fragmented_min_probe_should_mask_but_times_out() { run_blackhat_generic_fragmented_probe_should_mask( truncated_in_range_record(1), &[1, 1, 1, 1, 1, 1], 250, b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_client_handler_truncated_min_body_1_should_mask() { run_blackhat_client_handler_fragmented_probe_should_mask( truncated_in_range_record(1), &[6], 0, b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_client_handler_truncated_min_body_8_should_mask() { run_blackhat_client_handler_fragmented_probe_should_mask( truncated_in_range_record(8), &[13], 0, b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_client_handler_truncated_min_body_99_should_mask() { run_blackhat_client_handler_fragmented_probe_should_mask( truncated_in_range_record(MIN_TLS_CLIENT_HELLO_SIZE - 1), &[5, MIN_TLS_CLIENT_HELLO_SIZE - 1], 0, b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_client_handler_fragmented_header_then_close_should_mask() { run_blackhat_client_handler_fragmented_probe_should_mask( truncated_in_range_record(0), &[1, 1, 1, 1, 1], 0, b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_client_handler_fragmented_header_plus_partial_body_should_mask() { run_blackhat_client_handler_fragmented_probe_should_mask( truncated_in_range_record(5), &[1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 0, b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; } #[tokio::test] async fn blackhat_client_handler_slowloris_fragmented_min_probe_should_mask_but_times_out() { run_blackhat_client_handler_fragmented_probe_should_mask( truncated_in_range_record(1), &[1, 1, 1, 1, 1, 1], 250, b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n".to_vec(), ) .await; }