差分测试:逆向工程中拦住模型幻觉的唯一关卡

最后更新: 2026-07-30 10:58:49

模型读完你的叶子函数,给出一个看起来很利落的结论:“这是 ChaCha20,只是轮数被改成了由密钥派生的变量。”这个说法听上去相当可信,你甚至已经想直接照着它开始重写。

先别动。在这句话被写成可运行的 assert 之前,它就只是一句话。模型见过再多公开实现,也无法验证自己是否猜对了眼前这个版本。这里要回答的是事实问题,不是语言问题;唯一可靠的办法,是让原实现和你的重写对同一份输入各跑一次,再逐字节比对输出。

能回答这个问题的,只有差分测试。它是四阶段流程中的第 3 阶段,也是所有偏差点排查假设最终必须抵达的地方:模型负责提出怀疑,差分测试负责下结论。

别再问模型“这个实现对吗”

初学者最爱问模型的一句话,往往是:“你能确认这个实现正确吗?”

这其实是个走不通的问题,原因有三层。

首先,模型天然倾向于顺着你的预期回答。“这个实现对吗”本身就带着暗示:你显然希望它是对的,而模型很容易捕捉到这种语气,接着给出一个“对”。

其次,它根本没有判断依据。所谓“正确”,只能建立在黑盒和重写实现对同一批输入的输出比对之上,而这些事实并不在模型上下文中。它没有可对照的真值,只能从代码“看起来是否合理”来猜;而混淆代码最擅长的,恰恰就是让自己看起来合理。

最后,模型也不该承担这个角色。正确性不是可以协商的观点,而是某个等式是否成立的事实。别把这个问题交给模型,把它交给 assert:模型说对、测试失败,听测试的;模型说错、所有测试都通过,还是听测试的。一旦把裁决权交给模型,你的工作就是建立在幻觉之上。

边界输入该怎么设计

差分测试的框架很简单:把原实现当作黑盒,让重写实现针对同一批输入逐个与其比较。真正有价值的不是那个循环,而是你喂进去的输入集合。

即使拿一万条普通随机字符串去跑,全部绿灯也说明不了什么。普通输入大多只会走主路径,偏差点通常藏在边角处。真正信息密度高的,是边界条件:

# Boundary cases: derived from block size B
def boundary_cases(B: int) -> list[bytes]:
    return [
        b"",                 # empty: exposes initial state and padding logic
        b"\x00",             # single zero byte
        b"\xff",             # single high byte: checks sign-bit / unsigned handling
        b"A" * (B - 1),      # one below the block boundary
        b"A" * B,            # exactly one block
        b"A" * (B + 1),      # one above: checks carry and padding
        bytes(range(256)),   # full byte coverage: checks the alphabet map covers the whole domain
    ]

每个用例都在追问一条具体分支。高位字节 \xff 会把符号位处理直接暴露出来:JS 中 >>>>> 的区别、Python 中是否遗漏 & 0xff,都能在这一例里现形。B-1 / B / B+1 这组三连击尤其凶狠,分组填充逻辑通常只有在这里才会露出真相。全字节覆盖则用于检查自定义字母表:映射表里多一个字符或少一个字符,这个用例都会毫不留情地失败。

差分测试主体本身不到十行:

# Original as black box, rewrite compared case by case
def diff_test(blackbox, rewritten, B: int) -> None:
    for case in boundary_cases(B):
        got, want = rewritten(case), blackbox(case)
        assert got == want, f"len={len(case)} hex={case.hex()}"

还有一点值得注意:如果你的偏差点假设涉及标准算法,甚至不一定非得依赖原始黑盒。公开标准往往自带权威测试向量。例如,RFC 8439 §2.1.1 给出了 ChaCha20 quarter round 的固定输入输出对:输入 a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567,输出为 a=0xea2a92f4, …。先让重写实现通过标准向量,再拿它和目标黑盒对比,就能清晰区分两类问题:“ChaCha 实现本身写错了”,还是“目标修改了 ChaCha”。

注意 assert 的报错信息里带着 len(case)这是整套测试中最有用的一行诊断信息。如果七个用例里只有 B+1 失败,问题几乎必然在填充或进位;如果只有全字节覆盖失败,问题就在字母表映射。失败用例的长度会直接把你带到出错层,不必靠猜。

同一份输入,连续跑两次

重写签名逻辑时,最常见的卡点不是算法识别错了,而是某个熵源还没有被拆出来。

要抓住这个问题,只需一行:让同一个输入连续运行两次。

# Same input twice: differing results mean a hidden RNG / timestamp
def assert_deterministic(fn, case: bytes) -> None:
    assert fn(case) == fn(case), "unfixed entropy source or timestamp present"

如果两次结果不同,说明实现中混入了 time.time()、nonce、自增计数器,或其他每次调用都会变化的东西。这时你甚至还无法做差分比较:黑盒每次给出的答案都不一样,又该拿什么作为对照?

正确做法不是删掉熵源——删掉以后签名就错了——而是把它从内部逻辑中提取为可注入参数,并在差分测试时固定其值:

# Lift the entropy source into an injectable parameter, pin it with a stub for diffing
class Rewritten:
    def __init__(self, clock=time.time, rng=os.urandom):
        self._clock = clock          # formerly an inline time.time(), now injected
        self._rng = rng

    def __call__(self, data: bytes) -> bytes:
        ts = int(self._clock())      # for diffing, clock=lambda: 0
        nonce = self._rng(16)        # for diffing, rng=lambda n: b"\x00" * n
        ...

目标黑盒也要做同样处理:找到它注入时间戳或随机值的位置,并设法固定下来,例如在页面中 hook Date.now,或向 Node 传入固定 seed。两边的熵源都固定后,输出就重新具备确定性,差分比对才有意义。端到端通过后,再将 clockrng 换回真实实现。

这同样不是模型能替你解决的步骤:熵源藏在哪里、如何被注入,属于运行时行为。不是靠读代码猜出来的,而是靠连续跑两次把它逼出来的。

逐层剥离,不要只盯着最终输出

假设你的重写结果和黑盒最终输出不一致。别盯着最后那串字节死磕——它是多层嵌套处理的产物,你并不知道究竟是哪一层出了问题。

一个签名器通常由多层组成:最内层是 hash 或分组密码,中间包一层编码,例如 Base64 变体、hex 或自定义表,最外层再负责组装,比如拼接前缀、插入字段、添加长度头。所谓逐层剥离,就是从最内层开始比,当前层通过后才向外推进一层:

# Peel layer by layer: match the innermost first, then move outward
LAYERS = ["digest", "encode", "assemble"]   # inner → outer

def first_divergent_layer(bb_dump, rw_dump, case: bytes) -> str | None:
    for layer in LAYERS:
        if bb_dump(case)[layer] != rw_dump(case)[layer]:
            return layer                     # the first layer to diverge is the faulty one
    return None

这要求你的重写实现能导出各层中间状态,也要求你能从黑盒中挖出对应中间值,通常需要在运行时做插桩。但这些额外工作非常值得:第一个出现差异的层就是故障层,排查范围会瞬间收窄。

这正好能接上偏差点的四个类别:差异出现在 digest 层,通常意味着常量被扰动或轮数被修改;出现在 encode 层,通常是字母表顺序被重排;出现在 assemble 层,则常常说明有额外材料被嵌入输出。出现差异的层,会告诉你该回到指纹识别文章中的哪一类问题里查。

Fixture 的长期价值

差分测试转绿的那一刻确实很爽,但那只是一次性验证。上游明天发了新版本,今天验证过的实现可能立刻就完全不对了。

真正会随时间增值的产物,是提交到仓库里的 fixture:一张已知输入 → 已知输出的表。

# Fixture: known input → known output, committed to the repo
# Re-run on every upstream change; green yesterday, red today = upstream changed, not you
VECTORS = load_json("vectors.json")   # [{"in": "<hex>", "out": "<hex>"}, ...]

for v in VECTORS:
    got = rewritten(bytes.fromhex(v["in"])).hex()
    assert got == v["out"], v["in"]

它的价值会在上游变更时立即显现。某天 CI 变红,昨天没动过的实现开始对 fixture 失败——这条信息极其宝贵:它排除了“是不是我写错了”,将问题唯一指向“上游变了”。没有 fixture,你很可能会花半天调试一段完全正确的代码,因为你无法判断究竟是自己出错,还是别人改了规则。

fixture 最适合收录的输入,正是前面那些边界用例——它们本来就是你手中覆盖率最高的一组。

在这一步,模型只做两件事

把差分测试中的分工说得直白一点:模型只做两件事,而且没有裁决权。

第一,批量生成用例。跨多个原语铺开边界输入,针对可疑常量生成只差一位的输入批次,这些都是枚举工作,不需要推理能力。选便宜、高并发的档位最划算。

第二,解释差异。当某个用例失败时,把两边的层级 dump 摆在它面前,让它针对具体字节说明:第一个差异在哪里,它最可能属于四类偏差中的哪一类。这个环节需要中等推理能力,也需要能够“对着字节解释”——这才是模型在本文流程里真正发挥作用的地方。

裁决权始终属于 assert,这一点不会改变。模型的解释只是线索,不是结论;线索指错方向很正常,assert 会把它拦下来。

这两项工作对模型能力的要求不同。如果全程只用一个档位,要么浪费钱,要么牺牲精度:

差分测试子步骤

所需能力

推荐选择

model id

批量生成边界/对照用例(覆盖多个原语)

低成本、高并发;枚举无需推理

Claude Sonnet 5

claude-sonnet-5

读取单个失败 diff,并针对字节解释

中等推理能力;归因要落到具体层

GPT-5.6 Sol

gpt-5.6-sol

归因无法收敛时深挖根因(如常量扰动)

强推理能力;可跨多轮中间状态推断

Claude Opus 5

claude-opus-5

吞入大量层级 dump 或整批 fixture,寻找差异点

长上下文

Kimi K3

kimi-k3

真正的主力是第二档。差异归因是否值得专门选模型,你自己跑一轮测试就会知道。流程很短:

  1. 从差分测试套件中挑一个确实失败的用例,并准备两份层级 dump:黑盒的和你的重写实现的。

  2. 把同一份 diff 分别交给 gpt-5.6-solclaude-opus-5,只问两个问题:第一个差异位于哪一层,以及它最可能属于四类偏差中的哪一类。

  3. 只看归因是否能落到具体字节和具体层,还是只给出“可能是 padding 问题”之类的模糊说法。

  4. 归因精度就是你的选择标准——它直接决定要经过多少轮修改,这一个失败用例才能转绿。

一轮测试就足以看出差异,比任何模型排行榜都更直接。

真正的阻力在于切换成本

这四个档位来自三个供应商,对应三套 SDK、三种鉴权方式、三类错误格式。为了在不同子步骤间切换档位,专门接三遍客户端并不划算。也正因如此,多数人最后会拿一个模型做完所有事,用一个只会泛泛而谈的模型做差异归因,再在不知道原因的情况下白白修改好几轮。

AIReiter 把这一层抹平了:一个 key、一个兼容 OpenAI 的接口,四个档位都在后面;切换时只需改请求体里的 model 字段。

# Difference attribution: the mid-reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-5.6-sol",
    "messages": [{"role": "user", "content": "<diff of the two layer dumps + locate the first divergent layer and deviation category>"}]
  }'

# Attribution won't converge, escalate to dig the root cause: change one field
#   "model": "claude-opus-5"
# Bulk-generate boundary cases:
#   "model": "claude-sonnet-5"

已经在用 OpenAI SDK?把 base_url 指向 https://aireiter.com/api/v1,其他地方都不用改。使用 Anthropic SDK?带同一个 key 请求 POST /api/v1/messages 即可。

价格方面,Claude 模型按标价七折,GPT 模型半价。对于这套流程,折扣正好落在调用最密集的环节:差异归因是差分测试中调用最频繁的部分——每个失败用例都要跑一轮;每次上游变更,又意味着 fixture 重建和一批新的失败用例等待归因。主力模型 gpt-5.6-sol 属于半价 GPT 模型,因此最密集的部分直接减半;偶尔升级到 claude-opus-5 深挖根因,调用次数虽少,但 Claude 的七折同样适用。

  • 获取 API key

  • 免注册试用——先手动把一份真实 diff 分别喂给两个档位,亲自比较归因精度,再决定要接入哪一个。

结语

逆向重写这件事,真正的骨架其实只有两根梁:模型给出假设,assert 给出裁决。

模型是见过无数公开实现的怀疑生成器。它能在几秒内告诉你“这里可能被改过”,但它永远不知道这一次究竟对不对。差分测试则是把“可能”变成“是或否”的机器:边界输入逼出分支,同一输入跑两次逼出熵源,逐层剥离锁定故障层,fixture 则帮你区分“我写错了”和“对方改了”。

四阶段流程偏差点排查文章交到你手上的每一个判断,最终都必须通过这道关。模型说什么都不算,只有 assert 说的算。