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 stealth_sni: String,
|
||||
pub tcp_fragmentation: bool,
|
||||
pub mtu: usize,
|
||||
pub kill_switch: bool,
|
||||
pub reload_tx: Option<watch::Sender<crate::config::ExclusionConfig>>,
|
||||
|
|
@ -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<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_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 {
|
||||
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; }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
stealth_sni: Option<String>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
}
|
||||
|
||||
#[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(),
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ struct TunConfig {
|
|||
struct TransportConfigRaw {
|
||||
mode: Option<String>,
|
||||
stealth_sni: Option<String>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
}
|
||||
|
||||
#[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(),
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ fn parse_ostp_link(link: &str) -> Result<ClientConfig> {
|
|||
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<String>,
|
||||
stealth_sni: Option<String>,
|
||||
tcp_fragmentation: Option<bool>,
|
||||
}
|
||||
|
||||
#[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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue