通过云计算对嵌入式软件进行模糊处理时,我们是否可以借助GPU获得10倍的性能/美元提升呢?基于我们的前期工作经验,我们认为答案是肯定的!
Fuzzing是一种软件测试技术;该技术能够通过向程序提供大量的随机输入来引发软件意外的行为。这是一种重要的行业标准技术,常用于挖掘安全漏洞。然而,模糊测试不仅非常耗时,并且对嵌入式软件进行模糊测试时,还需要面对额外的各种挑战。
我们知道,进行模糊测试时,通常需要用到插桩技术,并要求设备具有高吞吐量的计算能力,而嵌入式平台在这些方面是有所欠缺的。这是因为,如果无法访问源代码的话,针对这些平台的实际模糊测试就需要通过仿真器(这会很慢)或者很多物理设备来完成:这些要求往往是不切实际的。
尽管大多数模糊测试方法都采用传统的CPU架构或仿真器,但我们决定使用其他商品化硬件来解决这个问题——具体来说,这里使用的硬件为GPU。最近,随着机器学习的蓬勃发展,不仅使得GPU价格越来越亲民,并且使得所有主要的云提供商都可以随时提供大量的GPU计算能力。众所周知,GPU非常擅长并行执行任务,而模糊测试正好非常容易实现并行化。
在这篇文章中,我将为读者详细介绍基于GPU的大规模并行模糊器的设计和实现方法。到目前为止,我们已经实现了一个执行引擎,其性能(执行次数/秒/美元)已经达到了libFuzzer的5倍,更重要的是,我们还有非常大的优化空间。
利用GPU进行模糊测试
模糊测试的目的,是为程序生成“非预期”的输入,以引发不良的行为(例如,崩溃或内存错误)。当前,最常用的Fuzzer都是覆盖率导向型的,其工作重点是寻找能够提高代码覆盖率的输入(例如执行以前未执行的函数),以探究可能会使程序崩溃的极端情况。
为了达到这一目的,Fuzzer会为目标程序提供大量的随机化输入。因此,这项任务是很容易实现并行化的,因为每个输入都可以独立于其他输入单独执行。
目前,GPU的价格已经相当便宜了,谷歌云上可抢占型Tesla T4的费率仅为0.11美元/小时。另外,GPU的确擅长并行执行大量工作:一个Tesla T4可以在40000个线程之间进行上下文切换,并且可以同时并行执行其中的2560个线程;而且,如前所述,模糊测试是一个非常适合并行化处理的问题。通过使用数千个线程,理论上来说,我们可以同时测试数千个不同的输入。
为什么以前没有人这样做呢?
简而言之,在GPU上运行代码与在CPU上运行代码在几个关键方面都有很大的差异。
首先,GPU不能直接执行x86/aarch64/等架构的指令,因为GPU有自己的指令集。而我们的目标是对没有源代码的嵌入式软件进行模糊测试:由于手中只有二进制代码,所以,我们没有简单的方法来生成GPU汇编代码并运行。
其次,GPU没有操作系统。传统的并行化Fuzzer需要启动多个进程,并要求可以分别处理不同的输入,而不干扰其他进程,也就是说,如果一个输入导致一个进程崩溃,其他进程必须不受影响。但是,由于GPU没有进程和地址空间隔离的概念,任何内存违规都会导致整个Fuzzer崩溃,所以,我们需要找到一些方法来隔离需模糊测试的程序的并行实例。
另外,如果没有操作系统,就无法对系统调用进行响应,也就是说,程序将无法执行打开文件、使用网络等操作。因此,我们必须对系统调用进行仿真,或将其中继回CPU,这样才能被主机操作系统执行。
最后,GPU的内存管理也是一个非常棘手的问题。由于GPU具有非常复杂的内存层次结构,多种不同类型的内存,并且每种类型的内存都有不同的易用性和性能;而GPU的性能又高度依赖于内存的访问模式,例如控制线程何时访问内存以及如何访问内存,这些对性能的影响是至关重要的。此外,GPU也没有太多的内存可供使用,这使得正确管理内存布局和访问模式变得更加困难。拥有16GB的设备内存听起来可能令人印象深刻,但把它分给40000个执行线程,每个线程就只有微不足道的419KiB内存空间了。
我们能打造出GPU fuzzer吗?是的,我们可以!
尽管创建一个可行的GPU fuzzer面临许多障碍,但没有一个障碍是不可逾越的。
通过翻译二进制文件来执行代码
首先,让我们看看是否能让 arch64 架构的二进制文件在GPU上运行。
如前所述,我们希望在GPU上模糊测试嵌入式二进制代码(如ARMv7、arch64等)。由于NVIDIA GPU使用了一种不同的指令集架构,称为PTX(Parallel Thread eXecution,RTX),因此,我们不能直接执行我们想要模糊测试的二进制文件。解决这个问题的一个常见方法是模拟一个嵌入式CPU,但是为GPU开发一个CPU模拟器不仅代价高昂,而且性能也会很差。另一种选择是将二进制代码翻译成PTX代码,这样它们就可以直接在GPU上执行而无需进行仿真处理了。
我们已经开发了一个名为Remill的二进制翻译工具,可以用来实现上述目标。 Remill 可以将二进制代码翻译为LLVM IR(中间表示代码),然后可以将其重新定向并编译为LLVM项目支持的任何体系结构。恰好LLVM支持以PTX代码的形式发射LLVM IR,这对我们的目的来说是非常完美的。
假设我们有一个简单的示例函数,其功能非常简单:把w19设为0,加5,然后返回结果。
main:
mov w19, #0 // Store the number 0 in register w19
add w19, w19, #5 // Add 5
mov w0, w19 // Return the result
ret
我们可以将这些指令的字节传递给Remill,让Remill生成相应的LLVM IR,以模拟在ARM处理器上执行的原始程序:
// Simplified for brevity :)
define dso_local %structmory* @_Z5sliceP6MemorymPm(%structmory* readnone returned %0, i64 %1, i64* nocapture %2) local_unnamed_addr #0 {
%4=add i64 %1, 1
store i64 %4, i64* %2, align 8, !tbaa !2
ret %structmory* %0
}
然后,通过一些优化,我们可以让LLVM将上面的LLVM IR编译成PTX汇编代码:
ld.param.u64 %rd1, [sub_0_param_0];
ld.param.u64 %rd2, [sub_0_param_1];
mov.u64 %rd4, 5;
st.u64 [%rd1+848], %rd4;
add.s64 %rd5, %rd2, 12;
st.u64 [%rd1+1056], %rd5;
ret;
最后,我们可以将这个PTX加载到GPU中,并执行它,就好像我们可以访问源代码一样:
内存管理
如前所述,由于GPU没有操作系统来实现进程之间的隔离。因此,我们需要自己来实现地址空间的隔离,以便被模糊测试的程序的多个实例可以访问同一组内存地址而不相互干扰,同时,我们还需要自己来检测目标程序中的内存安全错误。
实际上,Remill会将原程序中所有的内存访问替换为函数read_memory和write_memory的调用。通过这些函数,我们可以实现一个软件内存管理单元,以弥补所缺失的古董交易操作系统内存管理功能,并对内存访问进行相应的处理。
例如,考虑下面的函数,它会接受一个指针,并让它指向的整数递增:
add_one:
ldr w8, [x0] // Load the value pointed to by the pointer
add w8, w8, #1 // Increment the value
str w8, [x0] // Write the new value back
ret
Remill将上面的汇编代码转换为IR代码,其中包含read_memory函数的调用、add指令以及write_memory函数的调用,具体如下所示:
define %structmory* @slice(%structmory*, i64 %X8, i64* nocapture %X0_output) local_unnamed_addr #2 {
%2=tail call i32 @__remill_read_memory_32(%structmory* %0, i64 undef) #3
%3=add i32 %2, 1
%4=tail call %structmory* @__remill_write_memory_32(%structmory* %0, i64 undef, i32 %3) #3
%5=tail call %structmory* @__remill_function_return(%struct.State* nonnull undef, i64 16, %structmory* %4) #2, !noalias !0
ret %structmory* %5
}
通过__remill_read_memory_32和__remill_write_memory_32函数,我们可以给每个线程提供自己的虚拟地址空间。此外,我们还可以检查内存访问的出错情况,并在非法访问使整个fuzzer崩溃之前进行拦截。
不过请记住,16GB的设备内存供40000个线程共享时,其实这些内存空间并不算多。为了节约内存,我们可以在MMU中使用copy-on-write策略,让多个线程共享内存空间,直到其中一个线程向内存执行写入操作为止,这时将复制该内存空间中的内容。实际上,以这种方式节约内存是一种非常有效的策略。
初始性能
太棒了!现在,我们已经找到了可行的路径:先把一个二进制程序翻译成LLVM,并将其转换为PTX,混入一个MMU,然后在数千个GPU线程上并行运行。
但是,我们的目标是:构建一个性能提升10倍的Fuzzer(这里比较的对象是运行在CPU上面的Fuzzer)。这一目标能否实现呢?
实际上,评估Fuzzer的性能是一件非常棘手的事情,并且研究人员已经发表了许多有关如何有效比较它们的论文。另外,我们的模糊器还太粗糙,还很难对其进行正确的评估,因为我们还缺少关键的Fuzzer组件,比如生成程序新的输入的突变器(mutator)等。不过,要是只衡量执行器的性能的话,我们可以考察Fuzzer在目标程序中运行输入的速度(执行次数/秒)。通过对计算硬件的成本进行标准化(要知道,GPU通常比运行其他Fuzzer的CPU更贵),我们可以比较执行次数/秒/美元。
在我们的基准测试中,我们应该对什么代码进行模糊测试呢?实际上,来自 libpcap 的BPF数据包过滤代码似乎是一个很好的选择,原因如下所示:
· 它实现了一个复杂的状态机,对人类来说太难推理了,所以是一个很好的模糊测试对象。
· BPF组件在过去曾被曝出过安全漏洞,所以,它们可以作为我们模糊测试的现实目标。
· 它没有系统调用(我们的迷你Fuzzer还不支持系统调用)。
下面,让我们写一个测试应用程序,让它从Fuzzer中获取一个数据包,并对其运行一个复杂的BPF过滤程序:
dst host 1.2.3.4 or tcp or udp or ip or ip6 or arp or rarp
or atalk or aarp or decnet or iso or stp or ipx
这个测试程序并没有做很多事情,但是它却使用了复杂的逻辑,并且需要大量的内存访问操作。
为了评估我们的Fuzzer,我们可以将它与 libFuzzer 进行比较;libFuzzer是一个速度很快且应用广泛的Fuzzer。当然,这并不是一个非常公平的比较。一方面,libFuzzer面临的是一个更加轻松的问题:它可以使用测试程序的源代码来进行模糊测试,但是,我们的Fuzzer则需要翻译和处理为不同架构编译的二进制代码。而在进行安全研究时,源代码往往是无法获取的。另一方面,libFuzzer可以通过突变来生成新的输入,而我们还没有这样做。虽然这种比较不够完美,但用于提供数量级的估计的话,也算可以了。
本文中,我们将使用Google Compute Engine 8核N1实例(撰写本文时,非抢占式实例每小时费用为$0.379998)和Tesla T4 GPU(撰写本文时,每小时费用为$0.35)进行比较。
不幸的是,我们的fuzzer与libFuzzer相比,其性能并不是很好。libFuzzer的性能达到5.2M 执行次数/秒/美元,而我们的fuzzer仅达到361k 执行次数/秒/美元。
让我们来看看是否还有更大的改善空间……
交叉存储
在开始优化性能之前,我们应该对Fuzzer进行剖析,以便更好地了解它的性能。实际上,Nvidia的 Nsight Compute 剖析器可用于解释硬件的利用率和性能瓶颈。
通过分析数据,我们可以看到,GPU只使用了3%的计算能力。并且,大多数时间中,GPU计算硬件都是闲置的,也就是什么也没做。
这种情况的发生,通常是因为内存延迟过高所致:GPU一直在等待内存读写的完成。然而,我们的情况并非如此,因为我们的fuzzer需要访问大量的内存空间。实际上,通过性能分析数据就会发现,GPU只使用了45%的可用内存带宽。相反,对于我们来说,原因在于访问内存的效率太低所致:每一次内存访问都需要很长的时间,而且不能为计算提供足够的数据。
为了解决这个问题,我们需要更好地理解GPU的执行模型。
GPU线程以32个为一组执行,称为线程束。线程束中的所有线程在并行多处理器中一起执行,并且它们以锁步方式运行,即在同一时间运行相同的指令。
当线程读取或写入内存时,内存访问会以128字节的内存块为单位进行。如果线程束中的32个线程试图读取位于同一个128字节块中的内存,那么硬件只需要从内存总线上请求一个内存块(这称为一个内存事务)即可。
然而,如果线程各自从不同的内存块中读取内存内容,硬件可能需要进行32个独立的内存事务,而这些事务通常是串行化的。这就导致了我们性能分析结果中发现的行为。也就是计算硬件几乎总是处于空闲状态,因为它必须等待这么多的内存事务来完成。内存带宽的利用率看起来并没有那么糟糕,因为很多128字节的内存块正被读取,但每个内存块中只有4个或8个字节的内容被实际用到,所以很多带宽被浪费了。
目前,我们是为每个线程分配了单独的内存的,所以,当一个线程访问内存时,很少会和不同的线程访问同一个128字节的内存块。我们可以改变这种情况,为一个线程束(32个线程)分配一块内存,并将线程的内存交错分布在这个线程束内。这样一来,当线程需要从内存中访问一个值时,它们的值都是相邻的,GPU完全可以通过一个内存事务来完成这些内存读取操作。
通过尝试,我们发现性能提高了一个数量级! 显然,在为GPU编程时,注意内存访问模式是极其重要的。
减少数据传输和内核启动次数
重新运行剖析器,我们可以看到,我们的计算利用率得到了很大的改善(已经从原来的3%提升到了33%),但我们距离完全的利用率还差很远。我们还能做得更好吗?
继续我们对内存使用模式的检查,让我们看看使用的内存类型。Nvidia GPU提供了多种位于不同物理位置的内存,但最容易使用的类型叫做“统一内存”,它意味着我们可以在不同物理位置之间自动传输数据。我们一直在使用的就是这种方式,因为它不需要我们过多地考虑字节的物理存储位置,但如果管理不当的话,就会导致性能瓶颈,因为数据会在物理内存位置之间低效传输。
由于我们目前的内存延迟依然很高,所以,让我们仔细看看这些传输有没有问题。
我们的简单Fuzzer是以逐“轮”的方式进行工作的:如果GPU可以运行40000个线程,我们就把40000个输入传给GPU,每个线程在启动下一轮之前,先对这些输入进行模糊测试。在两轮之间,我们会重置所使用的内存(例如,覆盖率跟踪数据结构和被模糊测试的程序所使用的内存)。然而,这将导致在每一轮之间,GPU和CPU之间会产生大量的数据传输,因为内存被分页回CPU,进行重置,然后再分页回GPU。当这些传输发生时,GPU什么也不做。当GPU等待CPU启动下一轮操作时,会产生额外的延迟。
我们可以通过对GPU代码进行单次启动,避免CPU和GPU之间的同步来改善这种情况。由于很多数据不需要放在统一内存中,所以,我们可以在GPU上分配全局内存来保存它们,然后,当我们需要发送有关模糊测试进度的信息时(比如哪些输入导致崩溃),异步机制会将这些数据传输给CPU。这样一来,当一个线程完成对一个输入的模糊测试后,就可以重置内存,继续测试下一个输入,既不需要数据传输,也不需要等待CPU。
这几乎实现了另一个数量级的速度提升! 现在,我们的Fuzzer的速度已经比libFuzzer快了5倍。
由此看来,这种方法还是非常有前途的——尽管我们的Fuzzer还缺乏突变引擎(mutation engine),并且也不能处理系统调用,但通过与libFuzzer的性能的比较表明,使用GPU进行模糊测试对某些应用可能非常有用。
基于GPU的模糊测试的下一步要做什么?
虽然我们已经比较接近这个模糊测试项目的性能目标,但这个项目还有很长的路要走;不过,由于硬件利用率仍然很低,所以,我们还有很大的优化空间。
此外,我们还需要构建对处理系统调用的支持,这对于I/O密集型的应用的模糊测试来说,会对性能产生显著影响。不过,为了让这个Fuzzer发挥实际作用,我们还需要构建突变引擎,尽管这个问题比构建执行引擎要好理解得多。
不过,在早期开发阶段就能获得如此有前景的结果,我们还是非常兴奋的。我们期待着在嵌入式二进制文件的模糊测试方面能够有一个数量级的提升。
我们很想听听您对这项工作的看法! 欢迎大家通过[email protected]或[email protected]联系我们。
最后,我还要感谢Artem Dinaburg,感谢他对该系统的初始设计,并在整个项目实施过程中给予的热心指导。另外,我还要感谢Peter Goodman提供的设计反馈和调试建议。