打开一个经 webpack 打包的签名 SDK,迎面而来的通常是这样的场景:变量名不是 a,就是 _0x3f2b;控制流被拍平;字符串全塞进数组,再靠下标取值。目标倒是很明确:把它还原成一个独立实现,并让输出与原始代码逐字节一致。
现在多数人的第一反应,是把整个 bundle 扔给大语言模型,问一句“这段代码是干什么的?”问题往往从这里就开始了。模型会给出一份读起来头头是道的解释,你照着重写,最后却和目标输出差了一个字节。
这不代表模型不够强,而是分工摆错了位置。做去混淆时,模型擅长的是模式识别与生成假设,不擅长的是事实验证。它能从大量位运算中看出某个密码原语的骨架,却无法确认自己的判断是否正确;这件事必须交给测试。
下面是一套基于这条边界组织的四阶段工作流,以及我从不交给模型处理的三类事情。
第一阶段:机械拆分别交给模型
很多人会说:“上下文窗口都这么大了,整包代码直接塞进去不就行了?”不建议这么做,原因有两个。
第一是浪费。混淆 bundle 里大部分内容都是 polyfill、运行时垫片,以及与你当前目标毫无关系的业务模块。你付费把它们送进上下文,换回来的却是被稀释的注意力。
更关键的是:上下文越大,模型幻觉可落脚的地方就越多。它可能把两个无关模块的特征拼到一起,给你一个逻辑自洽、但代码里根本不存在的结论。这种错误比明显胡说更难发现。
拆分本身是确定性工作,应该用脚本完成:
使用 AST 工具,例如
@babel/parser、acorn,按模块和函数拆开 bundle,并按作用域建立索引;提取所有数值字面量和字符串常量,按出现频率与位宽分组;
构建调用图,标记入度为 0 的节点,也就是入口点,以及出度为 0 的节点,也就是叶子原语;
找出位运算密度异常高的函数。
^、>>>、<<、&若集中出现在同一个函数里,那里通常就是算法核心。
真正值得交给模型的是叶子原语。它们通常只有几十行,不依赖上层状态,输入输出边界也比较清楚。一段 40 行左右的叶子函数,加上它引用的常量,正是模型能稳定处理的粒度。
这一阶段结束后,你应该得到一份“候选原语清单”:每项包含函数体、引用常量,以及调用来源。从这里起,每次模型调用都只处理清单中的一项,而且一次只处理一项。
第二阶段:让模型判断算法家族
这是模型最难替代的环节。
密码算法和编码算法都有很强的指纹:固定常量、特定的移位组合、特定的循环结构。人靠经验积累识别这些特征;模型看过大量公开实现,因此在这件事上天然占优。
下面列几个公开案例,帮助你理解什么叫“算法指纹”:
0x811c9dc5与0x01000193同时出现,分别是 32 位 FNV-1a 哈希的 offset basis 和 prime。它们是由共同作者 Landon Curt Noll 维护的FNV 参考页中公布的标准值,页面以十进制给出:2166136261 和 16777619;0x61707865, 0x3320646e, 0x79622d32, 0x6b206574是 ASCII 字符串"expand 32-byte k"的小端字表示,属于 ChaCha20 初始状态常量,见 RFC 8439 §2.3;若代码结构类似
a += b; d ^= a; d = rotl(d, 16); c += d; b ^= c; b = rotl(b, 12);,并且四个旋转位数是 16/12/8/7,那就是 ChaCha20 quarter round 的典型特征;其同源算法 Salsa20 用的是 7/9/13/18,仅凭这四个数字就能区分二者;一个以
0xd76aa478开头、共 64 项的常量表,是 MD5 的 T 表,见 RFC 1321 §3.4;以
0x63, 0x7c, 0x77, 0x7b开头的 256 字节表,是 AES S-box,见 FIPS 197 的表 4;0xEDB88320是反射式 CRC-32 多项式,gzip 规范 RFC 1952 使用的正是这个值。
关键不在于问什么,而在于怎么问。“这段代码做什么?”只会得到一段说明文字。你真正需要的是可验证的结构化判断,因此提示词必须强制模型输出三部分:候选项、证据、反证。
Below is a leaf function extracted from an obfuscated bundle, together with
every numeric constant it references.
<function>
{{function body}}
</function>
<constants>
{{constant list, with locations}}
</constants>
Answer in the following structure. Do not write prose.
1. Candidate algorithm families (at most 3, ranked by likelihood)
For each: the name, and which class it belongs to
(hash / stream cipher / block cipher / encoding / compression / checksum)
2. Supporting evidence
Every piece of evidence must point to a specific constant value or a
specific line above. "Structurally similar" and other unverifiable
phrasing is not allowed.
3. Counter-evidence and deviation points
If this were the standard implementation of that algorithm, what should
appear here but doesn't? What appears that a standard implementation
would never contain? Are these deviations a "variant," or "I got it wrong"?
4. The minimal test to decide this hypothesis
Give 3 concrete inputs and, if the hypothesis holds, the shape of the
output each should produce. Include boundary cases.
第 3 部分正是这个提示词最有价值的地方。与标准实现不一致的部分,恰恰可能是代码被改动的位置:自定义字母表、被替换的常量、修改后的轮数。真正需要你花力气重写的,也只有这些偏差;其余大部分都可以直接参考公开实现。如何让模型尽可能找全这些偏差,是算法指纹识别一文的核心主题。
第 4 部分则把模型的判断直接转成下一步要执行的测试,省去一次来回沟通。
顺带一提,这一阶段经常会遇到非标准编码。判断方法很机械:字母表长度为 65,即 64 个字符加 1 个填充符;每组按 6 bit 编码;输出长度为 ceil(n/3)*4,这就是 Base64 家族。字母表顺序不同,则意味着用了自定义表。同样地,如果字典从 256 开始扩展,且随着字典填满输出码宽不断增加,那就是 LZW。仅通过输入输出长度关系就能验证这些结论,甚至无需读一行代码。
第三阶段:把假设变成差分测试
模型给你的只是一个假设。在测试写出来以前,它也只是一句话。
这里没有捷径,而且它是整个流程中唯一能拦住幻觉的关卡。至于为什么它是唯一的关卡,差分测试一文有完整说明。做法是把原始实现视为黑盒,逐个用例比对你的重写版本:
# Differential-test skeleton: original as black box, rewrite compared case by case
CASES = [
b"", # empty input: exposes initial state and padding logic
b"\x00", # single zero byte
b"\xff", # single high byte: checks sign-bit handling
b"a" * 63, # one below the block boundary
b"a" * 64, # exactly one block
b"a" * 65, # one above: checks padding and carry
bytes(range(256)), # full byte coverage: checks alphabet mapping
]
for case in CASES:
assert rewritten(case) == blackbox(case), case.hex()
这里有几条实用原则:
跨越边界的信息量最大。对于分块算法,64n 和 64n±1 最容易暴露填充逻辑。如果整轮测试中只有一个用例失败,输入长度往往能直接告诉你是哪一层出了问题。
同一输入要跑两次。如果结果不同,说明实现混入了随机数或时间戳。此时必须找到注入点,让外部可以覆写它;否则根本无法进行差分比较。重写签名逻辑时,这也是最常见的卡点:算法本身未必错了,只是熵源还没有被剥离出来。
按层剥开比,不要直接比较最终结果。先对齐最内层哈希;这一层通过后,再比编码层;最后再比组装层。完整输出不一致时,你不知道错在何处;逐层拆开后,第一个失败的层就是故障点。
把固定测试向量提交为 fixture。一张已知输入 → 已知输出的表,能让你在上游更新后迅速判断:“是我写错了,还是对方改了?”这份 fixture 的价值只会随时间增加。
在这个阶段,模型的工作是生成测试用例、解释差异,而不是裁定谁对谁错。真正做裁定的是 assert。
第四阶段:按运行承载方式逐级降级落地
当所有假设都通过验证后,接下来要把它变成能长期运行的代码。这里有明确的优先级,越靠前的层级越值得争取:
用目标语言原生重写。彻底脱离原始运行时,只依赖标准库。这是唯一不增加额外进程、不增加额外依赖,并且能干净接入 CI 的形式。
本地 JS 引擎运行最小代码片段。有些逻辑短期内纯化成本太高,可以保留一小段原始 JS,在本地 Node/V8 中执行。需要注意,JS 引擎上下文不是线程安全的;多个线程调用同一个已编译上下文时必须加锁:
class Signer:
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)
被动式浏览器桥接。对于只能从真实页面运行时获得的状态,浏览器暂时是唯一选择。这应被视为临时形态:在接口说明中明确标注,绝不能让它成为默认实现。
值得把这种降级顺序写进项目规范。每降低一层,依赖面、故障模式和部署成本都会上一个数量级:第 1 层是纯函数,第 3 层则是需要人工维持会话的外部进程。默认争取第 1 层,能避免“先跑起来再说”悄悄积累的大部分长期成本。三层边界如何划分,以及怎样避免被动桥接失控扩张,详见协议纯化阶梯一文。
给一个参考量级:一个经过混淆的签名 SDK,走完这四个阶段后,可以落成不到 600 行的独立实现,只依赖运行时内置的 crypto。这种压缩并不意味着模型“理解了”原始文件,而是算法家族一旦确认正确,绝大多数代码都能直接从公开实现中获取。
每个阶段该用什么模型
这四个阶段需要的能力完全不同。全程只跑一种模型,要么浪费成本,要么牺牲准确性:
阶段 | 真正需要的能力 | 我的选择 | model id |
|---|---|---|---|
拆分后的结构梳理 | 长上下文,能一次读完整个模块的调用图 | Kimi K3 |
|
算法家族识别与反证 | 强推理能力;能找出偏差,也愿意反驳自身判断 | Claude Opus 5 |
|
批量符号重命名、补充注释 | 成本低,能以高并发执行数百次调用 | Claude Sonnet 5 |
|
差异归因:测试失败时解读 diff | 中等推理能力,能针对具体字节解释差异 | GPT-5.6 Sol |
|
第二阶段尤其值得展开。算法家族识别是唯一一个切换模型会显著改变结果的步骤,因为它考验的正是“你见过多少公开实现”,以及“你是否愿意反驳自己”。面对同一个叶子函数,较弱的模型会自信地给出错误答案;更强的模型则可能在反证部分推翻自己的候选项。
别只听我的判断,自己测一次就知道差别。测试流程如下:
从你自己的混淆 bundle 中挑出 3 个叶子函数,其中至少 1 个是你已知答案的,用作对照组。
使用第二阶段的三段式提示词,分别向
claude-opus-5和gpt-5.6-sol提交完全相同的输入。只看两点:是否命中候选算法家族;以及反证部分是否真的在反驳自身结论,而不是换种说法重复候选项。
反证部分的质量就是你的选型标准,因为它直接决定第三阶段会多写多少无效测试。
第三、四阶段几乎不需要挑模型,能跑就行。第一阶段只需要长上下文模型,帮你省掉自己写分块检索的工作。
真正的阻力不是选模型,而是切换成本
四个模型、三个供应商,意味着三套 SDK、三种鉴权方式、三类错误格式。为了省一点钱而重写客户端并不划算——这也正是为什么大多数人最后会用同一个模型包打天下。
AIReiter 抹平了这一层:一把 key、一个兼容 OpenAI 的接口,四个模型都在后面;切换模型只需改请求体中的 model 字段。
# Algorithm-family ID: the reasoning tier
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": "<Stage 2 prompt + leaf function + constants>"}]
}'
# Bulk symbol renaming: change the model field, leave everything else
# "model": "claude-sonnet-5"
如果你已经在用 OpenAI SDK,只需把 base_url 指向 https://aireiter.com/api/v1,其他都不用改。使用 Anthropic SDK 的话,则带同一把 key 请求 POST /api/v1/messages。
价格方面,Claude 模型按官方定价打 7 折,GPT 模型半价。对这套工作流而言,这比听起来更重要:第三阶段的批量符号重命名很容易跑到数百次调用;第一阶段单次长上下文输入则有几十万 token。这两项构成了主要成本,而折扣恰好落在最贵的部分。
无需注册即可试用——先手动跑几条提示词,比较两个模型的反证部分;确定选择后,再把流程写成脚本。
有三件事,不要交给模型
第一,不要让它直接产出最终实现。让模型“完整重写”时,它给出的代码看似完整,也能运行,却经常藏着细微偏差。更麻烦的是,你无法定位偏差,因为这份代码并不是由你逐层验证出来的。正确用法是:让它为每个原语提出假设,你逐一验证,再自行组装。这样确实慢一点,但你知道每一行为什么这么写。
第二,不要让它判断结果是否正确。“你能确认这个实现对吗?”是个死胡同,模型往往会顺着你给出的结论表示同意。正确与否的唯一裁判是差分测试。模型说对,但测试失败?以测试为准。模型说不对,但所有测试都通过?依然以测试为准。
第三,不要让它替你做合规判断。你是否可以接触目标、是否可以公开结论、如何使用获得的数据,都取决于你的司法辖区、目标方的条款和你的具体用途。模型没有事实依据来判断这些问题;它的回答只是模仿自己见过的免责声明语气。这个决定应由你自己做,或者交给真正的法律顾问。
结语
模型在这类工作中的位置很明确:它是一个看过大量公开算法实现的模式识别器,能在几秒内给出候选假设。它不是答案来源,也不是验证者。
这套流程的骨架与是否使用 AI 无关:机械拆分、提出假设、验证、纯化。模型出现之前,这四步就是既有流程。模型压缩的只是“提出假设”这一步,把原本数天的资料翻查缩短为几分钟。其余三步,成本与过去没有任何区别。
当你把这四个步骤中的模型调用固化为脚本后,整个流程会变得很快。此时剩下的主要阻力只有模型切换——这是基础设施问题,可以通过统一接口之上的模型选择解决。
