first init

This commit is contained in:
nouri_a
2026-04-15 11:11:44 +03:30
parent f41bc57b71
commit 1bad6394d1
20 changed files with 2844 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
dist/
build/
*.egg
.eggs/
.venv/
venv/
env/
.env
*.log
.DS_Store
Thumbs.db
*.swp
*.swo
*~
.idea/
.vscode/
*.bak
.github/
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
LABEL maintainer="Rainman69"
LABEL description="SNISPF - Cross-platform DPI bypass tool"
WORKDIR /app
COPY pyproject.toml .
COPY sni_spoofing/ ./sni_spoofing/
COPY run.py .
COPY config.json .
COPY README.md .
COPY LICENSE .
RUN pip install --no-cache-dir -e .
EXPOSE 40443
ENTRYPOINT ["snispf"]
CMD ["--config", "config.json"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Rainman69
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+12
View File
@@ -0,0 +1,12 @@
{
"LISTEN_HOST": "0.0.0.0",
"LISTEN_PORT": 40443,
"CONNECT_IP": "188.114.98.0",
"CONNECT_PORT": 443,
"FAKE_SNI": "auth.vercel.com",
"BYPASS_METHOD": "fragment",
"FRAGMENT_STRATEGY": "sni_split",
"FRAGMENT_DELAY": 0.1,
"USE_TTL_TRICK": false,
"FAKE_SNI_METHOD": "prefix_fake"
}
+43
View File
@@ -0,0 +1,43 @@
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "snispf"
version = "1.1.0"
description = "SNISPF - Cross-platform DPI bypass tool via SNI spoofing"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.8"
authors = [
{name = "Rainman69"},
]
keywords = ["sni", "spoofing", "dpi", "bypass", "censorship", "tls", "fragment", "cli"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet",
"Topic :: Security",
"Topic :: System :: Networking",
]
[project.urls]
Homepage = "https://github.com/Rainman69/SNISPF"
Repository = "https://github.com/Rainman69/SNISPF"
Issues = "https://github.com/Rainman69/SNISPF/issues"
[project.scripts]
snispf = "sni_spoofing.cli:main"
[tool.setuptools.packages.find]
include = ["sni_spoofing*"]
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""Direct entry point for running without installation."""
import sys
import os
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sni_spoofing.cli import main
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
"""
SNISPF - Cross-platform SNI spoofing and DPI bypass tool.
"""
__version__ = "1.1.0"
__author__ = "Rainman69"
+16
View File
@@ -0,0 +1,16 @@
"""Bypass strategy implementations."""
from .base import BypassStrategy
from .fragment import FragmentBypass
from .fake_sni import FakeSNIBypass
from .combined import CombinedBypass
from .raw_injector import RawInjector, is_raw_available
__all__ = [
"BypassStrategy",
"FragmentBypass",
"FakeSNIBypass",
"CombinedBypass",
"RawInjector",
"is_raw_available",
]
+44
View File
@@ -0,0 +1,44 @@
"""Base class for bypass strategies."""
import abc
import socket
from typing import Optional
class BypassStrategy(abc.ABC):
"""Abstract base for DPI bypass strategies.
Each strategy implements a different technique for evading
Deep Packet Inspection when forwarding TCP connections.
"""
name: str = "base"
@abc.abstractmethod
async def apply(
self,
client_sock: socket.socket,
server_sock: socket.socket,
fake_sni: str,
first_data: bytes,
loop=None,
) -> bool:
"""Apply the bypass strategy to an outgoing connection.
This method is called after the TCP connection to the server
is established but before any real data is forwarded.
Args:
client_sock: The incoming client socket
server_sock: The outgoing socket to the real server
fake_sni: The fake SNI hostname to use
first_data: First data received from the client
loop: asyncio event loop
Returns:
True if strategy was applied successfully, False otherwise
"""
pass
def __repr__(self):
return f"<{self.__class__.__name__} strategy='{self.name}'>"
+130
View File
@@ -0,0 +1,130 @@
"""Combined bypass strategy.
Combines multiple bypass techniques for maximum effectiveness.
With raw sockets (Linux + root):
1. The raw injector sends a fake ClientHello with an out-of-window
seq number during the TCP handshake (DPI parses it, server drops it)
2. Then the real ClientHello is fragmented at the SNI boundary
Both techniques hit DPI at once.
Without raw sockets (fallback):
Uses fragmentation only (with optional TTL trick for the fake).
The fake_sni prefix method is NOT used on the real TCP stream
because it corrupts the TLS handshake.
"""
import asyncio
import logging
import socket
import time
from typing import Optional
from .base import BypassStrategy
from ..tls import ClientHelloBuilder
from ..tls.fragment import fragment_client_hello, fragment_data
logger = logging.getLogger("snispf")
class CombinedBypass(BypassStrategy):
"""Combined DPI bypass using multiple techniques simultaneously.
With raw injector available:
1. Fake ClientHello injected out-of-window (by the sniffer/injector)
2. Real ClientHello fragmented at SNI boundary
3. Small inter-fragment delays
Without raw injector:
1. (Optional) TTL trick to send fake ClientHello that expires
before reaching the server
2. Real ClientHello fragmented at SNI boundary
3. Small inter-fragment delays
"""
name = "combined"
def __init__(
self,
fragment_strategy: str = "sni_split",
use_ttl_trick: bool = False,
fragment_delay: float = 0.1,
fake_first: bool = True,
raw_injector=None,
):
self.fragment_strategy = fragment_strategy
self.use_ttl_trick = use_ttl_trick
self.fragment_delay = fragment_delay
self.fake_first = fake_first
self.raw_injector = raw_injector
async def apply(
self,
client_sock: socket.socket,
server_sock: socket.socket,
fake_sni: str,
first_data: bytes,
loop=None,
) -> bool:
if loop is None:
loop = asyncio.get_running_loop()
try:
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# Step 1: Handle fake ClientHello
if self.raw_injector is not None:
# Raw injector already sent the fake out-of-window during
# the TCP handshake. Wait for server confirmation.
local_port = server_sock.getsockname()[1]
confirmed = await loop.run_in_executor(
None,
self.raw_injector.wait_for_confirmation,
local_port,
2.0,
)
if not confirmed:
logger.warning(
f"port={local_port}: no confirmation that server "
f"ignored the fake packet (timeout)"
)
elif self.fake_first and self.use_ttl_trick:
# TTL trick: send fake with low TTL so it reaches DPI
# but expires before the server
fake_hello = ClientHelloBuilder.build_client_hello(sni=fake_sni)
try:
original_ttl = server_sock.getsockopt(
socket.IPPROTO_IP, socket.IP_TTL
)
server_sock.setsockopt(
socket.IPPROTO_IP, socket.IP_TTL, 3
)
await loop.sock_sendall(server_sock, fake_hello)
await asyncio.sleep(0.05)
server_sock.setsockopt(
socket.IPPROTO_IP, socket.IP_TTL, original_ttl
)
except OSError:
# TTL trick not available, skip the fake entirely
pass
await asyncio.sleep(0.001)
# NOTE: Without raw sockets or TTL trick, we do NOT send a fake
# ClientHello on the real TCP stream. It would corrupt the
# handshake because the server receives it as real data.
# Step 2: Fragment and send the real ClientHello
fragments = fragment_client_hello(first_data, self.fragment_strategy)
for i, fragment in enumerate(fragments):
await loop.sock_sendall(server_sock, fragment)
if i < len(fragments) - 1 and self.fragment_delay > 0:
await asyncio.sleep(self.fragment_delay)
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)
return True
except Exception:
return False
+190
View File
@@ -0,0 +1,190 @@
"""Fake SNI bypass strategy.
Sends a fake TLS ClientHello with an allowed SNI that DPI will parse
and whitelist, but the real server will ignore.
Two operating modes:
- With raw sockets (Linux + root): Uses the seq_id trick from the
original tool. Injects a fake ClientHello with an out-of-window
TCP sequence number. DPI parses it, server drops it.
- Without raw sockets (fallback): Sends the real ClientHello in
fragments so DPI cannot read the SNI from any single packet.
The fake_sni prefix method does NOT work without raw sockets
because sending the fake on the same TCP stream corrupts the
TLS handshake.
"""
import asyncio
import logging
import socket
from typing import Optional
from .base import BypassStrategy
from ..tls import ClientHelloBuilder
from ..tls.fragment import fragment_client_hello
logger = logging.getLogger("snispf")
class FakeSNIBypass(BypassStrategy):
"""Bypass DPI by injecting a fake TLS ClientHello with spoofed SNI.
The only reliable way to do this is with raw socket injection
(out-of-window seq trick). When raw sockets are not available,
this falls back to TLS fragmentation which hides the SNI by
splitting it across TCP segments.
Methods:
- "raw_inject" - Inject fake ClientHello with wrong seq number
via AF_PACKET. DPI sees it, server drops it. (Linux + root)
- "ttl_trick" - Send fake with low IP TTL. May reach DPI but
expire before the server. Unreliable, platform-dependent.
- "fragment_fallback" - Falls back to fragmenting the real
ClientHello. No fake is sent on the real stream.
"""
name = "fake_sni"
def __init__(self, method: str = "prefix_fake", raw_injector=None):
self.method = method
self.raw_injector = raw_injector
async def apply(
self,
client_sock: socket.socket,
server_sock: socket.socket,
fake_sni: str,
first_data: bytes,
loop=None,
) -> bool:
if loop is None:
loop = asyncio.get_running_loop()
# If we have a raw injector running, the fake was already injected
# during the TCP handshake. Just send the real data and go.
if self.raw_injector is not None:
return await self._raw_inject_send(
server_sock, first_data, loop
)
# Without raw sockets, the old "prefix_fake" method of sending
# a fake ClientHello on the same TCP stream is broken - the server
# receives both and the TLS handshake fails. Fall back to
# TTL trick if requested, otherwise just fragment the real hello.
if self.method == "ttl_trick":
return await self._ttl_trick(
server_sock, fake_sni, first_data, loop
)
else:
# Fragment fallback: split the real ClientHello so DPI can't
# read the SNI from any single packet.
return await self._fragment_fallback(
server_sock, first_data, loop
)
async def _raw_inject_send(
self,
server_sock: socket.socket,
first_data: bytes,
loop,
) -> bool:
"""With raw injection, the fake was already sent out-of-window.
Just send the real ClientHello normally."""
try:
local_port = server_sock.getsockname()[1]
# Wait for the sniffer to confirm the server ignored the fake.
confirmed = await loop.run_in_executor(
None,
self.raw_injector.wait_for_confirmation,
local_port,
2.0,
)
if not confirmed:
logger.warning(
f"port={local_port}: server did not confirm fake was "
f"ignored (timeout). Sending real data anyway."
)
# Send the real ClientHello (untouched)
await loop.sock_sendall(server_sock, first_data)
return True
except Exception:
return False
async def _ttl_trick(
self,
server_sock: socket.socket,
fake_sni: str,
first_data: bytes,
loop,
) -> bool:
"""Send fake ClientHello with low TTL, then real data normally.
The fake packet has a TTL low enough to expire before reaching
the server, but the DPI middlebox (typically 1-3 hops away)
will see it. This is unreliable depending on network topology.
"""
try:
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
fake_hello = ClientHelloBuilder.build_client_hello(sni=fake_sni)
# Save original TTL
original_ttl = server_sock.getsockopt(
socket.IPPROTO_IP, socket.IP_TTL
)
# Low TTL: reaches DPI (1-5 hops) but expires before server
server_sock.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, 3)
try:
await loop.sock_sendall(server_sock, fake_hello)
except OSError:
pass # May get ICMP TTL exceeded
await asyncio.sleep(0.05)
# Restore normal TTL
server_sock.setsockopt(
socket.IPPROTO_IP, socket.IP_TTL, original_ttl
)
# Send real ClientHello normally
await loop.sock_sendall(server_sock, first_data)
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)
return True
except Exception:
return False
async def _fragment_fallback(
self,
server_sock: socket.socket,
first_data: bytes,
loop,
) -> bool:
"""Fallback: fragment the real ClientHello at the SNI boundary.
Without raw sockets we cannot safely send a fake ClientHello
(it would corrupt the TLS stream). Instead, fragment the real
ClientHello so DPI cannot read the full SNI from a single packet.
"""
try:
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
fragments = fragment_client_hello(first_data, "sni_split")
for i, fragment in enumerate(fragments):
await loop.sock_sendall(server_sock, fragment)
if i < len(fragments) - 1:
await asyncio.sleep(0.1)
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)
return True
except Exception:
return False
+89
View File
@@ -0,0 +1,89 @@
"""Fragment-based DPI bypass strategy.
Splits the real TLS ClientHello into fragments so that DPI systems
that only inspect the first packet or don't reassemble TCP streams
cannot read the SNI.
"""
import asyncio
import socket
import time
from typing import Optional
from .base import BypassStrategy
from ..tls.fragment import fragment_client_hello
class FragmentBypass(BypassStrategy):
"""Bypass DPI by fragmenting the TLS ClientHello.
This is the most compatible cross-platform bypass method.
It works by splitting the ClientHello into multiple TCP segments,
with the split point strategically placed in the middle of the
SNI extension value.
DPI systems that don't reassemble TCP streams will see an incomplete
SNI in the first packet and won't be able to filter it.
"""
name = "fragment"
def __init__(
self,
strategy: str = "sni_split",
fragment_delay: float = 0.1,
tcp_nodelay: bool = True,
):
"""Initialize fragment bypass.
Args:
strategy: Fragmentation strategy (sni_split, half, multi, tls_record_frag)
fragment_delay: Delay between fragments in seconds
tcp_nodelay: Enable TCP_NODELAY to send fragments immediately
"""
self.strategy = strategy
self.fragment_delay = fragment_delay
self.tcp_nodelay = tcp_nodelay
async def apply(
self,
client_sock: socket.socket,
server_sock: socket.socket,
fake_sni: str,
first_data: bytes,
loop=None,
) -> bool:
"""Apply fragmentation to the first TLS record.
The first_data from the client (usually a TLS ClientHello) is
fragmented and sent to the server in multiple TCP segments.
"""
if loop is None:
loop = asyncio.get_running_loop()
try:
# Enable TCP_NODELAY so each send() becomes its own segment
if self.tcp_nodelay:
server_sock.setsockopt(
socket.IPPROTO_TCP, socket.TCP_NODELAY, 1
)
# Fragment the ClientHello
fragments = fragment_client_hello(first_data, self.strategy)
# Send each fragment as a separate TCP segment
for i, fragment in enumerate(fragments):
await loop.sock_sendall(server_sock, fragment)
if i < len(fragments) - 1 and self.fragment_delay > 0:
await asyncio.sleep(self.fragment_delay)
# Disable TCP_NODELAY after fragments are sent (optional)
if self.tcp_nodelay:
server_sock.setsockopt(
socket.IPPROTO_TCP, socket.TCP_NODELAY, 0
)
return True
except Exception:
return False
+418
View File
@@ -0,0 +1,418 @@
"""Raw socket packet injection for out-of-window fake SNI.
Implements the seq_id trick from the Go reference:
1. Sniff the outbound SYN to record the ISN (Initial Sequence Number)
2. Sniff the outbound 3rd ACK (handshake complete)
3. Inject a fake TLS ClientHello with seq = ISN+1 - len(fake)
This puts it BEFORE the server's receive window, so the server drops it,
but DPI sees and parses the fake SNI.
4. Wait for the server to ACK with ack == ISN+1, confirming the fake was
ignored and the server still expects the real data.
Linux only. Requires CAP_NET_RAW (run as root).
"""
import logging
import os
import socket
import struct
import threading
import time
from typing import Optional, Dict
logger = logging.getLogger("snispf")
ETH_P_IP = 0x0800
ETH_P_ALL = 0x0003
IPPROTO_TCP = 6
# TCP flags
FIN = 0x01
SYN = 0x02
RST = 0x04
PSH = 0x08
ACK = 0x10
def _htons(v):
return socket.htons(v)
def _ip_hdr_len(ip_bytes):
return (ip_bytes[0] & 0x0F) * 4
def _checksum_fold(s):
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return (~s) & 0xFFFF
def _sum16(data):
s = 0
for i in range(0, len(data) - 1, 2):
s += (data[i] << 8) | data[i + 1]
if len(data) % 2 == 1:
s += data[-1] << 8
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return s
def _ip_checksum(iph):
return _checksum_fold(_sum16(iph))
def _tcp_checksum(iph, tcp_with_payload):
ihl = _ip_hdr_len(iph)
pseudo = bytearray(12)
pseudo[0:4] = iph[12:16] # src IP
pseudo[4:8] = iph[16:20] # dst IP
pseudo[9] = 6 # TCP protocol
struct.pack_into("!H", pseudo, 10, len(tcp_with_payload))
return _checksum_fold(_sum16(pseudo) + _sum16(tcp_with_payload))
def _build_fake_frame(template_pkt, isn, fake_payload):
"""Build the injection frame from a captured 3rd-ACK packet template.
Takes the captured Ethernet+IP+TCP headers from the 3rd handshake ACK,
appends the fake TLS ClientHello as payload, and sets:
- seq = ISN + 1 - len(fake_payload) (out of window for the server)
- PSH flag added
- Proper IP and TCP checksums recalculated
"""
ip_off = 14 # Ethernet header is 14 bytes
ihl = _ip_hdr_len(template_pkt[ip_off:])
tcp_off = ip_off + ihl
tcp_hdr_len = (template_pkt[tcp_off + 12] >> 4) * 4
# Copy headers (Ethernet + IP + TCP) and append fake payload
headers = bytearray(template_pkt[:tcp_off + tcp_hdr_len])
out = headers + fake_payload
# Update IP total length
struct.pack_into("!H", out, ip_off + 2, len(out) - ip_off)
# Increment IP ID
old_id = struct.unpack("!H", out[ip_off + 4:ip_off + 6])[0]
struct.pack_into("!H", out, ip_off + 4, (old_id + 1) & 0xFFFF)
# Recalculate IP checksum
out[ip_off + 10] = 0
out[ip_off + 11] = 0
ip_cksum = _ip_checksum(out[ip_off:ip_off + ihl])
struct.pack_into("!H", out, ip_off + 10, ip_cksum)
# Set PSH flag
out[tcp_off + 13] |= PSH
# Set out-of-window sequence number: ISN + 1 - len(fake)
seq = (isn + 1 - len(fake_payload)) & 0xFFFFFFFF
struct.pack_into("!I", out, tcp_off + 4, seq)
# Recalculate TCP checksum
out[tcp_off + 16] = 0
out[tcp_off + 17] = 0
tcp_cksum = _tcp_checksum(
out[ip_off:ip_off + ihl],
bytes(out[tcp_off:]),
)
struct.pack_into("!H", out, tcp_off + 16, tcp_cksum)
return bytes(out)
class PortState:
"""Per-connection state tracked by the sniffer."""
def __init__(self, syn_seq, fake_hello):
self.syn_seq = syn_seq
self.fake_hello = fake_hello
self.fake_sent = False
self.confirmed = threading.Event()
self.lock = threading.Lock()
class RawInjector:
"""Raw socket sniffer and injector for out-of-window fake SNI.
This is the core mechanism that makes the seq_id trick work:
- Monitors all TCP traffic between local and target IPs
- When a new outbound SYN is detected, records the ISN
- When the 3rd handshake ACK is seen, injects the fake ClientHello
- Waits for server confirmation (ACK with ack == ISN+1)
"""
def __init__(self, local_ip, remote_ip, remote_port, fake_sni_builder):
self.local_ip = socket.inet_aton(local_ip)
self.remote_ip = socket.inet_aton(remote_ip)
self.remote_port = remote_port
self.fake_sni_builder = fake_sni_builder
self.ports: Dict[int, PortState] = {}
self.ports_lock = threading.Lock()
self.raw_fd = None
self.iface_idx = None
self.iface_name = None
self.running = False
self._sniffer_thread = None
def start(self):
"""Open the raw socket and start the sniffer loop."""
try:
self.raw_fd = socket.socket(
socket.AF_PACKET,
socket.SOCK_RAW,
socket.htons(ETH_P_ALL),
)
except (PermissionError, OSError) as e:
logger.warning(f"Cannot open AF_PACKET socket: {e}")
logger.warning("Raw injection unavailable - need root/CAP_NET_RAW")
return False
# Find the interface
iface_info = self._find_interface()
if iface_info is None:
logger.warning("Cannot determine outgoing interface for raw injection")
self.raw_fd.close()
self.raw_fd = None
return False
self.iface_name, self.iface_idx = iface_info
self.raw_fd.bind((self.iface_name, ETH_P_ALL))
self.running = True
self._sniffer_thread = threading.Thread(
target=self._sniff_loop, daemon=True
)
self._sniffer_thread.start()
logger.info("Raw packet injector started")
return True
def stop(self):
"""Stop the sniffer."""
self.running = False
if self.raw_fd:
try:
self.raw_fd.close()
except Exception:
pass
def _find_interface(self):
"""Find the network interface name and index for the target IP.
Returns:
Tuple of (interface_name, interface_index) or None if not found.
"""
import fcntl
import array
try:
# Use a UDP connect to find which interface is used
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect((socket.inet_ntoa(self.remote_ip), 53))
local_addr = s.getsockname()[0]
s.close()
# Get all interfaces and find the matching one
# Using SIOCGIFCONF
max_bytes = 8096
buf = array.array("B", b"\0" * max_bytes)
ifconf = struct.pack("iL", max_bytes, buf.buffer_info()[0])
result = fcntl.ioctl(
self.raw_fd.fileno(), 0x8912, ifconf # SIOCGIFCONF
)
out_bytes = struct.unpack("iL", result)[0]
offset = 0
while offset < out_bytes:
name = buf[offset:offset + 16].tobytes().split(b"\0", 1)[0]
ip_bytes = buf[offset + 20:offset + 24].tobytes()
ip_str = socket.inet_ntoa(ip_bytes)
if ip_str == local_addr:
iface_name = name.decode("ascii", errors="replace")
# Get interface index
ifreq = struct.pack("16sI", name, 0)
result = fcntl.ioctl(
self.raw_fd.fileno(), 0x8933, ifreq # SIOCGIFINDEX
)
idx = struct.unpack("16sI", result)[1]
logger.debug(f"Using interface {iface_name} (index {idx})")
return (iface_name, idx)
offset += 40 # struct ifreq size
except Exception as e:
logger.debug(f"Interface detection error: {e}")
return None
def register_port(self, local_port, fake_hello):
"""Register a port for monitoring (called before connect)."""
with self.ports_lock:
self.ports[local_port] = PortState(0, fake_hello)
def wait_for_confirmation(self, local_port, timeout=2.0):
"""Wait for the server to confirm it ignored the fake packet.
Returns True if confirmed, False on timeout.
"""
with self.ports_lock:
ps = self.ports.get(local_port)
if ps is None:
return False
return ps.confirmed.wait(timeout=timeout)
def cleanup_port(self, local_port):
"""Clean up state for a port."""
with self.ports_lock:
self.ports.pop(local_port, None)
def _inject_frame(self, frame):
"""Inject a raw Ethernet frame."""
try:
addr = (
self.iface_name or "", # interface name
ETH_P_IP,
0, # packet type
0, # arp hardware type
frame[0:6], # destination MAC
)
self.raw_fd.sendto(frame, addr)
return True
except Exception as e:
logger.debug(f"Inject error: {e}")
# Fallback: try sendto with sockaddr_ll style
try:
sll = struct.pack(
"HH I BB 8s",
socket.htons(ETH_P_IP), # protocol
self.iface_idx, # ifindex
0, # pkttype
6, # halen
0,
frame[0:8], # addr
)
os.write(self.raw_fd.fileno(), frame)
return True
except Exception as e2:
logger.debug(f"Inject fallback error: {e2}")
return False
def _sniff_loop(self):
"""Main sniffer loop - watches TCP handshakes and injects fake packets."""
while self.running:
try:
pkt, _ = self.raw_fd.recvfrom(65536)
except (OSError, socket.error):
if not self.running:
break
continue
if len(pkt) < 14 + 20 + 20:
continue
# Check Ethernet type is IPv4
eth_type = struct.unpack("!H", pkt[12:14])[0]
if eth_type != ETH_P_IP:
continue
ip = pkt[14:]
if (ip[0] >> 4) != 4 or ip[9] != IPPROTO_TCP:
continue
ihl = _ip_hdr_len(ip)
src_ip = ip[12:16]
dst_ip = ip[16:20]
tcp = ip[ihl:]
if len(tcp) < 20:
continue
flags = tcp[13]
tcp_hdr_len = (tcp[12] >> 4) * 4
payload_len = len(tcp) - tcp_hdr_len
outbound = (src_ip == self.local_ip and dst_ip == self.remote_ip)
inbound = (src_ip == self.remote_ip and dst_ip == self.local_ip)
if outbound:
src_port = struct.unpack("!H", tcp[0:2])[0]
seq = struct.unpack("!I", tcp[4:8])[0]
# SYN (no ACK): new outbound connection
if (flags & SYN) and not (flags & ACK):
with self.ports_lock:
ps = self.ports.get(src_port)
if ps is not None:
with ps.lock:
ps.syn_seq = seq
logger.debug(
f"[sniff] SYN port={src_port} isn={seq}"
)
continue
# 3rd-handshake ACK: ACK only, no payload
if (flags & ACK) and not (flags & (SYN | FIN | RST)) and payload_len == 0:
with self.ports_lock:
ps = self.ports.get(src_port)
if ps is None:
continue
with ps.lock:
if ps.fake_sent:
continue
ps.fake_sent = True
syn_seq = ps.syn_seq
fake = ps.fake_hello
# Inject after a tiny delay (like the Go version's 1ms)
tpl_copy = bytearray(pkt)
def _do_inject(tpl=tpl_copy, isn=syn_seq, payload=fake, port=src_port):
time.sleep(0.001)
frame = _build_fake_frame(bytes(tpl), isn, payload)
if self._inject_frame(frame):
out_seq = (isn + 1 - len(payload)) & 0xFFFFFFFF
logger.debug(
f"[inject] port={port} fake seq={out_seq} "
f"(ISN={isn}, fake_len={len(payload)})"
)
else:
logger.debug(f"[inject] port={port} injection failed")
threading.Thread(target=_do_inject, daemon=True).start()
if inbound:
dst_port = struct.unpack("!H", tcp[2:4])[0]
ack_num = struct.unpack("!I", tcp[8:12])[0]
# Server's ACK confirming fake was ignored
if (flags & ACK) and not (flags & (SYN | FIN | RST)) and payload_len == 0:
with self.ports_lock:
ps = self.ports.get(dst_port)
if ps is None:
continue
with ps.lock:
if ps.fake_sent and ack_num == (ps.syn_seq + 1) & 0xFFFFFFFF:
if not ps.confirmed.is_set():
ps.confirmed.set()
logger.debug(
f"[sniff] port={dst_port} CONFIRMED "
f"server acked ISN+1={ack_num}"
)
def is_raw_available():
"""Check if raw socket injection is available on this system."""
try:
s = socket.socket(
socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ALL)
)
s.close()
return True
except (PermissionError, OSError, AttributeError):
return False
+495
View File
@@ -0,0 +1,495 @@
"""
SNISPF - Cross-platform SNI spoofing and DPI bypass tool.
Works on Windows, macOS, and Linux without requiring kernel drivers.
On Linux with root, enables raw packet injection for the seq_id trick.
Usage:
snispf --config config.json
snispf --listen 0.0.0.0:40443 --connect 188.114.98.0:443 --sni auth.vercel.com
"""
import argparse
import asyncio
import json
import logging
import os
import platform
import signal
import sys
from pathlib import Path
# Add parent to path for direct script execution
if __name__ == "__main__":
sys.path.insert(0, str(Path(__file__).parent.parent))
from sni_spoofing import __version__
from sni_spoofing.bypass import (
BypassStrategy,
CombinedBypass,
FakeSNIBypass,
FragmentBypass,
RawInjector,
is_raw_available,
)
from sni_spoofing.forwarder import start_server
from sni_spoofing.utils import (
check_platform_capabilities,
get_default_interface_ipv4,
is_valid_ip,
is_valid_port,
resolve_host,
)
# ─── Banner ──────────────────────────────────────────────────────────────────
BANNER = r"""
███████╗███╗ ██╗██╗███████╗██████╗ ███████╗
██╔════╝████╗ ██║██║██╔════╝██╔══██╗██╔════╝
███████╗██╔██╗ ██║██║███████╗██████╔╝█████╗
╚════██║██║╚██╗██║██║╚════██║██╔═══╝ ██╔══╝
███████║██║ ╚████║██║███████║██║ ██║
╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═╝ ╚═╝
┌──────────────────────────────────────────────────────────────────┐
│ SNISPF - Cross-Platform DPI Bypass Tool │
│ SNI Spoofing + TLS Fragmentation │
│ Works on Windows / macOS / Linux │
│ https://github.com/Rainman69/SNISPF │
└──────────────────────────────────────────────────────────────────┘
"""
# ─── Logging ─────────────────────────────────────────────────────────────────
def setup_logging(verbose: bool = False, quiet: bool = False):
"""Configure logging."""
if quiet:
level = logging.WARNING
elif verbose:
level = logging.DEBUG
else:
level = logging.INFO
formatter = logging.Formatter(
"%(asctime)s%(levelname)-7s%(message)s",
datefmt="%H:%M:%S",
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
logger = logging.getLogger("snispf")
logger.setLevel(level)
logger.addHandler(handler)
return logger
# ─── Config ──────────────────────────────────────────────────────────────────
DEFAULT_CONFIG = {
"LISTEN_HOST": "0.0.0.0",
"LISTEN_PORT": 40443,
"CONNECT_IP": "188.114.98.0",
"CONNECT_PORT": 443,
"FAKE_SNI": "auth.vercel.com",
"BYPASS_METHOD": "fragment",
"FRAGMENT_STRATEGY": "sni_split",
"FRAGMENT_DELAY": 0.1,
"USE_TTL_TRICK": False,
"FAKE_SNI_METHOD": "prefix_fake",
}
def load_config(config_path: str) -> dict:
"""Load configuration from JSON file."""
try:
with open(config_path, "r") as f:
user_config = json.load(f)
# Merge with defaults
config = DEFAULT_CONFIG.copy()
config.update(user_config)
return config
except FileNotFoundError:
print(f"Error: Config file not found: {config_path}")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in config file: {e}")
sys.exit(1)
def generate_config(output_path: str):
"""Generate a default configuration file."""
config = {
"LISTEN_HOST": "0.0.0.0",
"LISTEN_PORT": 40443,
"CONNECT_IP": "188.114.98.0",
"CONNECT_PORT": 443,
"FAKE_SNI": "auth.vercel.com",
"BYPASS_METHOD": "fragment",
"FRAGMENT_STRATEGY": "sni_split",
"FRAGMENT_DELAY": 0.1,
"USE_TTL_TRICK": False,
"FAKE_SNI_METHOD": "prefix_fake",
}
with open(output_path, "w") as f:
json.dump(config, f, indent=2)
print(f"Generated default config: {output_path}")
print(json.dumps(config, indent=2))
# ─── Strategy Builder ────────────────────────────────────────────────────────
def build_strategy(config: dict, raw_injector=None) -> BypassStrategy:
"""Build the appropriate bypass strategy from config.
Available methods:
- "fragment": Fragment TLS ClientHello at SNI boundary
- "fake_sni": Send fake ClientHello with spoofed SNI (needs raw sockets
for the seq_id trick; falls back to fragmentation without them)
- "combined": Both fragmentation and fake SNI (recommended)
"""
method = config.get("BYPASS_METHOD", "fragment").lower()
if method == "fragment":
return FragmentBypass(
strategy=config.get("FRAGMENT_STRATEGY", "sni_split"),
fragment_delay=config.get("FRAGMENT_DELAY", 0.1),
)
elif method == "fake_sni":
return FakeSNIBypass(
method=config.get("FAKE_SNI_METHOD", "prefix_fake"),
raw_injector=raw_injector,
)
elif method == "combined":
return CombinedBypass(
fragment_strategy=config.get("FRAGMENT_STRATEGY", "sni_split"),
use_ttl_trick=config.get("USE_TTL_TRICK", False),
fragment_delay=config.get("FRAGMENT_DELAY", 0.1),
raw_injector=raw_injector,
)
else:
print(f"Warning: Unknown bypass method '{method}', using 'fragment'")
return FragmentBypass()
# ─── CLI ─────────────────────────────────────────────────────────────────────
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
prog="snispf",
description=(
"SNISPF - Cross-platform DPI bypass tool.\n\n"
"This tool forwards TCP connections while applying DPI bypass\n"
"techniques (SNI spoofing, TLS fragmentation) to circumvent\n"
"internet censorship."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" %(prog)s --config config.json\n"
" %(prog)s -l 0.0.0.0:40443 -c 188.114.98.0:443 -s auth.vercel.com\n"
" %(prog)s -l :40443 -c 188.114.98.0:443 -s dl.google.com -m combined\n"
" %(prog)s --generate-config my_config.json\n"
"\nBypass Methods:\n"
" fragment - Fragment TLS ClientHello at SNI boundary (default)\n"
" fake_sni - Inject fake ClientHello (needs root for seq_id trick)\n"
" combined - Both fragmentation and fake SNI (most effective)\n"
"\nFragment Strategies (for fragment/combined methods):\n"
" sni_split - Split in middle of SNI value (default)\n"
" half - Split record in half\n"
" multi - Split into many small fragments\n"
" tls_record_frag - Use TLS-level record fragmentation\n"
"\nhttps://github.com/Rainman69/SNISPF"
),
)
# Config file
parser.add_argument(
"--config", "-C",
help="Path to JSON config file",
)
parser.add_argument(
"--generate-config",
metavar="PATH",
help="Generate a default config file and exit",
)
# Connection settings
parser.add_argument(
"--listen", "-l",
metavar="HOST:PORT",
help="Listen address (default: 0.0.0.0:40443)",
)
parser.add_argument(
"--connect", "-c",
metavar="IP:PORT",
help="Target server address (default: 188.114.98.0:443)",
)
parser.add_argument(
"--sni", "-s",
metavar="HOSTNAME",
help="Fake SNI hostname (default: auth.vercel.com)",
)
# Bypass settings
parser.add_argument(
"--method", "-m",
choices=["fragment", "fake_sni", "combined"],
help="Bypass method (default: fragment)",
)
parser.add_argument(
"--fragment-strategy",
choices=["sni_split", "half", "multi", "tls_record_frag"],
help="Fragment strategy (default: sni_split)",
)
parser.add_argument(
"--fragment-delay",
type=float,
metavar="SECONDS",
help="Delay between fragments in seconds (default: 0.1)",
)
parser.add_argument(
"--ttl-trick",
action="store_true",
help="Use IP TTL trick for fake packets (may need privileges)",
)
parser.add_argument(
"--no-raw",
action="store_true",
help="Disable raw socket injection even if available",
)
# Output settings
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Verbose output (debug logging)",
)
parser.add_argument(
"--quiet", "-q",
action="store_true",
help="Quiet output (warnings only)",
)
parser.add_argument(
"--version", "-V",
action="version",
version=f"SNISPF {__version__}",
)
parser.add_argument(
"--info",
action="store_true",
help="Show platform capabilities and exit",
)
return parser.parse_args()
def parse_host_port(addr: str, default_host: str = "0.0.0.0", default_port: int = 443) -> tuple:
"""Parse HOST:PORT string."""
if not addr:
return default_host, default_port
if addr.startswith(":"):
return default_host, int(addr[1:])
parts = addr.rsplit(":", 1)
if len(parts) == 2:
host = parts[0] or default_host
port = int(parts[1])
return host, port
else:
return parts[0], default_port
def show_platform_info():
"""Display platform capability information."""
caps = check_platform_capabilities()
# Also check raw injection availability
caps["raw_injection"] = is_raw_available()
print("\n╔══════════════════════════════════════════╗")
print("║ Platform Capabilities ║")
print("╠══════════════════════════════════════════╣")
for key, value in caps.items():
status = "" if value is True else ("" if value is False else str(value))
print(f"{key:<28} {status:>8}")
print("╚══════════════════════════════════════════╝")
print("\nRecommended bypass methods for your platform:")
if caps["raw_injection"]:
print(" ✓ Raw packet injection available (running as root)")
print(" ★ Recommended: combined (uses seq_id trick + fragmentation)")
print(" ★ Also good: fake_sni (uses seq_id trick)")
elif caps["raw_socket"]:
print(" ✓ All methods available (running with sufficient privileges)")
print(" ★ Recommended: combined --ttl-trick")
else:
print(" ✓ fragment - TLS ClientHello fragmentation")
print(" ✓ combined - Fragmentation (fake_sni needs root for seq_id)")
print(" ★ Recommended: fragment or combined")
if platform.system() != "Windows":
print(" Run with sudo/root for raw injection (seq_id trick)")
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
"""Main entry point."""
args = parse_args()
# Handle special commands
if args.generate_config:
generate_config(args.generate_config)
return
if args.info:
print(BANNER)
show_platform_info()
return
# Print banner
print(BANNER)
# Setup logging
logger = setup_logging(verbose=args.verbose, quiet=args.quiet)
# Load configuration
if args.config:
config = load_config(args.config)
else:
config = DEFAULT_CONFIG.copy()
# Override with CLI arguments
if args.listen:
host, port = parse_host_port(args.listen, "0.0.0.0", 40443)
config["LISTEN_HOST"] = host
config["LISTEN_PORT"] = port
if args.connect:
host, port = parse_host_port(args.connect, "188.114.98.0", 443)
config["CONNECT_IP"] = host
config["CONNECT_PORT"] = port
if args.sni:
config["FAKE_SNI"] = args.sni
if args.method:
config["BYPASS_METHOD"] = args.method
if args.fragment_strategy:
config["FRAGMENT_STRATEGY"] = args.fragment_strategy
if args.fragment_delay is not None:
config["FRAGMENT_DELAY"] = args.fragment_delay
if args.ttl_trick:
config["USE_TTL_TRICK"] = True
# Validate configuration
if not is_valid_port(config["LISTEN_PORT"]):
print(f"Error: Invalid listen port: {config['LISTEN_PORT']}")
sys.exit(1)
if not is_valid_port(config["CONNECT_PORT"]):
print(f"Error: Invalid connect port: {config['CONNECT_PORT']}")
sys.exit(1)
# Resolve target host if needed
config["CONNECT_IP"] = resolve_host(config["CONNECT_IP"])
# Detect interface IP
interface_ip = get_default_interface_ipv4(config["CONNECT_IP"])
logger.info(f"Default interface: {interface_ip or 'auto'}")
# Try to start raw injector (Linux + root only)
raw_injector = None
use_raw = not getattr(args, 'no_raw', False)
method = config.get("BYPASS_METHOD", "fragment").lower()
if use_raw and method in ("fake_sni", "combined") and interface_ip:
if is_raw_available():
from sni_spoofing.bypass.raw_injector import RawInjector
raw_injector = RawInjector(
local_ip=interface_ip,
remote_ip=config["CONNECT_IP"],
remote_port=config["CONNECT_PORT"],
fake_sni_builder=None,
)
if not raw_injector.start():
logger.warning(
"Raw injector failed to start. "
"Falling back to fragmentation."
)
raw_injector = None
else:
if method == "fake_sni":
logger.warning(
"Raw sockets not available (need root/CAP_NET_RAW). "
"fake_sni will fall back to fragmentation."
)
elif method == "combined":
logger.info(
"Raw sockets not available. "
"Using fragmentation-only bypass."
)
# Build bypass strategy
strategy = build_strategy(config, raw_injector=raw_injector)
# Show configuration summary
logger.info(f"Platform: {platform.system()} {platform.machine()}")
logger.info(f"Python: {platform.python_version()}")
# Setup signal handlers for graceful shutdown
def signal_handler(sig, frame):
print("\n\nShutting down...")
if raw_injector:
raw_injector.stop()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, signal_handler)
# Run the server
try:
asyncio.run(
start_server(
listen_host=config["LISTEN_HOST"],
listen_port=config["LISTEN_PORT"],
connect_ip=config["CONNECT_IP"],
connect_port=config["CONNECT_PORT"],
fake_sni=config["FAKE_SNI"],
bypass_strategy=strategy,
interface_ip=interface_ip,
raw_injector=raw_injector,
)
)
except KeyboardInterrupt:
print("\nShutting down...")
except PermissionError:
print(f"\nError: Permission denied on port {config['LISTEN_PORT']}.")
if config["LISTEN_PORT"] < 1024:
print("Ports below 1024 require root/administrator privileges.")
print(f"Try: sudo {sys.argv[0]} ... or use a port >= 1024")
sys.exit(1)
except OSError as e:
if "address already in use" in str(e).lower():
print(f"\nError: Port {config['LISTEN_PORT']} is already in use.")
print("Use --listen :PORT to specify a different port.")
else:
print(f"\nError: {e}")
sys.exit(1)
finally:
if raw_injector:
raw_injector.stop()
if __name__ == "__main__":
main()
+248
View File
@@ -0,0 +1,248 @@
"""Core TCP forwarder with DPI bypass.
This is the main engine that:
1. Listens for incoming TCP connections
2. Reads the first TLS ClientHello from the client
3. Applies the chosen DPI bypass strategy
4. Relays data bidirectionally between client and server
When a raw injector is available (Linux + root), it registers each
outgoing connection so the sniffer can capture the SYN/ACK handshake
and inject the fake ClientHello with an out-of-window seq number.
Uses pure userspace techniques on platforms without raw socket support.
"""
import asyncio
import logging
import socket
import sys
import traceback
from typing import Optional
from .bypass.base import BypassStrategy
from .tls import ClientHelloBuilder
logger = logging.getLogger("snispf")
# Buffer size for socket operations
BUFFER_SIZE = 65535
async def handle_connection(
incoming_sock: socket.socket,
incoming_addr: tuple,
connect_ip: str,
connect_port: int,
fake_sni: str,
bypass_strategy: BypassStrategy,
interface_ip: Optional[str] = None,
raw_injector=None,
):
"""Handle a single incoming connection.
Flow:
1. Read first data from client (should be TLS ClientHello)
2. Create outgoing socket, optionally register with raw injector
3. Connect to target server (3-way handshake happens here;
the raw injector captures SYN and injects after 3rd ACK)
4. Apply the bypass strategy (sends real data, waits for inject confirmation)
5. Relay data bidirectionally
"""
loop = asyncio.get_running_loop()
outgoing_sock = None
local_port = None
try:
# Read the first data from client (should be TLS ClientHello)
first_data = await asyncio.wait_for(
loop.sock_recv(incoming_sock, BUFFER_SIZE),
timeout=30.0,
)
if not first_data:
incoming_sock.close()
return
# Parse to see if it's a TLS ClientHello
parsed = ClientHelloBuilder.parse_client_hello(first_data)
client_sni = parsed.get("sni", "unknown")
logger.info(
f"[{incoming_addr[0]}:{incoming_addr[1]}] -> "
f"{connect_ip}:{connect_port} | SNI: {client_sni} | "
f"Bypass: {bypass_strategy.name}"
)
# Create outgoing socket
outgoing_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
outgoing_sock.setblocking(False)
# Bind to specific interface if configured
if interface_ip:
outgoing_sock.bind((interface_ip, 0))
# Set keepalive
outgoing_sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
try:
outgoing_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
outgoing_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
outgoing_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)
except (AttributeError, OSError):
pass # Not available on all platforms
# If raw injector is available, register the outgoing port
# BEFORE connecting so the sniffer can see the SYN.
if raw_injector is not None:
# We need to bind first to know the local port
if not interface_ip:
outgoing_sock.bind(("", 0))
local_port = outgoing_sock.getsockname()[1]
fake_hello = ClientHelloBuilder.build_client_hello(sni=fake_sni)
raw_injector.register_port(local_port, fake_hello)
# Connect to target server (triggers SYN -> SYN+ACK -> ACK)
await asyncio.wait_for(
loop.sock_connect(outgoing_sock, (connect_ip, connect_port)),
timeout=30.0,
)
# If we didn't know the port before, grab it now
if local_port is None and raw_injector is not None:
local_port = outgoing_sock.getsockname()[1]
# Apply DPI bypass strategy
# The strategy handles:
# - Waiting for raw injection confirmation (if available)
# - Sending the real ClientHello (fragmented or not)
success = await bypass_strategy.apply(
client_sock=incoming_sock,
server_sock=outgoing_sock,
fake_sni=fake_sni,
first_data=first_data,
loop=loop,
)
if not success:
logger.warning(
f"[{incoming_addr[0]}:{incoming_addr[1]}] "
f"Bypass strategy '{bypass_strategy.name}' failed, "
f"falling back to direct relay"
)
# Fallback: just send the data directly
await loop.sock_sendall(outgoing_sock, first_data)
# Bidirectional relay
done = asyncio.Event()
async def _relay(s_in, s_out, label):
try:
while True:
data = await loop.sock_recv(s_in, BUFFER_SIZE)
if not data:
break
await loop.sock_sendall(s_out, data)
except (ConnectionResetError, BrokenPipeError, OSError):
pass
except Exception:
logger.debug(f"Relay error ({label}): {traceback.format_exc()}")
finally:
done.set()
c2s_task = loop.create_task(_relay(incoming_sock, outgoing_sock, "C->S"))
s2c_task = loop.create_task(_relay(outgoing_sock, incoming_sock, "S->C"))
# Wait until one direction closes, then cancel the other
await done.wait()
c2s_task.cancel()
s2c_task.cancel()
await asyncio.gather(c2s_task, s2c_task, return_exceptions=True)
except asyncio.TimeoutError:
logger.debug(f"[{incoming_addr[0]}:{incoming_addr[1]}] Connection timeout")
except Exception:
logger.debug(f"Connection handler error: {traceback.format_exc()}")
finally:
try:
incoming_sock.close()
except Exception:
pass
try:
if outgoing_sock:
outgoing_sock.close()
except Exception:
pass
# Clean up raw injector port state
if raw_injector is not None and local_port is not None:
raw_injector.cleanup_port(local_port)
async def start_server(
listen_host: str,
listen_port: int,
connect_ip: str,
connect_port: int,
fake_sni: str,
bypass_strategy: BypassStrategy,
interface_ip: Optional[str] = None,
raw_injector=None,
):
"""Start the TCP forwarding server.
Creates a listening socket and handles incoming connections,
applying the DPI bypass strategy to each one.
"""
# Create listening socket
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setblocking(False)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((listen_host, listen_port))
# Set keepalive on the listening socket
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
try:
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)
except (AttributeError, OSError):
pass
server_sock.listen(128)
loop = asyncio.get_running_loop()
logger.info(f"Listening on {listen_host}:{listen_port}")
logger.info(f"Forwarding to {connect_ip}:{connect_port}")
logger.info(f"Fake SNI: {fake_sni}")
logger.info(f"Bypass strategy: {bypass_strategy.name}")
if raw_injector is not None:
logger.info("Raw packet injection: ACTIVE (seq_id trick enabled)")
else:
logger.info("Raw packet injection: not available (fragmentation only)")
logger.info(f"Interface IP: {interface_ip or 'auto'}")
logger.info("=" * 60)
logger.info("Ready! Configure your application to use:")
logger.info(f" Address: 127.0.0.1:{listen_port}")
logger.info("=" * 60)
try:
while True:
incoming_sock, addr = await loop.sock_accept(server_sock)
incoming_sock.setblocking(False)
loop.create_task(
handle_connection(
incoming_sock=incoming_sock,
incoming_addr=addr,
connect_ip=connect_ip,
connect_port=connect_port,
fake_sni=fake_sni,
bypass_strategy=bypass_strategy,
interface_ip=interface_ip,
raw_injector=raw_injector,
)
)
except asyncio.CancelledError:
pass
finally:
server_sock.close()
logger.info("Server stopped.")
+433
View File
@@ -0,0 +1,433 @@
"""TLS ClientHello builder and parser module.
Constructs TLS 1.3 ClientHello messages with customizable SNI fields
for DPI bypass purposes.
"""
import struct
import os
from typing import Optional
class ClientHelloBuilder:
"""Builds TLS ClientHello packets with spoofed SNI.
The ClientHello is the first message in a TLS handshake. DPI systems
inspect the SNI (Server Name Indication) extension to determine the
destination hostname. By sending a ClientHello with a fake SNI to an
allowed domain, we can bypass SNI-based filtering.
"""
# Pre-built template parts from the original tool
# TLS Record Header + Handshake Header + Client Version + ...
# Cipher suites, compression methods, and most extensions are static
# Only SNI, session_id, random, and key_share are dynamic
# TLS 1.3 cipher suites that look legitimate
CIPHER_SUITES = bytes.fromhex(
"0024" # length = 36 bytes (18 cipher suites x 2)
"1302" # TLS_AES_256_GCM_SHA384
"1303" # TLS_CHACHA20_POLY1305_SHA256
"1301" # TLS_AES_128_GCM_SHA256
"c02c" # TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
"c030" # TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
"c02b" # TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
"c02f" # TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
"cca9" # TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
"cca8" # TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
"c024" # TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
"c028" # TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
"c023" # TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
"c027" # TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
"009f" # TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
"009e" # TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
"006b" # TLS_DHE_RSA_WITH_AES_256_CBC_SHA256
"0067" # TLS_DHE_RSA_WITH_AES_128_CBC_SHA256
"00ff" # TLS_EMPTY_RENEGOTIATION_INFO_SCSV
)
# Supported groups extension
SUPPORTED_GROUPS = bytes.fromhex(
"000a" # extension type: supported_groups
"0016" # length
"0014" # list length
"001d" # x25519
"0017" # secp256r1
"001e" # x448
"0019" # secp521r1
"0018" # secp384r1
"0100" # ffdhe2048
"0101" # ffdhe3072
"0102" # ffdhe4096
"0103" # ffdhe6144
"0104" # ffdhe8192
)
# Signature algorithms extension
SIGNATURE_ALGORITHMS = bytes.fromhex(
"000d" # extension type: signature_algorithms
"002a" # length
"0028" # list length
"0403" # ecdsa_secp256r1_sha256
"0503" # ecdsa_secp384r1_sha384
"0603" # ecdsa_secp521r1_sha512
"0807" # ed25519
"0808" # ed448
"0809" # ...
"080a"
"080b"
"0804" # rsa_pss_rsae_sha256
"0805" # rsa_pss_rsae_sha384
"0806" # rsa_pss_rsae_sha512
"0401" # rsa_pkcs1_sha256
"0501" # rsa_pkcs1_sha384
"0601" # rsa_pkcs1_sha512
"0303" # ...
"0301"
"0302"
"0402"
"0502"
"0602"
)
# EC point formats
EC_POINT_FORMATS = bytes.fromhex(
"000b" # extension type: ec_point_formats
"0004" # length
"0300" # list length + uncompressed
"0102" # ansiX962_compressed_prime + ansiX962_compressed_char2
)
# Session ticket extension (empty)
SESSION_TICKET = bytes.fromhex(
"0023" # extension type: session_ticket
"0000" # length: 0
)
# ALPN extension (h2, http/1.1)
ALPN = bytes.fromhex(
"0010" # extension type: ALPN
"000e" # length
"000c" # protocols length
"0268" # length + 'h'
"3208" # '2' + length
"6874" # 'ht'
"7470" # 'tp'
"2f31" # '/1'
"2e31" # '.1'
)
# Encrypt then MAC
ENCRYPT_THEN_MAC = bytes.fromhex("0016" "0000")
# Extended master secret
EXTENDED_MASTER_SECRET = bytes.fromhex("0017" "0000")
# Supported versions extension (TLS 1.3, TLS 1.2)
SUPPORTED_VERSIONS = bytes.fromhex(
"002b" # extension type: supported_versions
"0005" # length: 5 bytes of data follow
"04" # supported_versions list length: 4 bytes (2 versions x 2 bytes)
"0304" # TLS 1.3
"0303" # TLS 1.2
)
# PSK key exchange modes
PSK_KEY_EXCHANGE = bytes.fromhex(
"002d" # extension type: psk_key_exchange_modes
"0002" # length
"0101" # psk_dhe_ke
)
@classmethod
def build_sni_extension(cls, sni: str) -> bytes:
"""Build the SNI (Server Name Indication) extension."""
sni_bytes = sni.encode("ascii")
sni_len = len(sni_bytes)
# Server name entry: type(1) + length(2) + name
entry = struct.pack("!BH", 0, sni_len) + sni_bytes
# Server name list: length(2) + entries
name_list = struct.pack("!H", len(entry)) + entry
# Extension: type(2) + length(2) + data
return struct.pack("!HH", 0x0000, len(name_list)) + name_list
@classmethod
def build_key_share_extension(cls, public_key: Optional[bytes] = None) -> bytes:
"""Build the key_share extension with x25519 key."""
if public_key is None:
public_key = os.urandom(32)
# Key share entry: group(2) + key_length(2) + key
entry = struct.pack("!HH", 0x001D, 32) + public_key
# Key share extension: length(2) + entries
data = struct.pack("!H", len(entry)) + entry
return struct.pack("!HH", 0x0033, len(data)) + data
@classmethod
def build_padding_extension(cls, target_length: int, current_length: int) -> bytes:
"""Build padding extension to reach target ClientHello size.
Padding is used to make the ClientHello a specific size, which helps
avoid fingerprinting and ensures consistent packet sizes.
"""
# Extension header is 4 bytes (type + length)
padding_needed = target_length - current_length - 4
if padding_needed < 0:
return b""
return struct.pack("!HH", 0x0015, padding_needed) + (b"\x00" * padding_needed)
@classmethod
def build_client_hello(
cls,
sni: str,
session_id: Optional[bytes] = None,
random_bytes: Optional[bytes] = None,
key_share: Optional[bytes] = None,
target_size: int = 517,
) -> bytes:
"""Build a complete TLS ClientHello record.
Args:
sni: The Server Name Indication to include
session_id: 32-byte session ID (random if None)
random_bytes: 32-byte client random (random if None)
key_share: 32-byte x25519 public key (random if None)
target_size: Target total size for the TLS record (default 517)
Returns:
Complete TLS record bytes ready to send
"""
if session_id is None:
session_id = os.urandom(32)
if random_bytes is None:
random_bytes = os.urandom(32)
# Client version: TLS 1.2 (0x0303) - real version in extensions
client_version = b"\x03\x03"
# Session ID
session_id_field = struct.pack("!B", len(session_id)) + session_id
# Compression methods: null only
compression = b"\x01\x00"
# Build extensions
sni_ext = cls.build_sni_extension(sni)
key_share_ext = cls.build_key_share_extension(key_share)
# Assemble extensions (order matters for fingerprint matching)
extensions = b"".join([
sni_ext,
cls.EC_POINT_FORMATS,
cls.SUPPORTED_GROUPS,
cls.SESSION_TICKET,
cls.ALPN,
cls.ENCRYPT_THEN_MAC,
cls.EXTENDED_MASTER_SECRET,
cls.SIGNATURE_ALGORITHMS,
cls.SUPPORTED_VERSIONS,
cls.PSK_KEY_EXCHANGE,
key_share_ext,
])
# Calculate size for padding
# Handshake body (without record header): version(2) + random(32) + session_id_field + cipher_suites + compression + extensions_header(2) + extensions
handshake_body_no_pad = (
client_version
+ random_bytes
+ session_id_field
+ cls.CIPHER_SUITES
+ compression
)
extensions_len_so_far = len(extensions)
# Total handshake msg = 4 (handshake header) + body + 2 (extensions length) + extensions
total_so_far = 4 + len(handshake_body_no_pad) + 2 + extensions_len_so_far
# TLS record = 5 (record header) + handshake
record_so_far = 5 + total_so_far
# Add padding to reach target size
padding_ext = cls.build_padding_extension(target_size, record_so_far)
extensions += padding_ext
# Extensions length prefix
extensions_with_len = struct.pack("!H", len(extensions)) + extensions
# Handshake body
handshake_body = handshake_body_no_pad + extensions_with_len
# Handshake message: type(1) + length(3) + body
handshake_len = len(handshake_body)
handshake = (
b"\x01" # ClientHello
+ struct.pack("!I", handshake_len)[1:] # 3-byte length
+ handshake_body
)
# TLS record: content_type(1) + version(2) + length(2) + data
record = (
b"\x16" # Handshake
+ b"\x03\x01" # TLS 1.0 (legacy for compatibility)
+ struct.pack("!H", len(handshake))
+ handshake
)
return record
@classmethod
def build_client_response(cls, random_bytes: Optional[bytes] = None) -> bytes:
"""Build a fake TLS client response (ChangeCipherSpec + ApplicationData).
This simulates the client's response after receiving ServerHello,
which is useful for making the connection look legitimate to DPI.
"""
if random_bytes is None:
random_bytes = os.urandom(32)
# Change Cipher Spec
ccs = b"\x14\x03\x03\x00\x01\x01"
# Application Data (fake encrypted payload)
app_data = (
b"\x17" # Application Data
+ b"\x03\x03" # TLS 1.2
+ struct.pack("!H", len(random_bytes))
+ random_bytes
)
return ccs + app_data
@staticmethod
def parse_client_hello(data: bytes) -> dict:
"""Parse a TLS ClientHello to extract SNI and other fields.
Args:
data: Raw TLS record bytes
Returns:
Dictionary with parsed fields
"""
result = {}
if len(data) < 5:
return result
# TLS Record header
content_type = data[0]
tls_version = struct.unpack("!H", data[1:3])[0]
record_len = struct.unpack("!H", data[3:5])[0]
result["content_type"] = content_type
result["tls_version"] = f"0x{tls_version:04x}"
if content_type != 0x16: # Not handshake
return result
pos = 5 # Skip record header
# Handshake header
if pos + 4 > len(data):
return result
hs_type = data[pos]
hs_len = struct.unpack("!I", b"\x00" + data[pos + 1 : pos + 4])[0]
pos += 4
if hs_type != 0x01: # Not ClientHello
return result
result["handshake_type"] = "ClientHello"
# Client version
client_version = struct.unpack("!H", data[pos : pos + 2])[0]
result["client_version"] = f"0x{client_version:04x}"
pos += 2
# Random (32 bytes)
result["random"] = data[pos : pos + 32].hex()
pos += 32
# Session ID
sess_len = data[pos]
pos += 1
result["session_id"] = data[pos : pos + sess_len].hex()
pos += sess_len
# Cipher suites
cs_len = struct.unpack("!H", data[pos : pos + 2])[0]
pos += 2 + cs_len
# Compression
comp_len = data[pos]
pos += 1 + comp_len
# Extensions
if pos + 2 > len(data):
return result
ext_len = struct.unpack("!H", data[pos : pos + 2])[0]
pos += 2
ext_end = pos + ext_len
while pos + 4 <= ext_end:
ext_type = struct.unpack("!H", data[pos : pos + 2])[0]
ext_data_len = struct.unpack("!H", data[pos + 2 : pos + 4])[0]
ext_data = data[pos + 4 : pos + 4 + ext_data_len]
pos += 4 + ext_data_len
if ext_type == 0x0000: # SNI
if len(ext_data) >= 5:
name_list_len = struct.unpack("!H", ext_data[0:2])[0]
name_type = ext_data[2]
name_len = struct.unpack("!H", ext_data[3:5])[0]
sni = ext_data[5 : 5 + name_len].decode("ascii", errors="replace")
result["sni"] = sni
return result
@staticmethod
def parse_server_hello(data: bytes) -> dict:
"""Parse a TLS ServerHello message."""
result = {}
if len(data) < 5:
return result
content_type = data[0]
if content_type != 0x16:
return result
pos = 5 # Skip record header
if pos + 4 > len(data):
return result
hs_type = data[pos]
pos += 4
if hs_type != 0x02: # Not ServerHello
return result
result["handshake_type"] = "ServerHello"
# Server version
server_version = struct.unpack("!H", data[pos : pos + 2])[0]
result["server_version"] = f"0x{server_version:04x}"
pos += 2
# Server random
result["random"] = data[pos : pos + 32].hex()
pos += 32
# Session ID
sess_len = data[pos]
pos += 1
result["session_id"] = data[pos : pos + sess_len].hex()
pos += sess_len
# Cipher suite
cipher = struct.unpack("!H", data[pos : pos + 2])[0]
result["cipher_suite"] = f"0x{cipher:04x}"
pos += 2
# Compression
result["compression"] = data[pos]
pos += 1
return result
+157
View File
@@ -0,0 +1,157 @@
"""TLS record fragmentation utilities.
Implements various strategies for splitting TLS records to confuse
DPI (Deep Packet Inspection) systems that don't fully reassemble
TLS handshakes.
"""
import struct
from typing import List, Tuple
def fragment_client_hello(data: bytes, strategy: str = "sni_split") -> List[bytes]:
"""Fragment a TLS ClientHello into multiple TCP segments.
DPI systems often only inspect the first packet or fail to reassemble
fragmented TLS records. By splitting the ClientHello at strategic points
(especially around the SNI extension), we can hide the real SNI.
Args:
data: Complete TLS record bytes
strategy: Fragmentation strategy:
- "sni_split": Split right in the middle of the SNI value
- "half": Split the record in half
- "multi": Split into many small fragments
- "tls_record_frag": Use TLS-level record fragmentation
- "none": No fragmentation
Returns:
List of byte fragments to send as separate TCP segments
"""
if strategy == "none" or len(data) < 10:
return [data]
if strategy == "sni_split":
return _fragment_at_sni(data)
elif strategy == "half":
mid = len(data) // 2
return [data[:mid], data[mid:]]
elif strategy == "multi":
return _fragment_multi(data)
elif strategy == "tls_record_frag":
return _tls_record_fragment(data)
else:
return [data]
def _find_sni_offset(data: bytes) -> Tuple[int, int]:
"""Find the offset and length of the SNI value in a ClientHello.
Returns:
Tuple of (sni_value_offset, sni_value_length) or (-1, 0) if not found
"""
# Look for SNI extension type (0x0000) followed by reasonable length
pos = 0
while pos < len(data) - 10:
# Look for the SNI extension pattern: 00 00 xx xx xx xx 00 xx xx 00
if data[pos] == 0x00 and data[pos + 1] == 0x00:
try:
ext_len = struct.unpack("!H", data[pos + 2 : pos + 4])[0]
if 4 < ext_len < 256: # Reasonable SNI extension length
list_len = struct.unpack("!H", data[pos + 4 : pos + 6])[0]
name_type = data[pos + 6]
name_len = struct.unpack("!H", data[pos + 7 : pos + 9])[0]
if name_type == 0 and name_len > 0 and name_len < 256:
sni_start = pos + 9
# Verify it looks like a domain name
sni_data = data[sni_start : sni_start + name_len]
if all(0x20 <= b < 0x7F for b in sni_data):
return sni_start, name_len
except (struct.error, IndexError):
pass
pos += 1
return -1, 0
def _fragment_at_sni(data: bytes) -> List[bytes]:
"""Split the TLS record right in the middle of the SNI value."""
sni_offset, sni_len = _find_sni_offset(data)
if sni_offset < 0:
# Fallback to half split
mid = len(data) // 2
return [data[:mid], data[mid:]]
# Split in the middle of the SNI hostname
split_point = sni_offset + sni_len // 2
return [data[:split_point], data[split_point:]]
def _fragment_multi(data: bytes, chunk_size: int = 24) -> List[bytes]:
"""Split into many small fragments.
Each fragment gets sent as its own TCP segment with TCP_NODELAY.
A chunk size of 24 bytes keeps the fragment count reasonable
(about 22 fragments for a 517-byte ClientHello) while still being
small enough that no single fragment contains the entire SNI.
"""
fragments = []
for i in range(0, len(data), chunk_size):
fragments.append(data[i : i + chunk_size])
return fragments
def _tls_record_fragment(data: bytes) -> List[bytes]:
"""Use TLS-level record fragmentation.
Instead of splitting at the TCP level, we create multiple valid
TLS records that together contain the full handshake message.
This is a more sophisticated approach that some DPI systems
can't handle.
"""
if len(data) < 6 or data[0] != 0x16:
return [data]
# Extract the handshake data from the TLS record
record_version = data[1:3]
handshake_data = data[5:]
# Split the handshake data into two parts
mid = len(handshake_data) // 2
part1 = handshake_data[:mid]
part2 = handshake_data[mid:]
# Create two separate TLS records
record1 = b"\x16" + record_version + struct.pack("!H", len(part1)) + part1
record2 = b"\x16" + record_version + struct.pack("!H", len(part2)) + part2
return [record1, record2]
def fragment_data(data: bytes, sizes: List[int]) -> List[bytes]:
"""Fragment data into specified sizes.
Args:
data: Raw bytes to fragment
sizes: List of fragment sizes. Last fragment gets remaining data.
Returns:
List of byte fragments
"""
fragments = []
pos = 0
for i, size in enumerate(sizes):
if pos >= len(data):
break
if i == len(sizes) - 1:
# Last specified size: include all remaining data
fragments.append(data[pos:])
else:
fragments.append(data[pos : pos + size])
pos += size
# If we consumed all specified sizes but data remains
if pos < len(data) and len(fragments) < len(sizes):
fragments.append(data[pos:])
return fragments if fragments else [data]
+130
View File
@@ -0,0 +1,130 @@
"""Network utility functions.
Cross-platform network interface detection and helpers.
"""
import socket
import sys
import platform
from typing import Optional
def get_default_interface_ipv4(dest: str = "8.8.8.8") -> Optional[str]:
"""Get the IPv4 address of the default network interface.
Creates a UDP socket and connects to a public address to determine
which local IP would be used for outgoing connections.
Args:
dest: Destination IP to determine route (not actually contacted)
Returns:
Local IPv4 address string, or None on failure
"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect((dest, 53))
addr = s.getsockname()[0]
s.close()
return addr
except OSError:
return None
def get_default_interface_ipv6(dest: str = "2001:4860:4860::8888") -> Optional[str]:
"""Get the IPv6 address of the default network interface.
Args:
dest: Destination IPv6 to determine route
Returns:
Local IPv6 address string, or None on failure
"""
try:
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.connect((dest, 53))
addr = s.getsockname()[0]
s.close()
return addr
except OSError:
return None
def check_platform_capabilities() -> dict:
"""Check what DPI bypass capabilities are available on this platform.
Returns:
Dictionary of available features
"""
caps = {
"platform": platform.system(),
"python_version": sys.version,
"fragment_support": True, # Always available (userspace TCP)
"tls_record_frag": True, # Always available (application layer)
"fake_sni": True, # Always available (application layer)
"tcp_nodelay": True, # Always available
"raw_socket": False, # Platform-dependent
"ip_ttl_trick": False, # Platform-dependent
}
# Check raw socket support (needed for advanced tricks)
try:
if platform.system() != "Windows":
s = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP)
s.close()
caps["raw_socket"] = True
caps["ip_ttl_trick"] = True
else:
# Windows raw sockets are limited
caps["raw_socket"] = False
except (PermissionError, OSError):
pass
# Check AF_PACKET support (Linux only, needed for seq_id injection)
try:
if platform.system() == "Linux":
s = socket.socket(
socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003)
)
s.close()
caps["af_packet"] = True
caps["raw_injection"] = True
else:
caps["af_packet"] = False
caps["raw_injection"] = False
except (PermissionError, OSError, AttributeError):
caps["af_packet"] = False
caps["raw_injection"] = False
return caps
def resolve_host(host: str) -> str:
"""Resolve hostname to IP address.
Args:
host: Hostname or IP address
Returns:
IP address string
"""
try:
return socket.gethostbyname(host)
except socket.gaierror:
return host
def is_valid_ip(addr: str) -> bool:
"""Check if string is a valid IPv4 or IPv6 address."""
for family in (socket.AF_INET, socket.AF_INET6):
try:
socket.inet_pton(family, addr)
return True
except (socket.error, OSError):
continue
return False
def is_valid_port(port: int) -> bool:
"""Check if port number is valid."""
return isinstance(port, int) and 1 <= port <= 65535
View File
+357
View File
@@ -0,0 +1,357 @@
"""Unit tests for TLS ClientHello builder and parser."""
import os
import sys
import struct
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sni_spoofing.tls import ClientHelloBuilder
from sni_spoofing.tls.fragment import (
fragment_client_hello,
fragment_data,
_find_sni_offset,
)
class TestClientHelloBuilder(unittest.TestCase):
"""Test TLS ClientHello construction."""
def test_build_client_hello_basic(self):
"""Test basic ClientHello construction."""
hello = ClientHelloBuilder.build_client_hello(sni="example.com")
# Should start with TLS record header
self.assertEqual(hello[0], 0x16) # Handshake
self.assertEqual(hello[1], 0x03) # TLS major version
self.assertEqual(hello[2], 0x01) # TLS 1.0 (legacy)
# Record length should match
record_len = struct.unpack("!H", hello[3:5])[0]
self.assertEqual(record_len, len(hello) - 5)
# Handshake type should be ClientHello
self.assertEqual(hello[5], 0x01)
def test_build_client_hello_target_size(self):
"""Test that ClientHello hits 517 bytes (matching Go template)."""
hello = ClientHelloBuilder.build_client_hello(sni="mci.ir")
self.assertEqual(len(hello), 517)
def test_build_client_hello_contains_sni(self):
"""Test that built ClientHello contains the specified SNI."""
sni = "auth.vercel.com"
hello = ClientHelloBuilder.build_client_hello(sni=sni)
# The SNI should be present in the packet
self.assertIn(sni.encode("ascii"), hello)
def test_build_client_hello_different_snis(self):
"""Test building with different SNI values."""
for sni in ["google.com", "cloudflare.com", "example.org", "test.co"]:
hello = ClientHelloBuilder.build_client_hello(sni=sni)
self.assertIn(sni.encode("ascii"), hello)
self.assertEqual(hello[0], 0x16)
def test_build_client_hello_custom_session_id(self):
"""Test with custom session ID."""
session_id = os.urandom(32)
hello = ClientHelloBuilder.build_client_hello(
sni="test.com", session_id=session_id
)
self.assertIn(session_id, hello)
def test_build_client_hello_custom_random(self):
"""Test with custom random bytes."""
random_bytes = os.urandom(32)
hello = ClientHelloBuilder.build_client_hello(
sni="test.com", random_bytes=random_bytes
)
self.assertIn(random_bytes, hello)
def test_parse_client_hello_roundtrip(self):
"""Test build and parse roundtrip."""
sni = "auth.vercel.com"
hello = ClientHelloBuilder.build_client_hello(sni=sni)
parsed = ClientHelloBuilder.parse_client_hello(hello)
self.assertEqual(parsed.get("handshake_type"), "ClientHello")
self.assertEqual(parsed.get("sni"), sni)
self.assertEqual(parsed.get("content_type"), 0x16)
def test_parse_client_hello_multiple(self):
"""Test parsing multiple different ClientHellos."""
for sni in ["test.com", "example.org", "cloudflare.com"]:
hello = ClientHelloBuilder.build_client_hello(sni=sni)
parsed = ClientHelloBuilder.parse_client_hello(hello)
self.assertEqual(parsed.get("sni"), sni)
def test_build_sni_extension(self):
"""Test SNI extension construction."""
ext = ClientHelloBuilder.build_sni_extension("test.com")
# Extension type should be 0x0000 (SNI)
ext_type = struct.unpack("!H", ext[0:2])[0]
self.assertEqual(ext_type, 0x0000)
# Should contain the hostname
self.assertIn(b"test.com", ext)
def test_build_key_share_extension(self):
"""Test key share extension construction."""
key = os.urandom(32)
ext = ClientHelloBuilder.build_key_share_extension(key)
# Extension type should be 0x0033 (key_share)
ext_type = struct.unpack("!H", ext[0:2])[0]
self.assertEqual(ext_type, 0x0033)
# Should contain the key
self.assertIn(key, ext)
def test_build_client_response(self):
"""Test client response (CCS + AppData) construction."""
resp = ClientHelloBuilder.build_client_response()
# Should start with Change Cipher Spec
self.assertEqual(resp[0], 0x14) # CCS content type
self.assertEqual(resp[1], 0x03)
self.assertEqual(resp[2], 0x03)
def test_parse_empty_data(self):
"""Test parsing empty or too-short data."""
self.assertEqual(ClientHelloBuilder.parse_client_hello(b""), {})
self.assertEqual(ClientHelloBuilder.parse_client_hello(b"\x00"), {})
def test_parse_non_handshake(self):
"""Test parsing non-handshake data."""
result = ClientHelloBuilder.parse_client_hello(b"\x17\x03\x03\x00\x05hello")
self.assertEqual(result.get("content_type"), 0x17)
self.assertNotIn("handshake_type", result)
class TestFragmentation(unittest.TestCase):
"""Test TLS record fragmentation."""
def test_sni_split_fragments(self):
"""Test SNI-split fragmentation produces exactly 2 fragments."""
hello = ClientHelloBuilder.build_client_hello(sni="test.example.com")
fragments = fragment_client_hello(hello, "sni_split")
self.assertEqual(len(fragments), 2)
# Reassembled should equal original
self.assertEqual(b"".join(fragments), hello)
def test_half_split(self):
"""Test half-split fragmentation."""
hello = ClientHelloBuilder.build_client_hello(sni="test.com")
fragments = fragment_client_hello(hello, "half")
self.assertEqual(len(fragments), 2)
self.assertEqual(b"".join(fragments), hello)
def test_multi_split(self):
"""Test multi-fragment split."""
hello = ClientHelloBuilder.build_client_hello(sni="test.com")
fragments = fragment_client_hello(hello, "multi")
self.assertGreater(len(fragments), 2)
self.assertEqual(b"".join(fragments), hello)
def test_tls_record_fragment(self):
"""Test TLS record-level fragmentation."""
hello = ClientHelloBuilder.build_client_hello(sni="test.com")
fragments = fragment_client_hello(hello, "tls_record_frag")
self.assertEqual(len(fragments), 2)
# Each fragment should be a valid TLS record
for frag in fragments:
self.assertEqual(frag[0], 0x16) # Handshake type
def test_no_fragmentation(self):
"""Test 'none' strategy returns single fragment."""
hello = ClientHelloBuilder.build_client_hello(sni="test.com")
fragments = fragment_client_hello(hello, "none")
self.assertEqual(len(fragments), 1)
self.assertEqual(fragments[0], hello)
def test_find_sni_offset(self):
"""Test SNI offset detection."""
hello = ClientHelloBuilder.build_client_hello(sni="example.com")
offset, length = _find_sni_offset(hello)
self.assertGreater(offset, 0)
self.assertEqual(length, len("example.com"))
# Verify the SNI at that offset
self.assertEqual(hello[offset:offset + length], b"example.com")
def test_fragment_data_custom_sizes(self):
"""Test custom size fragmentation."""
data = b"A" * 100
fragments = fragment_data(data, [10, 20, 30])
self.assertEqual(len(fragments[0]), 10)
self.assertEqual(len(fragments[1]), 20)
self.assertEqual(b"".join(fragments), data)
def test_fragment_preserves_data(self):
"""Test that fragmentation preserves all data."""
for strategy in ["sni_split", "half", "multi", "tls_record_frag", "none"]:
hello = ClientHelloBuilder.build_client_hello(sni="test.example.org")
fragments = fragment_client_hello(hello, strategy)
if strategy != "tls_record_frag":
# For TLS record frag, the output is re-wrapped
reassembled = b"".join(fragments)
self.assertEqual(
len(reassembled),
len(hello),
f"Strategy '{strategy}' changed data length",
)
class TestRawInjector(unittest.TestCase):
"""Test raw injector frame construction."""
def test_build_fake_frame_checksum(self):
"""Test that _build_fake_frame produces valid IP and TCP checksums."""
try:
from sni_spoofing.bypass.raw_injector import (
_build_fake_frame,
_ip_checksum,
_ip_hdr_len,
_tcp_checksum,
)
except ImportError:
self.skipTest("raw_injector not importable")
# Build a minimal Ethernet+IP+TCP template (14+20+20 = 54 bytes)
# Ethernet: dst(6) + src(6) + type(2)
eth = bytes(6) + bytes(6) + b"\x08\x00"
# IP header: version/ihl(1)+tos(1)+totlen(2)+id(2)+flags/frag(2)+ttl(1)+proto(1)+cksum(2)+src(4)+dst(4)
iph = bytearray(20)
iph[0] = 0x45 # IPv4, IHL=5
iph[8] = 64 # TTL
iph[9] = 6 # TCP
iph[12:16] = b"\xc0\xa8\x01\x02" # src 192.168.1.2
iph[16:20] = b"\x68\x12\x04\x82" # dst 104.18.4.130
struct.pack_into("!H", iph, 2, 40) # total length
# TCP header: srcport(2)+dstport(2)+seq(4)+ack(4)+offset/flags(2)+window(2)+cksum(2)+urgent(2)
tcph = bytearray(20)
struct.pack_into("!H", tcph, 0, 54321) # src port
struct.pack_into("!H", tcph, 2, 443) # dst port
struct.pack_into("!I", tcph, 4, 1000) # seq
struct.pack_into("!I", tcph, 8, 2000) # ack
tcph[12] = 0x50 # data offset = 5 words
tcph[13] = 0x10 # ACK flag
template = bytes(eth) + bytes(iph) + bytes(tcph)
# Build the fake frame
fake_payload = ClientHelloBuilder.build_client_hello(sni="test.com")
frame = _build_fake_frame(template, 999, fake_payload)
# Check that the frame is longer than the template
self.assertGreater(len(frame), len(template))
# Verify the seq number: ISN + 1 - len(fake)
tcp_off = 14 + 20
seq = struct.unpack("!I", frame[tcp_off + 4:tcp_off + 8])[0]
expected_seq = (1000 - len(fake_payload)) & 0xFFFFFFFF
self.assertEqual(seq, expected_seq)
# Check PSH flag is set
self.assertTrue(frame[tcp_off + 13] & 0x08)
def test_is_raw_available(self):
"""Test raw availability detection doesn't crash."""
from sni_spoofing.bypass.raw_injector import is_raw_available
result = is_raw_available()
self.assertIsInstance(result, bool)
class TestUtilities(unittest.TestCase):
"""Test utility functions."""
def test_imports(self):
"""Test that all modules import correctly."""
from sni_spoofing.bypass import (
BypassStrategy,
CombinedBypass,
FakeSNIBypass,
FragmentBypass,
RawInjector,
is_raw_available,
)
from sni_spoofing.forwarder import handle_connection, start_server
from sni_spoofing.utils import (
get_default_interface_ipv4,
check_platform_capabilities,
resolve_host,
is_valid_ip,
is_valid_port,
)
def test_is_valid_ip(self):
"""Test IP validation."""
from sni_spoofing.utils import is_valid_ip
self.assertTrue(is_valid_ip("127.0.0.1"))
self.assertTrue(is_valid_ip("192.168.1.1"))
self.assertTrue(is_valid_ip("0.0.0.0"))
self.assertFalse(is_valid_ip("not-an-ip"))
self.assertFalse(is_valid_ip(""))
def test_is_valid_port(self):
"""Test port validation."""
from sni_spoofing.utils import is_valid_port
self.assertTrue(is_valid_port(80))
self.assertTrue(is_valid_port(443))
self.assertTrue(is_valid_port(40443))
self.assertTrue(is_valid_port(65535))
self.assertFalse(is_valid_port(0))
self.assertFalse(is_valid_port(65536))
self.assertFalse(is_valid_port(-1))
def test_platform_capabilities(self):
"""Test platform capabilities detection."""
from sni_spoofing.utils import check_platform_capabilities
caps = check_platform_capabilities()
self.assertIn("platform", caps)
self.assertIn("fragment_support", caps)
self.assertIn("tls_record_frag", caps)
self.assertIn("af_packet", caps)
self.assertIn("raw_injection", caps)
self.assertTrue(caps["fragment_support"])
self.assertTrue(caps["tls_record_frag"])
self.assertTrue(caps["fake_sni"])
def test_strategy_construction(self):
"""Test bypass strategy construction."""
from sni_spoofing.bypass import FragmentBypass, FakeSNIBypass, CombinedBypass
frag = FragmentBypass(strategy="sni_split")
self.assertEqual(frag.name, "fragment")
fake = FakeSNIBypass(method="prefix_fake")
self.assertEqual(fake.name, "fake_sni")
combo = CombinedBypass()
self.assertEqual(combo.name, "combined")
def test_strategy_with_raw_injector(self):
"""Test strategy construction with raw_injector parameter."""
from sni_spoofing.bypass import FakeSNIBypass, CombinedBypass
fake = FakeSNIBypass(raw_injector="mock")
self.assertEqual(fake.raw_injector, "mock")
combo = CombinedBypass(raw_injector="mock")
self.assertEqual(combo.raw_injector, "mock")
if __name__ == "__main__":
unittest.main(verbosity=2)