Shadowsocks 协议
位置:核心协议层 | 难度:中等 | 前置:密码学基础、DPI 机制源码证据:
proxy/shadowsocks/protocol.go(342行)、validator.go(166行)、shadowsocks.go、config.go外部参考:Shadowsocks AEAD 规范 (shadowsocks.org)、GFW Report IMC '20 "How China Detects and Blocks Shadowsocks"
1. 协议定位
Shadowsocks 由 Clowwindy 于 2012 年创建,是反审查领域历史最悠久、部署最广泛的加密代理协议。其设计核心理念是极简:没有握手、没有协议头、没有版本协商——服务端通过尝试解密来判断一个连接是否是 Shadowsocks 客户端。
这个"无特征"的设计在 2019 年之前被认为是对抗 DPI 的理想方案——流量看起来像随机数据,没有可识别的协议特征。但 GFW 的进化证明了"看起来像随机数据"本身就是一种特征。
2. 协议版本演进
| 版本 | 时期 | 加密方式 | 弱点 |
|---|---|---|---|
| 原始版本 | 2012-2015 | Stream Cipher (AES-256-CFB 等) | 主动探测:密文可锻造,攻击者可修改密文观察服务端行为差异 |
| OTA (One-Time Auth) | 2015-2016 | Stream Cipher + MAC | 缓解了密文锻造,但 OTA 实现有缺陷 |
| AEAD (SIP004) | 2016-至今 | AEAD (AES-256-GCM, ChaCha20-Poly1305) | 主动探测:认证失败后的行为差异仍可被探测 |
| Shadowsocks 2022 | 2022-至今 | Noise 协议框架 | 独立的控制通道和数据通道,多用户支持 |
Xray-core 同时支持传统 AEAD Shadowsocks(proxy/shadowsocks/)和 Shadowsocks 2022(proxy/shadowsocks_2022/)。本文重点分析 AEAD 版本,因为它的部署量最大、与 GFW 的对抗历史最丰富。
3. AEAD TCP 数据包格式
Shadowsocks AEAD 的 TCP 数据流格式:
[Salt: 变长 (16 或 32 bytes)] [AEAD Chunk 1] [AEAD Chunk 2] ...
每个 AEAD Chunk:
[加密的载荷长度: 2B + 16B GCM Tag] [加密的载荷: 变长 + 16B GCM Tag]载荷长度 = 2 字节 BigEndian uint16,表示明文载荷的字节数(不含 Tag)。
第一个 Chunk 的载荷包含目标地址:
[Address Type: 1B] [Address: 变长] [Port: 2B] [实际数据: 变长]
Address Type:
0x01 = IPv4 (4 bytes 地址)
0x03 = 域名 (1 byte 长度 + 域名)
0x04 = IPv6 (16 bytes 地址)3.1 地址类型掩码
源码位置:proxy/shadowsocks/protocol.go 行 24-31
Shadowsocks 在地址类型的最高 4 位嵌入了额外信息:
var addrParser = protocol.NewAddressParser(
protocol.AddressFamilyByte(0x01, net.AddressFamilyIPv4),
protocol.AddressFamilyByte(0x04, net.AddressFamilyIPv6),
protocol.AddressFamilyByte(0x03, net.AddressFamilyDomain),
protocol.WithAddressTypeParser(func(b byte) byte {
return b & 0x0F // 只取低 4 位作为地址类型
}),
)这意味着一个地址字节 0x11 = 高 4 位 0x1 (可能用于标记某些特性) + 低 4 位 0x1 (IPv4)。
4. AEAD 子密钥派生
这是 Shadowsocks 密码学中最关键的环节。用户配置中的密码(password)不直接用作加密密钥,而是通过 HKDF 派生出主密钥,再从主密钥 + 每次连接的随机 Salt 派生出连接子密钥。
4.1 主密钥派生
MasterKey = HKDF-SHA1(ikm=password, salt="ss-subkey", info=..., length=keyBytes)不同加密算法需要不同长度的密钥:
- AES-128-GCM:16 字节
- AES-256-GCM:32 字节
- ChaCha20-Poly1305:32 字节
4.2 连接子密钥派生
源码位置:proxy/shadowsocks/validator.go 行 112-154
服务端收到客户端连接后,读取 Salt,派生出本次连接的子密钥:
func (v *Validator) Get(bs []byte, command protocol.RequestCommand) (
u *protocol.MemoryUser, aead cipher.AEAD, ret []byte, ivLen int32, err error) {
for _, user := range v.users {
if account := user.Account.(*MemoryAccount); account.Cipher.IsAEAD() {
aeadCipher := account.Cipher.(*AEADCipher)
ivLen = aeadCipher.IVSize()
iv := bs[:ivLen] // Salt = bs 的前 ivLen 字节
subkey := make([]byte, 32)
subkey = subkey[:aeadCipher.KeyBytes]
hkdfSHA1(account.Key, iv, subkey) // SubKey = HKDF-SHA1(MasterKey, Salt)
aead = aeadCipher.AEADAuthCreator(subkey) // 创建 AEAD 加密器
// 尝试解密第一个 Chunk 的长度部分
switch command {
case protocol.RequestCommandTCP:
data := make([]byte, 4+aead.NonceSize())
ret, matchErr = aead.Open(data[:0], data[4:], bs[ivLen:ivLen+18], nil)
case protocol.RequestCommandUDP:
data := make([]byte, 8192)
ret, matchErr = aead.Open(data[:0], data[8192-aead.NonceSize():8192], bs[ivLen:], nil)
}
if matchErr == nil {
u = user // AEAD 解密成功 → 匹配到此用户
return
}
}
}
return nil, nil, nil, 0, ErrNotFound
}关键设计: 服务端不预先知道是哪个用户连接。它遍历所有已注册用户,对每个用户尝试派生 SubKey + AEAD 解密。只有密码匹配的用户才能成功解密。
安全性分析:HKDF 的 Salt 参数确保了——即使两个不同的客户端使用相同的密码,只要每次连接生成不同的 Salt,派生出的 SubKey 就完全不同。这提供了每连接的前向安全性。
4.3 为什么用 HKDF-SHA1 而不是 SHA256
这是历史原因。Shadowsocks AEAD 规范(SIP004)制定时(2016 年),SHA1 在 HMAC 中的使用被认为仍然是安全的(HMAC 的安全性不直接依赖哈希函数的抗碰撞性)。更新的实现可选用更强的哈希,但为了互操作性保留了 SHA1。
5. ReadTCPSession:服务端连接处理
源码位置:proxy/shadowsocks/protocol.go 行 57-131
这是服务端处理 Shadowsocks TCP 连接的核心函数。逐阶段分析:
阶段 1:初始化 Drainer
func ReadTCPSession(validator *Validator, reader io.Reader) (*protocol.RequestHeader, buf.Reader, error) {
behaviorSeed := validator.GetBehaviorSeed()
drainer, errDrain := drain.NewBehaviorSeedLimitedDrainer(int64(behaviorSeed), 16+38, 3266, 64)behaviorSeed 是每个服务端实例的伪随机种子,由所有用户的密钥通过 CRC64 累积计算得出。不同密码配置的服务端有不同的 behaviorSeed。
NewBehaviorSeedLimitedDrainer(seed, 16+38, 3266, 64) 创建一个 drainer:
- 最小排空长度 = 54 字节 (16+38)
- 最大排空长度 = 3266 字节
- 排空块大小 = 64 字节
Drainer 的作用:当认证失败时(即连接不是来自合法 Shadowsocks 客户端),drainer 会排空(读取并丢弃)一段随机长度的数据后再关闭连接。这使主动探测者无法通过"认证失败后是否立即断开"来识别 Shadowsocks 服务端。
阶段 2:读取首批数据
var r buf.Reader
buffer := buf.New()
defer buffer.Release()
if _, err := buffer.ReadFullFrom(reader, 50); err != nil {
drainer.AcknowledgeReceive(int(buffer.Len()))
return nil, nil, drain.WithError(drainer, reader, errors.New("failed to read 50 bytes").Base(err))
}读取前 50 字节。这个数字覆盖了:
- Salt (16 或 32 字节)
- 第一个 AEAD Chunk 的长度密文 (2 + 16 = 18 字节)
阶段 3:用户匹配(AEAD 解密尝试)
bs := buffer.Bytes()
user, aead, _, ivLen, err := validator.Get(bs, protocol.RequestCommandTCP)
switch err {
case ErrNotFound:
drainer.AcknowledgeReceive(int(buffer.Len()))
return nil, nil, drain.WithError(drainer, reader, errors.New("failed to match an user").Base(err))
case ErrIVNotUnique:
drainer.AcknowledgeReceive(int(buffer.Len()))
return nil, nil, drain.WithError(drainer, reader, errors.New("failed iv check").Base(err))
default:
reader = &FullReader{reader, bs[ivLen:]}
drainer.AcknowledgeReceive(int(ivLen))三种结果的差异化处理:
| 结果 | 处理 | 原因 |
|---|---|---|
| 匹配成功 | 继续解析地址 | SubKey 派生正确,AEAD 解密成功 |
| ErrNotFound | drain + 关闭 | 没有用户能解密——这可能是主动探测或误连接 |
| ErrIVNotUnique | drain + 关闭 | Salt 重复——这可能是重放攻击 |
ErrIVNotUnique 的安全性:Shadowsocks 2022 之前的版本使用固定密钥。如果两个不同的连接使用相同的 Salt + 相同的 MasterKey,则派生出的 SubKey 相同——这允许攻击者进行密钥流重用攻击。ErrIVNotUnique 检测阻止了这种攻击。
阶段 4:读取目标地址
request := &protocol.RequestHeader{
Version: Version,
User: user,
Command: protocol.RequestCommandTCP,
}
buffer.Clear()
addr, port, err := addrParser.ReadAddressPort(buffer, br)
request.Address = addr
request.Port = portAEAD 解密成功后,第一个 Chunk 的载荷就是目标地址。解析后构造 RequestHeader,交给 Dispatcher 进行路由。
6. WriteTCPRequest:客户端发送
源码位置:proxy/shadowsocks/protocol.go 行 134-163
func WriteTCPRequest(request *protocol.RequestHeader, writer io.Writer) (buf.Writer, error) {
user := request.User
account := user.Account.(*MemoryAccount)
var iv []byte
if account.Cipher.IVSize() > 0 {
iv = make([]byte, account.Cipher.IVSize())
common.Must2(rand.Read(iv)) // 生成随机 Salt
if err := buf.WriteAllBytes(writer, iv, nil); err != nil {
return nil, errors.New("failed to write IV")
}
}
w, err := account.Cipher.NewEncryptionWriter(account.Key, iv, writer)
header := buf.New()
if err := addrParser.WriteAddressPort(header, request.Address, request.Port); err != nil {
return nil, errors.New("failed to write address").Base(err)
}
if err := w.WriteMultiBuffer(buf.MultiBuffer{header}); err != nil {
return nil, errors.New("failed to write header").Base(err)
}
return w, nil
}客户端的操作非常简洁:
- 生成随机 Salt
- 发送 Salt
- 用 MasterKey + Salt 创建加密 Writer
- 加密并发送目标地址
无需等待服务端确认——Shadowsocks 是无状态协议,客户端发送完请求头后直接开始中继数据。
7. UDP 支持
Shadowsocks 通过 TCP 隧道传输 UDP 数据包(UDP over TCP)。
7.1 UDP 编码
源码位置:proxy/shadowsocks/protocol.go 行 207-228
func EncodeUDPPacket(request *protocol.RequestHeader, payload []byte) (*buf.Buffer, error) {
buffer := buf.New()
ivLen := account.Cipher.IVSize()
if ivLen > 0 {
common.Must2(buffer.ReadFullFrom(rand.Reader, ivLen)) // 生成 Salt
}
addrParser.WriteAddressPort(buffer, request.Address, request.Port) // 写入目标地址
buffer.Write(payload) // 写入 UDP 载荷
account.Cipher.EncodePacket(account.Key, buffer) // 整体加密
return buffer, nil
}与 TCP 不同,UDP 模式下每个 UDP 数据包独立加密,各自包含地址信息和 Salt。
7.2 UDP 解码
源码位置:proxy/shadowsocks/protocol.go 行 230-280
func DecodeUDPPacket(validator *Validator, payload *buf.Buffer) (*protocol.RequestHeader, *buf.Buffer, error) {
rawPayload := payload.Bytes()
user, _, d, _, err := validator.Get(rawPayload, protocol.RequestCommandUDP)
// ...
account := user.Account.(*MemoryAccount)
if account.Cipher.IsAEAD() {
payload.Clear()
payload.Write(d) // 已解密的明文
}
payload.SetByte(0, payload.Byte(0)&0x0F) // 清除地址类型的高 4 位
addr, port, err := addrParser.ReadAddressPort(nil, payload)
// ...
}8. BehaviorSeed 与 Drainer:防主动探测
源码位置:proxy/shadowsocks/validator.go 行 38-45 + 行 156-165
func (v *Validator) Add(u *protocol.MemoryUser) error {
account := u.Account.(*MemoryAccount)
if !v.behaviorFused {
hashkdf := hmac.New(sha256.New, []byte("SSBSKDF"))
hashkdf.Write(account.Key)
v.behaviorSeed = crc64.Update(v.behaviorSeed, crc64.MakeTable(crc64.ECMA), hashkdf.Sum(nil))
}
// ...
}
func (v *Validator) GetBehaviorSeed() uint64 {
v.behaviorFused = true
if v.behaviorSeed == 0 {
v.behaviorSeed = dice.RollUint64()
}
return v.behaviorSeed
}SSBSKDF(Shadowsocks Behavior Seed KDF)的设计意图:
- 将每个用户的 MasterKey 通过 HMAC-SHA256 混合到 behaviorSeed 中
- behaviorSeed 决定了认证失败时 drainer 排空的数据量(伪随机,但可复现)
- 不同 Shadowsocks 服务端实例有不同的 behaviorSeed → 不同的排空行为
- GFW 无法通过"排空行为"的共性来识别 Shadowsocks
为什么这很重要——GFW Report IMC '20 论文揭示,GFW 的主动探测系统不仅发送探测包,还分析服务端的响应行为(如认证失败后是否立即关闭连接、等待多长时间)。Drainer 机制使每个 Shadowsocks 实例在认证失败时表现出不同的行为模式,增加了 GFW 分类的难度。
9. GFW 对 Shadowsocks 的检测:IMC '20 论文分析
来源:GFW Report "How China Detects and Blocks Shadowsocks" (2020, IMC 会议)
9.1 被动检测
GFW 通过首个数据包的以下特征识别疑似 Shadowsocks 流量:
长度:Shadowsocks AEAD 的首个数据包 = Salt(16/32) + AEAD 长度密文(18) = 34 或 50 字节。这个固定长度是一个可检测的特征。
熵:Salt 和 AEAD 密文都是高熵数据 → popcount/len ≈ 4.0 → 落入 GFW Algorithm 1 Ex1 的 3.4-4.6 范围。
9.2 七种主动探测类型
GFW 在被动检测确认可疑 IP 后,发送 7 种不同类型的主动探测包。这些探测利用了 Shadowsocks 历史上不同版本实现中的行为差异。
具体的探测类型(基于论文的公开摘要):
| 探测类型 | 利用的漏洞 |
|---|---|
| 空连接探测 | 早期版本对空连接的行为差异 |
| 无效 Salt 探测 | Salt 不匹配时的错误响应模式 |
| 截断密文探测 | 发送不完整的 AEAD Chunk |
| 重放探测 | 重放有效载荷观察服务端响应 |
| 时序探测 | 分析服务端处理时间和响应模式 |
为什么 AEAD 版本比 Stream Cipher 版本更能抵抗主动探测:
Stream Cipher 模式下,攻击者可以通过修改密文的特定字节(XOR 翻转攻击)来"锻造"密文。即使不知道密钥,也能构造出"解密后地址字节异常"的密文——服务端解密失败的行为模式暴露了 Shadowsocks 的身份。
AEAD 模式增加了认证标签(GCM Tag),任何密文修改都会导致认证失败——但认证失败的行为本身仍然可被探测。
9.3 Drainer 的对抗效果
drain.WithError(drainer, reader, err) 在返回错误前先排空一段随机长度的数据。这使 GFW 无法通过"服务端在收到多少字节后关闭连接"来确认 Shadowsocks 的协议边界。
局限性:Drainer 只能改变关闭连接的时机,不能改变"认证失败"这一事实。如果 GFW 能识别"认证失败前的行为模式"(如读取 50 字节后开始解密),drainer 无法隐藏这一点。
10. Shadowsocks 在 GFW Algorithm 1 中的检测路径
USENIX Security '23 论文的 Algorithm 1 逐条分析 Shadowsocks AEAD 首个数据包:
结论:纯 Shadowsocks(无外层伪装)在 2021 年 GFW 部署被动检测后,在 Algorithm 1 的五条规则中没有一条能豁免。这就是为什么 Shadowsocks 需要与其他伪装技术(如 WebSocket + TLS 前置、Cloak 等)组合使用。
11. 源码文件索引
| 文件 | 核心内容 | 行数 |
|---|---|---|
proxy/shadowsocks/shadowsocks.go | 协议注册、配置类型 | 50+ |
proxy/shadowsocks/config.go | 配置解析 | ~100 |
proxy/shadowsocks/protocol.go | TCP/UDP 编解码、ReadTCPSession、WriteTCPRequest | 342 |
proxy/shadowsocks/server.go | 入站处理器 | ~200 |
proxy/shadowsocks/client.go | 出站处理器 | ~200 |
proxy/shadowsocks/validator.go | 用户管理、SubKey 派生、BehaviorSeed | 166 |
proxy/shadowsocks_2022/ | Shadowsocks 2022 实现(独立目录) | ~300 |
12. 外部参考资料
资料:Shadowsocks AEAD 规范 (SIP004) 类型:协议规范 链接:https://shadowsocks.org/doc/sip004.html 可信度:A
资料:How China Detects and Blocks Shadowsocks 类型:学术论文(GFW Report, IMC '20) 链接:https://gfw.report/publications/imc20/en 可信度:A 建议参考:GFW 的 7 种主动探测类型和首个数据包检测方法
资料:Shadowsocks active-probing attacks and defenses 类型:技术讨论 链接:https://groups.google.com/g/traffic-obf/c/CWO0peBJLGc 可信度:B 建议参考:Stream Cipher 到 AEAD 迁移的历史原因
读者自检
- AEAD 子密钥派生的完整链路:Password → HKDF-SHA1 → MasterKey → HKDF-SHA1(MasterKey, Salt) → SubKey
- 为什么服务端要遍历所有用户尝试解密(无状态设计的必然结果)
- BehaviorSeed 如何通过 CRC64 累积所有用户的密钥信息
- Drainer 如何对抗主动探测——以及它的局限性
- Shadowsocks 在 GFW Algorithm 1 中为什么五条豁免规则都不适用