434 lines
14 KiB
Python
434 lines
14 KiB
Python
"""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
|