面向大规模源代码的内存安全性动态分析技术

2021-08-02 03:35冲,孙毅,仵
计算机技术与发展 2021年7期
关键词:指针语句内存

王 冲,孙 毅,仵 俊

(南京航空航天大学 计算机科学与技术学院,江苏 南京 211100)

0 引 言

C语言常用于诸如操作系统、嵌入式软件系统等对性能要求较高的系统的编写。然而C语言本身缺乏对内存安全性检测的相关功能,因此使用其编写的程序可能存在较为严重的内存安全性漏洞[1-5]。动态分析[6-8]是目前常用的对程序进行内存安全检测的方法,目前常用的实现方法有二进制代码插桩、中间代码插桩、源代码插桩[9-10]等。

二进制代码插桩是对可执行程序进行插桩,优点是不需要源代码就可以对程序进行动态分析;中间代码插桩是对编译后的代码插桩,可以利用优化,减少不必要的插桩;源代码插桩是指对源码上进行修改,添加行为监代码,对程序进行检测,优点是可以获取源代码中的位置,准确地报告错误信息。

为了能更准确有效地检测程序的错误并能将错误的变量信息准确地反馈给用户,该文采用了源代码插桩技术进行插桩,并在基于指针技术[11-13]的基础上,借助开源编译器Clang和C++语言实现了内存安全分析工具Movec,完成其对大规模C程序的内存安全性检测,并通过实验进行了验证,表明该内存分析工具对大规模程序的内存检测是有效且高效的。

1 基础知识

基于指针的内存安全性检测技术的主要思想是对程序中的所有指针变量构造一个指针元数据,用来记录该指针的内存状态、上下界以及指向当前内存的指针的个数。然后,当指针赋值或者以函数参数传递的时候,更新这个指针的元数据,用来保持数据的一致性。最后,在对指针进行解引用或者通过指针对内存进行读写时,根据指针元数据中记录的内存状态信息,来判断该次内存访问是否是合法的,从而检测出内存的安全性。

采用源代码插桩实现基于指针内存安全性检测的过程分为三个部分:一是对指针变量定义进行插桩以初始化元数据,对指针变量赋值进行插桩来更新元数据的信息;二是在指针解引用的时候来检查该指针所引用的对象的元数据;三是对函数定义进行插桩以初始化函数参数的元数据、计算存储返回值的元数据。然后,对函数定义生成一个包装函数,该包装函数用来对程序检测并传递指针元数据。接着,对原函数调用重命名,并插入元数据,然后将原函数调用重定向到其包装函数来完成检测。

2 大规模C程序的内存安全性检测的研究与实现

2.1 项目插桩的改进

目前的源代码插桩工具对程序的插桩一般有两种模式,一种是单文件插桩模式,一种是项目插桩模式。单文件插桩模式适用于一些文件数量比较少的情况。对于项目插桩模式,目前常采用的方法是使用搜索后缀的方法将文件中所有的.c和.h文件进行搜索,然后将所有的文件添加到插桩列表中,把每个.c和.h文件都当成一个翻译单元进行解析插桩,插桩完成后将新的文件生成到目标文件夹中。这种方法在对大规模的程序进行插桩时过于简单,会导致如下问题:一对每个.c和.h文件进行搜索,会将一些不必要的文件进行搜索并插桩,增加了项目插桩时间;二是当文件编译命令中使用了-D定义了宏或者使用-I头文件目录时,这种项目插桩的方式获取到的语法树会和原语法树完全不同,导致插桩错误;三是当项目中的头文件出现一个不完整文件时,将该文件当作一个完整的翻译单元处理时,无法获取其完整的语法树,导致程序插桩失败。

对于问题一和问题二,该文利用编译数据库的概念,对源代码插桩工具的项目插桩模式进行改进。编译数据库是在项目实际编译过程中对编译器调用的监控记录,其中包含了每个文件在编译时的编译选项。利用编译数据库获取待插桩文件的存储路径和该文件对应的编译指令,构造出每个文件的原始编译命令,从而在对文件进行解析时获取到的语法树和原始语法树是一致的。同时,通过编译数据库,可以获取一个可执行文件的所有的依赖文件,不需要进行.c和.h的搜索,降低了程序插桩的时间。

对于问题三,该文提供的解决方法是将不完整头文件扩展到源文件中,不再对该头文件进行单独插桩。因此,该文提供了一个头文件扩展算法,该算法可以将指定的文件进行扩展,当遇到该文件时,不对其插桩,同时将其内容扩展到所有引入该头文件的文件中。当对程序进行内存安全性检测时,由于系统库文件中的接口是编译器提供的标准接口,不需要对其进行插桩检测,所以该文提供的头文件扩展算法对所有的系统库文件不进行扩展,这不仅减少了对程序的插桩时间,也减少了代码的膨胀率。同时,该文提供的算法还支持不扩展用户指定的头文件。

该算法的主要思想是:首先,创建一个文件输出流,然后利用Clang前端接口创建一个原始词法解析器,该解析器只解析当前主文件中的内容,然后当解析到#include指令时,在头文件列表中查找该include的文件标识,然后判断该文件是否是系统库文件,若不是,则将其内容写入到输出流,同时递归地调用本方法去继续扩展头文件中引入的头文件。若是系统库文件则保持不变,继续解析下一个#include命令。其中,头文件列表是当读取的文件发生切换时记录的,它通过Clang提供的PPCallbacks中的FileChanged()回调函数记录,每当文件发生切换,记录该文件的ID、类型等信息。当一个文件中所有的#include指令的内容扩展完成后,再将#include指令后的内容写入到输出流,最后写回到原文件中,从而实现对头文件的扩展。具体实现如图1所示。

图1 头文件扩展算法

2.2 包装函数插桩改进算法

基于指针的内存安全性动态分析技术对包含指针参数或返回值为指针类型的函数,需要对其插桩包装函数,用来初始化函数参数和返回值变量的指针元数据。对函数定义生成其包装函数定义,然后在其函数调用中重命名该方法,将其定位到包装函数以完成内存检测。但是由于库函数的定义在系统头文件中,无法根据其定义生成包装函数。通常,内存分析工具会提供常用库函数的包装函数,但是当程序调用的库函数较多或者使用了第三方库时,内存分析工具无法提供所有的库函数的包装函数。若没有提供包装函数的库函数,则会对其进行插桩,此时会因为找不到包装函数定义而导致编译失败。针对这类问题,该文提供的解决方法是:首先,对于一个函数,判断其是否是库函数,然后判断该函数的包装函数工具是否提供,若提供了其包装函数,则对该库函数进行插桩,若不提供,则不对该库函数进行插桩。

因此该文给出一个库函数判断算法,该算法的思想根据是库文件是存储在系统特定位置下,通过判断一个函数所引用的声明的文件是否在当前工作目录中,来判断该函数是否为库函数。具体的实现如图2所示。

图2 库函数判断算法

当判断一个函数是库函数后,此时需要判断函数是否需要插桩,该文利用Clang获取用户文件语法树,然后通过函数声明与定义访问函数VisitFunctionDecl记录下每一个函数名,将其传递给插桩模块,配合系统提供的包装函数列表,完成函数是否需要插桩的判定。

2.3 匿名结构体插桩改进

对于结构体指针解引用,需要获取该指针指向区域的上界和下界。对于指向命名结构体的指针变量如struct st *ptr,它指向区域的上界和下界分别为ptr和ptr+sizeof(struct st)。但是对于匿名结构体,无法获取它的名字,所以sizeof的括号中缺少结构体名字,导致插桩后的程序出现编译错误,如:struct {int a; int b;} *ptr;对*ptr插桩后获取的下界ptr+sizeof(struct(anonymous struct at /home/a.c:3:1)。

对于该问题,该文提供的解决方法是:对匿名结构体添加一个唯一的ID,在使用sizeof获取匿名结构体变量类型的时候,使用该ID构造函数的名字,通过该名字确定结构体类型的大小。使用AST上该结构体定义节点的地址作为ID。在结构体定义时添加有该ID构造的名字,然后在访问该结构体变量时获取该变量的结构体定义节点,并获取其地址,从而保证了构造的ID是唯一的并且是一致的。具体的实现算法如图3所示。

图3 匿名结构体插桩算法

2.4 循环结构和switch分支结构的改进

当一个指针无效时,需要对该指针的元数据进行清除,以节省空间和时间。在循环结构中包含break语句和continue语句,switch分支结构中包含break语句,这些语句会改变程序的执行流程,所以需要对break语句和continue语句进行重写,来实现对程序的指针元数据的清除,具体重写的规则如下:

循环中的break替换为:{bc_flag_LOOP_BLOCK_ID=1;goto PRFlbl_THIS_BLOCK_ID;}。

continue语句替换为:{bc_flag_LOOP_BLOCK_ID=2;goto PRFlbl_THIS_BLOCK_ID;}。

switch中的break替换为:{bc_flag_SWIT_BLOCK_ID=1;goto PRFlbl_THIS_BLOCK_ID;}。

其中bc_flag_LOOP_BLOCK_ID值为1时表示循环中的break语句,值为2时表示continue语句,bc_flag_SWIT_BLOCK_ID表示switch语句中的break语句。LOOP_BLOCK_ID表示该循环语句块的ID,SWIT_BLOCK_ID表示语句switch语句块的ID,lbl_THIS_BLOCK_ID插入在该语句块最后用来清除在该语句块内定义的元数据,然后再根据bc_flag判断执行流程。

在对break的替换的时候,需要考虑一些复杂结构,如循环中嵌套switch结构或switch语句中嵌套循环结构,此时插桩时需要对该break语句进行判断来实现不同的替换。针对该问题,该文提出的解决方法是:对于一个break语句,在插桩前需要记录它的父语句块PBS,在进行函数插桩时记录循环结构语句块LBS和switch语句块SBS,如果不存在循环结构或switch结构体,则LBS和SBS为空。然后通过比较break语句父语句块PBS和LBS、SBS的关系,判断出break语句是属于循环结构还是属于switch分支结构,从而根据对应的方法对break语句替换,以保证程序在清除完元数据之后能正常运行。具体的算法如图4所示。

图4 break语句插桩算法

3 工具实现

该文所述的对大规模C程序的应用理论在内存动态分析Movec上进行了实现。该工具实现采用的是基于Clang编译器来对源代码进行检测逻辑的插桩,插桩过后的代码仍然是标准C程序。同时,保证了改进过的Movec能正常地插桩和检测大规模C程序。其架构如图5所示。

图5 Movec架构

在对大规模程序内存安全性进行分析时,Movec的输入是待检测项目和一个编译数据库文件,即JSON文件,输出是插桩完整的项目Movec对该JSON进行解析,并构造出完整的文件编译规则,将其传递给C解析器,构造每个文件的抽象语法树。最后通过AST visitor对语法树进行访问,在语法树上获取需要插桩的节点位置,通过Clang提供的SourceManager接口和Rewriter接口实现内容的获取和重写,完成对包装函数的插桩改进实现,对匿名结构体的插桩实现以及对break语句改进的实现,完成对项目的源代码插桩。将该文提出的插桩改进规则应用到Movec工具上,使其能有效地对大规模C程序进行插桩,并对其进行动态内存分析。

4 实验与分析

基于上面介绍的算法,将其在Movec上进行了实现。本节将介绍优化后的Movec对大规模程序分析的有效性和高效性。

4.1 有效性实验

为了验证改进部分插桩规则后工具的有效性,将Movec应用到Mibench标准测试集上。实验平台为64位的Ubuntu16.04操作系统,处理器为Intel(R) Core(TM) i5-7200U CPU 2.70 GHz,内存是8.00 GB,编译器为gcc4.8.2。

选取了其中8个大规模的测试集进行实验,并与SoftBoundCets[14]、ASan[15]、Valgrind[16]进行了对比。通过实验表明,Movec可以正确地对这8个大规模的测试集进行安全检测。Movec和ASan在blowfish、jpeg、rijndael和rsynth中检测出了错误,但是Movec还检测出了ASan未检测出的错误,如在blowfish中的数组访问越界错误:

void BF_set_key(key, int len, unsigned char* data){

unsigned char * end=&(data[len]);}

unsigned char ukey[8];

BF_set_key(&key,8,ukey);

而SoftBoundCets则对5个测试集无法正常插桩,并且其余三个没有检测出错误。Valgrind正常对程序检测,但未发现任何错误。

通过结果表明,Movec对大规模程序的检测是有效的,且没有发生漏报和误报。

4.2 性能实验

本节将Movec与内存检测工具SoftBoundCets、ASan、Valgrind进行性能对比。从Mibench中选取了规模较大的8个测试集进行对比验证,考虑到误差,选用了三次实验结果去平均值的方式。实验结果如表1所示。

表1 运行时间对比结果

综合表中数据可以看出,SoftBoundCet由于使用了静态分析,其在gsm和blowfish(l)优于Movec,但它仅仅只能在其中三个测试集中运行成功;Valgrind采用的二进制代码插桩,虽然可以成功运行在大规模C程序上,但运行时间远远超过Movec;ASan在gsm和lame上的性能优于Movec,但是当在检测出错误的测试集中(如blowfish、jpeg、rijndael、rsynth),Movec的性能是好于ASan的。Movec还可以设置在发现错误后继续运行,可以检测出整个程序中可能存在的内存错误,而ASan和SoftBoundCets在发生错误后立即终止,导致后面的错误无法正常检测。

由以上分析结果可以看出,改进后的Movec不仅能够正确地在所有Mibench上运行,而且在有效性和高效性上都是优于其他工具的,是一个可靠的大规模C程序内存安全分析工具。

5 结束语

对大规模C程序进行动态内存分析时可能出现的问题进行了描述,并给出了相应的解决方法,然后将其在内存动态分析工具Movec上进行了实现,使其能对大规模C程序进行内存安全性检测。通过实验,表明Movec不仅能有效地对大规模C程序进行检测,同时在综合性能上是更优的。在接下来的工作中,将继续优化其对大规模程序检测的运行时间,例如结合静态分析,以减少对程序不必要的插桩和检测。

猜你喜欢
指针语句内存
笔记本内存已经在涨价了,但幅度不大,升级扩容无须等待
“春夏秋冬”的内存
郊游
为什么表的指针都按照顺时针方向转动
内存搭配DDR4、DDR3L还是DDR3?
基本算法语句
我喜欢
浅析C语言指针
作文语句实录
上网本为什么只有1GB?