UTOPIA: Automatic Generation of Fuzz Driver using Unit Tests
这篇论文主要由三星研究院发表于2023 IEEE Symposium on Security and Privacy (SP)会议上
论文获取链接: https://gts3.org/assets/papers/2023/jeong:utopia.pdf
背景
模糊测试分为两种:
-
将整个程序看作一个黑盒使用模糊测试对其进行端到端的测试
-
基于库的模糊测试:库模糊器需要人工参与并与库函数进行深度集成,这个过程称为Fuzzer Drivers,它描述了处理模糊器提供的输入的一系列API调用
与第一种相比,第二种模糊测试能更加高效的发现程序中存在的脆弱点,针对第二种方法,为了提高模糊测试的执行效率,高质量的Fuzzer Drivers 应该首先制定一个适当的API调用序列,以此来详尽的探索程序状态。
而如何更加高效准确的获得程序的调用序列,通过对单元测试进行分析,作者提出了以下发现:
- 现有的单元测试明确传达了开发人员关心的对API的依赖关系
- 单元测试检查具有更多API的库所提供的功能的各个方面,而不是目标程序
- 许多现有项目都有编写良好的单元测试
基于此,作者提出将现有的单元测试以自动化和可扩展的方式转换为有效的fuzzer Driver
已有的解决方案
AFL及在其基础上进行的改进的Fuzzer:
- 通过插装来获取覆盖信息,但大多数都集中在使用文件或命令作为输入的模糊测试程序上
Library Fuzzing:要求对目标库有深入了解
- FuzzBuilder 提出了一个将UT转换为模糊驱动程序的工具,但需要手动配置来指定测试功能和目标API参数以生成模糊驱动程序,而最终驱动程序的质量很大程度取决于手工工作
自动模糊驱动生成
- Fudge寻找缓冲区访问参数签名并提取依赖的代码行组成fuzz驱动程序
- FuzzGen静态分析API依赖并将他们合并为fuzz驱动程序的长API调用序列
- 这两项工作都是从目标代码中推断API的使用情况,导致无效或效率较低。Fudge需要手工在循环中评估和更新模糊测试驱动,FuzzGen需要人工审查过程来修复生成的驱动程序
- WINNIE通过自动生成模糊测试驱动程序和快速克隆Windows进程来模糊Windows上的闭源库
- APICraft 针对MacOS SDK的闭源库
- 两者都通过执行目标程序在运行时直接跟踪API序列,但这两种方法并不适合大规模采用
本文提出的方法
生成高质量的模糊驱动首先要解决两个问题
-
合成有效的API调用序列
-
合成有效的API调用参数
解决这两个挑战可以避免引入人工分析来解决由于无效API的使用导致的虚假崩溃的情况
图1是一个包含用于读取或写入OpenCV库中原始数据的API的UT, UTOPIA可以用它生成一个重现CVE-2019-5063的模糊驱动程序。基本上,UTOPIA通过对原始UT代码进行微小更改来将UT转换为模糊驱动程序,以便将模糊输入分配给作为API参数来源进行分析的现有变量。例如,在图1中,UTOPIA分别将第5、9、14、22行转换为第6、10、18、23行,并插入一条赋值语句(第17行),为影响api调用参数的变量提供模糊输入。请注意,API的某些参数故意不模糊,因为UTOPIA分析改变它们可能会导致严重的虚假崩溃
合成有效的API调用序列
生成模糊驱动程序的一个主要挑战是确定调用哪个库API以及以什么顺序调用它们,因为API可能经常具有严格的顺序依赖关系,正如我们在上图的示例中所观察到的那样:FileStorage()→writeRaw()→release()。因此,为模糊驱动程序构建随机的API序列可能只是浪费了模糊测试的努力(例如,在release()之后调用writeRaw()而没有调用构造函数导致的任何崩溃将被认为是虚假的崩溃而不是错误)。想要生成高质量的模糊驱动,首先需要知道目标程序调用了哪些库以及他们的调用顺序,现有的研究通过直接对调用代码进行分析来获取整个API使用模式,但对于复杂的调用代码,存在提取的模式过于臃肿的问题,这导致驱动程序可能调用大量的API调用而导致空间膨胀而影响模糊效率另一种方法是限制用于生成单个模糊驱动程序的调用代码数量,这会导致获取的API序列并不完整,产生虚假的崩溃。
基于现有研究的不足,作者提出使用单元测试中编写的显式API序列来完全避免API序列合成的挑战,首先,单元测试(UT)存在以下优势:
- UT中每个测试用例的库状态的显式构建意味着在生成模糊驱动时没有API模式推断或提取的负担
- UT测试与模糊驱动程序的目的是一致的,设计的测试用例都是针对开发人员认为非常重要的特定变量或属性
- 不容易生成臃肿的API调用序列
合成有效的API调用参数
对于一个完整的程序,既有API内的调用,也有API之间的调用关系,因此,在推断API调用序列时,还需要了解API内部和API之间的逻辑,并根据他们的语义关系适当地分配模糊输入值,例如上图的FileStarage的类对象fs的不同库API调用关系
作者提到,在API之间主要存在以下三种关系:
- out-to-in :一个API输出作为另一个的输入
- fixed :参数在API调用中应一致
- relative:不同的APIs的参数从相同的值派生,如x=f(y),API_1(x);z=x+g(y),API_2(z)
例如var a=3 - > b=func(a);->Target_API(b) 这里在模糊测试赋值时如果不关注API间的调用可能会直接对b进行赋值而不是a。
而对于API内部,则主要存在以下两种调用关系:
- array<->length 一个输入参数表示另一个输入参数的长度
- array<->index 一个输入参数是另一个输入参数的索引
例如,Mat类构造函数中的第一个参数(图1中的第14行)要求在第二个和第四个参数中声明的数组大小之间保持对应。如果这些是随机模糊的,驱动程序通常会导致段错误(size参数>数组的实际大小),或者浪费精力来改变未使用的模糊输入字节(size参数<数组的实际大小)。
作者提出通过保留UT中的原始数据流,使用静态分析找到模糊输入的位置以及它们是如何突变的。为了识别注入模糊输入的合适位置,引入根定义这一概念,这是一个赋值语句,其中变量由常量定义,通过仅在根定义上分配模糊输入,保留原始数据流和现有的API间语义。
在图1中,UTOPIA通过将模糊输入赋值给根定义(第23行),将模糊输入传递给writeRaw() API中的第三个参数rawdata(第31行),其中向量rawdata的每个元素都被赋值为常量。
定位根定义后,UTOPIA根据分析的属性为从根定义接收其值的API参数注入模糊输入,例如,在Mat类的构造函数中(图1中的第18行),UTOPIA推断出数组↔长度关系,并将dim(数组属性)的大小分配给第18行上的第一个参数(ArrayLength属性)和第17行上具有模糊输入的每个元素。
单元测试存在的挑战
- 分析障碍 :一些UT框架通常由复杂的类层次结构和接口混合定义的,通过这些接口,用户定义的测试用例被间接调用,使用现有方法从单元测试生成的驱动程序可能会导致虚假的崩溃,需要在进行有意义的模糊测试之前进行手动修复。动态分析可以管理间接调用,但由于过度近似和参数值之间关联语义的困难,它不适合。
- UT框架的多样性:
- 断言:由于UT中的断言不仅检查临界状态,而且还可用于根据UT中定义的特定测试值验证结果,因此必须考虑它们将如何影响模糊处理并适当地处理断言,因为将模糊输入输入到参数中很容易触发断言条件。如果所有断言都被忽略,指针上的nullptr检查将更有可能导致因解引用nullptr而导致的虚假崩溃。但是,如果强制执行所有断言,则测试值检查通常会阻止模糊驱动程序在断言语句之外执行。
为了解决以上问题,作者提出通过理解UT框架中使用的习惯语法来补充静态分析,同时在接下来的测试中也研究了几种基本策略来处理断言。
设计
如上图所示是UTOPIA的整体工作流程
- UTOPIA利用了UT框架的架构特性,因此只需要分析开发人员实现的测试功能,而不需要分析整个UT框架。
- UTOPIA分析库以识别API参数的属性。
- 执行UT分析以识别根定义,在不影响有效API使用语义的情况下注入模糊输入。
- 根据分析结果进行驱动器合成。
UT框架结构分析
一般来说,UT框架提供的API允许用户为每个测试用例定义三个功能,预测试、测试和后测试。如图三是gtest的UT框架,它向每个测试类公开了SetUp()、TestBody()和TearDown()接口(分别是前测试、测试和后测试)。这些功能隐式地确保1)每个测试用例仅依赖于那些功能,2)测试用例彼此独立。UTOPIA利用这些特性来构造有效的API序列,在一个模糊周期内显式地按顺序调用这些函数,以确保每个模糊周期的独立性。
为了定位这些函数,UTOPIA利用clang AST Matchers在抽象语法树(AST)上查找具有模式的函数。例如,在图3中,UTOPIA寻找一个CXXRecordDecl,其子节点中的Testing::Test类为CXXCtorInitializer。此后,通过在找到的CXXRecordDecl中搜索名称为SetUp的CXXMethodDecl,就可以找到SetUp。
API属性分析
UTOPIA将库的所有导出函数视为公开的API,并分析每个API参数以确定其属性。UTOPIA通过利用从API参数开始的自定义使用链来执行程序间分析,以确定五个属性:Output、FilePath、AllocSize、LoopCount和Array↔Length(索引)
模糊目标选择
UTOPIA可以向适当的模糊库API调用参数(即参数的根定义)插入模糊输入。原则上,这基本上是通过查找定义最终流入API参数的值的根定义来完成的。
根定义分析。根定义分析是一种反向数据流分析,其目的是获得右值为常数值的定义。当然,常量值不能从测试代码中其他语句中使用的任何其他变量中派生出来。因此,根定义中这些常数值的转换使UTOPIA能够在不违反测试代码语义的情况下注入模糊输入。特别是,UTOPIA从所有API参数执行根定义分析,以收集每个可能的模糊目标候选项。
如下图所示为,'int A=10’是识别到的唯一根节点。根节点的右值变化影响着每个API参数,同时保持API之间的关系,为了确定所有可能影响API参数的定义,分析是控制流敏感的和跨过程的,以找到所有可能影响API参数的定义。
为了确定突变策略,UTOPIA必须将根定义与相应参数的属性配对。这是通过将参数属性分配给参数直接使用的根定义来实现的。例如,在图5中,根定义’ int A = 10 ‘具有API_1和API_2的第一个参数的属性。但是,API_4的第一个参数的属性没有被继承,因为根定义没有直接用于该参数。在根定义分析期间,通过’ int C = API_3(B) '将跟踪目标从C更改为B。如果跟踪目标是由外部函数定义的,UTOPIA将跟踪所有输入参数,以查找任何可能的定义。
模糊驱动的合成
模糊输入分配
UTOPIA通过用模糊输入赋值语句替换已识别的模糊目标,将每个测试用例转换为模糊驱动程序。在已识别的模糊目标中,如果无法修改其源代码或无法确定生成模糊输入的适当方法,则UTOPIA会排除某些根定义。排除标准如下:
- 头文件或项目文件中的根定义
- 在编译时确定的常量(例如sizeof(int))
- 赋值带有外部函数的返回或输出参数(非输入参数)
- 根定义带有nullptr赋值,因为不知道如何初始化指针引用的对象
- 函数指针参数
- 依赖于忽略值的值(例如忽略Array的ArrayLen)
- 文件属性
排除后,UTOPIA根据赋值语句的数据类型和变异策略,将赋值语句的右值替换为模糊输入。
模糊回路构造。
TOPIA构建了一个入口函数,在每个模糊测试循环中调用一次。入口函数从模糊测试引擎(例如,libfuzzer)接收模糊输入,并按顺序调用识别和转换的测试函数(例如,gtest中的SetUp(), TestBody()和TearDown())来执行带有指定模糊输入的模糊驱动程序。
初始种子提取
UTOPIA在UT分析期间执行的一个简单而有效的过程是获取嵌入在测试代码中的初始种子语料库,这些语料库是根定义语句中确定为模糊目标的常数值。这些初始种子允许模糊驱动在模糊的早期阶段达到深度程序状态,并帮助模糊器将其探索扩展到深度路径
效果分析
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RzzpE8Vb-1688824637595)(https://gitee.com/tulz/tor-images/raw/master/imgs/image-20230708183346084.png)]
如图所示,在项目中的5,523个tc中,作者排除了1,039个使用原型实现中未处理的宏函数实现的tc(test case),即除了TEST和TEST F(用于gtest)或BOOST AUTO TEST CASE FIXTURE(用于boost)(Oths)之外的tc。对于剩余的4,484个tc,根据文章的排除标准,在确定根定义的过程中,UTOPIA删除了1,769个tc(占检查的4,484个tc的39%)。总的来说,UTOPIA自动从这些项目中可行的候选tc中生成所有2,715个模糊驱动程序。
总共发现了123个bug,其中109个是在25个OSS项目中生成的2715个模糊驱动程序的短时间运行发现的,其中有56个得到维护者的确认或修复;14个是在30个Tizen原生库生成的2411个fuzz驱动程序的大约两周发现的,其中一些已经潜伏长达七年,这些bug都被Tizen确认。UTOPIA在测试用例中使用完全相同的API序列但仍发现了新的错误,这说明利用TCs可以发现开发人员在测试期间错过的新类型的bug。使用UTOPIA为Tizen的30个项目生成了模糊驱动源代码,被该社区采用。
手动编写的模糊驱动应用在OSS-FUZZ上和自动生成模糊驱动的UTOPIA覆盖率对比:
如上面两图所示,UTOPIA的模糊驱动程序在6个项目中有4个项目的表现平均高出20.5%,在2个项目中表现不佳(平均为9.7%),但都存在unique coverage。
接下来作者还分析了对断言的不同处理方式对模糊测试的影响,如下图所示:
可以看到忽略断言会对模糊测试产生不利影响。
通过库分析获得的分析属性ArrayLength、AllocSize和LoopCount对减少由有害模糊输入引起的虚假崩溃和崩溃的影响。为了进行评估,我们从三个项目中选择了模糊驱动程序,这些项目通过删除其中一个属性进行比较来测试带有三个属性的API参数。如表5所示,没有ArrayLength或AllocSize属性的设置会导致崩溃的急剧增加,最多增加两个数量级,而覆盖率则略有增加。另一方面,如果没有LoopCount属性,在崩溃时不会观察到任何差异,但是exec/sec性能会显著下降,最高可达40%。对于assimp项目,当移除AllocSize属性时,与包含属性相比,覆盖率和exec/sec分别减少到37%和2%。在libtp项目的情况下,没有ArrayLength属性,覆盖率和exec/sec性能较差,崩溃增加了645倍。此外,leveldb中LoopCount的省略将exec/sec性能降低到41%。
效果
- 几乎零人工参与的从现有的单元测试中有效合成模糊驱动
- 将 UTOPIA 应用于55个开源项目库,包括 Tizen 和 Node.js,并从8K 个合格的单元测试中自动生成5K 个模糊驱动程序
- 每核小时执行约500万次生成的fuzzers,发现了123个 bug
- 2.4 K 生成的模糊驱动程序被应用到 Tizen 的持续集成过程中,表明了UTOPIT生成的模糊驱动的有效性
不足
- 依然存在虚假崩溃 (只能平衡状态探索和虚假崩溃之间的关系)
- 非常规关系,对一些非常规和高度自定义的参数和关系用法无法生成模糊驱动
- 错误处理不足 开发人员在进行单元测试时会跳过对对象的正确构造和分配检查,硬编码一些非必要的参数,这种UT在成为模糊测试驱动时可能导致虚假报错
- 文件路径的根定义:在某些测试用例中,文件路径字符串是通过多个字符串操作创建的。在这种情况下,如果UTOPIA创建一个用于模糊测试的文件,并在字符串的根定义处(在所有操作之前)分配其路径,则API访问的实际路径将是不正确的,为了避免这种情况,UTOPIA启发式地将生成的模糊文件路径分配给API之前最接近的字符串分配/操作。然而,由于这种启发式,UTOPIA可能无法在生成的模糊驱动程序中反映原始UT逻辑
配检查,硬编码一些非必要的参数,这种UT在成为模糊测试驱动时可能导致虚假报错 - 文件路径的根定义:在某些测试用例中,文件路径字符串是通过多个字符串操作创建的。在这种情况下,如果UTOPIA创建一个用于模糊测试的文件,并在字符串的根定义处(在所有操作之前)分配其路径,则API访问的实际路径将是不正确的,为了避免这种情况,UTOPIA启发式地将生成的模糊文件路径分配给API之前最接近的字符串分配/操作。然而,由于这种启发式,UTOPIA可能无法在生成的模糊驱动程序中反映原始UT逻辑
- 逻辑中的常数值别名:当测试用例直接使用常量值时UTOPI可能难以生成合适的驱动程序