initial commit
This commit is contained in:
commit
369b49412e
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
__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
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
config.json
Normal file
12
config.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"LISTEN_HOST": "0.0.0.0",
|
||||||
|
"LISTEN_PORT": 40443,
|
||||||
|
"CONNECT_IP": "104.19.230.21",
|
||||||
|
"CONNECT_PORT": 443,
|
||||||
|
"FAKE_SNI": "www.hcaptcha.com",
|
||||||
|
"BYPASS_METHOD": "fragment",
|
||||||
|
"FRAGMENT_STRATEGY": "sni_split",
|
||||||
|
"FRAGMENT_DELAY": 0.1,
|
||||||
|
"USE_TTL_TRICK": false,
|
||||||
|
"FAKE_SNI_METHOD": "prefix_fake"
|
||||||
|
}
|
||||||
43
pyproject.toml
Normal file
43
pyproject.toml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "snispf"
|
||||||
|
version = "2.0.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
run.py
Normal file
12
run.py
Normal 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
sni_spoofing/__init__.py
Normal file
6
sni_spoofing/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
SNISPF - Cross-platform SNI spoofing and DPI bypass tool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "2.0.0"
|
||||||
|
__author__ = "Rainman69"
|
||||||
16
sni_spoofing/bypass/__init__.py
Normal file
16
sni_spoofing/bypass/__init__.py
Normal 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
sni_spoofing/bypass/base.py
Normal file
44
sni_spoofing/bypass/base.py
Normal 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}'>"
|
||||||
143
sni_spoofing/bypass/combined.py
Normal file
143
sni_spoofing/bypass/combined.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
"""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 via a SEPARATE socket with low TTL
|
||||||
|
# so it reaches DPI but expires before the server. The
|
||||||
|
# main socket stays clean for the real TLS handshake.
|
||||||
|
fake_hello = ClientHelloBuilder.build_client_hello(sni=fake_sni)
|
||||||
|
try:
|
||||||
|
remote_addr = server_sock.getpeername()
|
||||||
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
probe.setblocking(False)
|
||||||
|
probe.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
for ttl in (1, 2, 3):
|
||||||
|
try:
|
||||||
|
probe.setsockopt(
|
||||||
|
socket.IPPROTO_IP, socket.IP_TTL, ttl
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
loop.sock_connect(probe, remote_addr),
|
||||||
|
timeout=0.3,
|
||||||
|
)
|
||||||
|
await loop.sock_sendall(probe, fake_hello)
|
||||||
|
except (asyncio.TimeoutError, OSError):
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
probe.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# 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
|
||||||
296
sni_spoofing/bypass/fake_sni.py
Normal file
296
sni_spoofing/bypass/fake_sni.py
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
"""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")
|
||||||
|
|
||||||
|
# Delay between TLS fragments when fragmenting the real ClientHello
|
||||||
|
# after the seq_id fake injection. Matches the value used in the
|
||||||
|
# combined strategy so behaviour is consistent.
|
||||||
|
_REAL_FRAGMENT_DELAY = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
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 the TTL trick (sending a fake ClientHello with
|
||||||
|
low IP TTL) combined with TLS fragmentation.
|
||||||
|
|
||||||
|
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. Works on macOS, Android, Linux.
|
||||||
|
- "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,
|
||||||
|
use_ttl_trick: bool = False,
|
||||||
|
fragment_real: bool = True,
|
||||||
|
fragment_strategy: str = "sni_split"):
|
||||||
|
"""Initialise the fake_sni strategy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: Sub-method name (kept for backwards compatibility).
|
||||||
|
raw_injector: Active ``RawInjector`` instance or ``None``.
|
||||||
|
use_ttl_trick: Force the TTL trick fallback path.
|
||||||
|
fragment_real: When a raw injector is active, also fragment
|
||||||
|
the real ClientHello at the SNI boundary after the
|
||||||
|
out-of-window fake has been confirmed. This protects
|
||||||
|
against DPI that reassembles TCP and matches the SNI
|
||||||
|
on the real stream (observed with some xhttp / ws
|
||||||
|
configs that carry larger ClientHello records, e.g.
|
||||||
|
multi-value ALPN). Defaults to ``True``.
|
||||||
|
fragment_strategy: Fragmentation strategy passed through to
|
||||||
|
``fragment_client_hello`` when ``fragment_real`` is on.
|
||||||
|
"""
|
||||||
|
self.method = method
|
||||||
|
self.raw_injector = raw_injector
|
||||||
|
self.use_ttl_trick = use_ttl_trick
|
||||||
|
self.fragment_real = fragment_real
|
||||||
|
self.fragment_strategy = fragment_strategy
|
||||||
|
|
||||||
|
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, use TTL trick if enabled (auto-enabled
|
||||||
|
# on macOS/Android/unprivileged Linux), otherwise fragment only.
|
||||||
|
if self.method == "ttl_trick" or self.use_ttl_trick:
|
||||||
|
return await self._ttl_trick_and_fragment(
|
||||||
|
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.
|
||||||
|
|
||||||
|
After the server confirms it ignored the fake, send the real
|
||||||
|
ClientHello. By default the real ClientHello is also split at
|
||||||
|
the SNI boundary; this matches the behaviour of the ``combined``
|
||||||
|
strategy and is required for stricter DPI that reassembles the
|
||||||
|
TCP stream and matches the SNI on the real handshake (observed
|
||||||
|
with xhttp / ws configs that carry larger ClientHellos, e.g.
|
||||||
|
multi-value ALPN such as ``h3,h2,http/1.1``).
|
||||||
|
|
||||||
|
Set ``fragment_real=False`` to restore the previous behaviour
|
||||||
|
of sending the real ClientHello as a single segment.
|
||||||
|
"""
|
||||||
|
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. Fragmenting at the SNI boundary
|
||||||
|
# in addition to the seq_id trick covers DPI that does TCP
|
||||||
|
# reassembly on the real stream (some xhttp / ws configs).
|
||||||
|
if self.fragment_real:
|
||||||
|
try:
|
||||||
|
server_sock.setsockopt(
|
||||||
|
socket.IPPROTO_TCP, socket.TCP_NODELAY, 1
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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 _REAL_FRAGMENT_DELAY > 0:
|
||||||
|
await asyncio.sleep(_REAL_FRAGMENT_DELAY)
|
||||||
|
|
||||||
|
try:
|
||||||
|
server_sock.setsockopt(
|
||||||
|
socket.IPPROTO_TCP, socket.TCP_NODELAY, 0
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Legacy path: send the real ClientHello untouched.
|
||||||
|
await loop.sock_sendall(server_sock, first_data)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _ttl_trick_and_fragment(
|
||||||
|
self,
|
||||||
|
server_sock: socket.socket,
|
||||||
|
fake_sni: str,
|
||||||
|
first_data: bytes,
|
||||||
|
loop,
|
||||||
|
) -> bool:
|
||||||
|
"""Send fake ClientHello via a separate socket, then real data fragmented.
|
||||||
|
|
||||||
|
The fake ClientHello is sent through a **separate** raw TCP socket
|
||||||
|
(not the proxied connection) with a very low IP TTL. This ensures
|
||||||
|
the fake reaches DPI middleboxes (typically 1-3 hops away) but
|
||||||
|
expires before the real server, so the server never sees it and
|
||||||
|
the real TLS handshake on the main socket stays clean.
|
||||||
|
|
||||||
|
If the separate-socket approach fails (e.g. no permission),
|
||||||
|
we fall back to pure fragmentation which still works well.
|
||||||
|
|
||||||
|
This is the default fallback on macOS, Android/Termux, and
|
||||||
|
unprivileged Linux where AF_PACKET raw sockets are not available.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
|
||||||
|
# --- Send fake on a SEPARATE socket with low TTL ---
|
||||||
|
# This prevents corrupting the real TLS stream.
|
||||||
|
remote_addr = server_sock.getpeername()
|
||||||
|
fake_hello = ClientHelloBuilder.build_client_hello(sni=fake_sni)
|
||||||
|
|
||||||
|
try:
|
||||||
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
probe.setblocking(False)
|
||||||
|
probe.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
# Use a very low TTL so the packet dies before the server
|
||||||
|
for ttl in (1, 2, 3):
|
||||||
|
try:
|
||||||
|
probe.setsockopt(
|
||||||
|
socket.IPPROTO_IP, socket.IP_TTL, ttl
|
||||||
|
)
|
||||||
|
# Non-blocking connect -- we don't care if it
|
||||||
|
# completes; we just want the SYN + fake data
|
||||||
|
# to traverse the DPI middlebox path.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
loop.sock_connect(probe, remote_addr),
|
||||||
|
timeout=0.3,
|
||||||
|
)
|
||||||
|
await loop.sock_sendall(probe, fake_hello)
|
||||||
|
except (asyncio.TimeoutError, OSError):
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
probe.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
# Separate socket approach failed, that's fine
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# --- Send the real ClientHello fragmented on the main socket ---
|
||||||
|
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
|
||||||
|
|
||||||
|
async def _ttl_trick(
|
||||||
|
self,
|
||||||
|
server_sock: socket.socket,
|
||||||
|
fake_sni: str,
|
||||||
|
first_data: bytes,
|
||||||
|
loop,
|
||||||
|
) -> bool:
|
||||||
|
"""Legacy TTL trick: send fake with low TTL then real data normally."""
|
||||||
|
return await self._ttl_trick_and_fragment(
|
||||||
|
server_sock, fake_sni, first_data, loop
|
||||||
|
)
|
||||||
|
|
||||||
|
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
sni_spoofing/bypass/fragment.py
Normal file
89
sni_spoofing/bypass/fragment.py
Normal 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
|
||||||
425
sni_spoofing/bypass/raw_injector.py
Normal file
425
sni_spoofing/bypass/raw_injector.py
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
"""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
|
||||||
|
try:
|
||||||
|
self.raw_fd.bind((self.iface_name, ETH_P_ALL))
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Cannot bind raw socket to {self.iface_name}: {e}")
|
||||||
|
logger.warning("Raw injection unavailable on this platform")
|
||||||
|
self.raw_fd.close()
|
||||||
|
self.raw_fd = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
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
|
||||||
670
sni_spoofing/cli.py
Normal file
670
sni_spoofing/cli.py
Normal file
@ -0,0 +1,670 @@
|
|||||||
|
"""
|
||||||
|
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 104.18.38.202:443 --sni cdnjs.cloudflare.com
|
||||||
|
snispf --check-domains domains.txt --output verified.txt
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_banner() -> str:
|
||||||
|
"""Render the startup banner with the current package version.
|
||||||
|
|
||||||
|
The version is read from ``sni_spoofing.__version__`` so the banner always
|
||||||
|
matches the installed package (fixes the long-standing "source still says
|
||||||
|
v1.7.0" reports from users who clone the repo at a newer release).
|
||||||
|
"""
|
||||||
|
version_line = f"SNI Spoofing + TLS Fragmentation v{__version__}"
|
||||||
|
# Inside-the-box content is 63 chars wide (between '│ ' and the trailing '│').
|
||||||
|
return (
|
||||||
|
"\n"
|
||||||
|
" ███████╗███╗ ██╗██╗███████╗██████╗ ███████╗\n"
|
||||||
|
" ██╔════╝████╗ ██║██║██╔════╝██╔══██╗██╔════╝\n"
|
||||||
|
" ███████╗██╔██╗ ██║██║███████╗██████╔╝█████╗\n"
|
||||||
|
" ╚════██║██║╚██╗██║██║╚════██║██╔═══╝ ██╔══╝\n"
|
||||||
|
" ███████║██║ ╚████║██║███████║██║ ██║\n"
|
||||||
|
" ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═╝ ╚═╝\n"
|
||||||
|
"\n"
|
||||||
|
" ┌──────────────────────────────────────────────────────────────────┐\n"
|
||||||
|
" │ SNISPF - Cross-Platform DPI Bypass Tool │\n"
|
||||||
|
f" │ {version_line:<63}│\n"
|
||||||
|
" │ Works on Windows / macOS / Linux │\n"
|
||||||
|
" │ https://github.com/Rainman69/SNISPF │\n"
|
||||||
|
" └──────────────────────────────────────────────────────────────────┘\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BANNER = _build_banner()
|
||||||
|
|
||||||
|
# ─── Logging ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def setup_logging(verbose: bool = False, quiet: bool = False):
|
||||||
|
"""Configure logging with deduplication guard."""
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("snispf")
|
||||||
|
# Prevent handler accumulation on repeated calls
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(level)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Config ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"LISTEN_HOST": "0.0.0.0",
|
||||||
|
"LISTEN_PORT": 40443,
|
||||||
|
"CONNECT_IP": "104.18.38.202",
|
||||||
|
"CONNECT_PORT": 443,
|
||||||
|
"FAKE_SNI": "cdnjs.cloudflare.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": "104.18.38.202",
|
||||||
|
"CONNECT_PORT": 443,
|
||||||
|
"FAKE_SNI": "cdnjs.cloudflare.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,
|
||||||
|
use_ttl_trick=config.get("USE_TTL_TRICK", False),
|
||||||
|
# When a raw injector is active, also fragment the real
|
||||||
|
# ClientHello at the SNI boundary so that DPI which
|
||||||
|
# reassembles the TCP stream cannot match the SNI on the
|
||||||
|
# real handshake. Required for some xhttp / ws configs
|
||||||
|
# (e.g. multi-value ALPN like h3,h2,http/1.1).
|
||||||
|
fragment_real=config.get("FAKE_SNI_FRAGMENT_REAL", True),
|
||||||
|
fragment_strategy=config.get("FRAGMENT_STRATEGY", "sni_split"),
|
||||||
|
)
|
||||||
|
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 104.18.38.202:443 -s cdnjs.cloudflare.com\n"
|
||||||
|
" %(prog)s -l :40443 -c 104.18.38.202:443 -s www.speedtest.net -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"
|
||||||
|
"\nDomain Checker:\n"
|
||||||
|
" %(prog)s --check-domains domains.txt\n"
|
||||||
|
" %(prog)s --check-domains domains.txt --output verified.txt\n"
|
||||||
|
" %(prog)s --check-domains domains.txt --check-http\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: 104.18.38.202:443)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--sni", "-s",
|
||||||
|
metavar="HOSTNAME",
|
||||||
|
help="Fake SNI hostname (default: cdnjs.cloudflare.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",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Domain checker ──────────────────────────────────────────────
|
||||||
|
domain_group = parser.add_argument_group("Domain Checker Options")
|
||||||
|
domain_group.add_argument(
|
||||||
|
"--check-domains",
|
||||||
|
metavar="FILE",
|
||||||
|
help="Check domains from a file to find Cloudflare-backed ones",
|
||||||
|
)
|
||||||
|
domain_group.add_argument(
|
||||||
|
"--check-workers",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
metavar="N",
|
||||||
|
help="Parallel workers for domain checking (default: 50)",
|
||||||
|
)
|
||||||
|
domain_group.add_argument(
|
||||||
|
"--check-timeout",
|
||||||
|
type=float,
|
||||||
|
default=3.0,
|
||||||
|
metavar="SECONDS",
|
||||||
|
help="Per-domain timeout for checking (default: 3.0)",
|
||||||
|
)
|
||||||
|
domain_group.add_argument(
|
||||||
|
"--output",
|
||||||
|
metavar="FILE",
|
||||||
|
help="Export verified Cloudflare domains to a file",
|
||||||
|
)
|
||||||
|
domain_group.add_argument(
|
||||||
|
"--check-http",
|
||||||
|
action="store_true",
|
||||||
|
help="Also verify HTTP connectivity during domain check",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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. Returns (host, port)."""
|
||||||
|
if not addr:
|
||||||
|
return default_host, default_port
|
||||||
|
|
||||||
|
if addr.startswith(":"):
|
||||||
|
try:
|
||||||
|
return default_host, int(addr[1:])
|
||||||
|
except ValueError:
|
||||||
|
print(f"Error: Invalid port in '{addr}'")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
parts = addr.rsplit(":", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
host = parts[0] or default_host
|
||||||
|
try:
|
||||||
|
port = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
print(f"Error: Invalid port in '{addr}'")
|
||||||
|
sys.exit(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(" ✓ fake_sni - TTL trick + fragmentation (auto-enabled)")
|
||||||
|
print(" ✓ combined - TTL trick + fragmentation (recommended)")
|
||||||
|
print(" ★ Recommended: combined (auto-uses TTL trick)")
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
print(" ℹ Run with sudo/root for raw injection (seq_id trick)")
|
||||||
|
print(" ℹ TTL trick is auto-enabled when raw sockets are unavailable")
|
||||||
|
|
||||||
|
print("\nDomain Checker:")
|
||||||
|
print(" ✓ Bulk Cloudflare-backed domain verifier (no privileges needed)")
|
||||||
|
print(" ★ Use --check-domains FILE to validate SNI candidates")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Domain Checker Command ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run_domain_check(args, logger):
|
||||||
|
"""Execute a bulk domain check and print results."""
|
||||||
|
from sni_spoofing.scanner import DomainChecker
|
||||||
|
|
||||||
|
checker = DomainChecker(
|
||||||
|
concurrency=args.check_workers,
|
||||||
|
timeout=args.check_timeout,
|
||||||
|
verify_tls=True,
|
||||||
|
verify_http=getattr(args, "check_http", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load domains from file
|
||||||
|
try:
|
||||||
|
domains = checker.load_domains_from_file(args.check_domains)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Error: File not found: {args.check_domains}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading file: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not domains:
|
||||||
|
print("No domains found in file.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n Checking {len(domains)} domains...\n")
|
||||||
|
|
||||||
|
# Progress display
|
||||||
|
def progress(done, total):
|
||||||
|
pct = done * 100 // total
|
||||||
|
bar = "█" * (pct // 5) + "░" * (20 - pct // 5)
|
||||||
|
print(f"\r Checking: [{bar}] {done}/{total} ({pct}%)", end="", flush=True)
|
||||||
|
|
||||||
|
results = checker.check_domains(domains, progress_cb=progress)
|
||||||
|
print() # newline after progress bar
|
||||||
|
|
||||||
|
# Display results
|
||||||
|
cf_results = [r for r in results if r.is_cloudflare]
|
||||||
|
usable = [r for r in results if r.usable_as_sni]
|
||||||
|
|
||||||
|
print(f"\n{'═' * 90}")
|
||||||
|
print(f" Domain Check Results")
|
||||||
|
print(f"{'═' * 90}")
|
||||||
|
print(f" Total domains: {len(results)}")
|
||||||
|
print(f" Behind Cloudflare: {len(cf_results)}")
|
||||||
|
print(f" Usable as SNI: {len(usable)}")
|
||||||
|
print(f"{'═' * 90}\n")
|
||||||
|
|
||||||
|
# Show Cloudflare-backed domains
|
||||||
|
print(checker.results_table(results, cloudflare_only=True))
|
||||||
|
|
||||||
|
# Export if requested
|
||||||
|
if args.output:
|
||||||
|
count = checker.export_sni_list(results, args.output)
|
||||||
|
print(f"\n Exported {count} verified domains to {args.output}")
|
||||||
|
|
||||||
|
# Also show summary for non-CF domains
|
||||||
|
non_cf = [r for r in results if not r.is_cloudflare and r.ip]
|
||||||
|
if non_cf:
|
||||||
|
print(f"\n Note: {len(non_cf)} domains are NOT behind Cloudflare")
|
||||||
|
print(f" (these will not work for SNI spoofing through Cloudflare IPs)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 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, "104.18.38.202", 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
|
||||||
|
|
||||||
|
# ── Domain checker mode ───────────────────────────────────────────
|
||||||
|
if args.check_domains:
|
||||||
|
run_domain_check(args, logger)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Auto-load config.json if present and no --config given ──────
|
||||||
|
if not args.config:
|
||||||
|
for candidate in ["config.json", "snispf.json"]:
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
logger.info("Auto-loading config from %s", candidate)
|
||||||
|
user_config = load_config(candidate)
|
||||||
|
# Only apply file values for keys that were NOT
|
||||||
|
# explicitly overridden by CLI arguments.
|
||||||
|
cli_overridden = set()
|
||||||
|
if args.listen:
|
||||||
|
cli_overridden.update(["LISTEN_HOST", "LISTEN_PORT"])
|
||||||
|
if args.connect:
|
||||||
|
cli_overridden.update(["CONNECT_IP", "CONNECT_PORT"])
|
||||||
|
if args.sni:
|
||||||
|
cli_overridden.add("FAKE_SNI")
|
||||||
|
if args.method:
|
||||||
|
cli_overridden.add("BYPASS_METHOD")
|
||||||
|
if args.fragment_strategy:
|
||||||
|
cli_overridden.add("FRAGMENT_STRATEGY")
|
||||||
|
if args.fragment_delay is not None:
|
||||||
|
cli_overridden.add("FRAGMENT_DELAY")
|
||||||
|
if args.ttl_trick:
|
||||||
|
cli_overridden.add("USE_TTL_TRICK")
|
||||||
|
|
||||||
|
for key, val in user_config.items():
|
||||||
|
if key not in cli_overridden:
|
||||||
|
config[key] = val
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── Validate config ───────────────────────────────────────────────
|
||||||
|
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'}")
|
||||||
|
|
||||||
|
# ── Raw injector setup ────────────────────────────────────────────
|
||||||
|
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. "
|
||||||
|
"Enabling TTL trick as fallback."
|
||||||
|
)
|
||||||
|
raw_injector = None
|
||||||
|
config["USE_TTL_TRICK"] = True
|
||||||
|
else:
|
||||||
|
# No raw sockets (macOS, Android/Termux, unprivileged Linux).
|
||||||
|
# Auto-enable the TTL trick: sends a fake ClientHello with a
|
||||||
|
# low IP TTL that reaches the nearby DPI middlebox but expires
|
||||||
|
# before the real server. Works on any platform that supports
|
||||||
|
# setsockopt(IP_TTL).
|
||||||
|
config["USE_TTL_TRICK"] = True
|
||||||
|
if method == "fake_sni":
|
||||||
|
logger.info(
|
||||||
|
"Raw sockets not available (need root/CAP_NET_RAW). "
|
||||||
|
"fake_sni will use TTL trick + fragmentation."
|
||||||
|
)
|
||||||
|
elif method == "combined":
|
||||||
|
logger.info(
|
||||||
|
"Raw sockets not available. "
|
||||||
|
"Using TTL trick + fragmentation 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()
|
||||||
377
sni_spoofing/forwarder.py
Normal file
377
sni_spoofing/forwarder.py
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
"""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. Connects to the configured upstream IP
|
||||||
|
4. Applies the chosen DPI bypass strategy
|
||||||
|
5. 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# `resource` is a POSIX-only module (Linux/macOS/BSD). It does not exist on
|
||||||
|
# Windows, so we import it defensively and skip the fd-limit tweak there.
|
||||||
|
try:
|
||||||
|
import resource # type: ignore
|
||||||
|
except ImportError: # pragma: no cover -- Windows
|
||||||
|
resource = None # type: ignore
|
||||||
|
|
||||||
|
from .bypass.base import BypassStrategy
|
||||||
|
from .tls import ClientHelloBuilder
|
||||||
|
|
||||||
|
logger = logging.getLogger("snispf")
|
||||||
|
|
||||||
|
# Buffer size for socket operations
|
||||||
|
BUFFER_SIZE = 65535
|
||||||
|
|
||||||
|
# How many consecutive failures on a single IP before triggering failover
|
||||||
|
FAILOVER_THRESHOLD = 3
|
||||||
|
|
||||||
|
# Rapid failure window -- if we get FAILOVER_THRESHOLD failures within
|
||||||
|
# this many seconds, the IP is considered blocked.
|
||||||
|
FAILOVER_WINDOW = 30.0
|
||||||
|
|
||||||
|
# Maximum concurrent connections. Keeps the process well under the
|
||||||
|
# OS file-descriptor limit and avoids "Too many open files" crashes
|
||||||
|
# that macOS users hit with the default 256 fd limit.
|
||||||
|
MAX_CONCURRENT_CONNECTIONS = 512
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_fd_limit():
|
||||||
|
"""Try to raise the OS file-descriptor soft limit.
|
||||||
|
|
||||||
|
macOS defaults to 256, which is far too low for a proxy that handles
|
||||||
|
many parallel connections (each needs 2 fds: incoming + outgoing).
|
||||||
|
We attempt to raise the soft limit to the hard limit, or at least
|
||||||
|
to a reasonable value.
|
||||||
|
|
||||||
|
No-op on Windows, where the `resource` module does not exist and
|
||||||
|
socket count is governed differently.
|
||||||
|
"""
|
||||||
|
if resource is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||||
|
if soft < 4096:
|
||||||
|
target = min(hard, 65536) if hard > soft else 4096
|
||||||
|
try:
|
||||||
|
resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard))
|
||||||
|
logger.debug("Raised fd limit from %d to %d", soft, target)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
# On some systems we cannot raise beyond hard limit
|
||||||
|
try:
|
||||||
|
resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
# resource module not available (unlikely) or unsupported platform
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTracker:
|
||||||
|
"""Tracks per-IP connection failures to detect blocking."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._failures = {} # ip -> list of failure timestamps
|
||||||
|
self._successes = {} # ip -> count
|
||||||
|
|
||||||
|
def record_failure(self, ip: str) -> int:
|
||||||
|
"""Record a failure and return how many occurred within the window."""
|
||||||
|
now = time.monotonic()
|
||||||
|
if ip not in self._failures:
|
||||||
|
self._failures[ip] = []
|
||||||
|
self._failures[ip].append(now)
|
||||||
|
# Prune old entries
|
||||||
|
cutoff = now - FAILOVER_WINDOW
|
||||||
|
self._failures[ip] = [t for t in self._failures[ip] if t > cutoff]
|
||||||
|
return len(self._failures[ip])
|
||||||
|
|
||||||
|
def record_success(self, ip: str):
|
||||||
|
"""Record a successful connection (resets the failure counter)."""
|
||||||
|
self._failures.pop(ip, None)
|
||||||
|
self._successes[ip] = self._successes.get(ip, 0) + 1
|
||||||
|
|
||||||
|
def should_failover(self, ip: str) -> bool:
|
||||||
|
count = len(self._failures.get(ip, []))
|
||||||
|
return count >= FAILOVER_THRESHOLD
|
||||||
|
|
||||||
|
def clear(self, ip: str):
|
||||||
|
self._failures.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level tracker shared across connections
|
||||||
|
_conn_tracker = ConnectionTracker()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
active_ip = connect_ip
|
||||||
|
active_sni = fake_sni
|
||||||
|
|
||||||
|
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"{active_ip}:{connect_port} | SNI: {client_sni} | "
|
||||||
|
f"Fake: {active_sni} | Method: {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=active_sni)
|
||||||
|
raw_injector.register_port(local_port, fake_hello)
|
||||||
|
|
||||||
|
# Connect to target server (triggers SYN -> SYN+ACK -> ACK)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
loop.sock_connect(outgoing_sock, (active_ip, connect_port)),
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
except (asyncio.TimeoutError, ConnectionRefusedError, OSError) as exc:
|
||||||
|
fail_count = _conn_tracker.record_failure(active_ip)
|
||||||
|
logger.debug(
|
||||||
|
"[%s:%d] Connect to %s failed (%d/%d): %s",
|
||||||
|
incoming_addr[0], incoming_addr[1], active_ip,
|
||||||
|
fail_count, FAILOVER_THRESHOLD, exc,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 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=active_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)
|
||||||
|
|
||||||
|
# NOTE: Do NOT mark success yet. We need to verify the server
|
||||||
|
# actually responds with valid data (not a block page or RST).
|
||||||
|
# Success is recorded only after the first server response
|
||||||
|
# is received in the relay loop below.
|
||||||
|
|
||||||
|
# Bidirectional relay
|
||||||
|
done = asyncio.Event()
|
||||||
|
server_responded = False
|
||||||
|
|
||||||
|
async def _relay(s_in, s_out, label):
|
||||||
|
nonlocal server_responded
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await loop.sock_recv(s_in, BUFFER_SIZE)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
await loop.sock_sendall(s_out, data)
|
||||||
|
# Record success only when we get the first
|
||||||
|
# response from the server (S->C direction).
|
||||||
|
# This proves the connection is actually working
|
||||||
|
# and the server accepted our ClientHello.
|
||||||
|
if label == "S->C" and not server_responded:
|
||||||
|
server_responded = True
|
||||||
|
_conn_tracker.record_success(active_ip)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# If the server never responded, record a failure.
|
||||||
|
# This catches cases where DPI allows the handshake but
|
||||||
|
# blocks or RSTs actual application data.
|
||||||
|
if not server_responded:
|
||||||
|
_conn_tracker.record_failure(active_ip)
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
# Raise the OS file-descriptor limit before binding
|
||||||
|
_raise_fd_limit()
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# Semaphore limits concurrent connections to prevent fd exhaustion.
|
||||||
|
# Each proxied connection uses 2 fds (client + server), plus the
|
||||||
|
# listening socket itself. This cap prevents the "Too many open
|
||||||
|
# files" crash that happens on macOS (default fd limit 256) and
|
||||||
|
# Android/Termux when VPN clients open many connections at once.
|
||||||
|
conn_semaphore = asyncio.Semaphore(MAX_CONCURRENT_CONNECTIONS)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def _guarded_handle(sock, addr):
|
||||||
|
"""Wrap handle_connection with the concurrency semaphore."""
|
||||||
|
async with conn_semaphore:
|
||||||
|
await handle_connection(
|
||||||
|
incoming_sock=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,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
incoming_sock, addr = await loop.sock_accept(server_sock)
|
||||||
|
incoming_sock.setblocking(False)
|
||||||
|
|
||||||
|
loop.create_task(_guarded_handle(incoming_sock, addr))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
server_sock.close()
|
||||||
|
logger.info("Server stopped.")
|
||||||
21
sni_spoofing/scanner/__init__.py
Normal file
21
sni_spoofing/scanner/__init__.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"""Domain utilities: bulk Cloudflare-domain checker.
|
||||||
|
|
||||||
|
This module previously hosted a Cloudflare clean-IP scanner together with
|
||||||
|
an SNI rotator and a background re-scan engine. The scanner was never
|
||||||
|
finished and frequently produced misleading results (rate-limit false
|
||||||
|
negatives, stale caches, mis-ordered latency rankings), so the entire
|
||||||
|
``--scan`` / ``--auto`` / ``--rescan`` feature surface has been removed.
|
||||||
|
|
||||||
|
What stays is the lightweight bulk **domain checker**: given a text file
|
||||||
|
of hostnames it tells you which ones are actually fronted by Cloudflare
|
||||||
|
and therefore usable as a fake SNI value. It is fully synchronous, has
|
||||||
|
no background threads, and is safe to invoke from the CLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .domain_checker import DomainChecker, DomainResult, is_cloudflare_ip
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DomainChecker",
|
||||||
|
"DomainResult",
|
||||||
|
"is_cloudflare_ip",
|
||||||
|
]
|
||||||
430
sni_spoofing/scanner/domain_checker.py
Normal file
430
sni_spoofing/scanner/domain_checker.py
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
"""Bulk SNI domain checker and verifier.
|
||||||
|
|
||||||
|
Checks a list of domains to determine which ones are behind Cloudflare's
|
||||||
|
CDN and suitable for use as SNI spoof targets. Performs DNS resolution,
|
||||||
|
ASN lookup, TLS handshake, and HTTP validation to verify each domain.
|
||||||
|
|
||||||
|
Inspired by community scanner tools that identify Cloudflare-fronted
|
||||||
|
domains for censorship bypass. The goal is to maintain a large,
|
||||||
|
verified list of domains that can be used as fake SNI values when
|
||||||
|
connecting through Cloudflare IPs.
|
||||||
|
|
||||||
|
Usage (standalone)::
|
||||||
|
|
||||||
|
checker = DomainChecker(concurrency=50, timeout=3.0)
|
||||||
|
results = checker.check_domains(["example.com", "test.org"])
|
||||||
|
for r in results:
|
||||||
|
if r.is_cloudflare:
|
||||||
|
print(f"{r.domain} -> {r.ip} (CF)")
|
||||||
|
|
||||||
|
Usage (from CLI)::
|
||||||
|
|
||||||
|
snispf --check-domains domains.txt
|
||||||
|
snispf --check-domains domains.txt --output verified.txt
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
logger = logging.getLogger("snispf")
|
||||||
|
|
||||||
|
# Known Cloudflare ASN numbers
|
||||||
|
CLOUDFLARE_ASNS = {13335, 209242}
|
||||||
|
|
||||||
|
# Cloudflare's published IPv4 ranges (https://www.cloudflare.com/ips-v4).
|
||||||
|
# Kept inline here so this module has zero intra-package dependencies; the
|
||||||
|
# list is small and very stable. Update when Cloudflare publishes new ranges.
|
||||||
|
CLOUDFLARE_IPV4_RANGES = [
|
||||||
|
"173.245.48.0/20",
|
||||||
|
"103.21.244.0/22",
|
||||||
|
"103.22.200.0/22",
|
||||||
|
"103.31.4.0/22",
|
||||||
|
"141.101.64.0/18",
|
||||||
|
"108.162.192.0/18",
|
||||||
|
"190.93.240.0/20",
|
||||||
|
"188.114.96.0/20",
|
||||||
|
"197.234.240.0/22",
|
||||||
|
"198.41.128.0/17",
|
||||||
|
"162.158.0.0/15",
|
||||||
|
"104.16.0.0/13",
|
||||||
|
"104.24.0.0/14",
|
||||||
|
"172.64.0.0/13",
|
||||||
|
"131.0.72.0/22",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cloudflare IP networks (parsed once for fast lookups)
|
||||||
|
_CF_NETWORKS = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cf_networks():
|
||||||
|
"""Lazily parse Cloudflare CIDR ranges into network objects."""
|
||||||
|
global _CF_NETWORKS
|
||||||
|
if _CF_NETWORKS is None:
|
||||||
|
_CF_NETWORKS = []
|
||||||
|
for cidr in CLOUDFLARE_IPV4_RANGES:
|
||||||
|
try:
|
||||||
|
_CF_NETWORKS.append(ipaddress.IPv4Network(cidr, strict=False))
|
||||||
|
except (ipaddress.AddressValueError, ValueError):
|
||||||
|
pass
|
||||||
|
return _CF_NETWORKS
|
||||||
|
|
||||||
|
|
||||||
|
def is_cloudflare_ip(ip: str) -> bool:
|
||||||
|
"""Check whether an IP belongs to a known Cloudflare range.
|
||||||
|
|
||||||
|
This is the primary detection method -- it doesn't require any
|
||||||
|
external databases or network requests. The IP ranges are from
|
||||||
|
Cloudflare's official published list.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
addr = ipaddress.IPv4Address(ip)
|
||||||
|
except (ipaddress.AddressValueError, ValueError):
|
||||||
|
return False
|
||||||
|
return any(addr in net for net in _get_cf_networks())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DomainResult:
|
||||||
|
"""Result of checking a single domain."""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
ip: str = ""
|
||||||
|
is_cloudflare: bool = False
|
||||||
|
tcp_ok: bool = False
|
||||||
|
tls_ok: bool = False
|
||||||
|
http_ok: bool = False
|
||||||
|
http_status: int = 0
|
||||||
|
tls_ms: float = 0.0
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usable_as_sni(self) -> bool:
|
||||||
|
"""Domain is usable as a fake SNI for Cloudflare IP spoofing.
|
||||||
|
|
||||||
|
Must be:
|
||||||
|
1. Resolved to a Cloudflare IP (so TLS handshake works through CF)
|
||||||
|
2. TCP port 443 reachable
|
||||||
|
3. TLS handshake succeeds
|
||||||
|
"""
|
||||||
|
return self.is_cloudflare and self.tcp_ok and self.tls_ok
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
parts = [self.domain]
|
||||||
|
if self.ip:
|
||||||
|
parts.append(self.ip)
|
||||||
|
if self.is_cloudflare:
|
||||||
|
parts.append("CF")
|
||||||
|
parts.append("TCP:OK" if self.tcp_ok else "TCP:FAIL")
|
||||||
|
parts.append("TLS:OK" if self.tls_ok else "TLS:FAIL")
|
||||||
|
if self.http_ok:
|
||||||
|
parts.append(f"HTTP:{self.http_status}")
|
||||||
|
if self.error:
|
||||||
|
parts.append(f"ERR:{self.error}")
|
||||||
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainChecker:
|
||||||
|
"""Bulk domain checker for Cloudflare CDN detection.
|
||||||
|
|
||||||
|
Resolves domains, checks if they're behind Cloudflare, and
|
||||||
|
verifies TLS connectivity. Results can be filtered to produce
|
||||||
|
a verified list of domains suitable for SNI spoofing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
concurrency: int = 50,
|
||||||
|
timeout: float = 3.0,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
verify_http: bool = False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
concurrency: Maximum parallel checks.
|
||||||
|
timeout: Per-check timeout in seconds.
|
||||||
|
verify_tls: Also perform TLS handshake (not just DNS+IP check).
|
||||||
|
verify_http: Also perform HTTP request for deeper validation.
|
||||||
|
"""
|
||||||
|
self.concurrency = concurrency
|
||||||
|
self.timeout = timeout
|
||||||
|
self.verify_tls = verify_tls
|
||||||
|
self.verify_http = verify_http
|
||||||
|
|
||||||
|
def check_domains(
|
||||||
|
self,
|
||||||
|
domains: List[str],
|
||||||
|
progress_cb: Optional[Callable] = None,
|
||||||
|
) -> List[DomainResult]:
|
||||||
|
"""Check a list of domains in parallel.
|
||||||
|
|
||||||
|
Returns results sorted with Cloudflare-backed domains first,
|
||||||
|
then by TLS latency.
|
||||||
|
"""
|
||||||
|
results: List[DomainResult] = []
|
||||||
|
done_count = 0
|
||||||
|
total = len(domains)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Checking %d domains (workers=%d, timeout=%.1fs, tls=%s, http=%s)",
|
||||||
|
total, self.concurrency, self.timeout,
|
||||||
|
self.verify_tls, self.verify_http,
|
||||||
|
)
|
||||||
|
|
||||||
|
t_start = time.monotonic()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(
|
||||||
|
max_workers=self.concurrency
|
||||||
|
) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(self._check_one, domain): domain
|
||||||
|
for domain in domains
|
||||||
|
}
|
||||||
|
for future in concurrent.futures.as_completed(futures):
|
||||||
|
done_count += 1
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
results.append(result)
|
||||||
|
except Exception as exc:
|
||||||
|
domain = futures[future]
|
||||||
|
results.append(DomainResult(
|
||||||
|
domain=domain, error=str(exc)
|
||||||
|
))
|
||||||
|
|
||||||
|
if progress_cb:
|
||||||
|
try:
|
||||||
|
progress_cb(done_count, total)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - t_start
|
||||||
|
cf_count = sum(1 for r in results if r.is_cloudflare)
|
||||||
|
usable_count = sum(1 for r in results if r.usable_as_sni)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Domain check complete: %d/%d Cloudflare, %d usable (%.1fs)",
|
||||||
|
cf_count, total, usable_count, elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort: Cloudflare + usable first, then by TLS latency
|
||||||
|
results.sort(
|
||||||
|
key=lambda r: (
|
||||||
|
not r.usable_as_sni,
|
||||||
|
not r.is_cloudflare,
|
||||||
|
r.tls_ms if r.tls_ms > 0 else 9999,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _check_one(self, domain: str) -> DomainResult:
|
||||||
|
"""Check a single domain."""
|
||||||
|
result = DomainResult(domain=domain)
|
||||||
|
|
||||||
|
# Step 1: DNS resolution
|
||||||
|
try:
|
||||||
|
ip = socket.gethostbyname(domain)
|
||||||
|
result.ip = ip
|
||||||
|
except (socket.gaierror, socket.herror, OSError):
|
||||||
|
result.error = "dns_fail"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Step 2: Check if IP is in Cloudflare ranges
|
||||||
|
result.is_cloudflare = is_cloudflare_ip(ip)
|
||||||
|
|
||||||
|
# Step 3: TCP connect test
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(self.timeout)
|
||||||
|
sock.connect((ip, 443))
|
||||||
|
result.tcp_ok = True
|
||||||
|
except (socket.timeout, TimeoutError):
|
||||||
|
result.error = "tcp_timeout"
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
except (ConnectionRefusedError, OSError) as exc:
|
||||||
|
result.error = f"tcp_{getattr(exc, 'errno', 'error')}"
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Step 4: TLS handshake (optional but recommended)
|
||||||
|
if self.verify_tls:
|
||||||
|
try:
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
ctx.set_alpn_protocols(["h2", "http/1.1"])
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
ssl_sock = ctx.wrap_socket(sock, server_hostname=domain)
|
||||||
|
result.tls_ms = (time.monotonic() - t0) * 1000
|
||||||
|
result.tls_ok = True
|
||||||
|
|
||||||
|
# Step 5: HTTP check (optional)
|
||||||
|
if self.verify_http and result.tls_ok:
|
||||||
|
self._http_check(ssl_sock, domain, result)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ssl_sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except ssl.SSLError as exc:
|
||||||
|
result.error = f"tls_{exc.reason}"
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except (socket.timeout, TimeoutError):
|
||||||
|
result.error = "tls_timeout"
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
result.error = "tls_error"
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _http_check(
|
||||||
|
self, ssl_sock: ssl.SSLSocket, domain: str, result: DomainResult
|
||||||
|
):
|
||||||
|
"""Send a lightweight HTTP request and check the status code."""
|
||||||
|
try:
|
||||||
|
req = (
|
||||||
|
f"GET / HTTP/1.1\r\n"
|
||||||
|
f"Host: {domain}\r\n"
|
||||||
|
f"User-Agent: Mozilla/5.0\r\n"
|
||||||
|
f"Accept: */*\r\n"
|
||||||
|
f"Connection: close\r\n\r\n"
|
||||||
|
).encode()
|
||||||
|
ssl_sock.settimeout(self.timeout)
|
||||||
|
ssl_sock.sendall(req)
|
||||||
|
|
||||||
|
response = b""
|
||||||
|
while len(response) < 4096:
|
||||||
|
try:
|
||||||
|
chunk = ssl_sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
response += chunk
|
||||||
|
if b"\r\n\r\n" in response:
|
||||||
|
break
|
||||||
|
except (socket.timeout, TimeoutError):
|
||||||
|
break
|
||||||
|
|
||||||
|
if response:
|
||||||
|
first_line = response.decode("utf-8", errors="replace").split("\r\n", 1)[0]
|
||||||
|
# Parse HTTP status code
|
||||||
|
parts = first_line.split(" ", 2)
|
||||||
|
if len(parts) >= 2:
|
||||||
|
try:
|
||||||
|
result.http_status = int(parts[1])
|
||||||
|
if 200 <= result.http_status < 400:
|
||||||
|
result.http_ok = True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Utility methods ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_domains_from_file(filepath: str) -> List[str]:
|
||||||
|
"""Load domain list from a text file (one per line).
|
||||||
|
|
||||||
|
Supports comments (#) and empty lines.
|
||||||
|
"""
|
||||||
|
domains = []
|
||||||
|
with open(filepath, "r") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
# Skip empty lines and comments
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
# Strip protocol prefixes if present
|
||||||
|
if line.startswith("http://"):
|
||||||
|
line = line[7:]
|
||||||
|
if line.startswith("https://"):
|
||||||
|
line = line[8:]
|
||||||
|
# Strip paths
|
||||||
|
line = line.split("/")[0]
|
||||||
|
# Strip port numbers
|
||||||
|
line = line.split(":")[0]
|
||||||
|
if line:
|
||||||
|
domains.append(line)
|
||||||
|
return domains
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def results_table(results: List[DomainResult], cloudflare_only: bool = False) -> str:
|
||||||
|
"""Format results as a human-readable table."""
|
||||||
|
lines = [
|
||||||
|
f"{'#':>4} {'Domain':<40} {'IP':<16} {'CDN':>4} "
|
||||||
|
f"{'TCP':>4} {'TLS':>4} {'TLS ms':>7} {'Status':<6}"
|
||||||
|
]
|
||||||
|
lines.append("-" * 90)
|
||||||
|
|
||||||
|
filtered = results
|
||||||
|
if cloudflare_only:
|
||||||
|
filtered = [r for r in results if r.is_cloudflare]
|
||||||
|
|
||||||
|
for i, r in enumerate(filtered, 1):
|
||||||
|
cdn = "CF" if r.is_cloudflare else "-"
|
||||||
|
tcp = "OK" if r.tcp_ok else "-"
|
||||||
|
tls = "OK" if r.tls_ok else "-"
|
||||||
|
tls_ms = f"{r.tls_ms:.0f}ms" if r.tls_ms > 0 else "-"
|
||||||
|
status = "SNI" if r.usable_as_sni else ("CF" if r.is_cloudflare else "skip")
|
||||||
|
lines.append(
|
||||||
|
f"{i:>4} {r.domain:<40} {r.ip:<16} {cdn:>4} "
|
||||||
|
f"{tcp:>4} {tls:>4} {tls_ms:>7} {status:<6}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_sni_list(
|
||||||
|
results: List[DomainResult],
|
||||||
|
filepath: str,
|
||||||
|
usable_only: bool = True,
|
||||||
|
) -> int:
|
||||||
|
"""Export verified domains to a text file.
|
||||||
|
|
||||||
|
Returns the number of domains written.
|
||||||
|
"""
|
||||||
|
domains = []
|
||||||
|
for r in results:
|
||||||
|
if usable_only and not r.usable_as_sni:
|
||||||
|
continue
|
||||||
|
elif not usable_only and not r.is_cloudflare:
|
||||||
|
continue
|
||||||
|
domains.append(r.domain)
|
||||||
|
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
f.write("# Verified Cloudflare-backed SNI domains\n")
|
||||||
|
f.write(f"# Generated by SNISPF domain checker\n")
|
||||||
|
f.write(f"# Total: {len(domains)} domains\n\n")
|
||||||
|
for d in domains:
|
||||||
|
f.write(d + "\n")
|
||||||
|
|
||||||
|
return len(domains)
|
||||||
433
sni_spoofing/tls/__init__.py
Normal file
433
sni_spoofing/tls/__init__.py
Normal 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
|
||||||
161
sni_spoofing/tls/fragment.py
Normal file
161
sni_spoofing/tls/fragment.py
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
"""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
|
||||||
|
"""
|
||||||
|
if not sizes or not data:
|
||||||
|
return [data] if data else []
|
||||||
|
|
||||||
|
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:])
|
||||||
|
pos = len(data)
|
||||||
|
else:
|
||||||
|
fragments.append(data[pos : pos + size])
|
||||||
|
pos += size
|
||||||
|
|
||||||
|
# If we consumed all specified sizes but data remains
|
||||||
|
if pos < len(data):
|
||||||
|
fragments.append(data[pos:])
|
||||||
|
|
||||||
|
return fragments if fragments else [data]
|
||||||
130
sni_spoofing/utils/__init__.py
Normal file
130
sni_spoofing/utils/__init__.py
Normal 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
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
448
tests/test_tls.py
Normal file
448
tests/test_tls.py
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
"""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 TestDomainChecker(unittest.TestCase):
|
||||||
|
"""Test the bulk Cloudflare-domain checker."""
|
||||||
|
|
||||||
|
def test_is_cloudflare_ip_positive(self):
|
||||||
|
"""Known Cloudflare IPs should be detected."""
|
||||||
|
from sni_spoofing.scanner import is_cloudflare_ip
|
||||||
|
# 104.16.0.0/13 belongs to Cloudflare
|
||||||
|
self.assertTrue(is_cloudflare_ip("104.16.1.1"))
|
||||||
|
self.assertTrue(is_cloudflare_ip("172.64.0.1"))
|
||||||
|
|
||||||
|
def test_is_cloudflare_ip_negative(self):
|
||||||
|
"""Non-Cloudflare IPs should be rejected."""
|
||||||
|
from sni_spoofing.scanner import is_cloudflare_ip
|
||||||
|
self.assertFalse(is_cloudflare_ip("8.8.8.8"))
|
||||||
|
self.assertFalse(is_cloudflare_ip("1.1.1.1")) # Cloudflare DNS, not CDN
|
||||||
|
self.assertFalse(is_cloudflare_ip("not-an-ip"))
|
||||||
|
self.assertFalse(is_cloudflare_ip(""))
|
||||||
|
|
||||||
|
def test_domain_result_usable_as_sni(self):
|
||||||
|
"""DomainResult.usable_as_sni requires CF + TCP + TLS."""
|
||||||
|
from sni_spoofing.scanner import DomainResult
|
||||||
|
r = DomainResult(domain="x.com", is_cloudflare=True, tcp_ok=True, tls_ok=True)
|
||||||
|
self.assertTrue(r.usable_as_sni)
|
||||||
|
r2 = DomainResult(domain="x.com", is_cloudflare=False, tcp_ok=True, tls_ok=True)
|
||||||
|
self.assertFalse(r2.usable_as_sni)
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
def test_fake_sni_ttl_trick_flag(self):
|
||||||
|
"""Test FakeSNIBypass accepts use_ttl_trick parameter."""
|
||||||
|
from sni_spoofing.bypass import FakeSNIBypass
|
||||||
|
|
||||||
|
fake = FakeSNIBypass(use_ttl_trick=True)
|
||||||
|
self.assertTrue(fake.use_ttl_trick)
|
||||||
|
self.assertIsNone(fake.raw_injector)
|
||||||
|
|
||||||
|
def test_fake_sni_ttl_trick_default(self):
|
||||||
|
"""Test FakeSNIBypass use_ttl_trick defaults to False."""
|
||||||
|
from sni_spoofing.bypass import FakeSNIBypass
|
||||||
|
|
||||||
|
fake = FakeSNIBypass()
|
||||||
|
self.assertFalse(fake.use_ttl_trick)
|
||||||
|
|
||||||
|
def test_combined_ttl_trick_flag(self):
|
||||||
|
"""Test CombinedBypass accepts use_ttl_trick parameter."""
|
||||||
|
from sni_spoofing.bypass import CombinedBypass
|
||||||
|
|
||||||
|
combo = CombinedBypass(use_ttl_trick=True)
|
||||||
|
self.assertTrue(combo.use_ttl_trick)
|
||||||
|
|
||||||
|
def test_build_strategy_fake_sni_with_ttl(self):
|
||||||
|
"""Test build_strategy passes USE_TTL_TRICK to FakeSNIBypass."""
|
||||||
|
from sni_spoofing.cli import build_strategy
|
||||||
|
|
||||||
|
config = {"BYPASS_METHOD": "fake_sni", "FAKE_SNI_METHOD": "prefix_fake",
|
||||||
|
"USE_TTL_TRICK": True}
|
||||||
|
strategy = build_strategy(config)
|
||||||
|
self.assertTrue(strategy.use_ttl_trick)
|
||||||
|
|
||||||
|
def test_build_strategy_combined_with_ttl(self):
|
||||||
|
"""Test build_strategy passes USE_TTL_TRICK to CombinedBypass."""
|
||||||
|
from sni_spoofing.cli import build_strategy
|
||||||
|
|
||||||
|
config = {"BYPASS_METHOD": "combined", "FRAGMENT_STRATEGY": "sni_split",
|
||||||
|
"USE_TTL_TRICK": True, "FRAGMENT_DELAY": 0.1}
|
||||||
|
strategy = build_strategy(config)
|
||||||
|
self.assertTrue(strategy.use_ttl_trick)
|
||||||
|
|
||||||
|
def test_parse_host_port_no_port(self):
|
||||||
|
"""Test parse_host_port with just an IP (no port)."""
|
||||||
|
from sni_spoofing.cli import parse_host_port
|
||||||
|
|
||||||
|
host, port = parse_host_port("104.19.229.21", "0.0.0.0", 443)
|
||||||
|
self.assertEqual(host, "104.19.229.21")
|
||||||
|
self.assertEqual(port, 443)
|
||||||
|
|
||||||
|
def test_parse_host_port_with_port(self):
|
||||||
|
"""Test parse_host_port with IP:PORT format."""
|
||||||
|
from sni_spoofing.cli import parse_host_port
|
||||||
|
|
||||||
|
host, port = parse_host_port("104.19.229.21:8443", "0.0.0.0", 443)
|
||||||
|
self.assertEqual(host, "104.19.229.21")
|
||||||
|
self.assertEqual(port, 8443)
|
||||||
|
|
||||||
|
def test_parse_host_port_port_only(self):
|
||||||
|
"""Test parse_host_port with :PORT format."""
|
||||||
|
from sni_spoofing.cli import parse_host_port
|
||||||
|
|
||||||
|
host, port = parse_host_port(":40443", "0.0.0.0", 443)
|
||||||
|
self.assertEqual(host, "0.0.0.0")
|
||||||
|
self.assertEqual(port, 40443)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
Loading…
Reference in New Issue
Block a user