Skip to content

Shadowsocks 协议

位置:核心协议层 | 难度:中等 | 前置密码学基础DPI 机制源码证据proxy/shadowsocks/protocol.go(342行)、validator.go(166行)、shadowsocks.goconfig.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-2015Stream Cipher (AES-256-CFB 等)主动探测:密文可锻造,攻击者可修改密文观察服务端行为差异
OTA (One-Time Auth)2015-2016Stream Cipher + MAC缓解了密文锻造,但 OTA 实现有缺陷
AEAD (SIP004)2016-至今AEAD (AES-256-GCM, ChaCha20-Poly1305)主动探测:认证失败后的行为差异仍可被探测
Shadowsocks 20222022-至今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 位嵌入了额外信息:

go
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,派生出本次连接的子密钥:

go
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

go
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:读取首批数据

go
    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 解密尝试)

go
    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 解密成功
ErrNotFounddrain + 关闭没有用户能解密——这可能是主动探测或误连接
ErrIVNotUniquedrain + 关闭Salt 重复——这可能是重放攻击

ErrIVNotUnique 的安全性:Shadowsocks 2022 之前的版本使用固定密钥。如果两个不同的连接使用相同的 Salt + 相同的 MasterKey,则派生出的 SubKey 相同——这允许攻击者进行密钥流重用攻击。ErrIVNotUnique 检测阻止了这种攻击。

阶段 4:读取目标地址

go
        request := &protocol.RequestHeader{
            Version: Version,
            User:    user,
            Command: protocol.RequestCommandTCP,
        }
        buffer.Clear()
        addr, port, err := addrParser.ReadAddressPort(buffer, br)
        request.Address = addr
        request.Port = port

AEAD 解密成功后,第一个 Chunk 的载荷就是目标地址。解析后构造 RequestHeader,交给 Dispatcher 进行路由。


6. WriteTCPRequest:客户端发送

源码位置:proxy/shadowsocks/protocol.go 行 134-163

go
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
}

客户端的操作非常简洁:

  1. 生成随机 Salt
  2. 发送 Salt
  3. 用 MasterKey + Salt 创建加密 Writer
  4. 加密并发送目标地址

无需等待服务端确认——Shadowsocks 是无状态协议,客户端发送完请求头后直接开始中继数据。


7. UDP 支持

Shadowsocks 通过 TCP 隧道传输 UDP 数据包(UDP over TCP)。

7.1 UDP 编码

源码位置:proxy/shadowsocks/protocol.go 行 207-228

go
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

go
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

go
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)的设计意图:

  1. 将每个用户的 MasterKey 通过 HMAC-SHA256 混合到 behaviorSeed 中
  2. behaviorSeed 决定了认证失败时 drainer 排空的数据量(伪随机,但可复现)
  3. 不同 Shadowsocks 服务端实例有不同的 behaviorSeed → 不同的排空行为
  4. 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 流量:

  1. 长度:Shadowsocks AEAD 的首个数据包 = Salt(16/32) + AEAD 长度密文(18) = 34 或 50 字节。这个固定长度是一个可检测的特征。

  2. :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.goTCP/UDP 编解码、ReadTCPSession、WriteTCPRequest342
proxy/shadowsocks/server.go入站处理器~200
proxy/shadowsocks/client.go出站处理器~200
proxy/shadowsocks/validator.go用户管理、SubKey 派生、BehaviorSeed166
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 中为什么五条豁免规则都不适用