嵌入式C代码释放后重用缺陷检测
2021-01-29王亚昕李孝庆伍高飞唐士建朱亚杰
王亚昕,李孝庆 ,伍高飞,唐士建,朱亚杰,董 婷
(1.北京空间机电研究所,北京,100094;2.西安电子科技大学 网络与信息安全学院,陕西 西安 710071)
C语言是一种面向过程的通用程序设计语言。编译系统的交叉编译能力使得C语言能够适用于ARM、C51等多种嵌入式体系架构。由C语言编写的嵌入式代码广泛应用于操作系统内核及驱动、应用程序及代码库、单片机软件等,其使用场景包括移动终端、IoT设备[1]、工控设施、航空航天系统[2]等。这对其安全性和可靠性提出了很高的要求。
因此,针对嵌入式C代码中的缺陷进行及时检测和修复极为重要。C代码中的释放后重用 (Use-after-Free,UaF) 缺陷极为常见且危害严重。虽然不同嵌入式平台使用的动态内存管理函数各不相同,但是UaF缺陷的成因是相同的,即C代码在内存释放之后未将内存指针清零,导致“野指针”留存,并在后续代码执行中继续被用来进行相应操作。Android手机所使用的嵌入式内核驱动[3]就曾因一个UaF缺陷导致了华为、三星等诸多厂商的智能手机面临极为严重的安全风险,恶意应用可获得手机的完全控制权。这一案例充分证明了嵌入式C代码中UaF缺陷的危害性。
现有的嵌入式代码缺陷检测工作未能有效支持UaF缺陷,在一般计算机系统中较为成熟的自动化UaF检测工具又不能支持复杂多样的嵌入式平台。因此,笔者设计了一套支持不同嵌入式平台的静态代码分析工具,实现了对于C代码UaF缺陷的自动化检测。主要贡献如下:
(1) 实现了基于内存指向的、支持数据结构操作的、上下文和路径约束敏感的过程间数据流分析。
(2) 归纳了UaF缺陷代码的数据操作和传递的特征,并基于数据流分析开展污点追踪。
(3) 利用大量测试用例与大型嵌入式项目代码开展验证性实验,论证了该工具在发现UaF缺陷的有效性、可靠性及准确性,证明了其在不同架构平台、大规模代码项目上的适用性。
1 相关工作
C语言具有广泛而复杂的应用场景。对C代码UaF缺陷开展检测有助于提高其在各种应用场景中的可靠性与安全性。现有的代码缺陷检测技术多针对操作系统或应用程序等单一场景开展研究,针对嵌入式C代码UaF的检测未能得到有效支持。
1.1 嵌入式代码缺陷检测
为了有效利用嵌入式设备的有限计算资源,通常情况下嵌入式系统对于代码复杂度有较高限定,并会以此来评价软件的代码质量。评价方法所涵盖的软件度量单元包括控制流节点度量、扇入扇出度量、循环深度度量、圈复杂度等[4]。Mccabe公司的ASM8086,奥吉通公司的CRESTS/ATAT等工具能对此类问题开展基于代码规范的自动化分析。
从代码缺陷角度来看:在高安全性高稳定性要求的领域,对于嵌入式软件的堆栈使用情况的安全测试必不可少,基于汇编代码的堆栈溢出静态测试方案可以实现对此类缺陷的自动化测试[5];具体到航天器软件产品,其常见的代码缺陷包括变量未初始化、数组越界、整型溢出、操作符优先级错误、循环变量错误等,使用代码分析可实现相关缺陷检测[6]。
1.2 UaF缺陷检测
常见的C语言UaF检测工作主要针对计算机系统及其程序,分为动态执行和静态分析两种方法。
动态执行以Fuzz测试[7]为代表,利用恶意构造的测试用例触发并捕获目标代码中的异常行为。主要难点在于如何捕获UaF代码异常。对于源代码,可利用编译框架提供的ASAN选项,进行异常检测代码的插装[8-9];对于二进制代码,通常需要仿真调试或者二进制指令插装[10]的方式实现异常监控。动态执行还需解决生成测试用例以触发更多代码分支的问题[11-12]。
静态分析不需运行被测代码。对于源代码,通常转换为中间语言(Intermediate Representation,IR)开展分析,现有工作涵盖了单线程应用程序[13]和多线程内核驱动[14]中UaF缺陷的检测方法。针对二进制的静态分析则需结合反汇编工具[15]。静态分析的优势在于理论上能覆盖代码所有执行路径,漏报率低;缺点在于误报率较高,其主要原因是无效的代码执行路径未被识别,通常会引入符号执行[16]工具以降低误报。
1.3 小 结
综合分析相关工作,现有的嵌入式代码缺陷检测方案并不能实现有效的UaF检测;而针对一般计算机C程序的UaF检测工作虽然已有较多,但其在复杂多样的嵌入式平台并不完全适用。因此,设计一种适配多类型嵌入式平台的UaF检测工具是十分必要的。
2 UaF缺陷检测方案设计
静态代码分析能更好适应于多种嵌入式平台,而动态执行技术则受限于代码执行和调试环境,适用场景有限。因此,选择静态代码分析中的污点追踪技术开展UaF自动化检测。由于C语言是一种直接面向内存的编程语言,其污点分析工具在设计与实现过程中比其他语言更为复杂,须支持:① 内存指向分析,跟踪内存指针在寄存器和内存之间的传递,记录指针变量指向关系;② 数据结构内部变量追踪,数据结构是C语言中极为重要和广泛应用的数据格式;③ 跨函数的过程间追踪,追踪函数调用和返回过程的数据传递;④ 上下文敏感,以处置基于代码上下文进行选择性变量赋值的操作;⑤ 路径约束敏感,记录约束条件并求解,防止无效路径。
基于上述分析,设计如图 1所示的系统架构。整个分析过程如下:
(1) 将C源码文件编译为IR代码。
(2) 开展直接函数调用图分析。
(3) 开展跨函数控制流分析。
(4) 开展跨函数数据流分析,并特别针对函数指针、内存指针和路径约束变量进行数据追踪。
(5) 基于数据流追踪,实现间接函数调用分析、指向分析和路径约束分析。
(6) 基于UaF漏洞特征开展污点追踪。
可以看出,全面的数据追踪是进一步开展指向分析、污点追踪和路径约束分析的基础。为了实现准确、全面的数据流分析,定义了如表1中所示的存储单元和存储元素,每种存储单元可存储任意一种存储元素。
表1 数据存储单元与数据存储元素
3 UaF缺陷检测工具实现
数据流分析是实现整个缺陷检测的核心,以此为基础可开展全面有效的污点追踪技术,从而准确判定UaF缺陷是否存在。静态代码分析中常见的路径爆炸、误报率高等难点也得到了有效处置。
表2 针对特定LLVM IR语句进行数据流分析
3.1 数据流分析
数据流分析采用正向分析,沿着执行路径分析每条语句是否会引起:① 存储单元之间传递了存储元素;② 新创建的存储元素被存入了目的存储单元;③ 存储单元中的原有存储元素遭到了覆盖。表2展示了不同LLVM IR语句所导致的存储元素从源存储单元向目的存储单元的数据传递关系。
3.2 污点追踪
结合UaF代码行为特征的污点追踪技术可实现有效的缺陷检测。算法1展示了污点源的判定规则。当一条指令进行内存释放,分析代码将获取内存指针对应的内存对象,并为其添加释放标签。算法2展示了污点陷入的判定规则。首先获取语句使用的变量集合,逐一分析其是否为内存指针,并且指向已添加了释放标签的内存对象,如是则判断是否为安全敏感的UaF操作。为了实现完整的污点追踪:在数据结构内部变量的获取时,需进行污点传递;在内存对象不被其他内存指针引用时,需进行污点消除。
算法1内存释放的标签添加过程。
输入1 待分析函数调用指令callInst。
输入2 代码状态记录analysisState。
返回值:更新后的代码状态记录analysisState。
① func =获取callInst 被调函数
② if(func不是内存释放函数)
③ 返回analysisState
④ freedOpe =获取被释放的目的内存寄存器
⑤ freedPointer =从analysisState 中查询freedOpe 存储的值
⑥ freedMemoryBlock=从analysisState 中查询freedPointer 指针指向的内存块
⑦ freeTag =创建记录了释放操作的内存块标签
⑧ 向analysisState 中添加记录:freedMemoryBlock 被打上了freeTag 标签
⑨ 返回analysisState
算法2释放后重用导致污点陷入的判定。
输入1 待分析指令inst。
输入2 代码状态记录analysisState。
返回值:更新后的代码状态记录analysisState。
① allOpes =获取inst 的所有操作数寄存器
② for(依次取出allOpes 里每一个的操作数寄存器)
③ ope=本次取出的操作数寄存器
④ value=从analysisState 中查询ope 存储的值
⑤ if(value不是一个内存指针
⑥ 进行下一轮循环
⑦ memoryBlock=从analysisState 中查询value 代表的内存块
⑧ hasFreeTag=从analysisState 中查询memoryBlock 是否有内存释放标签
⑨ if (hasFreeTag == false)
⑩ 进行下一轮循环
3.3 技术难点与解决方案
在工具实现过程中面临着静态代码分析工具普遍存在的一些难点。文中以降低漏报率、适度容忍误报率为原则,对这些难点设计实现了解决方案。
(1) 函数调用图不完善。直接函数调用分析无法涵盖复杂代码中基于函数指针进行间接调用的情况。文中设计了针对函数指针这一特殊的常量型存储元素的追踪方法,补全了函数调用图中的间接函数调用路径,从而提高了分析过程的准确性和全面性。
(2) 控制流路径爆炸。路径爆炸问题主要来源于循环语句、递归调用等。通过限制基础代码块在当前函数分析过程中的分析次数、限定代码执行路径上每个函数的被执行次数的手段提高了测试的成功率,并进一步利用路径约束求解降低进入无效代码路径的可能性,提高了测试准确性。
(3) 针对数组元素的数据流分析。如果在数组元素的数据访问过程中索引值为符号值,则文中将尝试统计目标数组中可访问范围内的所有元素,并在后续分析中对于每种取值情况开展数据流分析,从而覆盖所有可能的取值情况。这样可确保数据流分析过程的全面性,降低漏报率。
(4) 起始函数设计与测试流程调控。对于一些测试目标,需为其创建虚拟的测试起始函数,实现测试过程调控。例如Linux内核的seq_file文件操作接口,会利用代码段 1的数据结构对响应函数接口进行设定。
代码段 1 seq_file文件响应函数的结构定义如下:
① struct seq_operations {
② void * (*start) (struct seq_file *m,loff_t*pos);
③ void (*stop) (struct seq_file *m,void *v);
④ void * (*next) (struct seq_file *m,void *v,loff_t *pos);
⑤ int (*show) (struct seq_file *m,void *v);
⑥ };
⑦ struct seq_operationstest_op;
假设测试目标为名为test_op的该数据结构实例,则测试起始函数的设计如代码段 2所示,从而模拟进行读文件操作时的响应流程。此方案可解决在多次内核响应过程中的代码状态存留问题,提高准确率。
代码段 2 针对seq_file的测试起始函数如下:
① void TEST(struct seq_file *m,void *v,loff_t*pos){
② for(i=0;i< 2;i++){
③ test_op.start(m,pos);
④ test_op.next(m,v,pos);
⑤ test_op.show(m,v);
⑥ test_op.stop(m,pos);
⑦ }}
4 UaF缺陷检测工具实验验证
UaF缺陷检测工具实验验证分为两个部分,第1部分通过自行编写的和公开的测试用例集合(所用测试用例已开源:http://dwz.date/dn35),验证该工具发现代码安全问题的效果和准确性;第2部分则通过有真实漏洞编号的UaF案例,验证该工具在大型项目上的应用效果。
4.1 测试用例进行功能验证
4.1.1 自有测试用例设计与实验
在UaF缺陷检测工具工具的实现过程中,同步编写了自有测试用例,涵盖了数据流、调用图、控制流等多个方面。具体测试内容包括:① 调用图全面性测试,包括直接调用和间接调用;② 控制流分析,包括路径分支、代码循环;③ 数据流分析,包括面向局部变量、全局变量、数据结构、数组元素的数据追踪。图2左侧展示的为测试用例代码,右侧为测试结果。测试结果以基础代码块为单位,"L:XX"代表源代码行数,虚线框表示所属函数。其中代码块标注:椭圆,代表分析起点;点状,代表发生内存申请;横线,代表发生内存释放;竖线,代码发生内存重用。跳转的标注:C(all) 代表函数调用;Y(es)和N(o)分别代表条件语句为是和否;括号中的编号则标注了代码执行流程。该结果直接、清晰地呈现了UaF缺陷触发时的代码执行路径。
④ void foo(int argc) {
⑤ char* buf=malloc(10);
⑥ if(buf == NULL)
⑦ return;
⑧ buf[0]=100;
⑨ free(buf);
⑩ if(buf != NULL)
(a)测试用例代码
4.1.2 开源测试用例集实验验证
Juliet测试用例集是软件保障参考数据库中的一个公开测试样本集 ,其中包含138个C代码UaF缺陷样本,每个文件代码量为数百行。利用这些样本开展了验证性实验。实验结果如表3所示,证明了该工具能以较低的资源消耗完成准确、快速的UaF检测。限于篇幅,不再对单个用例的测试结果展开分析。
表3 Juliet测试结果统计
4.2 大型嵌入式软件系统实验验证
选取在嵌入式操作系统领域和应用软件领域有广泛应用的Linux操作系统内核和OpenSSL安全通信程序进行验证。实验过程使用ThinkPad X1,处理器为英特尔I7-8 750H,设备拥有16 GB内存。
4.2.1 针对嵌入式操作系统漏洞的实验验证
Linux内核被广泛应用于嵌入式系统,其代码量超过27 000 000行。在4.7.1版本之前的disk_seqf_stop函数存在UaF漏洞[17]。该函数是/proc/diskstats文件的内核响应接口,在内存释放后未对指针变量seqf->private进行清零,遗留了“野指针”,最终导致UaF触发。测试过程参考4.3节编写了针对性的测试起始代码。
选择Linux 4.7版本开展测试。实验过程进行了38分11秒,完成了1 399 020条路径组合的分析工作。在对无关函数调用进行了自动化“剪枝”后,得到了精简版的测试结果,如图3所示。结果显示disk_seqf_stop函数中被释放的内存在disk_seqf_next函数中发生了重用。
根据纵线方框的标注,定位disk_seqf_next异常代码,如代码段3。此段代码在第844行进行函数调用,将seqf->private作为调用参数,这一指针正是被disk_seqf_stop释放的内存。因此确认存在UaF缺陷。
代码段 3 Linux内核disk_seqf_next实现代码如下:
839 static void *disk_seqf_next(struct seq_file *seqf,
void *v,loff_t *pos)
840 {
841 struct device *dev;
842
843 (*pos)++;
844 dev=class_dev_iter_next(seqf->private);
…
此外,disk_seqf_start函数在第2次被调用的执行路径(编号为17-18的有向线段)与第1次调用时是显著不同的。结合代码段4中该函数的源码,可看出成功发现了一条可避免在第2次被调用时seqf->private野指针被覆盖的执行路径。这也验证了此测试报告的准确性。
代码段 4 disk_seqf_start实现代码如下:
818 static void *disk_seqf_start(struct seq_file *seqf,…){
820 loff_t skip=*pos;
821 struct class_dev_iter *iter;
822 struct device *dev;
824 iter=kmalloc(sizeof(*iter),GFP_KERNEL);
825 if (!iter)
826 return ERR_PTR(-ENOMEM);
827
828 seqf->private=iter;
…
图3 Linux内核UaF漏洞的测试结果
4.2.2 针对嵌入式应用软件的实验验证
OpenSSL是一款Linux嵌入式系统上广泛使用的软件,代码量约为450 000行。其1.1.0a版本中存在一个严重的UaF缺陷[18]。实验选取了针对漏洞版本开展测试,选取了以服务程序的读状态机实现函数read_state_machine为测试起始点。测试过程持续7分51秒,完成286 567条代码执行路径分析。为了验证结果准确性,设立了对比实验,基于ASAN异常捕获机制获取缺陷动态触发时的调用栈信息,如图4所示。将其与图5中的静态分析结果比较,可发现两者的结果相符合。
图4 OpenSSL UaF漏洞动态触发调用栈
图5 OpenSSL软件UaF漏洞测试结果
5 现有方法对比
在UaF的自动化缺陷检测领域,静态分析和动态测试是特点鲜明的两种方法。两者并没有优劣之分,只是因其特点的不同,有着各自的适用场景。表4中总结了在现有研究工作中具有代表性的检测方法。
表4 现有UaF缺陷检测方法对比
对比表4中各项工作可发现:动态测试环境更加适用于通用计算机代码的缺陷检测,该场景下的代码执行环境和异常捕获机制均较为完善,但对嵌入式代码言并不适用;二进制静态分析工具受限于其依赖的反汇编工具和符号执行工具的适用范围,仅能支持部分嵌入式平台上的缺陷检测。理论上来讲,源代码分析工具最适用于嵌入式代码UaF的检测,但现有工作[13-14]不能实现文中全面的数据流分析,使得开展UaF代码特征识别的过程中存在较高的误报率和漏报率,检测效果并不理想。
6 结束语
笔者提出了一种针对嵌入式C代码的UaF缺陷检测方法,并基于LLVM编译框架编写了自动化检测工具,实现了针对操作系统、应用程序、单片机程序等多种嵌入式代码的UaF缺陷检测。工具具有全面、准确的数据流分析能力,能够针对UaF缺陷代码特征开展污点追踪,从而实现自动化缺陷检测和报告输出。验证实验在测试样本集、嵌入式操作系统和应用程序等多个目标上开展。实验结果表明,文中方法能够准确、高效地实现不同场景下UaF缺陷的自动化检测,并且能适用于大规模嵌入式代码项目。