你把 webpack bundle 全量导出,做完 AST 切分,连叶子节点的基础原语都列出来了,连那些熟悉的指纹也已经能识别。可现在,请求头里有一个签名值:每次都不同,你想找到生成它的函数。于是你翻遍全部静态资源,搜索所有可疑的位运算和哈希骨架,却一无所获。
问题不在于你搜得不够仔细,而在于那段逻辑本来就不在你导出的代码里。
有一类签名,静态分析注定找不到,因为算法只会在运行时出现。遇到这类目标,继续寻找一个根本不存在的函数没有意义。正确的切换方式是:别逆算法,尽量以最低成本去执行它,或者直接读取它的结果。
静态分析可能已经走到尽头的两个信号
先确认你碰到的确实是这类目标,而不是单纯漏看了代码。有两个信号尤其明显。
第一个信号:值会随会话或请求变化,但静态资源里没有任何生成它的逻辑。你能在网络请求中看到这个值,刷新一次就变;可把所有 .js 下载下来全文搜索,仍找不到任何拼装它的地方。因为真正的组装逻辑是在运行时下发的。
第二个信号:你能在 bundle 中搜到这个字面量,但昨天记录下来的值今天已经失效。它是构建产物中的普通字符串常量,当天复制出来还能用;过几天接口开始报错,再回头看,原来的“常量”已经被替换为另一个同样可搜到的新字面量。它确实在构建阶段被硬编码进去了,但会随着每次前端发布滚动更新。
这两种情况的共同点是:静态代码只是一个瞬时快照,真实值却是持续变化的流。要么你在代码里根本找不到它,要么找到了却发现它是活的。无论哪种,“静态导出一次然后复制”这条路都已经失效了。这和“认不出算法家族”不是同一个问题,不是你的指纹匹配能力不够,而是你手里那份代码副本中根本没有可供匹配的对象。
第一类:服务端下发一段让你执行的挑战脚本
第一类目标的机制通常是:访问某个接口前,服务端先下发一段一次性的 JS。脚本在浏览器中运行,生成 cookie 或 token;带上它,你才能继续访问。脚本内容通常会因会话而异,有时甚至每个请求都不同。
静态分析之所以必然失效,是因为这段逻辑本来就是运行时下发的,并不包含在静态 bundle 中。你偶然抓到的一段脚本只是某一次实例;今天导出的内容和明天服务端下发的内容,可能已经完全不同。去逆它,等于在追一个持续移动的目标。
正确策略是把它当黑盒执行。你不需要理解它具体算了什么,只需要提供一个足够接近真实环境的运行舞台,让它自己跑出结果,再把结果取走。实践上通常是:
搭建最小浏览器环境 shim:为
document、location、navigator、cookie,以及少量 Observer 和 timer 提供空壳或最小实现。缺什么补什么,直到脚本不再因全局对象缺失而抛错。在本地 Node/V8 沙箱中运行下发的源码,例如
node:vm,或由 execjs 启动的进程,并设置执行超时。脚本执行后会写入 cookie,或把值塞进某个全局变量。拦截这次写入,从中取出所需 token。
知乎的访客校验就是这种形态:下发的脚本会把访客标识写入浏览器 cookie。你通过 shim 补出 DOM 环境,让它相信自己运行在真实页面里,等执行结束后取走这个值。整个过程中,你没有逆向任何一行算法,只是搭了一个足够可信的舞台,让脚本自行完成表演。成本不在“理解算法”,而在“让环境足够真实”。脚本可能探测 navigator.webdriver,检查某个节点是否存在,或期待某个 API 返回特定结果;你的 shim 必须恰好骗过这些检查,又不能膨胀到无法维护。这正是后文“最小执行面”要解决的取舍。
第二类:值在代码里,但会随每次发布更新的动态标识符
第二类正好相反:这个值确实以字面量形式存在于静态 bundle 中,但它在构建时生成,并且会随每次前端发布更新。
典型例子是现代前端不再发送明文 GraphQL,而是使用预注册查询。每个操作,例如拉取时间线或搜索结果,都会映射到一个构建期生成的 operation id 或 query id,并作为接口路径的一部分传递。这个 id 能在构建产物中搜索到,但只要前端发布新版本,同一操作对应的 id 就会换成新的值。
静态分析在这里很容易给你一种“已经搞定”的错觉:你找到了它,复制出来,当天跑通,然后硬编码。两周后接口返回 400,你才发现这个“常量”原来一直在变化。它比第一类更隐蔽,因为你曾经以为自己已经找到了答案。
正确策略不是逆向,因为这里根本没有可逆的算法,它只是构建常量。你要做的是在请求时从当前页面读取最新值,并在会话内缓存。读取方式可以按成本从低到高逐级尝试:
先看页面已经加载过的资源。页面刚刚发出过携带该标识符的请求,当前值就在那个 URL 里,直接从资源记录中提取即可。这是最低成本的方案:读取已有事实,而不是碰算法。
如果资源记录中拿不到,就从脚本源码中按模式提取。在当前 bundle 里定位“操作名映射到 id”的声明,再取出对应值。
前两步都失败后,才去深挖打包器的模块表,或根据线索拉取并解析关联 bundle。这是成本最高的一层,应当留到最后。
Twitter 的时间线和搜索 operation id 就是这样读取的:从页面已经发送的 GraphQL 请求 URL 中提取 id,拿到后在当前会话内缓存并复用。
这类问题更系统的版本——预注册查询有哪些获取路径、各自成本如何、不同场景该选哪一种——可以看那篇关于 persisted operation 的文章。这里仅强调:它属于更大范围的“运行时派生值”问题。
两类目标的分界线
把两类情况放在一起,对应的处理方式就很清楚了。
第一类:挑战脚本 | 第二类:动态标识符 | |
|---|---|---|
真实值在哪里 | 运行时下发,静态代码中从不出现 | 存在于静态代码中,但每次发布都会更新 |
该怎么做 | 执行:运行真实算法,获取副作用结果 | 读取:定位并读取常量,无需运行算法 |
失败通常意味着什么 | 环境 shim 太薄,代码无法跑完 | bundle 重排后读取器失效,拿到旧值或空值 |
是谁触发变化 | 服务端,随时可能变化 | 前端发布,按部署节奏变化 |
一句话总结:两类目标都会让“静态导出一次然后复制”失效,区别只在于你是否必须实际运行代码。判断清楚自己属于哪一类,下一步该上沙箱还是写提取器,也就直接确定了。
为什么这类目标不该强行纯化
这里一定会有人问:能不能彻底逆出下发脚本的算法,或者完整重建动态标识符的生成规则,写成脱离原始运行时的原生实现?这就是四阶段工作流第四阶段的纯化:最干净的形式,一次投入,长期零依赖,还能进 CI。
但面对这两类目标,通常并不划算。可以用一个粗略但实用的回本模型判断:
纯化需要一次性成本 C,包括逆算法和用差分测试验证。纯化后,相比“每次执行或读取”,每单位时间可节省 s。回本周期约为 C / s。
真正决定结果的变量不是 C,也不是 s,而是目标的变更周期 T,也就是它多久变一次。
T < C/s:还没回本它就变了,纯化实现上线几天后便不再匹配,你又得重做。收益为负。
T 远大于 C/s:纯化显然值得,一次投入可以使用很久,应当沿着纯化阶梯向原生重写层推进。
两类目标天然落在不同位置:
第一类的 T 由服务端控制,可能短到无法预测。对方随时可以调整下发脚本的逻辑,因此它几乎总该落在“执行”一侧。硬要纯化一个对方明天就可能改掉的算法,只会把你的命运交给对方的发布节奏。
第二类的 T 是前端发布周期,可能是几天到几个月。读取器越稳健,例如增加更多 fallback、选用更稳定的锚点,C/s 就越低;两边都可能合理,所以确实应该算一笔账。
这就是标题的完整含义:代码里找不到它,不一定是你漏了,也可能根本不存在一个值得纯化的稳定算法。
如何划定最小执行面
当你决定“执行”而不是“纯化”后,工程目标就变了。你追求的不是干净,而是把执行面压缩到最小、可控、可归因。重点有三件事。
只运行必要片段。 不要把整个页面运行时搬进来。只提供算法真正依赖的代码,加上最小 shim。shim 的大小存在一个甜点区:刚好能让代码不抛错并完成运行即可。每多一个 stub,就多一份维护负担——对方改了探测逻辑,你就得跟着改;每少一个 stub,脚本又会当场崩掉。不要为了省事塞进完整浏览器环境,否则你维护的就不再是签名器,而是半个浏览器。
锁住上下文。 编译后的 V8/Node context 并不线程安全。一次性挑战天然适合每次新建沙箱,彼此不会干扰;但如果为了降低启动成本复用编译后的 context,或缓存动态标识符解析器供多个请求共享,并发调用就必须串行化:
class RuntimeSigner:
def __init__(self, source: Path) -> None:
self._context = execjs.get("Node").compile(source.read_text())
self._lock = threading.Lock() # V8 context is not thread-safe
def call(self, fn: str, *args):
with self._lock:
return self._context.call(fn, *args)
失败必须可归因。 这是执行型取值最容易被忽略、却最节省时间的一环。一次失败必须能明确指向所属层级:
环境太薄:代码抛出
ReferenceError,或者卡住直到超时。说明 shim 缺少脚本需要的东西。协议变化:代码顺利执行并有输出,但结果格式不对,JSON 无法解析。说明对方改了输出协议。
语义不满足:你拿到了值,也能解析,但内容不完整,或者实际使用时校验失败。说明算法本身已经变了。
这三类情况对应三种完全不同的修复方式:补 shim、跟进协议、检查算法。一个只会笼统报告“失败了”的执行器,会让你每次都从头排查。成熟的挑战执行器应当明确区分执行失败、输出不可解析、结果不完整和超时。
每一步该选什么模型
模型在这条流程中的定位,和做指纹识别时不同。那里模型负责识别算法家族;这里没有算法家族需要识别,模型主要用来辅助判断“该纯化还是执行”,以及为执行失败做归因。四个子步骤对模型能力的要求也完全不同:
子步骤 | 需要的能力 | 选择 | model id |
|---|---|---|---|
判断纯化还是执行,需要同时论证两边 | 强推理能力,愿意反驳自身结论 | Claude Opus 5 |
|
定位动态标识符藏在哪里:找常量注入点,遍历 bundle 中的读取候选 | 长上下文,一次读完整个 bundle | Kimi K3 |
|
批量筛选哪些候选脚本可能包含目标操作 | 成本低,支持高并发数百次调用 | Claude Sonnet 5 |
|
执行失败时,读取日志或栈信息判断哪一层出了问题 | 中等推理能力,能针对具体错误解释 | GPT-5.6 Sol |
|
第一行尤其值得单独强调,因为这是本文唯一一个换模型就会明显影响结果的步骤。它考验的是双向论证和自我反驳能力,和指纹工作中处理反证的能力完全一致。较弱的模型会替你选一条路,然后不断堆砌支持这条路的理由,却不会认真论证另一边。强推理模型则会把“纯化”和“执行”都推演到底,写出各自最强的论据和失败模式,再通过比较给出结论。
不用相信我的判断,自己做个测试:
找一个你已经做过决策的真实目标作为对照组,你自己大致知道它该执行还是该纯化。
把观察到的事实,例如逻辑是否运行时下发或是否为发布常量、变更频率、环境依赖深度,分别交给
claude-opus-5和gpt-5.6-sol,让它们各写一份“纯化还是执行”的决策备忘录。重点看两点:它是否识别出“变更周期”才是决定变量——如果它只比较实现难度,却没有谈变更周期,就不合格;以及它给出的反转条件是否可观察——例如“若下次发布后该标识符不再变化,则改判为纯化”,并给出触发信号,这才可用。
一轮测试就能看出来:哪个模型是在帮你做决策,哪个模型只是在替你拍板。
真正的问题是切换成本
四个模型、三个供应商、三套 SDK、三种鉴权方案、三类错误格式。为了在不同子步骤间切换模型而重写三遍客户端,并不划算。于是大多数人会从头到尾只用一个模型,在“纯化还是执行”这一步使用一个不会反驳自己的模型,一头扎进纯化;等对方第二天就改掉目标后,绕了很长一圈才发现方向从一开始就错了。
AIReiter 去掉了这一层阻力:一个 key、一套 OpenAI 兼容接口,背后覆盖全部四个层级;只需修改请求体中的 model 字段,就能切换模型。
# 纯化还是执行:使用能双向论证的推理层
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<observed facts + argue the strongest case for both purify and execute>"}]
}'
# 在 bundle 中定位动态标识符:只改 model 字段,其余不动
# "model": "kimi-k3"
# 批量筛选候选脚本:
# "model": "claude-sonnet-5"
# 为执行失败归因:
# "model": "gpt-5.6-sol"
如果你已经在使用 OpenAI SDK,只需将 base_url 指向 https://aireiter.com/api/v1,其他内容无需修改。使用 Anthropic SDK 时,则以同一个 key 请求 POST /api/v1/messages。
从价格看,这条流程的 token 消耗很集中:Kimi K3 需要通读整个前端 bundle 来定位动态标识符,单次输入可能达到数十万 token;Claude Sonnet 5 则要批量筛选候选脚本,轻易就是数百次调用。这两部分决定了账单的大头。Claude 七折适用于批量筛选的 Sonnet 和决策论证的 Opus;GPT 半价适用于失败归因的 GPT-5.6 Sol;而需要长上下文通读全 bundle 的 K3,也可以用同一个 key 调用。优惠覆盖的是 token 最重的批处理和最贵的推理层,而不是泛泛地提供低价模型。
免注册试用:先手动跑几份“纯化还是执行”的决策备忘录,对比两个模型识别变更周期的能力,再决定是否接入。
结语
静态分析一无所获,并不总是能力问题,有时是目标本身的问题:签名不在静态代码中,因为它在运行时下发,或者它会随着每次发布更新。
面对这两类目标,别再寻找不存在的函数。挑战脚本应以最小方式执行,搭一个刚好足以让它跑出结果的沙箱;动态标识符则应在运行时读取,并按会话缓存。两条路径的前提都是承认:依赖“静态快照”的思路本身已经失效。
而“纯化还是执行”是一个纯粹的架构决策,只由一个变量决定:目标的变更周期,是否短于你一次性投入的回本周期。对于短周期目标,尤其是服务端随时都能下发新脚本的那类,强行纯化就是负收益。把这场双向论证交给能够反驳自身结论的推理层,而不是凭直觉拍板,也别让模型替你完成纯化。纯化是确定性的工程工作,验证应依赖差分测试,这正是四阶段工作流中第三和第四阶段的内容。模型在这里仅仅帮你想清楚一件事:这个目标到底值不值得逆。