mirror of https://github.com/ospab/ostp.git
§E (base): port junk packets + TCP fragmentation (stream-only)
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 <noreply@anthropic.com>
This commit is contained in:
parent
89a5eea20d
commit
db65e3367f
|
|
@ -66,6 +66,7 @@ pub struct Bridge {
|
||||||
|
|
||||||
pub transport_mode: String,
|
pub transport_mode: String,
|
||||||
pub stealth_sni: String,
|
pub stealth_sni: String,
|
||||||
|
pub tcp_fragmentation: bool,
|
||||||
pub mtu: usize,
|
pub mtu: usize,
|
||||||
pub kill_switch: bool,
|
pub kill_switch: bool,
|
||||||
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
||||||
|
|
@ -98,6 +99,7 @@ impl Bridge {
|
||||||
|
|
||||||
transport_mode: config.transport.mode.clone(),
|
transport_mode: config.transport.mode.clone(),
|
||||||
stealth_sni: config.transport.stealth_sni.clone(),
|
stealth_sni: config.transport.stealth_sni.clone(),
|
||||||
|
tcp_fragmentation: config.transport.tcp_fragmentation,
|
||||||
mtu: config.ostp.mtu,
|
mtu: config.ostp.mtu,
|
||||||
kill_switch: config.kill_switch,
|
kill_switch: config.kill_switch,
|
||||||
reload_tx: None,
|
reload_tx: None,
|
||||||
|
|
@ -1024,6 +1026,7 @@ impl Bridge {
|
||||||
self.mux_sessions = cfg.multiplex.sessions.max(1);
|
self.mux_sessions = cfg.multiplex.sessions.max(1);
|
||||||
self.transport_mode = cfg.transport.mode.clone();
|
self.transport_mode = cfg.transport.mode.clone();
|
||||||
self.stealth_sni = cfg.transport.stealth_sni.clone();
|
self.stealth_sni = cfg.transport.stealth_sni.clone();
|
||||||
|
self.tcp_fragmentation = cfg.transport.tcp_fragmentation;
|
||||||
self.mtu = cfg.ostp.mtu;
|
self.mtu = cfg.ostp.mtu;
|
||||||
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec;
|
||||||
self.kill_switch = cfg.kill_switch;
|
self.kill_switch = cfg.kill_switch;
|
||||||
|
|
@ -1040,18 +1043,71 @@ impl Bridge {
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
let (mut read_half, mut write_half) = stream.into_split();
|
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<Vec<u8>> = {
|
||||||
|
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::<bytes::Bytes>(1024);
|
let (tx_out, mut rx_out) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
|
||||||
let (tx_in, rx_in) = tokio::sync::mpsc::channel::<bytes::Bytes>(1024);
|
let (tx_in, rx_in) = tokio::sync::mpsc::channel::<bytes::Bytes>(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 {
|
tokio::spawn(async move {
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let mut first_packet = true;
|
||||||
while let Some(data) = rx_out.recv().await {
|
while let Some(data) = rx_out.recv().await {
|
||||||
let mut len_buf = [0u8; 2];
|
let len_buf = (data.len() as u16).to_be_bytes();
|
||||||
len_buf.copy_from_slice(&(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(&len_buf).await.is_err() { break; }
|
||||||
if write_half.write_all(&data).await.is_err() { break; }
|
if write_half.write_all(&data).await.is_err() { break; }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Task to read from tcp stream to tx_in
|
// Task to read from tcp stream to tx_in
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,10 @@ pub struct TransportConfig {
|
||||||
/// TLS SNI and HTTP Host for xHTTP routing
|
/// TLS SNI and HTTP Host for xHTTP routing
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stealth_sni: String,
|
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() }
|
fn default_transport_mode() -> String { "udp".to_string() }
|
||||||
|
|
@ -88,6 +92,7 @@ impl Default for TransportConfig {
|
||||||
Self {
|
Self {
|
||||||
mode: default_transport_mode(),
|
mode: default_transport_mode(),
|
||||||
stealth_sni: String::new(),
|
stealth_sni: String::new(),
|
||||||
|
tcp_fragmentation: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +174,7 @@ struct RawUnifiedConfig {
|
||||||
struct RawTransportSection {
|
struct RawTransportSection {
|
||||||
mode: Option<String>,
|
mode: Option<String>,
|
||||||
stealth_sni: Option<String>,
|
stealth_sni: Option<String>,
|
||||||
|
tcp_fragmentation: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -242,6 +248,7 @@ impl ClientConfig {
|
||||||
transport: TransportConfig {
|
transport: TransportConfig {
|
||||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(default_transport_mode),
|
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(),
|
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 {
|
exclusions: ExclusionConfig {
|
||||||
domains: exclusions.domains.unwrap_or_default(),
|
domains: exclusions.domains.unwrap_or_default(),
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ struct TunConfig {
|
||||||
struct TransportConfigRaw {
|
struct TransportConfigRaw {
|
||||||
mode: Option<String>,
|
mode: Option<String>,
|
||||||
stealth_sni: Option<String>,
|
stealth_sni: Option<String>,
|
||||||
|
tcp_fragmentation: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[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 {
|
transport: ostp_client::config::TransportConfig {
|
||||||
mode: raw.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
|
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()),
|
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 {
|
exclusions: ostp_client::config::ExclusionConfig {
|
||||||
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
|
domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
||||||
transport: Some(TransportConfigRaw {
|
transport: Some(TransportConfigRaw {
|
||||||
mode: Some(transport_mode),
|
mode: Some(transport_mode),
|
||||||
stealth_sni: Some(sni.clone()),
|
stealth_sni: Some(sni.clone()),
|
||||||
|
tcp_fragmentation: None,
|
||||||
}),
|
}),
|
||||||
socks5_bind: Some("127.0.0.1:1088".to_string()),
|
socks5_bind: Some("127.0.0.1:1088".to_string()),
|
||||||
tun: Some(TunConfig {
|
tun: Some(TunConfig {
|
||||||
|
|
@ -317,6 +318,7 @@ struct ClientConfig {
|
||||||
struct TransportConfigRaw {
|
struct TransportConfigRaw {
|
||||||
mode: Option<String>,
|
mode: Option<String>,
|
||||||
stealth_sni: Option<String>,
|
stealth_sni: Option<String>,
|
||||||
|
tcp_fragmentation: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
|
@ -1627,6 +1629,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> {
|
||||||
transport: ostp_client::config::TransportConfig {
|
transport: ostp_client::config::TransportConfig {
|
||||||
mode: client_cfg.transport.as_ref().and_then(|t| t.mode.clone()).unwrap_or_else(|| "udp".to_string()),
|
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()),
|
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()),
|
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),
|
kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue