从 C 到 Rust:Google 如何用 AI 与差分模糊测试改造关键依赖

2026-09-27 35 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

把一个长期运行的 C 图像处理库改写成 Rust,难点从来不只是“让它编译通过”。真正棘手的是同时守住三条线:外部行为兼容、历史漏洞不被带入新实现、运行时性能不明显退化。Google 安全团队针对 giflib 的实践说明,AI 可以加快迁移,但只有配合差分模糊测试和持续人工审查,才可能把自动翻译变成可信的软件工程流程。

迁移目标不是语法转换,而是替换风险边界

C 图像解析代码会直接处理攻击者可控的字节流。长度字段、颜色表、帧尺寸和压缩数据只要有一个边界检查失误,就可能触发越界读写、释放后使用或整数溢出。Rust 能消除相当一部分内存安全问题,但“改成 Rust”并不自动等于“实现正确”。

这类迁移至少要区分三种目标:

  • 内存安全:让缓冲区访问、所有权和生命周期由类型系统约束。
  • 行为兼容:相同输入应产生等价输出,包括错误码、元数据和失败方式。
  • 漏洞清理:不能机械复刻旧实现中的缺陷,否则逻辑漏洞仍会原样保留。

因此,比较合理的迁移单位不是单个 C 函数,而是一条可观察的功能路径。例如“读取 GIF 头部并返回宽高”“解码一帧并输出像素”“识别损坏文件并返回错误”。每迁移一条路径,就同时建立对应的兼容性测试和模糊测试入口。

AI 适合生成初稿,不适合独立做安全裁决

AI 对机械性工作很有帮助:转换控制流、生成 Rust 数据结构、补齐错误处理框架,以及为旧 API 建立包装层。但它也容易生成几类隐蔽问题:

  1. 为了通过借用检查,加入不必要的复制,造成内存和性能回退。
  2. 用 unsafe 绕开类型系统,使迁移失去主要安全收益。
  3. 忠实翻译旧代码中的整数截断、异常状态和逻辑漏洞。
  4. 在罕见输入上改变错误语义,导致上层应用出现兼容问题。
  5. 生成看似合理但没有覆盖完整格式状态机的解析逻辑。

人工审查不应只检查代码风格,而要围绕风险提出具体问题:输入长度是否先于索引操作验证?尺寸乘法是否使用受检运算?资源上限是否明确?FFI 指针由谁拥有?错误路径是否释放资源?新实现是否引入了无界分配?

一个实用原则是:AI 负责扩大迁移吞吐量,编译器负责拦截类型和所有权错误,差分测试负责发现行为偏差,安全工程师负责判断哪些旧行为应该兼容、哪些必须主动丢弃。

差分模糊测试:让旧实现和新实现接受同一批输入

普通单元测试验证的是“已知样例能否通过”,差分模糊测试则不断构造新输入,同时运行 C 与 Rust 两套实现,并比较可观察结果。对图像库来说,可以比较:

  • 是否都接受或拒绝输入;
  • 图像宽高、帧数和颜色表信息;
  • 解码后的规范化像素摘要;
  • 错误类别,而不是依赖不稳定的错误文本;
  • 是否崩溃、超时或消耗异常多的内存。

旧实现不能被无条件视为正确答案。如果 C 版本崩溃,而 Rust 版本安全拒绝输入,这通常是新实现更合理;如果两边输出不同,则需要人工确认是兼容性回退,还是旧缺陷被修复。差分测试的价值正是在于快速找到这些“需要裁决”的输入。

测试时还应先规范化输出。例如不同实现可能产生不同的错误文案、日志顺序或内部调色板布局,这些不应制造无意义的差异。比较层最好只保留调用方真正依赖的字段。

可以这样实践:建立可复制的差分测试驱动

下面的脚本不是 Google 内部工具的复刻,而是一个可以直接改造的最小测试驱动。假设旧版和新版各提供一个命令行适配器,调用方式为 decoder FILE,并在标准输出中返回规范化 JSON,例如:

{"status":"ok","width":320,"height":200,"frames":1,"pixel_sha256":"..."}

将以下内容保存为 diff_check.py。运行前只需把 --legacy 和 --rust 指向自己的两个适配器:

#!/usr/bin/env python3
import argparse
import json
import subprocess
from pathlib import Path


def run_decoder(command: str, sample: Path, timeout: float) -> dict:
    try:
        result = subprocess.run(
            [command, str(sample)],
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return {"status": "timeout"}

    if result.returncode < 0:
        return {"status": "signal", "signal": -result.returncode}

    try:
        payload = json.loads(result.stdout)
    except json.JSONDecodeError:
        return {
            "status": "invalid-adapter-output",
            "exit_code": result.returncode,
        }

    # 只比较稳定、对调用方有意义的字段。
    keys = ("status", "width", "height", "frames", "pixel_sha256", "error_kind")
    return {key: payload[key] for key in keys if key in payload}


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--legacy", required=True)
    parser.add_argument("--rust", required=True)
    parser.add_argument("--corpus", required=True, type=Path)
    parser.add_argument("--timeout", type=float, default=2.0)
    args = parser.parse_args()

    mismatches = 0
    samples = sorted(path for path in args.corpus.rglob("*") if path.is_file())

    for sample in samples:
        old = run_decoder(args.legacy, sample, args.timeout)
        new = run_decoder(args.rust, sample, args.timeout)
        if old != new:
            mismatches += 1
            print(f"MISMATCH: {sample}")
            print(f"  legacy={json.dumps(old, sort_keys=True)}")
            print(f"  rust  ={json.dumps(new, sort_keys=True)}")

    print(f"checked={len(samples)} mismatches={mismatches}")
    return 1 if mismatches else 0


if __name__ == "__main__":
    raise SystemExit(main())

执行方式如下:

chmod +x diff_check.py
python3 diff_check.py \
  --legacy ./bin/legacy-gif-info \
  --rust ./target/release/rust-gif-info \
  --corpus ./corpus/gif

这个驱动可以接在 AFL++、libFuzzer 或其他输入生成器之后:模糊器负责持续扩充语料库,脚本负责重放并分类差异。工程规模扩大后,应把超时、崩溃、输出差异和资源超限分别归档,而不是全部记作一个普通失败。

对于 Rust 解析入口,还可以用 cargo-fuzz 单独检查崩溃和 panic。下面假设项目暴露了 gif_migration::decode_gif(&[u8]);运行前需要替换成真实 crate 名称和函数:

cargo install cargo-fuzz
cargo fuzz init
cargo fuzz add decode_gif

将 fuzz/fuzz_targets/decode_gif.rs 改为:

#![no_main]

use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    // 假设解析失败通过 Result 返回,而不是 panic。
    let _ = gif_migration::decode_gif(data);
});

然后执行:

cargo fuzz run decode_gif -- -max_len=1048576 -timeout=5

这里的 1 MiB 输入上限和 5 秒超时只是起点。真实阈值应根据 API 用途、线上资源预算以及 GIF 解压后的尺寸膨胀风险来设定。

性能验证不能只看平均耗时

来源信息表明,这次改造在增强安全性的同时维持了运行时性能。要在自己的项目里达到类似结果,基准测试需要覆盖正常文件和恶意边界输入,而不是只测一张小图。

建议至少记录:

  • 每秒处理文件数以及 P50、P95、P99 延迟;
  • 峰值常驻内存和每次解码的分配次数;
  • 大尺寸、多帧和高压缩比输入的表现;
  • 无效输入被拒绝前消耗的 CPU 时间;
  • C/Rust 边界上的复制次数和 FFI 调用成本。

如果 Rust 版本变慢,先检查是否为了简化所有权而反复复制缓冲区,或者是否在热路径中创建临时集合。不要为了追平基准而立即引入大段 unsafe;更稳妥的顺序是先调整数据布局和所有权,再缩小必须使用 unsafe 的范围,并为该范围增加独立测试。

渐进替换比一次性重写更容易控制风险

这类项目适合采用“适配器—双轨运行—逐步切换”的路径:

  • 固定旧实现的外部契约,建立规范化输出和回归语料库;
  • 优先迁移攻击面大、边界清晰的解析路径;
  • 对同一输入并行执行 C 与 Rust,实现差异告警;
  • 将确认过的差异样本永久加入回归测试;
  • 用性能门槛阻止复制、分配或延迟的意外回退;
  • 审计所有 unsafe、FFI 和整数转换;
  • 稳定后逐步提高 Rust 流量,最后移除旧路径。

Google 的案例最值得借鉴之处,不是“AI 能自动把 C 变成 Rust”,而是把 AI 放进了一个有反馈、有验证、有人类裁决的闭环。对于关键依赖,代码生成只是迁移的开始;真正决定结果的,是能否用差分测试证明兼容性,用模糊测试持续探索未知输入,并明确拒绝继承旧实现中不安全的行为。


相关推荐