Protocol Reference
Snell v4 / v5 TCP frame layout, padding interleave, dynamic record sizing, and the QUIC envelope side-by-side.
This page is the protocol-level reference. If you just want to use OpenSnell, skip to Server config — this is written for people who want to understand or reimplement the wire format.
Key derivation
For both TCP and QUIC, the per-flow AES key comes from the same KDF:
K_32 = Argon2id(psk_utf8, salt, t=3, m=8 KiB, p=1)
key = K_32[:16] // AES-128-GCMpsk_utf8is the configured PSK string, taken raw — not base64 decoded.saltis a 16-byte random value sent at the start of each flow (per-direction for TCP; once per envelope for QUIC).t=3, m=8 KiB, p=1are the exact parameters used by Surge'ssnell-server v5.0.1. Mismatched parameters produce a tag-verification failure on the first decrypt and the connection is silently dropped.
The fact that KDF parameters are baked in (not negotiated) is what keeps the wire format steganographically clean — there's no version byte to fingerprint.
TCP frame layout (v4 / v5)
A snell TCP stream begins with the per-direction 16-byte salt, then a sequence of AEAD frames.
[salt(16B, sent once per direction)]
[frame_1]
[frame_2]
…Each frame is two AEAD seals back-to-back, sharing a monotonically incrementing nonce counter:
frame = AEAD-Seal(K, nonce=N, header_7B) || 16B tag
|| padding(padLen)
|| AEAD-Seal(K, nonce=N+1, payload[payloadLen]) || 16B tag
header_7B = [0x04, 0x00, 0x00, padLen_be(2B), payloadLen_be(2B)]
nonce = 12-byte little-endian counter, ++ per AEAD-Seal callPadding interleave
The padding region between the two AEAD seals isn't sent in the clear
in the usual sense. Bytes at even indices of the padding are
swapped with the leading bytes of the payload ciphertext (see
swapPadding),
so the raw padding bytes never appear contiguously on the wire.
This matters for traffic-shape obfuscation: a passive observer counting "runs of high-entropy bytes" can't easily distinguish padding from payload, because they're shuffled.
First-frame ones/zeros balance
The first frame on every stream carries an extra 0x100..0x1FF-byte
padding chosen so the overall ones/zeros ratio of
salt + padding + ciphertext stays within a "natural" range — i.e.,
the first packet on the wire doesn't have a TLS-handshake-flavored
byte distribution.
Dynamic Record Sizing (v5)
Later frames scale the maximum payload up from a small initial size
(~1.5 KB) to MaxPayloadLength over the first several frames, then
reset back to small after a 30 s idle window. This is the
v5 Dynamic Record Sizing optimization — it lets the first few
frames behave like a normal "small message" application (lower latency,
less obvious bulk-transfer pattern) and only ramps up for sustained
flows.
CONNECT request header (TCP, plaintext after AEAD decrypt)
The first frame's payload, on a fresh stream, is the CONNECT request:
request_header = [version_1B, cmd_1B, clientID_len_1B, clientID,
hostlen_1B, host, port_be_2B]
version = 0x01
cmd = 0x01 (CONNECT) | 0x05 (PING) | 0x06 (CONNECT v2 / reuse)
| 0x07 (UDP-over-TCP)
clientID = optional; len=0 in OpenSnell
host = ASCII hostname or IPv4/v6 literalcmd = 0x06 (CONNECT v2) is what enables reuse mode: the server
half-closes its write side with a zero-length payload frame after the
upstream socket EOFs, and the client may then send another CONNECT
on the same TCP without re-doing the salt+frame handshake.
QUIC envelope layout (v5 only, client → server, first packet of a flow)
[salt(16B random)]
[AEAD-Seal(K, nonce=0, [0x04, 0, 0, padLen_be, payloadLen_be]) || 16B tag]
[padding(padLen)]
[AEAD-Seal(K, nonce=1, request_header || inner_QUIC_packet) || 16B tag]
request_header = [0x01, 0x01, 0x00, hostlen, host, port_be]
K = Argon2id(psk_utf8, salt, 3, 8 KiB, 1, 32)[:16]
AEAD = AES-128-GCMNote the structural symmetry with TCP frames: the same 7-byte header,
the same padding interleave (well, not the swap — the QUIC envelope is
one packet, so there's no streaming concern), the same KDF, the same
AEAD primitive. Once the envelope is decoded and the
(client_src, upstream) mapping is recorded, every subsequent UDP
packet in either direction is forwarded as raw QUIC with no
additional snell framing.
See QUIC mode for the full state machine and how the format was reverse-engineered.
UDP-over-TCP datagrams
When cmd = 0x07, the snell stream carries DNS-style UDP datagrams
inside the TCP frame envelope. Each datagram is framed as:
[hostlen_1B, host, port_be_2B, datalen_be_2B, data]Multiple datagrams can be concatenated in one frame's payload. This is
what udp = true on the server and SOCKS5 UDP ASSOCIATE on the client
both rely on. It is not the same path as QUIC mode — QUIC mode
talks raw UDP on a separate listener, UDP-over-TCP is multiplexed over
the snell TCP stream.
References
- MetaCubeX/mihomo #2816 — earlier reverse-engineered v5 proposal, described the AEAD frame layout and padding interleave.
- MetaCubeX/mihomo #2817 — merged outbound + inbound for mihomo; the TCP protocol layer in OpenSnell is a port of that code adapted into a standalone server/client and stripped of v1/v2/v3 support.
- Surge snell release notes — upstream's published feature list per release.
QUIC Proxy Mode
How OpenSnell's v5 QUIC envelope was reverse-engineered from real Surge HTTP/3 captures, and how the server interoperates with the official client.
Snell vs Shadowsocks 2022
A protocol-level comparison of Snell v4/v5 and Shadowsocks 2022 (SIP022) — what each defends against, what each leaks on the wire, and which threat model picks which.