少写一个长度字节,域名在服务端眼里是 101 字节长
先说现象。我给一个本地中转加了 SOCKS5 支持,代码跑起来以后,所有走它的请求都是超时, 没有一条成功,也没有一条报错。日志里干干净净,没有任何异常。
我第一反应是上游坏了。可这个中转的上游是一个带账密的 SOCKS5 服务, 它自带一个 --check:只验证上游的用户名密码能不能通过握手。 跑一次,通过。这就把范围缩掉一大半——上游是活的,问题在我自己拼出来的那段字节里。
SOCKS5 的 CONNECT 请求长什么样
握手分两步。第一步是协商方法:客户端发 VER NMETHODS METHODS, 服务端回 VER METHOD。第二步才是请求:
VER(1) CMD(1) RSV(1) ATYP(1) DST.ADDR DST.PORT(2)
1 = CONNECT 1 = IPv4 → 4 字节
3 = 域名 → [len][domain] ← 这里
4 = IPv6 → 16 字节
关键就在 ATYP=3 这一支。地址段的第一个字节是域名长度, 不是域名本身。我当时想当然地按「域名 + 端口」拼了过去:
# 错的
head = bytes([5, 1, 0, 3])
sock.sendall(head + domain.encode() + struct.pack("!H", port))
# 对的
sock.sendall(head + bytes([len(domain)]) + domain.encode() + struct.pack("!H", port))
少一个字节,肉眼几乎看不出来;对端的解析器看到的是完全另一件事。
量一次,比猜十次快
我写了个十来行的最小服务端,严格按 RFC 1928 解析,然后把两种请求各发一次。 下面是真实输出,第一个是补上长度字节的,第二个是漏掉的:
== 正确写法:带 1 字节长度前缀 ==
payload(18 bytes): 05 01 00 03 0b 65 78 61 6d 70 6c 65 2e 63 6f 6d 01 bb
服务端解析: {'atyp': 3, 'declared_len': 11, 'stage': 'parsed',
'domain': 'example.com', 'port': 443, 'elapsed': 0.0}
== 漏掉长度字节 ==
payload(17 bytes): 05 01 00 03 65 78 61 6d 70 6c 65 2e 63 6f 6d 01 bb
服务端解析: {'atyp': 3, 'declared_len': 101,
'stage': 'timeout_waiting_domain', 'waited': 12, 'need': 103,
'elapsed': 5.0}
两个数字就够了:0x65 是 e 的 ASCII 码, 0x65 = 101。服务端认为这个域名有 101 个字节,再加上 2 字节端口, 它要等满 103 字节才会往下走。而一条 example.com 的请求总共只有 12 个字节可读。 它就在那儿等,等满我给的 5 秒超时,一个字节都没回。
这也解释了为什么客户端侧看起来是「超时」而不是「失败」: SOCKS5 的服务端在地址段没读齐之前不会发 REP 应答, 客户端拿不到任何回应,只能挂到自己的超时。我第一次排查时看的是 netstat,一片 CLOSE_WAIT——那其实是「连上了、但对面一直不说话」的典型形态, 跟「连不上」完全不是一回事。
可复用的三条
- 长度前缀是协议字段,不是可省的优化。凡是「长度 + 内容」的编码(SOCKS5 域名、TLS 扩展、 各种 TLV),少一个字节不会报错,只会让对面读到另一个数——而且那个数往往很大,表现为「卡住」。
- 先把「上游」和「本地转发层」切开再修。我这次能用一条
--check把上游排除掉, 省掉了至少半小时的瞎猜。写自己的转发层时,顺手留一个只验上游的开关,是值得的。 - 字节级 dump 比日志有用。我原来的日志只在「发出去了」这一层打点,看不出内容。 把
payload.hex(" ")打出来以后,18 字节和 17 字节的差别一眼就看见了。
最后补一句我自己的教训:这类 bug 的可怕之处是「静默」。没有异常、没有错误码, 只有一条条挂到超时的连接。遇到这种形态,先怀疑自己拼的字节,再怀疑网络。
- 点赞
- 收藏
- 关注作者
评论(0)