手上有几百个端口要挨个确认后面到底是什么东西。第一轮我用的是最笨的办法,挨个 connect 一遍,能连上的算”在线”。跑完就发现这结论没什么用:一大堆端口连接成功,可真发请求过去,一个字节都不回。
后来把探测拆成几层,从最便宜的试到最贵的,才慢慢能给每个端口下个说得过去的判断。这篇记一下这套流程。
connect 成功说明不了什么
三次握手能完成,只说明对端有人接了这个包。中间挂一层转发的话,转发程序自己就能把握手做完,后端是死是活它根本不关心。
所以 connect() 成功是所有证据里最弱的一条。
写起来就一行:
import socket
s = socket.create_connection(("example.com", 25565), timeout=5)
timeout 一定要给。不给的话,碰上丢包的 IP 会卡在系统默认超时上,几百个端口跑一天都跑不完。
分层试探
顺序按代价排,便宜的能解决就不往下走。
什么都不发,先听听
有些协议的服务端自己会先开口。SSH、SMTP、FTP、MySQL 都是这样,连上就吐一段 banner,里面常常直接写着版本号。
timeout 5 nc -v host 22
有些 nc 版本要加 -w,拿不到东西就换 printf '' | nc host 22 试试。banner 抓到的字符串往往比后面所有探测加起来都管用。
发一个 HTTP 请求
覆盖面最大的一步。就算后面不是 HTTP 服务,回什么错误也有信息量。
curl -sS -m 8 -i http://host:8888/ | head -20
-i 把响应头带上,-m 限时。返回 400 也许是服务在,只是不喜欢这个请求;返回一串 HTML 就是明牌了。连接直接被拒或者超时,说明这层没有 HTTP。
TLS 握手
HTTPS 或者别的 TLS 服务,光握手就能拿到证书。证书里的 CN 和 SAN 经常直接写着后面是谁,不用猜。
openssl s_client -connect host:443 </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
带 -servername host 和不带各试一次。不少服务不带 SNI 时会发默认证书,两张证书不是一回事。
UDP
前面三步全在 TCP 上。如果 TCP 一路拒绝,而申报写的是 UDP 服务,探测方式得整套换掉。
UDP 没有连接这回事,没回包和”这里本来就没服务”在协议层面是同一件事。要判断,只能按目标协议的报文格式发对,再等回包。
批量跑的时候
几百个端口,一个个手敲不现实。
并发别开太大。线程池给个二三十就够,管道里塞太多连接容易在转发侧被限速,运气差还会被当成扫描处理。探测脚本本身长得就跟扫描器一模一样,节奏控制不住就是在给自己找麻烦。
超时要分开设。connect 超时和读超时不是一回事:connect 卡住说明路不通,读超时说明路通了但对面不理你,这两种要分开记,别混成一个”失败”。
结果文件里把每一层的原始返回都留下,不要只写最终判定。我第一版只记了个 ok/fail,后来想回头统计”有多少是连得上但不吭声的”,翻不出来了,只能重跑一遍。
应用层协议才给得出结论
上面几步只能告诉你”这像是个 HTTP 服务”或者”这像是个 TLS 服务”。要确认具体是什么,得按它自己的协议说话。
拿 Minecraft 举例,它有个专门用来问状态的协议,Server List Ping。
流程很短:
- 握手包:packet id
0x00,后面跟协议版本(VarInt)、服务器地址(字符串)、端口(无符号短整型)、下一状态(VarInt,填 1 表示只问状态) - 紧接着发状态请求:id
0x00,空包 - 服务端回一段 JSON
那段 JSON 里有版本号、MOTD、在线人数、最大人数。版本号是硬证据,转发层编不出这个。要么它真在跟一个 MC 服务端说话,要么后面什么都没有。
# 骨架,VarInt 编码和省掉的读包逻辑自己补
handshake = b"\x00" + varint(47) + encode_str(host) + port.to_bytes(2, "big") + varint(1)
s.sendall(with_len(handshake) + with_len(b"\x00"))
resp = read_packet(s)
print(resp[1:].decode()) # {"version":{"name":"..."},"players":{...}}
Bedrock 版是另一套,走 UDP 的 RakNet unconnected ping:一个 0x01,跟 8 字节时间戳,再跟 16 字节固定 magic,最后 8 字节 client GUID。回包是 0x1c,里面是分号隔开的字符串。
那串 magic 是 00ffff00fefefefefdfdfdfd12345678,写死就行。
零响应的那批怎么判
全套跑完还是没反应,也不止一种可能,别急着下结论:
- 后端真没服务,转发层空转
- 服务活着,但只认特定协议,你发的包它顺手就丢了
- 防火墙按源 IP 拦了
能拉开差距的是几个侧面信息。
一个是 connect 的耗时。本地转发和跨洋的真实服务,握手延迟差得很明显。
一个是连接结束的方式。RST 秒回通常意味着对端内核里压根没有监听,握完手晾着不动的更像转发层。
还有个笨办法,拿同一个节点上其他端口做对照。同一个转发服务商的一批端口如果表现完全一样(连接成功、之后完全不响应),基本能判定是同一类配置问题,而不是每个端口各自有事。
几个反直觉的地方
面板上写”在线”,看的是端口能不能连上,跟后面有没有服务是两件事。
端口开着、服务活着、后面真是什么服务,这是三件独立的事,别合并成一个判断。
UDP 端口”探测不通”这个说法本身就不成立,UDP 没有连接,你得先知道该发什么。
最后,别只做一次采样就下结论。网络抖动能让活着的服务判死,也能让挂掉的后端看着还行。我在这个问题上翻过车,拿单次结果做对比,得出的结论后来被自己推翻了。
这套流程后来我一直在用,从最便宜的试探开始一层层往下,多数端口在第二步就有答案。剩下不吭声的那些,交给应用层协议去问。