白帽故事 · 2024年4月7日

使用AI大模型打造模糊测试工具

前言

AI大模型(LLMs) 在很多事情上似乎都特别擅长,为了快速解决传统上需要大量人力才能完成的任务,每天都有人提出该技术的一些‘奇特’用例。

今天的案例来自 Brendan Dolan-Gavitt 的非凡实验,他使用 Claude 为解析 GIF 代码生成模糊测试器,Brendan 向 Claude 提供了 GIF 解析器的 C 代码,并要求 Claude 生成以 Python 实现的模糊测试器,并生成类似 GIF 的输入对 GIF 解析器进行Fuzz。

image-20240407085423796

Claude 尽职尽责地遵守了设定,最终的模糊测试发现了一系列漏洞,之后,Brendan 用一种稍显冷门的格式–VRML 文件继续发现了不少漏洞。编写一个好的模糊测试器非常耗时,因此,能够自动化这个过程是非常有吸引力的。

为什么会有效?

大众普遍认为让 LLMs 对代码进行精确推理并非一个好方法,然而事实上,许多人早已开开始使用 LLMs 直接在代码中查找错误/漏洞,方法是制作一个提示,其中包含 LLMs 做什么的指令,并将代码包含在Prompt中,再要求 LLMs 查看一段代码并发现其中的错误/漏洞,这本质上是要求 LLMs 执行静态分析。

然而LLMs其实并不擅长静态分析,因为它们是根据LLM训练出来的随机机器,生成统计上最有可能出现的输出,换句话说,就是生成统计上‘接近’预期输出的输出。

这就是为什么大众认为LLMs不精确的原因,但有效的静态分析首先需要精度:如果静态分析器告诉你代码中存在错误/漏洞,它必须非常确定该错误/漏洞是真实的,否则,开发人员的时间就会淹没在大量虚假的错误报告中。

但是,与静态分析相比,模糊测试有很大不同,模糊测试本质上就是一个随机过程,引用维基百科中的描述:

An effective fuzzer generates semi-valid inputs that are “valid enough” in that they are not directly rejected by the parser, but do create unexpected behaviors deeper in the program and are “invalid enough” to expose corner cases that have not been properly dealt with.
有效的模糊测试器会生成半有效输入,这些输入“足够有效”,因为它们不会被解析器直接拒绝,但会在程序更深层产生意外行为,并且“足够无效”以暴露尚未得到妥善处理的极端情况。

因此模糊测试器应该生成“接近”程序预期的输入,因此,我们可以期望 LLMs 更适合为给定程序生成“足够接近”的模糊测试器。

模糊未知的输入格式

关于 Brendan 的实验,我们忍不住想知道的一件事是“Claude 的表现究竟如何?因为 GIF 和 VRML 都是已知的输入格式”。因为LLMs的训练数据可能包含有关每种输入格式的大量文本,以及两种格式的大量解析和序列化代码(GIF 比 VRML 更多)。

因此,原作者Toby Murray决定用一种未知的输入格式来测试 Claude,8年前,当Toby第一次开始在墨尔本教学时,他给学生布置了一项作业,其中就创建了一种虚构的输入格式,并要求学生们为这种输入格式编写模糊测试器。

为了使任务具有挑战性,输入格式受到 CRC32 校验和的保护,具体来说,每个输入都是一个数据包,其结构如下所示,包括一个(无用的)序列号、一个两字节长度字段和一个数据负载(其最大大小为 4096 字节):

image-20240407085957940

数据有效载荷由一串指令组成(每条指令一个字节),用于在带符号整数堆栈上运行的简单算术堆栈机(有点像古老的 UNIX dc 工具),机器指令包括将小整数(在 [0,9] 范围内)压入堆栈;弹出堆栈;对堆栈顶部的两个项目进行加、减、乘、除,将它们从堆栈中弹出并推送结果的指令;以及对堆栈中的任意位置进行读写操作(使用堆栈顶部操作数作为从堆栈顶部向下偏移量的偏移量来识别)。

Toby为输入格式编写了一个解析器,为堆栈机器语言编写了一个解释器,实现此功能的 C 代码低于 400 SLOC,他在这段代码中植入了一系列漏洞,包括:

  • 解析代码中的 Heartbleed 样式越界 memcpy (盲目地信任输入数据的长度字段用作传递给 memcpy 的长度,即使源缓冲区和目标缓冲区’ 大小均为 4096 字节,而最大 2 字节长度值为 65535)
  • DIVIDE 指令的第二个参数为零时,未检查除零
  • 当堆栈操作数由于重复应用算术运算而变得太大时,未检查符号溢出
  • 由于执行 READ 指令时未检查偏移而导致的越界读取
  • 越界在 WRITE 指令中的写法类似

Toby要求 Claude Sonnet(默认免费的 Claude 模型)为 C 代码生成一个 Python 模糊测试器,Prompt给了 Claude 有关代码结构的一些提示,告诉它有一个由 parse_packet 函数实现的外部解析器,后面是一个由 execute 函数实现的解释器,Prompt(见下方链接)包括代码的相关解释以及对模型生成 Python 模糊测试器的请求。

随后Claude生成了一个非常有用的模糊测试器(当然还需要它修复生成代码中的一些类型错误),生成的 Python 模糊测试器代码如下:

import random
import struct
import sys
import binascii

# Constants
MAX_DATA_LEN = 0xfff
PACKET_LEN = MAX_DATA_LEN + 10
CRC_OFFSET = 0
SEQ_NUM_OFFSET = 4
LEN_OFFSET = 8
DATA_OFFSET = 10

# Instructions
INSTRUCTION_ADD = ord('a')
INSTRUCTION_SUBTRACT = ord('s')
INSTRUCTION_READ = ord('r')
INSTRUCTION_MULT = ord('m')
INSTRUCTION_DIVIDE = ord('d')
INSTRUCTION_POP = ord('p')
INSTRUCTION_WRITE = ord('w')
INSTRUCTIONS = [INSTRUCTION_ADD, INSTRUCTION_SUBTRACT, INSTRUCTION_READ, 
INSTRUCTION_MULT,
                INSTRUCTION_DIVIDE, INSTRUCTION_POP, INSTRUCTION_WRITE]

# Generate packets
for _ in range(10000):
    # Generate packet data
    data_len = random.randint(0, MAX_DATA_LEN)
    data = bytearray(random.randrange(256) for _ in range(data_len))
    padding = [random.choice(INSTRUCTIONS + list(range(ord('0'), ord('9') + 1))) for _ in range(MAX_DATA_LEN - data_len)]
    data += bytes(padding)

    # Generate sequence number
    seq_num = random.getrandbits(32)

    # Construct packet
    packet = bytearray(PACKET_LEN)
    struct.pack_into(">I", packet, CRC_OFFSET, 0)  # Placeholder for CRC32
    struct.pack_into(">I", packet, SEQ_NUM_OFFSET, seq_num)
    struct.pack_into(">H", packet, LEN_OFFSET, data_len)
    packet[DATA_OFFSET:] = data

    # Calculate CRC32 and update packet
    crc32 = binascii.crc32(packet[4:])
    struct.pack_into(">I", packet, CRC_OFFSET, crc32)

    # Write packet to stdout
    sys.stdout.buffer.write(packet)

打开 Clang 的 Address Sanitizer 和 Undefined Behaviour Sanitizer 的情况下编译 C 程序,运行 Python 代码生成 C 程序的输入,并针对生成的输入运行编译后的 C 程序,发现 Claude 的模糊测试器只能触发越界读写错误,无法触发其它错误。Toby 与 Brendan 分享了这段代码,Brendan 也得到了类似的结果。接下来要怎么做呢?

了解结果

为什么Claude的模糊测试器无法发现其它错误?如果我们看一下上面的模糊测试代码,就能明白了:

# Generate packet data
    data_len = random.randint(0, MAX_DATA_LEN)
    data = bytearray(random.randrange(256) for _ in range(data_len))
    padding = [random.choice(INSTRUCTIONS + list(range(ord('0'), ord('9') + 1))) for _ in range(MAX_DATA_LEN - data_len)]
    data += bytes(padding)

它生成的数据包数据是完全随机的字节,然后它生成所谓的 padding ,其中包含完全有效的指令,因此,格式正确的指令数据被隐藏在了大量‘噪声’后面。

相反,应该计算数据和填充,例如像下面这样:

    data = [random.choice(INSTRUCTIONS + list(range(ord('0'), ord('9') + 1))) for _ in range(data_len)]
    padding = bytearray(random.randrange(256) for _ in range(MAX_DATA_LEN - data_len))

通过这一更改,模糊器可以触发零错误,但是,其它错误仍无法触发,因为越界读写太容易触发,使得能够触发的几率非常小。这本身并不是模糊测试器的错,因为这是模糊测试的固有限制。

此外,Claude 的模糊测试器总是正确报告 len 字段中的数据包长度:

    struct.pack_into(">H", packet, LEN_OFFSET, data_len)

这意味着解析器中的 Heartbleed 漏洞永远不会被触发。

静态分析?

我们是否让 Claude 直接在代码中寻找漏洞(即面提到的静态分析)?这种方法其实并不科学,也不推荐,但无论如何,还是试试看吧,当要求Claude对这段代码进行静态分析,并指出漏洞时,Cluade发现:

  • 解析代码中Heartbleed式的缓冲区溢出
  • DIVIDE 指令中的除零
  • READ 指令中的内存读取越界
  • 指令执行函数 execute中未初始化的堆栈

Cluade发现了 5 个漏洞中的 3 个以及另外一个,而未初始化的堆栈其实可以算是一种误报。

前景如何?

  1. 尽管 LLMs 有一些限制,但却似乎非常擅长分析代码并编写模糊测试器来生成“足够接近”的输入以及查找错误/漏洞
  2. LLMs 可以进行静态分析,但会出现误报等问题

这表明我们或许应该考虑将这两种方法结合起来使用,具体来说,可以考虑以下步骤:

  1. 要求 LLMs 识别代码中的漏洞(即静态分析)
  2. 对于 LLMs 识别的每个漏洞,要求它生成一个定向模糊测试器,该模糊测试器生成尝试触发(仅触发)该漏洞的输入

Claude 的快速实验表明这种方法可能很有前途(在一些Prompt下,Claude 能够生成一个程序来生成输入以触发上述 Heartbleed 式漏洞),但还需要进一步来验证这种方法,并找出需要克服哪些挑战才能使其更加实用。

结论

在模糊处理方面,LLM 已被用于生成模糊驱动程序,目前人们对这一主题兴趣浓厚(这与使用 LLM 生成独立模糊测试器不同),事实上,DARPA 认为使用 LLM 进行漏洞发现、利用和修补大有可为,因此去年决定重新审视其 2016 年网络大挑战,启动 AIxCC 竞赛,以了解 LLM 相关技术对这些任务的影响。

考虑到这一切,Toby很好奇是否有任何 AIxCC 竞争对手试图通过模糊测试器来自动发现漏洞,无论是否有针对性,期望有更多的人尝试通过 LLMs 生成模糊驱动程序并进行静态分析。

更多Fuzz相关内容

原文链接:https://verse.systems/blog/post/2024-03-09-using-llms-to-generate-fuzz-generators/