多跳链路排查方法论:当每一跳都在替换身份

多跳链路排查方法论:当每一跳都在替换身份

一条每小时的奇怪请求,追了三天,最后追出一个公网 AI 网关的匿名暴露面。
真正难的从来不是抓包,而是在链路上判断"谁是谁"

引子:一个反直觉的现象

先说一个让排查一度跑偏的事实。

同一次请求,四个观测点看到的是四个不同的"发起方":

观测位置 看到的来源 看到的 UA
最外层入口(Cloudflare 账本) 31.77.203.199 ZOLTRAAK/0.2.0
末端服务(Mac 日志) 192.168.31.225 Go-http-client/1.1
中间跳(路由器抓包) 192.168.31.225 Go-http-client/1.1

如果只看末端日志,会得出"有个 Go 程序从内网打过来"——完全指向错误方向
如果只看最外层账本,会知道"有个芬兰扫描器在打入口"——正确,但看不到它是怎么穿透到末端的

只有把两端对上,才能拼出完整链路。

这就是多跳链路排查的第一性困难:任何一跳看到的来源身份,都只代表"它的上一跳",不代表最终用户。


一、为什么"代理链"必然让身份失真

这不是配置问题,是代理架构的固有特性。

1.1 每一跳都会重写身份字段

外部调用方 → 入口 Worker    : 注入自己的 Authorization  
           → 中间网关       : 用自己 provider 的 key 替换  
           → 末端服务       : 看到的是中间网关的身份  

User-Agent 同理:外部传入的 UA 在过了第一跳之后就被丢弃——Go 的 http.Client 默认发 Go-http-client/1.1,Python 的 requests 默认发 python-requests/x.y中间跳的语言运行时,会悄悄把自己的默认 UA 盖上去。

1.2 SNAT 会伪装源 IP

如果链路上有任何一层做了 NAT/SNAT,末端看到的源 IP 就是那个 NAT 设备的地址,不是真实发起方。

本例中,末端看到的源 IP 是路由器的 LAN 地址——真实发起方在另一台通过虚拟网络隧道连进来的机器上,经路由器 SNAT 后到达末端。不查路由器,永远不会知道这个地址背后是谁。

1.3 结论

在一条代理链上,"来源身份"是一个逐跳衰减的量。越靠近末端,越看不到真相。

所以排查方法论的第一原则是:同时采集"最外层"和"最内层"的元数据

  • 最外层(入口账本):告诉你"谁在调用"——追责/封禁的依据
  • 最内层(末端日志):告诉你"请求变成了什么"——理解链路作用的依据
  • 中间跳:告诉你"怎么连起来的"

二、排查的五个反直觉

反直觉 1:单看一层日志会误判

多跳链路上,每一环看到的"身份"都只代表上一跳。所以:

  • 末端日志没有来源信息 = 排查的最大障碍(本例中,末端当时只记请求体,不记 RemoteAddr/UA,直接导致必须靠明文抓包补上,多花了两小时)
  • 补齐末端日志([REQ] remote=... ua=...比抓出扫描器更有长期价值

反直觉 2:"每小时"不等于"cron"

直觉上"每小时一次"很像定时任务。但实测间隔是 62~77 分钟,不是精确 60

这个细节直接排除了所有 cron/定时器嫌疑——因为定时任务是精确对齐的,而"请求→等响应→sleep 一小时"的循环会漂移(响应耗时 in 变,所以间隔才漂)。

一条可复用的判据:间隔精确对齐 = 定时器;间隔漂移 = 请求-响应-sleep 循环。

反直觉 3:"报错"不等于"安全"

加固前实测暴露面:26 个模型中,15 个匿名可打通(200),11 个返回 500。

那 11 个 500 全部是上游自身故障,无一因鉴权被挡——请求照样穿透到了上游。上游修好的那一刻,它们立刻就能被白嫖。

验证防护要测"通不通",而不是看"有没有报错"。把报错当安全,是排查中最危险的误读。

反直觉 4:grep -I 是陷阱

排查编译型服务时,第一遍用 grep -I(跳过二进制)搜索特征串,结果什么都没找到,差点得出"源码里没有"的结论。

改用 grep -a(text 模式)重搜,才确认二进制里也没有。

搜编译型语言的程序,必须带 -a/--text 否则"搜不到"可能是假阴性。

反直觉 5:时间吻合不等于因果

某个进程的启动时间是 09-11 06:25,而首次探针是 03:28——早了 3 小时。如果只看到"日期相同"就下结论,就会误判。

时间线必须精确到小时,不能只看日期。


三、可复用的排查流程

基于这次实战,提炼出一套流程:

第 1 步:排除本机(穷举 + 清单法)

先用三条标准筛出所有嫌疑对象:

  1. 有没有 HTTP 客户端代码(能发请求)
  2. 有没有定时/循环机制(能每小时发)
  3. 有没有生成随机串的能力

逐个排查。排除法本身就是情报——每排除一个,就缩小一圈范围,同时摸清自家基础设施有哪些组件。

关键副产品:这一阶段的"排除"会顺带产出一份完整的调用方清单,这份清单在加固阶段直接派上用场——没有它就不敢打开强制开关

第 2 步:多点位同时布防

探针每小时才来一次,单点抓不到就等于浪费一小时。

同时在多个层布防(端点/中间设备/上游),做到"一次抓到"。这次布了 4 个点位,真正给出决定性证据的是其中一个(明文请求头),其余是保险——多点位的价值在于"只要有一个抓到就不算白等"

第 3 步:抓明文,别急着上 Wireshark

目标是 HTTP 明文时,tcpdump -A(ASCII 模式)就能在输出里直接看到请求头和 JSON body,不用开 Wireshark 解析。

第 4 步:用"账本"一击命中

本例的转折点,是入口 Worker 的 D1 记账表 access_logs(记录 ip / country / app_name / model_name / user_agent / duration)。

一条 SQL 就锁定了扫描器身份。记账表是排查的黄金档案。

"每个入口都记调用方元数据"是一个值得坚持的习惯。 它让"猜谁在打"变成"查谁在打"。

第 5 步:两端对上,拼出完整链路

把最外层账本和最内层日志对时间戳,才能拼出完整链路。


四、几个踩过的实操坑

现象 解法
SSH 断开会杀后台进程 tcpdump 输出始终 0 字节 setsid 脱离会话,显式重定向三路 IO
精简系统没有 timeout No such file or directory setsid + 从外部 pkill 收尾
pkill -f tcpdump 杀掉自己 SSH 退出码 255 因为命令行本身含 "tcpdump" 字符串;改按进程名精确匹配
conntrack 匹配格式 日志永远为空 dport=8082 不是 :8082
Zsh 的 glob grep -r "*.py" 直接报错 --include='*.py' 或引号

这些"命令静默失败"最容易浪费排查时间——因为看起来命令跑了,实际什么都没做。


五、加固的哲学:治本 vs 修补

排查出问题后,加固方案的选择比排查本身更能体现工程判断。

5.1 为什么否掉 IP/UA 黑名单

最直觉的方案是"封掉那个 IP 和 UA"。但它被明确否掉:

  • 扫描器换 IP、换 UA、换 ASN 只需几秒
  • 它只是把问题往后推,不解决根因

黑名单的失效模型

换 IP    → 失效  
换 UA    → 失效  
换 ASN   → ASN 级封禁失效  
用全新 IP → 所有规则重新配  

本质:黑名单是"枚举坏",而坏是无限的。

5.2 为什么选凭据白名单

根因是入口没有调用方身份概念。解法是给它一个:

  • 服务端持 X-Client-Token(64 位随机,猜不到)
  • 浏览器靠 Origin 白名单
  • 其余一切拒绝

白名单的失效模型枚举好,而好是有限的——合法调用方就那么几个,枚举得完。

维度 黑名单 凭据白名单
维护成本 持续增长 固定
抗绕过
可审计
误伤风险

代价:需要改客户端。但"先盘清调用方"的价值就在这里——先确认合法调用方全是自己的机器,改起来的成本完全可控。

5.3 校验放"代码里"还是"域名层"

本例有个关键决策:鉴权放 Cloudflare Worker 代码里,而非域名层的 Cloudflare Access。

原因:入口有两个门——主域名直连 + 另一个域名经服务端代理转发。Access 是域名层的保护,只能盖住一个门;放代码里则两个门都经过同一个 Worker,一次改动覆盖全部入口

多入口是鉴权的常见盲区。不查清楚有几个门,鉴权方案就会漏掉一半。


六、三条最值钱的教训

  1. "看不出是谁"本身就是最大的线索——它说明日志有盲区,而补齐这个盲区比抓出具体某个扫描器更有长期价值。

  2. 治本不是"拦住这个 IP",而是"让这个入口有身份概念"。前者是修补,后者是修复。

  3. 动手前先盘清调用方。正是因为先拉出了"所有合法调用方都是自己的机器"这个事实,才敢放心打开强制开关。


结语

多跳链路排查的核心难点,从来不是技术工具,而是认知

你看到的"来源",永远只是"上一跳"。

理解这一点,就会自然地同时看首尾两端、就会怀疑"精确间隔"、就会区分"报错"和"安全"、就会在做加固时优先考虑"身份"而非"特征"。

排查和加固其实是同一件事的两面——排查时排出的调用方清单,就是加固时的白名单。想清楚这一点,很多决策就顺了。


(本文基于一次真实的公网网关匿名调用事件整理,已隐去具体域名与凭据。)