feat(client): opt-in TTL-desync decoys on the UDP handshake

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.
This commit is contained in:
ospab 2026-08-19 19:23:55 +03:00
parent 3d2b9236e1
commit d4d4600d87
5 changed files with 95 additions and 0 deletions

View File

@ -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<watch::Sender<crate::config::ExclusionConfig>>,
@ -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<bytes::Bytes> = {
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;

View File

@ -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<u64>,
junk_pc: Option<[usize; 2]>,
junk_ps: Option<[usize; 2]>,
ttl_desync: Option<bool>,
ttl_desync_ttl: Option<u8>,
ttl_desync_count: Option<u8>,
}
#[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(),

View File

@ -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);
}
}

View File

@ -61,6 +61,9 @@ struct TransportConfigRaw {
frag_sleep: Option<u64>,
junk_pc: Option<[usize; 2]>,
junk_ps: Option<[usize; 2]>,
ttl_desync: Option<bool>,
ttl_desync_ttl: Option<u8>,
ttl_desync_count: Option<u8>,
}
#[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(),

View File

@ -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),