拆解 SOCKS5:用 Go 实现 CONNECT、BIND 与 UDP ASSOCIATE

这是 Relay Observatory 代理项目实现系列的第二篇。完整代码放在 GitHub

上一篇实现了 Go HTTP 正向代理。SOCKS5 与 HTTP 代理不同:它不解析 HTTP 文本,而是在 TCP 连接上交换紧凑的二进制报文,因此可以代理 SSH、数据库协议和其他 TCP 应用。

SOCKS5 是一段状态机

一条 SOCKS5 连接依次经过:

TCP Accept

认证方式协商

用户名密码认证

读取命令与目标地址

CONNECT / BIND / UDP ASSOCIATE

数据转发与流量统计

每一步都依赖前一步完成,不能把它当成普通 HTTP Handler。

第一步:协商认证方式

客户端首先发送:

+-----+----------+----------+
| VER | NMETHODS | METHODS  |
+-----+----------+----------+
|  1  |    1     | 1~255    |
+-----+----------+----------+

例如:

05 01 02

含义是:

  • 05:SOCKS5;
  • 01:提供一种认证方式;
  • 02:用户名密码认证。

读取固定长度协议字段时,应使用 io.ReadFull

header := make([]byte, 2)
if _, err := io.ReadFull(client, header); err != nil {
    return err
}

methods := make([]byte, int(header[1]))
if _, err := io.ReadFull(client, methods); err != nil {
    return err
}

不能假设一次 Read 就会拿到完整报文。TCP 是字节流,一次写入可能被拆成多次读取,也可能与后续数据一起到达。

服务端选择 0x02

05 02

如果客户端没有提供支持的方法,则返回:

05 FF

FF 表示“所有认证方式都不接受”。

RFC 1929 用户名密码认证

用户名密码认证不是 RFC 1928 主协议的一部分,而是 RFC 1929 扩展:

+-----+------+----------+------+----------+
| VER | ULEN | UNAME    | PLEN | PASSWD   |
+-----+------+----------+------+----------+
|  1  |  1   | 1~255    |  1   | 1~255    |
+-----+------+----------+------+----------+

这里的版本是 01,不是 SOCKS 版本 05

认证成功返回:

01 00

失败返回:

01 01

生产实现不应保存明文密码。项目把用户存入 SQLite,并使用 bcrypt:

identity, ok := authenticator.Authenticate(
    username,
    string(passwordBytes),
)

需要注意:RFC 1929 只定义了密码格式,没有提供传输加密。客户端到 SOCKS5 服务端之间如果是不可信公网,密码仍可能被窃听,应再套 VPN 或 TLS。

第二步:解析通用请求

认证成功后,三种命令共享同一报文结构:

+-----+-----+-----+------+----------+----------+
| VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
+-----+-----+-----+------+----------+----------+
|  1  |  1  |  1  |  1   | Variable |    2     |
+-----+-----+-----+------+----------+----------+

CMD

命令
01CONNECT
02BIND
03UDP ASSOCIATE

ATYP

地址长度
01IPv44 字节
03域名1 字节长度 + 域名
04IPv616 字节

端口固定为两字节大端序:

port := binary.BigEndian.Uint16(portBytes)

把地址解析抽成 endpoint 后,TCP 请求和 UDP 报文可以复用同一套编码器:

type endpoint struct {
    Host string
    Port uint16
}

func (e endpoint) Address() string {
    return net.JoinHostPort(e.Host, strconv.Itoa(int(e.Port)))
}

net.JoinHostPort 很重要,它会正确处理 IPv6 的方括号:

2001:db8::1 + 443
=> [2001:db8::1]:443

CONNECT:代理主动连接目标

CONNECT 与 HTTP CONNECT 的隧道阶段类似:

upstream, err := dialer.DialContext(
    context.Background(),
    "tcp",
    target.Address(),
)

连接成功后回复:

+-----+-----+-----+------+----------+----------+
| VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
+-----+-----+-----+------+----------+----------+

其中 REP=00 表示成功,BND.ADDR/BND.PORT 是代理用于这条上游连接的本地地址。

之后启动两个 io.Copy

client --------上传--------> upstream
client <-------下载--------- upstream

CONNECT 适用于绝大多数 TCP 场景,也是 curl 的 socks5h:// 主要使用的命令。

BIND:为什么必须返回两次

BIND 的连接方向相反。代理不主动连接目标,而是创建临时监听端口:

客户端 ----控制连接----> SOCKS5 代理
目标端 ----反向连接----> 代理临时端口

因此协议需要两次响应。

第一次响应

代理调用:

listener, err := net.ListenTCP(
    "tcp",
    &net.TCPAddr{IP: localIP, Port: 0},
)

端口 0 表示让操作系统分配空闲端口。第一次响应把这个监听地址告诉客户端:

REP=00, BND.ADDR=代理地址, BND.PORT=临时端口

等待并校验目标

客户端在请求中提供的 DST.ADDR/DST.PORT 表示预期连接者。代理不能接受第一个随便连入的连接,否则临时端口可能被第三方抢占。

校验逻辑包括:

  • 请求端口非零时必须匹配来源端口;
  • 请求 IP 非 0.0.0.0/:: 时必须匹配来源 IP;
  • 请求是域名时解析全部 IP 后比较;
  • 不匹配的连接关闭并继续等待;
  • 超过 30 秒返回超时。

第二次响应

目标通过校验后,代理再次返回成功,这次 BND.ADDR/BND.PORT 是目标的真实来源地址。

客户端只有收到第二次响应后,才能开始把 BIND 连接当成普通 TCP 隧道使用。

UDP ASSOCIATE:TCP 控制,UDP 传数据

UDP ASSOCIATE 最容易误解。它不是把 UDP 塞进 TCP,而是:

TCP:客户端 -------- 会话生命周期 -------- SOCKS5 代理

UDP:客户端 ---- SOCKS5 UDP 报文 ----> 代理 ---- 原始 UDP ----> 目标
UDP:客户端 <--- SOCKS5 UDP 报文 ----- 代理 <--- 原始 UDP ----- 目标

客户端发送 UDP ASSOCIATE 后,代理创建 UDP socket,并通过 TCP 回复它的地址。

TCP 控制连接必须保持打开。一旦 TCP 断开,代理立即关闭对应 UDP socket:

go func() {
    _, _ = io.Copy(io.Discard, controlConnection)
    _ = relaySocket.Close()
}()

这个 TCP 连接没有业务数据,只承担“租约”作用。

SOCKS5 UDP 报文格式

客户端不能把原始 UDP payload 直接发给代理,因为代理不知道目标地址。每个数据报都要带 Header:

+------+------+------+----------+----------+----------+
| RSV  | FRAG | ATYP | DST.ADDR | DST.PORT | DATA     |
+------+------+------+----------+----------+----------+
|  2   |  1   |  1   | Variable |    2     | Variable |
+------+------+------+----------+----------+----------+

例如把 hello udp 发到 127.0.0.1:53

00 00 | 00 | 01 | 7F 00 00 01 | 00 35 | hello udp
 RSV   FRAG ATYP      IP          PORT      DATA

代理解析 Header,把 DATA 作为普通 UDP 发给目标。目标响应后,代理使用目标来源地址重新封装,再发回客户端。

为什么拒绝 FRAG != 0

FRAG 用于 UDP 分片。完整实现需要:

  • 按客户端与目标维护分片队列;
  • 识别分片序号和结束位;
  • 设置重组超时;
  • 限制总大小与并发队列;
  • 防止内存耗尽攻击。

大多数 SOCKS5 客户端不会使用该能力。为了避免“看似支持、实际不安全”,实现中明确丢弃 FRAG != 0 的数据报。

这是协议实现中很重要的原则:不完整的复杂特性应该明确拒绝,而不是静默误处理。

UDP 中继的来源约束

公开 UDP 中继很容易变成反射攻击工具,因此至少要做三层限制:

  1. UDP 来源 IP 必须等于已认证 TCP 客户端 IP;
  2. 第一个合法 UDP 包锁定客户端真实端口;
  3. 目标响应必须来自客户端主动联系过的地址。
contactedTargets[targetAddress.String()] = struct{}{}

if _, allowed := contactedTargets[source.String()]; !allowed {
    continue
}

请求中的客户端地址经常是 0.0.0.0:0,这是因为客户端在发送第一个 UDP 包前可能还不知道 NAT 映射端口。因此“首次合法报文锁定”比盲目信任请求字段更实用。

三种命令的流量定义

项目采用:

命令上传下载
CONNECT客户端到目标的 TCP 字节目标到客户端的 TCP 字节
BIND客户端到反向连接者的字节反向连接者到客户端的字节
UDP ASSOCIATE去除 SOCKS Header 后的 UDP payload目标返回的 UDP payload

UDP 不统计 SOCKS5 封装头,便于衡量真正的业务数据。

测试不能只测 parser

协议 parser 单元测试只能证明字节解析正确。完整测试还应创建真实 socket:

测试客户端 -> SOCKS5 服务 -> 本地 TCP/UDP Echo Server

覆盖:

  • 正确和错误密码;
  • IPv4、IPv6、域名编码;
  • CONNECT 双向数据;
  • BIND 两次响应;
  • UDP ASSOCIATE 双向回显;
  • FRAG != 0 拒绝;
  • 上传、下载和请求次数。

再配合 go test -race,检查会话关闭、资源跟踪和统计写入是否存在数据竞争。

小结

SOCKS5 的难点不在 io.Copy,而在状态机和边界:

握手 -> 认证 -> 通用请求 -> 命令分派 -> 生命周期 -> 统计

CONNECT 是主动拨号,BIND 是两阶段反向接入,UDP ASSOCIATE 则是 TCP 控制下的 UDP 中继。把地址编解码、命令处理和资源关闭拆开后,协议实现才会既可读又可测试。

下一篇进入数据层:用 SQLite 持久化代理用户、分协议流量与小时趋势