展示如何系统化应用 LLM 进行代码漏洞发现,该方案获 2M 美金奖金并包含可复用的策略。
世界好!我们是 Team Atlanta,也是 Atlantis 背后的团队。Atlantis 是我们为参加久负盛名的 DARPA AIxCC 竞赛而打造的一款创新型 AI 驱动网络安全解决方案。
我们的团队由六家顶尖机构强强联合组成:Georgia Tech、GTRI、Samsung Research、Samsung Research America、KAIST 和 POSTECH。这些机构的团队均由 Georgia Tech 校友带领,成员中还包括 DEF CON CTF、Pwn2Own 和 kernelCTF 等知名黑客竞赛的往届获胜者。
过去几个月里,我们一直在为这场竞赛潜心准备,将我们在 AI、网络安全和软件工程领域的专业能力融为一体。上周,我们自豪地参加了 AIxCC 半决赛,展示了我们为通过人工智能推动网络安全进步而付出的努力与投入。
去年 AIxCC 公布后,我们迅速召集了一群朋友组建团队,其中包括 Zellic 和 SSLab。当时,许多事情都尚不确定:比赛形式、评分标准、漏洞证明(PoV)、sanitizer、harness、支持的编程语言,以及理解证明(PoU)等细节都不清楚。尽管如此,我们的团队还是从去年 10 月开始为比赛做准备。
我们团队的许多成员此前曾作为 Crspy 的一员参加 DARPA Cyber Grand Challenge(CGC),负责漏洞发现和利用生成。DARPA CGC 是一项雄心勃勃的计划,此后催生了许多创新研究方向。然而,这场竞赛也并非没有挑战,尤其是赛事的游戏化问题:评分指标和规则极大地影响了比赛结果。最终,那些采取被动响应式策略、优先保障可用性得分而非修复漏洞的 Cyber Reasoning System(CRS),往往能获得更高分数,因为事实证明,漏洞利用远比漏洞修补困难。
考虑到 CGC 中的游戏化问题,我们预判,要想在 AIxCC 中脱颖而出,我们的 CRS 应当在其流水线的不同深度和层级上积极利用 AI,尤其是 LLM。基于这一判断,我们从战略上决定将工作重点放在两个关键方向:
静态分析。为了鼓励使用 LLM,并让 AIxCC 与 CGC 区分开来,我们预计 AIxCC 会大力倡导采用静态分析,同时避免以模糊测试¹为主导。需要注意的是,发现漏洞与找到能够触发崩溃或漏洞的输入有很大不同。后者在客观、自动地验证已发现漏洞方面具有明显优势,但与前者相比,其覆盖范围要窄得多。在实践中,触发问题也称为可达性问题,是一个更加困难且至关重要的问题,而模糊测试等动态工具在这方面具有明显优势。
静态分析。为了鼓励使用 LLM,并让 AIxCC 与 CGC 区分开来,我们预计 AIxCC 会大力倡导采用静态分析,同时避免以模糊测试¹为主导。需要注意的是,发现漏洞与找到能够触发崩溃或漏洞的输入有很大不同。后者在客观、自动地验证已发现漏洞方面具有明显优势,但与前者相比,其覆盖范围要窄得多。在实践中,触发问题也称为可达性问题,是一个更加困难且至关重要的问题,而模糊测试等动态工具在这方面具有明显优势。
针对源代码微调 LLM。在条件允许时,专业化始终是一项优势。考虑到比赛期间每个 CRS 很可能需要支持 10 多种编程语言,我们决定同时微调内部模型和开源模型,用于分析代码。这种方法在概念上与 commitPack 类似,但重点关注与漏洞有关的提交,包括漏洞修复、引入漏洞的提交、漏洞描述,以及可获取的公开漏洞利用代码。我们预计,使用这些数据进行训练后,微调后的 LLM 将比基础模型更有效地推理安全漏洞、对应的修复方式以及可能的输入语料。
针对源代码微调 LLM。在条件允许时,专业化始终是一项优势。考虑到比赛期间每个 CRS 很可能需要支持 10 多种编程语言,我们决定同时微调内部模型和开源模型,用于分析代码。这种方法在概念上与 commitPack 类似,但重点关注与漏洞有关的提交,包括漏洞修复、引入漏洞的提交、漏洞描述,以及可获取的公开漏洞利用代码。我们预计,使用这些数据进行训练后,微调后的 LLM 将比基础模型更有效地推理安全漏洞、对应的修复方式以及可能的输入语料。
我们很快意识到,要有效推进这些方向,首先需要一个数据集,也就是一套基准测试。团队将任务划分为三个领域:1)使用 LLM 提示词/AI 智能体进行静态分析;2)基于 CGC 和 OSS-Fuzz 等来源开发一套 C 语言基准测试;3)为开源项目收集将 CVE 与补丁和 PoC 配对的训练数据集,用于微调 Samsung 的内部代码模型,或利用开源 LLM。
令人惊叹的是,我们在 4~5 个月内完成了全部三个目标。我们基于 LLM 的 Cyber Reasoning System(CRS)被命名为 Skynet,它在我们的基准测试中表现得出乎意料地好;在较小数据集上的微调也展现出了一些潜力,例如在 Python 方面。
时光飞逝。2023 年寒冷的冬季结束,我们迈入了崭新的 2024 年。我清楚地记得,大约就在这个时候,我们在 Zellic 的好朋友离开了团队,转而参加 Small Business Innovation Research(SBIR)赛道。DARPA 为该赛道的参赛者提供了 100 万美元的支持。遗憾的是,Georgia Tech 和 Samsung 没有资格获得这项资助。
在 3 月 29 日的启动会上,AIxCC 公布了第一个挑战项目:Linux 内核,同时还给出了一个示例漏洞 CVE-2021-43267。这个漏洞有非常完善的文档记录,其 PoC 利用代码也已公开,因此是一个非常适合着手研究的示例。
这个漏洞背后的故事让它更加耐人寻味。一位安全研究人员使用 CodeQL 审计了 Linux 内核源代码。具体来说,这位研究人员使用基于数据流的 CodeQL 查询,搜索将 16 位大小参数传递给 kmalloc() 函数进行内存分配的情况。其直觉是,在访问所分配的对象时,16 位大小参数很容易引发整数溢出。然而,最终发现的漏洞并非由整数溢出引起,而是由于缺少对大小及相关输入的合理性检查,导致了堆越界溢出。
static bool tipc_crypto_key_rcv(struct tipc_crypto *rx, struct tipc_msg *hdr)
{
struct tipc_crypto *tx = tipc_net(rx->net)->crypto_tx;
struct tipc_aead_key *skey = NULL;
u16 key_gen = msg_key_gen(hdr);
u16 size = msg_data_sz(hdr);
u8 *data = msg_data(hdr);
...
/* Allocate memory for the key */
skey = kmalloc(size, GFP_ATOMIC);
if (unlikely(!skey)) {
pr_err("%s: unable to allocate memory for skey\n", rx->name);
goto exit;
}
/* Copy key from msg data */
skey->keylen = ntohl(*((__be32 *)(data + TIPC_AEAD_ALG_NAME)));
memcpy(skey->alg_name, data, TIPC_AEAD_ALG_NAME);
memcpy(skey->key, data + TIPC_AEAD_ALG_NAME + sizeof(__be32), skey->keylen);
skey 的分配大小取决于用户提供的 hdr,但复制到 skey->key 中的数据长度可达到 skey->keylen,而后者同样由用户控制,因此可能与 size 不一致。遗憾的是,内核没有对这两个参数进行合理性检查,从而导致越界访问。
commit fa40d9734a57bcbfa79a280189799f76c88f7bb0
Author: Max VA <maxv@sentinelone.com>
Date: Mon Oct 25 17:31:53 2021 +0200
tipc: fix size validations for the MSG_CRYPTO type
The function tipc_crypto_key_rcv is used to parse MSG_CRYPTO messages
to receive keys from other nodes in the cluster in order to decrypt any
further messages from them.
This patch verifies that any supplied sizes in the message body are
valid for the received message.
```diff
diff --git a/net/tipc/crypto.c b/net/tipc/crypto.c
index c9391d38de85..dc60c32bb70d 100644
--- a/net/tipc/crypto.c
+++ b/net/tipc/crypto.c
@@ -2285,43 +2285,53 @@ static bool tipc_crypto_key_rcv(struct tipc_crypto *rx, struct tipc_msg *hdr)
u16 key_gen = msg_key_gen(hdr);
u16 size = msg_data_sz(hdr);
u8 *data = msg_data(hdr);
+ unsigned int keylen;
+
+ /* Verify whether the size can exist in the packet */
+ if (unlikely(size < sizeof(struct tipc_aead_key) + TIPC_AEAD_KEYLEN_MIN)) {
+ pr_debug("%s: message data size is too small\n", rx->name);
+ goto exit;
+ }
+
+ keylen = ntohl(*((__be32 *)(data + TIPC_AEAD_ALG_NAME)));
+
+ /* Verify the supplied size values */
+ if (unlikely(size != keylen + sizeof(struct tipc_aead_key) ||
+ keylen > TIPC_AEAD_KEY_SIZE_MAX)) {
+ pr_debug("%s: invalid MSG_CRYPTO key size\n", rx->name);
+ goto exit;
+ }
为了修复这个漏洞,添加了两项检查:验证 size 是否大于最小密钥大小,并确保 keylen 与 size 一致,从而防止访问已分配对象之外的内存。
面对一个庞大的 Linux 代码仓库(没错,足足有 2000 万行代码),我们应该从哪里入手?使用 LLM 的方法,关键在于提出正确的问题,这也被称为提示工程。我们采用了思维链(Chain-of-Thought,CoT)、思维树(Tree-of-Thoughts,ToT)等多种技术,并探索了检索增强生成(Retrieval Augmented Generation,RAG),以快速识别已知的 1-day 漏洞。
当时的上下文大小还很有限;OpenAI 最先进的模型 gpt-3.5 turbo(没错,那还是 gpt-4 之前的时代)仅支持 16k token,因此提出正确的问题至关重要!一开始,我们尝试使用多种静态分析工具来识别可能存在漏洞的代码片段,包括 CodeQL、Semgrep,以及学术论文中介绍的各种工具,随后再使用 LLM 筛选结果。我们甚至考虑过对上游 Linux 内核与比赛提供的代码仓库进行差异比较,让 CRS 可以优先检查代码中被修改的部分。
我们对自己的判断很有信心:为了促进 AI 工具的使用,AIxCC 的组织者应该会采用这样一种竞赛设计,使单一 CRS 代码库能够探索使用十多种编程语言及其组合构建的任意代码仓库。
啊,大约就在那个时候,Google 刚刚发布了 gemini-pro,它拥有令人印象深刻的 128k 上下文,并且未来可能支持 100 万 token!与此同时,gpt-4 引入了一项颠覆性的功能——函数调用,它允许 LLM 选择要使用的回调函数,并在运行时将结果重新整合进提示中。我们感觉,一切都在朝着有利于 CRS 采用这些前沿技术的方向发展。
然而,PoV 实际上指的是能够触发漏洞的输入,或者导致程序崩溃的输入。为了证明漏洞确实存在,每个 CRS 都需要构造一个可供裁判快速验证的输入。尽管这种方式对竞赛来说简单直接且客观,却极大地阻碍了 LLM 在漏洞发现中的应用。我们团队很快意识到,为了参加比赛,我们必须转向模糊测试等动态方法。
void tipc_trigger(uint8_t *smashbuf, uint32_t smashlen, int seqno) {
uint8_t pkt[0x1000];
uint32_t w0, w1, w2, w3, w4, w5;
w0 = hdr_version(TIPC_VERSION);
w0 |= hdr_size(6);
w0 |= hdr_user(MSG_CRYPTO);
w0 |= hdr_msg_size(24 + 36 + KEY_SIZE);
w1 = 0;
w2 = seqno;
w3 = NODE_ID;
w4 = 0;
w5 = 0;
memset(pkt, 0, sizeof(pkt));
gen_tipc_hdr(pkt, w0, w1, w2, w3, w4, w5);
memcpy(pkt+24, "HAXX", 4);
*(uint32_t*)(pkt+24+32) = be32(KEY_SIZE + SMASH_SIZE + smashlen); // <- (1)
memset(pkt+24+36, 'C', KEY_SIZE);
memset(pkt+24+36+KEY_SIZE, 'D', SMASH_SIZE);
memcpy(pkt+24+36+KEY_SIZE + SMASH_SIZE, smashbuf, smashlen);
tipc_send(pkt, sizeof(pkt));
}
构造一个能够触发漏洞的输入,并确保相关代码路径可达,远比仅仅在代码仓库中找出有漏洞的代码更具挑战性。模糊测试或许与复杂的 LLM 方法截然相反,但它的优势在于:一旦发现漏洞,你几乎总能同时获得一个能够触发该漏洞的输入。
对于 CVE-2021-43267,可以使用 CodeQL 和代码审计识别出这个漏洞,但触发它则是完全不同的挑战,更不用说利用它了。例如,首先必须正确配置 TIPC,并且需要在位置 (1) 精确构造 keylen 才能触发该漏洞。
抱歉,要触发 CVE-2021-43267 究竟需要什么输入?即使使用模糊测试器也是如此吗?为了对 Linux 内核进行模糊测试,我们需要编写一个用户程序,以各种参数调用一系列系统调用。考虑到 Linux 内核有超过 400 个系统调用可供探索,这对于竞赛场景来说远非理想方案。
我们最初以为,组织方会提供测试夹具和测试用例,以指明应该检查 Linux 内核的哪些部分来寻找漏洞。为了解决这个问题,我们实现并采用了多个版本的 Linux 内核模糊测试器,包括一个使用 kcov 和 kcmp 的自定义内核系统调用模糊测试器,还使用了最流行的 Linux 模糊测试器 Syzkaller。然而,我们的重点仍然是确定应该测试哪些系统调用序列:通过系统调用跟踪和对所提供程序的静态分析来完成这一工作,随后正确构造一个端到端的用户空间程序来触发漏洞。
/***
* Blob begins with a 4 byte command count
* [4-bytes command count]
* Currently there are two commands:
* 0 - send a packet blob
* [4-bytes size][4-bytes send flags][size-bytes packet data]
* 1 - send a netlink packet
* [4-bytes Message Type][4-bytes Message Flags][4-bytes Netlink Protocol][4-bytes size][size bytes data]
* blob_size MUST be a trusted value
*/
int harness( uint8_t *blob, uint32_t blob_size)
{ ... }
Linux Kernel CP 于 4 月公布,并附带了一个测试夹具 linux_test_harness.c。这一公告充满了意外:程序结构由测试夹具提供,而遗憾的是,这恰恰是我们此前主要关注的部分;我们需要以能够触发漏洞的方式将 blob 输入测试夹具。我们能够交互的系统调用类型受到测试夹具的限制,而我们的任务则是找出正确的数据输入,使测试夹具以正确的参数调用必要的系统调用序列。换句话说,在处理 Linux 内核漏洞之前,我们首先需要理解测试夹具。
后来,Jenkins 测试夹具公布了。更令人意外的是,它是一个模糊测试驱动程序(通常称为模糊测试夹具),也就是一个专门用于调用 API 进行模糊测试的独立程序。5 月,一个名为 mock-cp 的新 CP(用户空间程序)随一种新的测试夹具格式一同发布;这种格式只是一个使用给定输入执行 CP 二进制文件的 shell 脚本。如此多样的格式让我们开始思考,CRS 应该首先采用 LLM 来弄清程序和 CP 的结构,例如如何编译、如何正确运行等。
到 6 月,测试夹具格式终于正式确定——这个结果令人意外,但又不完全出乎意料:用户空间程序(mock-cp 和 Nginx)使用 libfuzzer,Java 程序(Jenkins)使用 jazzer,而 Linux 内核则保留基于 blob 的测试夹具。我们不断更新 CRS 以适应这些变化,但其中许多决定让我们基于 LLM 的组件变得没有必要。不过,这项决定极大地帮助了所有参赛团队,减少了竞赛运行所需的工程时间。遗憾的是,我们对这些变化的反应过于积极,结果浪费了一些工程时间 😊。
测试夹具在 AIxCC 竞赛中扮演着至关重要的角色:它为 CRS 触发漏洞设定了上下文,也是调节漏洞发现难度的关键因素。因此,把握好平衡非常重要:它应该提供足够的信息,让 CRS 摆脱不必要的负担,从而专注于漏洞发现,但又不能泄露太多与漏洞有关的信息。
与 CGC 将 PoV(概念验证漏洞利用)视为发现漏洞的充分证明不同,AIxCC 还要求提供额外信息——具体而言,需要在提交 PoV 的同时,提供按照 CWE 分类的漏洞类型。这是一项很有意思的决定,因为 AIxCC 要求 CRS 在源代码中寻找漏洞,而 CGC 则侧重于在二进制程序中发现漏洞。
我们的团队花费大量时间集思广益,尝试准确识别 CWE 类别,主要通过使用 LLM 提示来利用崩溃输入、清理器报告、相关代码片段、静态分析器输出等信息。然而,当 CWE 被用作竞赛的评分机制时,其概念可能存在歧义。例如,CVE-2021-43267 应该被分类为 (1) CWE-122(堆缓冲区溢出)、(2) CWE-787(越界写入)还是 (3) CWE-20(不适当的输入验证)?前两个描述了漏洞造成的症状,而第三个识别了根本原因,因为该漏洞的修补涉及添加输入验证。
最后,AIxCC 将重点从漏洞证明转移到识别引入漏洞的提交(BIC)——git 仓库中的特定哈希值或提交 ID。结合模糊测试工具和漏洞证明,CRS 的任务是运行模糊测试工具并执行 git-bisect 来精确定位仓库中的 BIC。我们在半决赛中进行了简单的二分查找,但在最终赛事中需要进行大量改进才能正常运作。
补丁是 AIxCC 最有趣的方面之一。在 CGC 中,漏洞证明通常是简单的漏洞利用(如任意读/写/执行),因此缓解策略(例如添加栈金丝雀)可以有效阻止漏洞证明。事实上,补丁可以在不知道具体漏洞的情况下应用;例如,向二进制文件中的所有函数添加栈金丝雀可以防止可能存在于某些地方的缓冲区溢出漏洞利用。
CGC 的挑战在于重点放在了二进制文件上,组织者引入了规则,例如在评分标准中改动字节数的最小值和添加的性能开销(例如,对所有内存访问进行检测以防止越界错误)。这些规则旨在鼓励竞争者生成正确的补丁。最终,这迫使 CRS 在通用补丁的优缺点之间权衡,因为在 CGC 时代,漏洞利用和补丁都极其困难,导致了放弃漏洞利用积分与放弃补丁和可用性积分之间的权衡。
在 AIxCC 中,CRS 必须生成一个语义上正确的补丁,不仅要修复已识别的漏洞证明,还要保持测试对象的功能正确性。这是一项棘手的任务,因为对于 CRS 来说无法形式化地定义正确性——某些功能变化可能是可接受的,而其他的可能不是,这取决于代码所有者的标准。解决这种歧义的一种方法是提供测试代码来检查补丁是否通过所提供的公开测试。然而,CRS 仍然必须考虑组织者设置的私密测试。
在半决赛中,我们的 CRS 提交了一个补丁,成功地防止了崩溃并通过了竞赛期间提供给我们的公开测试,但最终在私密功能测试中被拒绝。我们很想了解更多关于这个漏洞和补丁的信息!
直到我们遇到了内存安全语言(如 Java)的具体实现,特别是针对用 Java 编写的 Web 应用程序 Jenkins 的实现时,我们团队对清理器的概念才有所了解。清理器的作用(本质上是一个漏洞预言机)是确定漏洞是否已被正确触发。
在 C 等内存不安全语言中,ASAN 和 UBSAN 等标准工具可以用作清理器,以极低或零假阳性的方式捕获内存安全问题(例如,越界访问永远不应该发生)。然而,在内存安全语言中,情况变得更复杂。例如,执行命令是 Jenkins 等 CI 工具中的合法功能,还是应该将其视为命令注入(CWE-78)?
换句话说,清理器更多是测试对象特定的,而不是编程语言特定的;每个测试对象都需要提供自定义清理器(例如路径遍历清理器)。
我们团队最初花时间在寻找 Jenkins 中的 Web 相关漏洞(如 XSS 或 CSRF)——这些领域我们认为 LLM 可以在种子生成中发挥优势。但是,一旦 AIxCC 宣布 Java 的清理器将是 jazzer 清理器,我们决定更多地转向基于标准 jazzer 的模糊测试。
我们团队将大部分工程努力投入到为 Linux 内核构建 CRS,我们为我们的 CRS 最终能够找到并为 CVE-2021-43267 正确生成补丁而感到自豪。然而,在半决赛期间,似乎只提供了一个模糊测试工具,类似于范例,没有任何 CRS 能够为 Linux 内核正常运作。我们很想了解更多关于我们的 Linux CRS 在竞赛期间是如何运作的。
总之,我们的 CRS 共获得六个成就徽章:五个用于发现漏洞(即首血),一个用于补丁。
我们的 CRS 发现了几个独特的漏洞,我们将在后续博客文章中描述!
除了已知的测试对象——Linux(C)、Jenkins(Java)和 Nginx(C)——还引入了新的测试对象,即 Tika(Java)和 sqlite3(C)。我们的 CRS 在 sqlite3 上表现相对良好,但不幸的是,我们的 Java CRS 在 Tika 上遇到了困难。我们很想了解更多关于竞赛期间发生了什么的信息。Tika 是一个流行的文件格式解析器,具有许多独特功能,例如递归解析嵌入式对象,这可能导致了我们面临的挑战。
我们很高兴我们的团队已经进入 AIxCC 最终赛事!我们有几个想法可以使竞赛更加精彩:
基于代码复杂性的不同执行时间。 Linux 内核拥有 6,000 个文件和 2,000 万行代码,需要大量时间用于构建、引导和二分查找等记录工作。与较小的程序相比(例如 Tika 中的 200k),为 CRS 分配更多时间来处理此类复杂代码库将是有益的。
更多编程语言及其组合。 首选候选包括 Python、Rust 和 JavaScript/HTML,以及 JNI(Java 中的 C)或 Linux 内核中的 Rust 设备驱动等组合。这些将在 CRS 最需要的多样化和具有挑战性的环境中提供对 CRS 能力的更全面评估。
标准化的执行环境。 提前标准化编译器(例如 clang-18)、运行时(例如 JVM 版本)和基础 Docker 镜像将帮助团队探索更先进的技术,例如基于 LLM 的检测,在受控的环境中进行。