From d4d4600d87317825885fc48124698d6812e09834 Mon Sep 17 00:00:00 2001 From: ospab Date: Wed, 19 Aug 2026 19:23:55 +0300 Subject: [PATCH] feat(client): opt-in TTL-desync decoys on the UDP handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a socket-level TTL desync: before the UDP handshake, the client fires a few decoy datagrams with a lowered IP TTL, then restores the socket's TTL and sends the real handshake. The decoys are meant to reach an on-path DPI box and expire before the server, so the box classifies the flow on the decoys while the server never sees them. Each decoy carries the key's junk marker, so any that does reach the server is dropped there silently. UDP only. On UoT the carrier is a single TCP stream, so a socket-level TTL change would apply to the real traffic too; proper TCP desync needs injected packets via a driver (WinDivert/NFQUEUE), which this deliberately does not attempt — it stays a no-op there rather than pretending to work. Off by default, and configurable under transport: ttl_desync (bool), ttl_desync_ttl (u8, default 8), ttl_desync_count (u8, default 2). The right TTL is the injector hop distance the prober's ttl_injector_probe reports, plus a hop or two so decoys die just past the DPI; the wrong value is simply inert, which is why this ships opt-in. Plumbed through the engine, the CLI, and the GUI config mapping; new fields default so existing configs are unaffected. Its actual DPI-evasion effect cannot be verified here — it needs a real censored path — so this is the mechanism, to be tuned against the prober. --- ostp-client/src/bridge.rs | 37 ++++++++++++++++++++++++++++++++ ostp-client/src/config.rs | 24 +++++++++++++++++++++ ostp-client/src/transport/mod.rs | 25 +++++++++++++++++++++ ostp-gui/src-tauri/src/lib.rs | 6 ++++++ ostp/src/main.rs | 3 +++ 5 files changed, 95 insertions(+) diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 5dd025f..13ddf02 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -133,6 +133,9 @@ pub struct Bridge { pub frag_sleep: u64, pub junk_pc: [usize; 2], pub junk_ps: [usize; 2], + pub ttl_desync: bool, + pub ttl_desync_ttl: u8, + pub ttl_desync_count: u8, pub mtu: usize, pub kill_switch: bool, pub reload_tx: Option>, @@ -184,6 +187,9 @@ impl Bridge { frag_sleep: config.transport.frag_sleep, junk_pc: config.transport.junk_pc, junk_ps: config.transport.junk_ps, + ttl_desync: config.transport.ttl_desync, + ttl_desync_ttl: config.transport.ttl_desync_ttl, + ttl_desync_count: config.transport.ttl_desync_count, mtu: config.ostp.mtu, kill_switch: config.kill_switch, reload_tx: None, @@ -1108,6 +1114,34 @@ impl Bridge { let is_uot = matches!(socket, crate::transport::Transport::Uot { .. }); let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (4, 1200) }; + // TTL-desync (UDP only, opt-in): fire decoy datagrams that reach an + // on-path DPI box but expire before the server, so the box classifies + // the flow on the decoys rather than the real handshake that follows. + // Each carries the key's junk marker, so any decoy that does reach + // the server is dropped there silently. + if self.ttl_desync && !is_uot && self.ttl_desync_count > 0 { + let marker = ostp_core::crypto::derive_junk_marker( + &self.access_key, + ostp_core::crypto::current_junk_window(), + ); + let decoys: Vec = { + let mut rng = rand::thread_rng(); + let [min_s, max_s] = self.junk_ps; + let min_s = min_s.max(4); + let max_s = max_s.max(min_s); + (0..self.ttl_desync_count) + .map(|_| { + let len = rng.gen_range(min_s..=max_s); + let mut b = vec![0u8; len]; + rng.fill(&mut b[..]); + b[..4].copy_from_slice(&marker); + bytes::Bytes::from(b) + }) + .collect() + }; + socket.send_ttl_decoys(&decoys, self.ttl_desync_ttl).await; + } + for attempt in 0..attempt_limit { if attempt > 0 { tx.send(UiEvent::Log(format!("Handshake attempt {} lost. Retransmitting...", attempt))).await.ok(); @@ -1205,6 +1239,9 @@ impl Bridge { self.frag_sleep = cfg.transport.frag_sleep; self.junk_pc = cfg.transport.junk_pc; self.junk_ps = cfg.transport.junk_ps; + self.ttl_desync = cfg.transport.ttl_desync; + self.ttl_desync_ttl = cfg.transport.ttl_desync_ttl; + self.ttl_desync_count = cfg.transport.ttl_desync_count; self.mtu = cfg.ostp.mtu; self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec; self.kill_switch = cfg.kill_switch; diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index 072dd61..38fe0bd 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -92,6 +92,19 @@ pub struct TransportConfig { /// [min, max] junk packet size in bytes #[serde(default = "default_junk_size")] pub junk_ps: [usize; 2], + /// TTL-desync (UDP only): before the handshake, send decoy datagrams with a + /// lowered IP TTL so they reach an on-path DPI box but expire before the + /// server, poisoning the box's classification of the flow. Off by default — + /// it needs the TTL calibrated to the network, and the wrong value is inert. + #[serde(default)] + pub ttl_desync: bool, + /// TTL the decoy datagrams are sent with. Set it to one or two hops past the + /// injector distance the prober reports, so decoys die just beyond the DPI. + #[serde(default = "default_ttl_desync_ttl")] + pub ttl_desync_ttl: u8, + /// How many decoy datagrams to send per handshake. + #[serde(default = "default_ttl_desync_count")] + pub ttl_desync_count: u8, } fn default_transport_mode() -> String { "udp".to_string() } @@ -99,6 +112,8 @@ fn default_frag_chunk() -> usize { 2 } fn default_frag_sleep() -> u64 { 2 } fn default_junk_count() -> [usize; 2] { [2, 5] } fn default_junk_size() -> [usize; 2] { [100, 1000] } +fn default_ttl_desync_ttl() -> u8 { 8 } +fn default_ttl_desync_count() -> u8 { 2 } impl Default for TransportConfig { fn default() -> Self { @@ -109,6 +124,9 @@ impl Default for TransportConfig { frag_sleep: default_frag_sleep(), junk_pc: default_junk_count(), junk_ps: default_junk_size(), + ttl_desync: false, + ttl_desync_ttl: default_ttl_desync_ttl(), + ttl_desync_count: default_ttl_desync_count(), } } } @@ -194,6 +212,9 @@ struct RawTransportSection { frag_sleep: Option, junk_pc: Option<[usize; 2]>, junk_ps: Option<[usize; 2]>, + ttl_desync: Option, + ttl_desync_ttl: Option, + ttl_desync_count: Option, } #[derive(Debug, Deserialize)] @@ -271,6 +292,9 @@ impl ClientConfig { frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep), junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or_else(default_junk_count), junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size), + ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false), + ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or_else(default_ttl_desync_ttl), + ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or_else(default_ttl_desync_count), }, exclusions: ExclusionConfig { domains: exclusions.domains.unwrap_or_default(), diff --git a/ostp-client/src/transport/mod.rs b/ostp-client/src/transport/mod.rs index 5de7fa2..bdab9a4 100644 --- a/ostp-client/src/transport/mod.rs +++ b/ostp-client/src/transport/mod.rs @@ -53,4 +53,29 @@ impl Transport { Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()), } } + + /// TTL-desync: send `decoys` as datagrams with the IP TTL lowered to `ttl`, + /// then restore the socket's original TTL. The decoys are meant to reach an + /// on-path DPI box and expire before the server — poisoning the box's view + /// of the flow (it classifies on the decoy) while the server never sees + /// them. Calibrate `ttl` to the injector hop distance the prober reports. + /// + /// UDP only: this manipulates individual datagrams' TTL. On UoT the carrier + /// is one TCP stream, so a socket-level TTL change would apply to the real + /// traffic too — proper TCP desync needs injected packets (a driver), which + /// this deliberately does not attempt. No-op there. + pub async fn send_ttl_decoys(&self, decoys: &[Bytes], ttl: u8) { + let Self::Udp(sock) = self else { return }; + if decoys.is_empty() { + return; + } + let restore = sock.ttl().unwrap_or(128); + if sock.set_ttl(ttl as u32).is_err() { + return; + } + for d in decoys { + let _ = sock.send(d).await; + } + let _ = sock.set_ttl(restore); + } } diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index 4efcdc1..47c0635 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -61,6 +61,9 @@ struct TransportConfigRaw { frag_sleep: Option, junk_pc: Option<[usize; 2]>, junk_ps: Option<[usize; 2]>, + ttl_desync: Option, + ttl_desync_ttl: Option, + ttl_desync_count: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -239,6 +242,9 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or(2), junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or([2, 5]), junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or([100, 1000]), + ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false), + ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or(8), + ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or(2), }, 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 1ccf75e..af7259a 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -1713,6 +1713,9 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> { frag_sleep: 2, junk_pc: [2, 5], junk_ps: [100, 1000], + ttl_desync: false, + ttl_desync_ttl: 8, + ttl_desync_count: 2, }, 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),