ShadowTLS Protocol
ShadowTLS is a transport-layer protocol that disguises proxy traffic as legitimate TLS traffic by hijacking the TLS handshake with a real server. It supports three protocol versions with increasing sophistication.
Source: protocol/shadowtls/inbound.go, protocol/shadowtls/outbound.go, sing-shadowtls
Protocol Concept
Unlike traditional TLS-based proxies that generate their own certificates (detectable via certificate checks), ShadowTLS performs a real TLS handshake with a legitimate server (e.g., www.microsoft.com), making the handshake indistinguishable from normal HTTPS traffic to observers. After the handshake, the data channel is hijacked to carry proxy traffic.
Protocol Versions
Version 1
The simplest version. The client initiates a TLS handshake through the ShadowTLS server, which relays it to a real TLS server (the "handshake server"). After the handshake completes, the TLS connection is repurposed for proxy data.
Limitation: Forces TLS 1.2 to ensure predictable handshake behavior.
if options.Version == 1 {
options.TLS.MinVersion = "1.2"
options.TLS.MaxVersion = "1.2"
}Version 2
Adds password-based authentication. The server can distinguish legitimate ShadowTLS clients from probes. Supports per-SNI handshake servers:
if options.Version > 1 {
handshakeForServerName = make(map[string]shadowtls.HandshakeConfig)
for _, entry := range options.HandshakeForServerName.Entries() {
handshakeForServerName[entry.Key] = shadowtls.HandshakeConfig{
Server: entry.Value.ServerOptions.Build(),
Dialer: handshakeDialer,
}
}
}Version 3
The most advanced version. Introduces session ID-based channel binding -- the client and server embed authentication data within the TLS session ID, enabling verification without an additional round trip.
case 3:
if idConfig, loaded := tlsConfig.(tls.WithSessionIDGenerator); loaded {
// Use the TLS library's session ID hook
tlsHandshakeFunc = func(ctx, conn, sessionIDGenerator) error {
idConfig.SetSessionIDGenerator(sessionIDGenerator)
return tls.ClientHandshake(ctx, conn, tlsConfig)
}
} else {
// Fallback to standard TLS with manual session ID injection
stdTLSConfig := tlsConfig.STDConfig()
tlsHandshakeFunc = shadowtls.DefaultTLSHandshakeFunc(password, stdTLSConfig)
}Inbound Architecture
type Inbound struct {
inbound.Adapter
router adapter.Router
logger logger.ContextLogger
listener *listener.Listener
service *shadowtls.Service
}Service Configuration
service, _ := shadowtls.NewService(shadowtls.ServiceConfig{
Version: options.Version,
Password: options.Password,
Users: common.Map(options.Users, func(it option.ShadowTLSUser) shadowtls.User {
return (shadowtls.User)(it)
}),
Handshake: shadowtls.HandshakeConfig{
Server: options.Handshake.ServerOptions.Build(),
Dialer: handshakeDialer,
},
HandshakeForServerName: handshakeForServerName, // per-SNI routing
StrictMode: options.StrictMode,
WildcardSNI: shadowtls.WildcardSNI(options.WildcardSNI),
Handler: (*inboundHandler)(inbound),
Logger: logger,
})Key fields:
- Handshake: The default handshake target server
- HandshakeForServerName: Map of SNI -> handshake server for multi-domain support
- StrictMode: Reject connections that fail authentication (vs. silently forwarding)
- WildcardSNI: Accept any SNI value (useful for CDN scenarios)
Wildcard SNI
The WildcardSNI option controls how SNI is handled:
serverIsDomain := options.Handshake.ServerIsDomain()
if options.WildcardSNI != option.ShadowTLSWildcardSNIOff {
serverIsDomain = true // force domain resolution for wildcard
}Connection Flow (Inbound)
func (h *Inbound) NewConnectionEx(ctx, conn, metadata, onClose) {
// ShadowTLS service handles the entire handshake relay and data extraction
err := h.service.NewConnection(ctx, conn, metadata.Source, metadata.Destination, onClose)
N.CloseOnHandshakeFailure(conn, onClose, err)
}After the ShadowTLS service extracts the real data stream, it calls the inbound handler:
type inboundHandler Inbound
func (h *inboundHandler) NewConnectionEx(ctx, conn, source, destination, onClose) {
metadata.Inbound = h.Tag()
metadata.InboundType = h.Type()
metadata.Source = source
metadata.Destination = destination
if userName, _ := auth.UserFromContext[string](ctx); userName != "" {
metadata.User = userName
}
h.router.RouteConnectionEx(ctx, conn, metadata, onClose)
}Outbound Architecture
type Outbound struct {
outbound.Adapter
client *shadowtls.Client
}ShadowTLS outbound is TCP-only and serves as a transport wrapper -- it is typically chained with another protocol (e.g., Shadowsocks):
func (h *Outbound) DialContext(ctx, network, destination) (net.Conn, error) {
switch network {
case "tcp":
return h.client.DialContext(ctx) // returns a "clean" conn
default:
return nil, os.ErrInvalid // UDP not supported
}
}
func (h *Outbound) ListenPacket(ctx, destination) (net.PacketConn, error) {
return nil, os.ErrInvalid // UDP not supported
}TLS Requirement
ShadowTLS outbound requires TLS to be enabled:
if options.TLS == nil || !options.TLS.Enabled {
return nil, C.ErrTLSRequired
}Client Configuration
client, _ := shadowtls.NewClient(shadowtls.ClientConfig{
Version: options.Version,
Password: options.Password,
Server: options.ServerOptions.Build(),
Dialer: outboundDialer,
TLSHandshake: tlsHandshakeFunc, // version-specific handshake
Logger: logger,
})Version-Specific TLS Handshake
var tlsHandshakeFunc shadowtls.TLSHandshakeFunc
switch options.Version {
case 1, 2:
// Simple: just do the TLS handshake
tlsHandshakeFunc = func(ctx, conn, _ TLSSessionIDGeneratorFunc) error {
return common.Error(tls.ClientHandshake(ctx, conn, tlsConfig))
}
case 3:
// Complex: inject session ID generator for channel binding
tlsHandshakeFunc = func(ctx, conn, sessionIDGenerator) error {
idConfig.SetSessionIDGenerator(sessionIDGenerator)
return common.Error(tls.ClientHandshake(ctx, conn, tlsConfig))
}
}How ShadowTLS Works (Detailed)
Client ShadowTLS Server Real TLS Server
| | |
|--- TLS ClientHello ---->|--- TLS ClientHello ----->|
| | |
|<-- TLS ServerHello -----|<-- TLS ServerHello ------|
|<-- Certificate ---------|<-- Certificate ----------|
|<-- ServerHelloDone -----|<-- ServerHelloDone ------|
| | |
|--- ClientKeyExchange -->|--- ClientKeyExchange --->|
|--- ChangeCipherSpec --->|--- ChangeCipherSpec ---->|
|--- Finished ----------->|--- Finished ------------>|
| | |
|<-- ChangeCipherSpec ----|<-- ChangeCipherSpec -----|
|<-- Finished ------------|<-- Finished -------------|
| | |
| [TLS handshake done - observer sees valid cert] |
| | |
|=== Proxy Data =========>| [data NOT sent to real |
|<=== Proxy Data =========| TLS server anymore] |After the handshake, the ShadowTLS server:
- Disconnects from the real TLS server
- Extracts the proxy data stream from the client
- Forwards it to the configured inner handler
Typical Usage Pattern
ShadowTLS is used as a detour for another protocol:
{
"outbounds": [
{
"type": "shadowsocks",
"tag": "ss-out",
"detour": "shadowtls-out",
"method": "2022-blake3-aes-256-gcm",
"password": "ss-password"
},
{
"type": "shadowtls",
"tag": "shadowtls-out",
"server": "my-server.com",
"server_port": 443,
"version": 3,
"password": "shadowtls-password",
"tls": {
"enabled": true,
"server_name": "www.microsoft.com"
}
}
]
}The Shadowsocks connection is tunneled through the ShadowTLS wrapper, which performs the handshake with www.microsoft.com's real certificate.
Configuration Example (Inbound)
{
"type": "shadowtls",
"tag": "shadowtls-in",
"listen": "::",
"listen_port": 443,
"version": 3,
"users": [
{ "name": "user1", "password": "user-password" }
],
"handshake": {
"server": "www.microsoft.com",
"server_port": 443
},
"handshake_for_server_name": {
"www.google.com": {
"server": "www.google.com",
"server_port": 443
}
},
"strict_mode": true
}