From db65e3367f66131fd7a07f6a36bc609a8b88284f Mon Sep 17 00:00:00 2001 From: ospab Date: Sat, 27 Jun 2026 17:11:12 +0300 Subject: [PATCH] =?UTF-8?q?=C2=A7E=20(base):=20port=20junk=20packets=20+?= =?UTF-8?q?=20TCP=20fragmentation=20(stream-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anti-DPI obfuscation the project wants to keep, ported from 0.3.x with the harmful UDP behaviour designed out from the start. - Junk: before the handshake on a UoT/TCP connection, send 2-5 random length-prefixed frames (100-1000 B). The server reads each as a frame, fails to authenticate it, drops it and keeps reading (drop-and-continue), so junk perturbs DPI flow analysis without breaking the connection. Junk is NEVER sent over UDP — there each junk would be a lone datagram indistinguishable from a port scan (probe-flood / wasted CPU / the very "self-ban" risk the plan calls out). Verified the server has no probe-based ban, and the unauthorized-probe log is already rate-limited (§B), so junk-over-UoT produces one debug line, not a flood. - TCP fragmentation: new `transport.tcp_fragmentation` flag (default off). When set, the writer splits the first real frame (the handshake) — length header byte-by-byte then payload in 2-byte chunks with short gaps — so DPI can't classify the handshake from a single read. - Ranges are hardcoded for now; §E fine-tuning (configurable Jc/Jmin/Jmax, S1/S2, H1..H4) is deferred. Verified by loopback E2E: a UoT client with tcp_fragmentation=true connects (junk logged as one rate-limited probe, then real handshake accepted) and curl via SOCKS5 tunnels HTTPS successfully. Co-Authored-By: Claude Opus 4.8 --- ostp-client/src/bridge.rs | 70 +++++++++++++++++++++++++++++++---- ostp-client/src/config.rs | 7 ++++ ostp-gui/src-tauri/src/lib.rs | 2 + ostp/src/main.rs | 3 ++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 0a804cd..a858471 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -66,6 +66,7 @@ pub struct Bridge { pub transport_mode: String, pub stealth_sni: String, + pub tcp_fragmentation: bool, pub mtu: usize, pub kill_switch: bool, pub reload_tx: Option>, @@ -98,6 +99,7 @@ impl Bridge { transport_mode: config.transport.mode.clone(), stealth_sni: config.transport.stealth_sni.clone(), + tcp_fragmentation: config.transport.tcp_fragmentation, mtu: config.ostp.mtu, kill_switch: config.kill_switch, reload_tx: None, @@ -1024,6 +1026,7 @@ impl Bridge { self.mux_sessions = cfg.multiplex.sessions.max(1); self.transport_mode = cfg.transport.mode.clone(); self.stealth_sni = cfg.transport.stealth_sni.clone(); + self.tcp_fragmentation = cfg.transport.tcp_fragmentation; self.mtu = cfg.ostp.mtu; self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec; self.kill_switch = cfg.kill_switch; @@ -1039,18 +1042,71 @@ impl Bridge { let stream = tokio::net::TcpStream::connect((target_ip, port)).await?; let _ = stream.set_nodelay(true); let (mut read_half, mut write_half) = stream.into_split(); - + + let tcp_fragmentation = self.tcp_fragmentation; + + // Amnezia-style junk to perturb DPI heuristics — ONLY over stream + // transports, where each junk frame rides inside the connection. The + // server reads it as a length-prefixed frame, fails to authenticate + // it, drops it, and keeps reading (drop-and-continue), so junk does + // not break the connection. Over plain UDP each junk would be a lone + // datagram indistinguishable from a port scan (probe-flood / wasted + // CPU), so junk is NEVER sent over UDP. Ranges are hardcoded for now; + // §E will make Jc/Jmin/Jmax configurable. (Ported from 0.3.x.) + { + use tokio::io::AsyncWriteExt; + // Build all junk frames up front so ThreadRng isn't held across an + // await point (keeps this future Send). + let junk_frames: Vec> = { + use rand::Rng; + let mut rng = rand::thread_rng(); + let num_junk = rng.gen_range(2..=5); + (0..num_junk) + .map(|_| { + let junk_len = rng.gen_range(100..=1000usize); + let mut frame = Vec::with_capacity(2 + junk_len); + frame.extend_from_slice(&(junk_len as u16).to_be_bytes()); + let start = frame.len(); + frame.resize(start + junk_len, 0); + rng.fill(&mut frame[start..]); + frame + }) + .collect() + }; + for frame in junk_frames { + if write_half.write_all(&frame).await.is_err() { break; } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + } + let (tx_out, mut rx_out) = tokio::sync::mpsc::channel::(1024); let (tx_in, rx_in) = tokio::sync::mpsc::channel::(1024); - - // Task to write from rx_out to tcp stream + + // Writer: length-prefix each frame. With tcp_fragmentation on, split + // the FIRST real frame (the handshake — junk above was written + // directly, so it doesn't count) into tiny TCP segments with short + // gaps so DPI can't reassemble/classify the handshake from one read. tokio::spawn(async move { use tokio::io::AsyncWriteExt; + let mut first_packet = true; while let Some(data) = rx_out.recv().await { - let mut len_buf = [0u8; 2]; - len_buf.copy_from_slice(&(data.len() as u16).to_be_bytes()); - if write_half.write_all(&len_buf).await.is_err() { break; } - if write_half.write_all(&data).await.is_err() { break; } + let len_buf = (data.len() as u16).to_be_bytes(); + if first_packet && tcp_fragmentation { + first_packet = false; + if write_half.write_all(&len_buf[0..1]).await.is_err() { break; } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + if write_half.write_all(&len_buf[1..2]).await.is_err() { break; } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let mut broke = false; + for chunk in data.chunks(2) { + if write_half.write_all(chunk).await.is_err() { broke = true; break; } + tokio::time::sleep(std::time::Duration::from_millis(2)).await; + } + if broke { break; } + } else { + if write_half.write_all(&len_buf).await.is_err() { break; } + if write_half.write_all(&data).await.is_err() { break; } + } } }); diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index 9f8c903..c6715d4 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -79,6 +79,10 @@ pub struct TransportConfig { /// TLS SNI and HTTP Host for xHTTP routing #[serde(default)] pub stealth_sni: String, + /// Split the first UoT/TCP packet (handshake) into tiny TCP segments to + /// break DPI that inspects the first packet. UoT/TCP only; ignored for UDP. + #[serde(default)] + pub tcp_fragmentation: bool, } fn default_transport_mode() -> String { "udp".to_string() } @@ -88,6 +92,7 @@ impl Default for TransportConfig { Self { mode: default_transport_mode(), stealth_sni: String::new(), + tcp_fragmentation: false, } } } @@ -169,6 +174,7 @@ struct RawUnifiedConfig { struct RawTransportSection { mode: Option, stealth_sni: Option, + tcp_fragmentation: Option, } #[derive(Debug, Deserialize)] @@ -242,6 +248,7 @@ impl ClientConfig { transport: TransportConfig { mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode), stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_default(), + tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false), }, exclusions: ExclusionConfig { domains: exclusions.domains.unwrap_or_default(), diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index e1b3302..2a5a70c 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -57,6 +57,7 @@ struct TunConfig { struct TransportConfigRaw { mode: Option, stealth_sni: Option, + tcp_fragmentation: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -163,6 +164,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi transport: ostp_client::config::TransportConfig { mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()), stealth_sni: raw.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()), + tcp_fragmentation: raw.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false), }, exclusions: ostp_client::config::ExclusionConfig { domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(), diff --git a/ostp/src/main.rs b/ostp/src/main.rs index ffed45d..6355374 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -103,6 +103,7 @@ fn parse_ostp_link(link: &str) -> Result { transport: Some(TransportConfigRaw { mode: Some(transport_mode), stealth_sni: Some(sni.clone()), + tcp_fragmentation: None, }), socks5_bind: Some("127.0.0.1:1088".to_string()), tun: Some(TunConfig { @@ -317,6 +318,7 @@ struct ClientConfig { struct TransportConfigRaw { mode: Option, stealth_sni: Option, + tcp_fragmentation: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -1627,6 +1629,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> { transport: ostp_client::config::TransportConfig { mode: client_cfg.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()), stealth_sni: client_cfg.transport.as_ref().and_then(|t| t.stealth_sni.clone()).unwrap_or_else(|| "microsoft.com".to_string()), + tcp_fragmentation: client_cfg.transport.as_ref().and_then(|t| t.tcp_fragmentation).unwrap_or(false), }, dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()), kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),