engineering

LLM 引导的模糊测试:更高覆盖率,更少无聊崩溃

有一个星期二,我们花了七个小时追查一个看起来很漂亮的 SIGSEGV。我们正在对某个客户的特定 JWT 解析器进行模糊测试,AFL++ 几乎立刻就吐出了一个可重现的崩溃。闻起来像是真正的发现。我们隔离了它,用 afl-tmin 最小化,编写了 PoC,然后带着已经在脑海中起草 CVE 的自信将它发送到内部频道。

崩溃是一个针对空缓冲区的 strncpy,因为 JWT 头部字面上就是字符串 { 后跟二进制字节。解析器在到达任何有意思的部分之前,运行了 base64url_decode 并在生产环境中永远不会触达的路径上炸了:前置的负载均衡器要求 Content-Type 并且 token 中至少有一个点。这是个 bug,没错。但它也毫无用处。

那天下午,我们决定够了就是够了。

为什么灰盒模糊测试器在语法上卡住

AFL 的变异策略(位翻转、拼接、算术)是语法盲的:大多数变异后的输入都过不了解析器。Wang 等人(Superion,ICSE 2019)已经指出"AFL 花了大量时间处理语法正确性,只能找到解析错误"。

对于 JSON、ASN.1 或任何有状态的格式,有效覆盖率会停滞。会出现大量崩溃,但大多数来自序言部分:验证器、base64 解码器、长度检查。有趣的内容(签名验证逻辑、kid 处理、algorithm confusion 类攻击)隐藏在位翻转无法穿越的墙后面。

LLM 作为语义变异引擎

Xia 等人在 Fuzz4All(ICSE 2024)[1]中将其形式化:LLM"隐式地学习语法、语义和有效的 API 约束"。它是一个生成合理输入的概率生成器。

Deng 等人使用 TitanFuzz(ISSTA 2023)[2]在 TensorFlow/PyTorch 上展示了 30-50% 更多的覆盖率。Meng 等人使用 ChatAFL(NDSS 2024)[3]将该原理应用于网络协议:状态转移多了 47.6%,9 个新 CVE。Yang 等人使用 WhiteFox(OOPSLA 2024)[4]增加了一个读取源代码以推导输入需求的代理。

共同的模式:LLM 不做模糊测试,它提出候选项。传统的模糊测试器仍然是覆盖率引导的引擎。

我们的架构:带有语义插件的 AFL++

Gwaihir CLI 封装了 AFL++ 并添加两样东西:一个将一定比例的变异委托给语义提供者的自定义变异器,以及 Beorn,它带来已知的语法、历史样本和同一格式的过去 CVE。

  1. Gwaihir 分析目标。如果 Beorn 识别该格式,它会注入种子语法。
  2. AFL++ 以 LLM 从规范生成的初始语料库启动。
  3. 自定义变异器挂载到 afl_custom_fuzz。每 N 次迭代,它向 LLM 请求一次语义变异。
  4. 增加覆盖率的输入被重新注入语料库。
// gwaihir_mutator.c — 示意片段
size_t afl_custom_fuzz(void *data, uint8_t *buf, size_t buf_size,
                       uint8_t **out_buf, uint8_t *add_buf,
                       size_t add_buf_size, size_t max_size) {
  gwaihir_ctx_t *ctx = (gwaihir_ctx_t *)data;
  ctx->counter++;
  if (ctx->counter % ctx->llm_every == 0) {
    return gwaihir_llm_mutate(ctx, buf, buf_size, out_buf, max_size);
  }
  return afl_havoc_mutate(ctx, buf, buf_size, out_buf, max_size);
}

JWT 解析器案例

对同一个二进制文件进行 24 小时活动:

  • 原生 AFL++:12.3% 覆盖率,47 个唯一崩溃,2 个可利用。
  • AFL++ + 字典 token:19.8% 覆盖率,31 个崩溃,3 个可利用。
  • AFL++ + Gwaihir/Beorn:41.7% 覆盖率,18 个唯一崩溃,7 个可利用。

总崩溃数较少,但更密集。七个中有三个最终是 kid 逻辑和非对称算法处理中的真实 bug。

Fuzz4All 与我们方法的对比

Fuzz4All 更具雄心:LLM 就是生成循环。极大的通用性(在 GCC/Clang/Z3/OpenJDK 中发现了 98 个 bug),但有两个限制:没有覆盖率反馈则盲目摸索,每次变异都是对模型的调用。我们的方法更朴素也更便宜:AFL++ 以位翻转的速度完成 98%,LLM 只在需要将覆盖率推过解析器时介入。

权衡

Token 成本。24小时 × 1 次调用/50 次执行 × 5,000 次执行/秒 = ~860万次调用。如果没有积极的缓存和本地模型则不可行。我们对 95% 使用本地 GPU 上的量化模型,并保留大模型用于救援变异。

延迟。一次 LLM 调用,即使是本地的,也需要数十/数百毫秒。AFL++ 是同步的。使用异步队列,LLM 在其中填充预生成变异的池。

规范不足。提示模糊 → LLM 幻觉出无聊的输入。提示狭窄 → 它复制已知案例。我们对提示的迭代几乎和对代码的迭代一样多。

我们的收获

LLM 引导的模糊测试不是 AFL++ 的替代品。它是一种补充,当目标具有复杂解析时可以提高覆盖率的上限。它不追求更多崩溃;它追求更好的崩溃。对团队而言,这意味着花在无用分诊上的小时数减少,花在对客户重要的 bug 上的时间增加。

参考文献