垂悬指针检测与防御方法*

2020-09-23 07:31高凤娟马可欣司徒凌云王林章陈碧欢赵建华李宣东
软件学报 2020年6期
关键词:指针指向静态

王 豫 , 高凤娟 , 马可欣 , 司徒凌云 , 王林章 , 陈碧欢 , 刘 杨, 赵建华, 李宣东

1(计算机软件新技术国家重点实验室(南京大学),江苏 南京 210023)2(复旦大学 计算机科学技术学院,上海 200433)3(上海市数据科学重点实验室(复旦大学),上海 200433)4(上海智能电子系统研究所(复旦大学),上海 200433)5(School of Computer Science and Engineering,Nanyang Technological University,Singapore)

信息物理融合系统(cyber-physical system,简称CPS)是计算过程和物理过程的融合系统.它强调信息世界与物理世界的融合,强调对交互环境的实时监测与控制.它通过与交互环境的实时交互来增加或扩展新的功能,以安全、可靠和实时的方式检测或者控制一个物理实体.当前,CPS 系统被广泛应用于重要基础设施的监测与控制、航天与空间系统、电力系统、铁路系统和智能家居等诸多领域[1,2].CPS 系统中,不同异构组件的组合引起系统行为极为复杂[3].作为使命攸关与安全攸关的系统,CPS 系统需要满足实时性、安全性和可用性等特性.然而,CPS 系统的基本组件多由嵌入式系统构成,该系统主要是由以C/C++语言为代表的编程语言实现.但是,由于开发者在C/C++语言程序中对于指针的错误使用,可能会导致CPS 系统行为不符合预期.其中,类似于缓冲区溢出、use-after-free 和double-free 的漏洞,会导致系统崩溃甚至执行任意恶意代码.在这3 种缺陷中,后两个缺陷都是由垂悬指针引起的.

垂悬指针(dangling pointer)是由于指针或其别名所指向的内存区域被释放但指针本身未被置空.虽然垂悬指针未被访问(解引用或释放)时是安全的,但开发人员可能会无意中使用垂悬指针,在运行时导致潜在的可利用程序状态.攻击者可以利用use-after-free 或double-free 漏洞来危害程序的正确性和安全性,如程序崩溃、信息泄露、权限提升,甚至执行恶意代码[4].自2008 年以来,此类漏洞的数量每年翻倍,并且由垂悬指针引起的漏洞越发普遍和危险[5].但是,由于复杂的函数调用和指针指向关系,开发人员很难杜绝垂悬指针.

当前,针对垂悬指针的方法有3 种.

· 首先,基于静态分析的方法[4-6]一般有较高的误报率和可扩展性问题(即仅适用于小规模程序);

· 其次,基于动态分析的方法[7-9]十分依赖于给定的一组测试用例,并且会面临代码覆盖问题,可能有较高的漏报率.此外,这些方法通常需要大量的工作来监控程序运行时的行为,从而导致过高的额外性能开销(>100%);

· 最后,运行时防御方法[10,11]可以通过在运行时置空所有释放内存区域的指针来动态地保护垂悬指针,以免受攻击.但是,这些方法需要额外的内存和计算资源在运行时记录和分析指针指向关系,然后根据指针指向关系置空潜在垂悬指针及其别名.因此,这些方法也会产生较高的额外性能开销(>25%).

为了避免CPS 系统的中的垂悬指针被攻击,开发者可以借助运行时防御方法,在运行时发现漏洞,从而避免该漏洞造成恶性影响.但是,当前主流[10,11]的运行时防御方法引入了较大的额外性能开销.为了解决该问题,在本文中,我们提出了名为DangDone 的垂悬指针防御方法,通过在编译时的程序转换来定位潜在的垂悬指针,并防御use-after-free 或double-free 漏洞.确切地说,该方法先通过静态方法分析潜在垂悬指针,然后通过在这些检测出的指针(即潜在垂悬指针及其别名)和它们指向的内存区域之间插入中间指针来保护每个潜在的垂悬指针.因此,将中间指针置空,能使潜在垂悬指针及其别名置空,消除垂悬指针的同时避免use-after-free 或double-free 漏洞造成的攻击.

我们基于LLVM[12]实现了DangDone,该方法不需要开发人员的干预,并且能在编译时自动运行.在实验研究中,DangDone 成功防护了11 个开源项目中的use-after-free 和double-free 漏洞,体现出DangDone 的有效性.此外,我们使用SPEC CPU Benchmark[13]评估了DangDone 引入的运行时开销.结果表明,运行时的开销可以忽略不计(即平均约为1%).这使得它比目前的方法[10,11]更加有效.

总而言之,本文的创新贡献如下.

· 提出基于静态分析的垂悬指针检测方法;

· 提出了一种通过程序转换的轻量级方法来防御由垂悬指针引起的use-after-free 和double-free 漏洞;

· 开发了原型工具DangDone,通过实验,体现DangDone 的有效性和高效率.

本文第1 节介绍垂悬指针及其相关漏洞.第2 节介绍DangDone 的方法.第3 节通过实验评估DangDone 的有效性和性能,并讨论DangDone 的局限性,第4 节回顾相关工作.第5 节对本文进行总结.

1 垂悬指针及其相关漏洞

如果指针指向已被释放的内存区域(包括栈空间和堆空间内存),则它是一个垂悬指针.如果一个垂悬指针永远不被解引用,那么它是一个良性的垂悬指针;否则是一个不安全的垂悬指针[10].垂悬指针可以是堆空间垂悬指针或栈空间垂悬指针.如果指针指向一个对象并且该对象已被释放,则指针变为堆空间垂悬指针;如果指针指向栈变量并且栈变量超出其范围,指针仍然指向栈上不再有效的地址,这时指针变为栈空间垂悬指针.图1 显示了堆空间垂悬指针的示例.在图1(a)中,p1 和p2 在第8 行代码后变成垂悬指针,因为在第8 行的内存区域被释放后p1 和p2 没有置空.

Fig.1 An example of heap space dangling pointer图1 堆空间垂悬指针示例

虽然垂悬指针分为栈空间指针和堆空间指针,但是本文主要关注堆空间指针,因为栈空间指针很难被利用[10].但是为了程序的正确性,DangDone 在必要的时候会转换栈空间指针(在第2.3.1 节讨论).堆空间中的垂悬指针是导致use-after-free 和double-free 漏洞的根本原因.释放垂悬指针时会导致use-after-free 漏洞,并可能会导致不可预测的行为,因为内存区域可能已被其他函数重用或存储完全不同的数据.如果垂悬指针指向的内存区域被重复使用,则use-after-free 漏洞可能会泄漏关键信息.特权升级攻击是通过与信息泄漏类似的方式实现.即:垂悬指针指向的内存区域用于检查权限,并且该垂悬指针可以对内存区域进行写入.当函数指针使用垂悬指针时,攻击者可以利用use-after-free 漏洞来执行任意函数.常见的劫持技术是扩散恶意内容以覆盖垂悬指针所指向的堆中的函数指针或对象的虚表地址[14].这种劫持技术在Linux 内核中容易成功,因为内核为了效率起见会更倾向于使用内存分配的重用机制,这使得攻击者更容易借助垂悬指针攻击内核[4].

use-after-free 漏洞的一个特例是double-free 漏洞,它是由两次调用free函数引起的.double-free 漏洞可能导致内存分配函数覆盖存储在块中的内存管理信息.用double-free 漏洞可能与利用use-after-free 漏洞一样具有破坏性,可能导致远程代码执行[15]等问题.

2 DangDone

2.1 方法概述以及示例

2.1.1 研究框架

DangDone 建立在对垂悬指针根本原因的观察基础上:指针在它们指向的内存区域被释放后没有置空.为了将这些指针置空,我们可以在释放内存区域的语句之后插入置空操作.但是因为指针可能有许多别名,所以难以精确有效地对其进行分析.特别是静态指针指向分析不能保证准确性;动态分析需要为每个指针操作设置跟踪指令,会导致高开销[7,10].因此,在指针被释放后直接置空指针本身及其每个别名的方法不适用于实际的程序.为了解决这个问题,我们的主要想法是:在内存区域和指向内存区域的潜在垂悬指针之间插入一个中间指针,强制转换中间指针的类型,使其对潜在的垂悬指针不可见,并使所有潜在垂悬指针指向中间指针.因此,在释放内存区域的语句之后对中间指针插入置空操作,将使所有潜在垂悬指针在执行置空操作后指向NULL.此时,程序在尝试解引用垂悬指针及其别名时会直接崩溃,而不会给攻击者利用use-after-free 的机会.

基于上述观察,我们提出了一种名为DangDone 的方法,通过编译时的程序转换自动消除垂悬指针.图2 展示了DangDone 的框架,它将目标程序和配置文件的源代码作为输入,并返回受保护的二进制程序.配置文件给出内存操作函数的信息(例如内存分配、释放和重新分配相关的函数名称和参数信息).随后进行缺陷检测,以静态地分析潜在垂悬指针.这些潜在垂悬指针将会作为输入传递给程序转换模块.DangDone 的核心是通过程序转换实现的,该转换在 LLVM 中间表示层[16]上进行,将目标程序变换为受保护的二进制程序.如图 2 所示,DangDone 先通过缺陷检测模块静态地分析潜在垂悬指针,再根据其结果执行指针传递和指针变换.在程序转换时,DangDone 先通过指针传递查找由其他指针传递的指针,然后基于指针变换规则在指针变换模块转换这些指针.以这种方式,DangDone 的程序转换将转换与潜在垂悬指针具有指针指向关系的所有指针.即:若别名是传递指针的子集,则该潜在垂悬指针的所有别名都将被转换.

具体来说,程序变换模块为每个潜在垂悬指针执行4 个步骤.

(1) 将分配的指针标识为潜在垂悬指针;

(2) 构造新的分配函数,分配一个中间指针,将分配的内存地址分配给中间指针,并返回中间指针;

(3) 用新的分配函数替换原来的分配函数(如malloc),转换指针解引用指令,使内存访问与原始程序一致;

(4) 最后将被释放的指针置为空.

Fig.2 Framework of Dangdone图2 DangDone 的框架图

DangDone 不需要别名分析或指针指向分析,因为所有与潜在垂悬指针相关的指针都将被转换.通过这种方式,对潜在垂悬指针的防御被缩小到对中间指针的防御,这可以简单地通过置空中间指针来实现,因为潜在垂悬指针和它们的别名都指向那些中间指针.这些转换由DangDone 自动完成,不需要用户干预.转换可以在源代码层、中间层或二进制层进行.虽然当前DangDone 是在中间层实现,但是为了便于演示,我们在文中使用源代码级的示例.

2.1.2 示例

图1(a)显示了一个具有use-after-free 漏洞的程序.第10 行的漏洞源于第8 行的内存释放.它的源码级转换程序如图1(b)第2 行~第12 行所示.这里,变量p1 是潜在垂悬指针,因为它指向由malloc分配的内存区域,DangDone 将内存分配函数替换为第14 行~第18 行定义的新分配函数.新的分配函数包装传统的分配函数,并在分配的内存区域和用户定义的指针之间插入一个中间指针.第6 行的指针传递语句p2=p1 表示p2 是p1 的使用点(也是别名),因此应转换p2.结果,两个指针都指向相同的中间指针.然后,DangDone 使用中间指针跟踪原始指针的别名(第6 行),这样指针p1 和p2 上的任何读取或写入的引用都将首先访问中间指针*(char * *)p1,然后是*(char * *)p1 指向的内存区域.由于库函数strcpy和free的参数类型是char*,因此,参数分别由*(char * *)p1和*(char * *)p2 替换(第5 行、第8 行和第7 行、第10 行),以避免修改函数定义和维护原始程序的语义.DangDone在第9 行插入一个置空语句,以使指针*(char **)p1 置空,该指针的目标内存区域被显式释放.因此在第10 行,由于p2 指向*(char * *)p1 且*(char * *)p1 指向NULL,*(char * *)p2 等于*(char * *)p1,程序将崩溃而不是触发useafter-free 漏洞以及给helloworld加上?.

图3 和图4 展示出了原始程序和图1 中的变换程序的别名指向关系.这里,我们在图3 中添加置空指针的操作(即内存释放后增加一个p1=null)以更好地说明最终的指向关系.在图3 中,在使p1 置为NULL之后,p2 仍然指向被释放的内存.程序员应该在释放对象时使所有别名无效,所以仅为被释放的对象提供自动置空的方法不能消除垂悬指针.相反,在图4 中,使两个指针中的任何一个置空(即*(char * *)p1 或*(char * *)p2)都可以消除两个垂悬指针.原因是p1 和p2 都指向相同的中间指针IP,并且置空被释放的对象将消除垂悬指针.

Fig.3 Point-to relations for aliases in the original program in Fig.1图3 图1 中原始程序的别名指向关系

Fig.4 Point-to relation for aliases in the transformed program in Fig.1图4 图1 中修改后程序的别名指向关系

为清楚起见,在下文中,我们将用原始指针指代那些易受攻击的指针,并以中间指针指代插入内存区域和原始指针之间的指针.以DD开头的函数表示这些函数是由DangDone 定义的.变量的使用点表示直接使用变量的指令.

2.2 基于静态分析的垂悬指针检测

DangDone 进行保守的静态分析,以确定需要转换的潜在易受攻击的指针.此步骤减少了在其后的程序转换中需要考虑的指针数目.实际上,这一步是可选的,因为DangDone 可以以效率为代价转换程序中的所有指针.

静态分析算法在算法1 中显示,它主要作用是排除不是垂悬指针的指针.首先,它读取配置文件以获取内存释放和重新分配函数(系统的和自定义的)的列表(第1 行),因为这些函数返回的指针可能是垂悬指针.这些内存释放和重新分配函数也会用于程序转换.然后分析程序的调用图并获得调用图的逆拓扑排序(第2 行、第3 行),这是为了确保在函数的调用点中,被调用者始终是被分析过的.接下来,对于每个函数,它获取此函数的所有函数调用,并标识作为内存释放或重新分配的函数调用的参数的指针(第5 行~第9 行).这些指针可能是垂悬指针,因为它们涉及内存释放或重新分配.

在第2 个循环中,对于每个可疑指针,分析其别名,如果有一个指针的所有别名未都被置空,DangDone 将该指针标记为垂悬指针(第10 行~第16 行).在第11 行、第12 行,因为全局指针分析复杂,DangDone 直接将全局指针视为垂悬指针.第16 行的updateMemFuns用于分析过程间垂悬指针.这种类型的垂悬指针是由在被调用者内,作为参数的指针被释放但没有在该函数内被置空引起的.为简单起见,DangDone 将这些函数视为一种特殊类型的内存释放函数.因此,DangDone 将在其他函数调用它们时检查对应的实参是否在调用者内被置空.第13行的别名分析基于Steensgard 的算法[17].最后,在第17 行、第2 行,它提取潜在垂悬指针的信息并输出.所输出的潜在垂悬指针的信息包括指针名称、函数名称、代码行和文件名称.开发人员可以进一步验证这些指针,以减少误报的数量.

算法1.保守的静态分析.

在设计静态分析时,更准确的静态分析也是可行的.例如,像Andersens 算法[18]这样更精确的别名分析可以进一步减少误报.DangDone 也可以在没有静态分析的情况下转换所有指针,目前的保守静态分析会引入许多误报,但可以避免漏报.没有漏报的原因是垂悬指针的漏报来源于不精确的别名分析,而基于Steensgard 的别名分析是没有漏报的.我们设计这种保守的静态分析的原因是误报只会增加开销,而漏报可以绕过DangDone 的策略.因此在这里,我们尝试通过排除不是垂悬指针的指针来减少开销.

2.3 基于多级指针的垂悬指针防御策略

在现实世界的程序中,指针非常复杂.因此,我们研究了SPEC CPU Benchmark[13]测试中指针的使用统计数据:27.3%的指针指向基本类型,41.7%的指针指向类或结构,16.3%的指针指向结构中的元素,6.4%的指针指向模板(包括用户定义的模板和容器),8.3%的指针是二级指针.

此外,我们根据操作是否对指针和内存产生副作用对操作进行分类:指针的副作用意味着指针指向不同的内存区域;对内存的副作用意味着内存区域被改变.

· 对指针和内存没有副作用:例如指针传递和解引用;

· 仅对指针产生副作用:例如malloc或第三方用户定义的分配函数以及指针运算;

· 副作用仅限于内存中:例如free或第三方或用户定义的释放函数;

· 对指针和内存的副作用:例如,realloc或第三方或用户定义的重新分配函数.

基于上述统计和类别,我们通过图1 和图5 中的示例展示变换规则.其中,ele表示复杂结构的类型.

Fig.5 Examples for pointer modification rules图5 指针变换规则的例子

2.3.1 指针传递和解引用的规则

我们首先介绍指针传递和解引用的变换规则,它们对指针和内存没有任何副作用.

规则1.DangDone 通过检查被分配的内存区域是否被访问来区分指针传递和解引用.如果未访问所分配的存储区,则视为指针传递;否则视为指针解引用.例如,指针传递包括指针赋值、函数参数和获取指针的地址.

规则2.如果指针由潜在垂悬指针传递,则指针也是潜在垂悬指针.示例如图1 中的第6 行,其中p2=p1 表示p2 也是潜在垂悬指针.

规则3.如果原始指针被解引用,DangDone 将指针转换为二级指针,并将其使用替换为额外的解引用.此时,原始指针变成了二级指针,因为新的分配函数(参见图1 的DDMalloc)与原来的分配函数不同.如图1 中的例子所示,第10 行中的p2 由*(char **)p2 代替.附加的引用可以使原始程序和转换程序之间的内存保持一致.因此,如果解引用原始一级指针,DangDone 将用类型转换和原始指针的双重解引用替换原先的使用点.

对于变换后的程序,原始指针的指针传递不会改变,但是原始指针的解引用会被类型转换和附加的解引用替换.因此,中间指针对于程序的其余部分是不可见的.

然后,我们将展示如何将这些规则应用于复杂的指针,包括过程间指针、函数指针、间接指针、结构中的指针和栈空间指针.

· 过程间指针

过程间指针可以是两种类型:全局指针和作为函数参数传递的局部指针.全局指针的转换规则同局部指针.

如果原始指针作为函数参数传递,DangDone 传递原始指针而不是指向参数的间接引用指针,以使指针指向关系保持与原始程序一致.例如:在图5 中的第6 行有一个函数调用foo(p),p是潜在垂悬指针;并且转换后的函数调用仍然是foo(p).但是,foo的参数p与原始语义不同.因此,如果函数的参数已经转换,则需要转换相应的函数参数.在这种情况下,还应转换foo的参数以保持内存访问的一致性.然后,DangDone 搜索定位函数的所有使用点,以使它们的调用者的相应参数也被转换.这里有一个例外是处理库函数的参数时,DangDone 将参数替换为对原始指针的强制转换和解引用(如图1 中的第10 行).原因是当函数的参数改变时,这些参数的使用点需要进行相应的修改.因此,DangDone 不能转换没有源代码的库函数.

· 函数指针

C/C++中的一个特殊结构是指向函数的指针,这些函数指针为攻击者提供了一种通过利用use-after-free 漏洞来注入代码的方法[4,19].据我们所知:尽管函数指针可以允许攻击者执行任意代码,但函数指针不会直接导致use-after-free 或double-free 漏洞,但其他类型的指针(如指向结构的指针)确实会引发此类漏洞.因此,DangDone不处理函数指针.但是,转换的指针可以用作函数指针的参数.在这种情况下,定义满足函数指针类型的函数将进行相应的转换.

· 间接指针

我们将具有两个或以上级别的指针称为间接指针.如果我们对所有一级指针(即直接指针)进行保护,那么间接指针不会变成垂悬指针.但是,尽管间接指针不需要关心内存区域,但DangDone 应该对它们进行转换,因为它们可以通过两个或多个解引用来访问中间指针.因此,DangDone 会分析间接指针并在它们访问中间指针时对它们进行转换,使得访问期望的内存空间.

· 结构体中的指针

对于结构体中的指针(例如数组、结构、类和模板),它们的转换遵循与其他指针相同的规则.不同之处在于解引用操作,因为访问结构的方式是多种多样的.为了使内存访问与原始程序保持一致,我们应该考虑内存访问的特性.数组中指针变换的一个例子如图5 中的第8 行~第13 行所示.当libFun调用它时,DangDone 应转换指针a1[i],因为它是一个库调用.另一个显示结构中指针转换的例子如图5 中的第14 行~第18 行所示.指针p→var被转换为二级指针并解引用,以便libFun的参数与原始程序保持一致.

· 栈空间指针及常量地址

虽然我们专注于堆空间垂悬指针,但由于指针传递,栈空间指针也可能会被转换.例如:指针在一个分支中,它指向的是分配的内存;在另一个分支中,它指向的是一个栈变量.DangDone 通过为潜在垂悬指针传递的指针构造一个中间栈指针来转换栈空间指针.中间指针的生命周期与它指向的内存区域相同.因此,栈和堆空间指针的操作是一致的.在CPS 系统中,常量地址相较于常规应用程序更为常见.它是指针传递过程中其中一个别名被赋值成一个常量地址.针对常量地址的赋值,我们也应用相同的处理方式.示例代码如图6 所示.

Fig.6 Examples of stack space pointer and pointer from constant address图6 栈空间指针和常量地址例子

2.3.2 内存分配函数的规则

现在我们介绍对指针和内存有副作用的操作规则,以便在转换后指针和内存仍然保持一致.潜在垂悬指针被释放或重新分配时,如果它们指向释放的内存,我们应该将它们置空.此外,还应考虑一个指针的多重分配.指针运算将在第3.4 节讨论.

规则4(内存释放).为了防护潜在垂悬指针,如果释放了一个内存区域,DangDone 会中间指针插入置空语句.如果此时程序执行到内存释放语句和置空语句后,原始指针及其别名都最终指向NULL.图1 中的第9 行显示了一个示例.由于所有别名都指向同一个中间指针,因此无论它们是显式释放还是隐式释放,所有别名都将置空.从这个意义上讲,DangDone 只需要为一次内存释放插入一个置空语句.

规则5(内存重新分配).DangDone 对重新分配进行包装,以便在重新分配函数返回的指针与传递给函数[11]的原始指针不同时,置空原始指针并为新返回的指针添加一个中间指针.这个规则是由于类似于realloc(·)的内存分配函数可以增加或减少所分配的内存空间.但是如果所需的内存空间太大,则重新分配的地址可能与原始地址不同.所以DangDone 在所分配地址和原始地址不同时,置空原始指针并为新指针加入中间指针.

规则6(内存分配和多重分配).对于每个潜在垂悬指针,我们用DangDone 定义的新内存分配函数替换原来的内存分配函数(包括堆和栈空间内存).新的内存分配函数在调用原内存分配函数的同时,还分配了指向内存区域的中间指针.然后返回中间指针的地址,而不是所分配的内存区域的地址.请注意:此时原始指针变为二级指针,DangDone 将类型转换为一级指针.使用新分配替换分配的目的是强制所有指针及其别名都指向中间指针.因此,该替换应该分析指针的级别(如是否是间接指针),除了在内存区域和一级指针之间插入中间指针外,应避免在其他指针之间插入中间指针.例如,图1 中的DDMalloc展示了这种新分配的过程.关于分析指针级别的例子,如例子程序char * *p=malloc(size_t);char *p=malloc(32),此转换应只替换第2 个内存分配(即malloc(32)).

多重分配是指一个程序分配了一个新的内存区域并将其分配给一个已经指向内存区域的指针.处理方式依旧遵循规则6 以避免语义上的差异.换句话说,DangDone 不去区分某处的内存分配是否是多重分配.但是,替换分配的副作用是内存泄漏.分配引入的潜在内存泄漏问题将在第3.4 节中讨论.

2.4 指针传递和指针变换

2.4.1 指针传递

DangDone 跟踪从原始指针传递的每个指针的指针传递,并递归地变换这些指针.它通过记录原始指针和转换原始指针的使用点来实现.原始指针的指针指向结果存储在它们相应的中间指针中,因此,指针保持着与原始程序相同的指针指向关系.

算法2 介绍了递归变换指针的过程.输入程序是需要保护的代码.DangDone 首先分析程序,找出指向已被分配的内存区域(第1 行)的潜在垂悬指针.然后将潜在垂悬指针存储在栈PointerStack(第2 行)中.对于栈顶部的每个指针,首先替换指针oriPointer(第5 行)的分配函数(若有)(第4 行).相反,如果它指向栈空间(第7 行),则为指针oriPointer(第6 行)插入一个中间指针,然后,DangDone 将原始指针标记为已修改以供进一步使用(第8 行).然后,DangDone 基于Def-Use Chains 和Use-Def Chains[16](第10 行)识别所有使用点(例如使用原始指针的指令).对于每个使用点,DangDone 通过函数getOtherHandSide获得使用点(第11 行)也使用的指针otherPointer,该函数确定与原始指针相关的指针(例如别名和解引用).由于某些指令只有一个操作数,因此otherPointer可以为NULL.如果存在otherPointer,则检查otherPointer是否已经转换(第13 行);如果没有,则将此指针存到PointerStack.请注意:如果未转换otherPointer,则不会转换此使用点;但当两个指针都被标记为已修改时,它将被转换.在添加潜在的潜在垂悬指针后,DangDone 使用第18 行的函数transformPointer来转换指令.如果指令是指针传递,它将保持指向关系;如果指令中未引用指针,则它将替换为其他引用.详细的转换算法见第2.4.2 节.由于DangDone 是在潜在垂悬指针使用到这些指针时变换这些指针,因此它不需要进行额外的别名分析.虽然递归变换可能会导致指针别名的大量转换代码带来的额外开销,但实验表明这个开销是很小的.

算法2.递归变换指针.

例:对于图1 中的程序,PointerStack只包含一个指针p1,因为只有这个指针被显式分配.DangDone 首先替换内存分配函数,以使原始指针p1 指向中间指针.然后搜索p1 的所有使用点,发现p1 有4 个使用点.因为其中3个没有对应的操作数,所以将他们传递到transformPointer.与之对应,由于p2=p1 有对应的操作数p2,并且发现该指针尚未被修改,即p2 未标记为已修改,因此,DangDone 将它存到PointerStack,以便之后的指针变换.注意:即使p2 不在初始的PointerStack中,DangDone 也可以保护这个指针,因为当p1 的转换完成时,PointerStack上的顶部变量是p2,此时指令p2=p1 将被转换.

2.4.2 指针变换

为清楚起见,算法3 仅显示常见场景的转换.输入包括指令、指令中使用的原始指针;算法的输出是转换后的指令.首先对指令进行分析,确定操作类型.如果指令用于指针传递,则不需要转换指令.这是因为转换的一个目标是使内存访问与原始程序保持一致,并且一级指针的指针传递与二级指针相同.换句话说,对于指针传递,无论指针的级别如何,指向关系都是相同的.如果指令解引用原始指针或调用库函数,它将用另外的解引用替换原始指针的解引用(第1 行~第3 行).由于解引用指针的目的是访问指针所指向的内存区域,因此它添加了原始指针的额外的解引用,以使原始指针的内容与原始指令想要访问的内容一致.大多数情况下,如果原始指针解引用一次,则只需要对原始指针进行两次解引用.对于对指针或内存有副作用的操作,转换策略显示在第4 行~第8行.如果指令释放原始指针,则它插入指令将中间指针置空(第6 行).如果指令重新分配原始指针,它将使用DangDone 定义的新的重分配替换.它添加了置空语句,根据重新分配的指针是否等于原始指针(第8 行),将指针置为空并插入一个中间指针.

算法3.变换指针.

例:继续图1 中的程序.DangDone 分析到在转换p1 时,p1 的3 个使用点被传递到这个阶段.因为它使用*(char**)p1 作为中间指针,p1=malloc(32)转换为p1=DDMalloc(32);strcpy(p1,“hello”)转换为strcpy(*(char * *)p1,“hello”);free(p1)被free(*(char **)p1)替换,并插入额外的置空操作.在转化p1 之后,p2 的3 种用途被传递到该阶段.两个函数调用的转换类似.p2=p1 不需要变换,因为它属于指针传递操作,并且两个指针被标记为已修改.

3 实现与实验

该方法适用于源代码、中间表示、甚至二进制.目前,为了提高效率和跨语言支持的能力,我们在基于LLVM[12]的中间表示(IR)级别实现了DangDone(https://bitbucket.org/fff000147369/nodang).DangDone 中的转换被实现为LLVM Pass.LLVM Pass 执行构成了编译器的转换和优化[20].因此,DangDone 的核心流程是:通过Clang的AST 分析潜在垂悬指针;开发者选择潜在垂悬指针用于被防护;Clang[21]将目标项目编译为中间表示(LLVM IR[16]);DangDone 应用LLVM Pass 来转换LLVM IR;Clang 将转换后的LLVM IR 编译为二进制.

3.1 实验设置

我们对DangDone 的实验旨在回答以下3 个研究问题.

(1) 防护能力:DangDone 是否有效地防护use-after-free 和double-free 漏洞?

(2) 运行时开销:就时间和内存而言,DangDone 是否有可接受的运行时开销?

(3) 垂悬指针检测效果:DangDone 的静态分析是否有足够的效果?

我们使用了 11 个真实的漏洞来评估 DangDone 的安全性,并使用 SPEC CPU Benchmark[13]来评估DangDone 的静态分析效果和运行时开销.SPEC CPU Benchmark 是一系列CPU 密集型程序的集合,着重于CPU、内存以及编译器的评估.实验是在运行64 位Ubuntu 14.04 的8 核Intel Xeon CPU E5620(2.40GHz)和16 GB RAM 的PC 上进行的.使用LLVM 3.6.0 以无优化(-O0)的方式编译SPEC CPU Benchmark.并且为了展示独立的实验效果,静态分析和代码转换模块是各自单独评估的.即,此次实验的程序变换模块将所有指针视为潜在的垂悬指针.

3.2 安全性评估(RQ1)

为了评估DangDone 对实际程序存在的漏洞的检测预防措施,我们选择在开源程序中并且有公开恶意输入(如Exploit Database[22])的CVE 漏洞作为基准程序.选择了11 个CVE 漏洞.这些漏洞的详细信息列在表1 的前6 列中,包括CVE ID、受影响的程序和版本、漏洞类型以及使用的程序版本和缺陷位置.

Table 1 Evaluation results for real-world vulnerabilities表1 基于真实漏洞的评估结果

与此同时,为了评估DangDone 在CPS 系统中的有效性,我们选取了5 个开源CPS 系统中注入特定的缺陷,并借助DangDone 进行防护.所选取系统的相关信息见表2 的前3 列所示.所注入的缺陷信息如第4 列~第6 列所示.针对Use-after-free,注入缺陷的方式是在第6 列所指定的位置释放指针,在下个使用点之前加入指针是否为空判断语句.针对Double free,注入缺陷的方式是在指定位置插入free语句,并在插入的语句之前加入指针是否为空判断语句.

Table 2 Evaluation results for real-world cyber-physical systems表2 基于CPS 系统的评估结果

实验结果在表1 和表2 的最后两列中报告,包括执行恶意输入时程序的行为以及由DangDone 转换的程序在执行恶意输入后的行为.在表1 中,我们发现其中3 个程序,在其代码被DangDone 保护后导致了段错误.原因是对空指针的解引用导致的.其余情况出现的原因则是因为在加入指针置空语句之后,原来的垂悬指针指向了NULL.而这些程序会判断所使用的指针是否为NULL,如果发现为NULL则会进行相应处理,如直接退出或者报错.在DangDone 转换易受攻击的程序之后,所有的漏洞都可以在执行恶意输入之后直接退出而不会被攻击.在表2 中,由于注入的缺陷的特征,直接执行有缺陷的结果都是程序崩溃,相比之下,借助DangDone 防护的程序则是能够正常退出.因此,我们的实验回答了RQ1,即DangDone 在防护垂悬指针引起的use-after-free 和double-free漏洞方面是有效的.并且,在实验过程中,我们没有发现DangDone 在指针变换时的误报.

3.3 运行时开销(RQ2)

当DangDone 向目标程序插入新的指令时,它会增加目标程序的执行时间和内存消耗.理论上,如果程序的所有变量都是指针,并且除了指针解引用和指针传递之外没有其他语句,那么最大运行时开销接近100%.为了评估DangDone 对实际应用程序的运行时开销,我们使用SPEC CPU Benchmark 分别运行3 次原始程序和转换后的程序来测量时间开销和内存开销.注意:由于编译问题,没有使用PerlBench 和Deall 这两个基准程序.

图7 显示了DangDone 对特定CPU Benchmark 施加的时间开销,这些Benchmark 的输入是在Benchmark中名为“Ref”目录中.我们使用几何平均数计算平均开销.平均而言,Dangdone 的时间开销为 0.3%.这表明DangDone 引入了可忽略的运行时开销.

图7 还显示了DangDone 引入的内存开销.我们通过当前内存使用率的频繁快照收集内存使用率,并比较它们的最大内存使用率.所有Benchmark 的内存开销都小于0.8%,这表明DangDone 引入的内存开销可以忽略不计.这是因为我们创建了很小的指针来跟踪指针.内存开销由中间指针引入,因此,DangDone 引入的内存开销取决于内存分配的数量.在大多数程序中,分配内存的大小远远大于指针的大小,导致内存开销较低.

此外,我们在表3 中报告了规范CPU Benchmark 的转换、指针覆盖率和编译统计信息.#m_ptr表示变换的指针数,#m_ins表示变换的指令数.由于指针可能有许多用途,因此变换过的指令的数量是一个更好的运行时开销指标.结合表3 中的变换的指针、指令数和图7 中的时间开销,我们可以看到,增加的时间开销和边换的指针、指令数都是正相关的.此外,Dyn.Cov报告那些执行代码中由DangDone创建的所有指针中覆盖的堆指针的比例.注意,我们使用动态覆盖率是因为很难静态地区分栈指针和堆指针.平均而言,DangDone 覆盖了83%的堆指针,同时仍然保持着较低的时间开销.编译时,大多数Benchmark 的开销可以忽略不计;平均而言,DangDone 在编译时预防漏洞的时间开销为0.9%,这表明DangDone 在编译时引入的额外开销也低.

Fig.7 Overhead for the SPEC CPU Benchmarks图7 SPEC CPU Benchmarks 的运行时开销

Table 3 Pointer coverage and compilation statistics for the SPEC CPU Benchmarks表3 基于the SPEC CPU Benchmarks 的指针转换覆盖率和编译数据

总之,运行时开销表明了DangDone 的正确性,因为SPEC CPU Benchmark 有结果比较机制,以确保结果与预先定义的结果相同.所以这部分实验表明,DangDone 的方法在SPEC CPU Benchmark 不会对程序执行有影响.同时,图7 和表3 中的结果肯定地回答了RQ2,即:DangDone 对程序的开销可以忽略不计,编译的时间开销也可以接受.

3.4 垂悬指针检测效果评估(RQ3)

表4 展示了静态分析的效果.该分析方法分为两部分:读取 AST 的时间和分析时间.为了提升效率,DangDone 通过动态读取AST 以优化内存开销.在该实验中,我们依旧使用SPEC CPU 2006 Benchmarks,配置的动态读取AST 的数目分别为1 000 和10 个.#ASTs和#KLOC是AST 的数目和程序行数.针对这两个AST 读取数目的配置,我们分别统计他们的读取时间,分析时间和内存消耗.#Warnings是静态分析报告中潜在垂悬指针的数目.在此次实验中,我们只考虑free和delete这两个内存释放函数.FPRate是DangDone 在特定程序下的误报率.误报是通过人工检查每个报告统计的.

我们可以看到:如果内存足够,DangDone 的静态分析是轻量级的,并且它的平均分析速度是7KLOC/s 左右.尽管该静态分析工具可能消耗4GB 的内存,但所有的程序都可以在22s 内进行分析.相比而言,当加载在内存中的AST数被设为10 时,分析速度会降为1.8KLOC/s,但是内存消耗会降到380Mb,几乎所有现代PC 都可以提供该要求.此外,不同的程序消耗不同的内存.一个原因在于AST的大小是不同的,例如有的AST大小为30MB 而有的为0.1MB.如果不限制内存中加载的AST的数量,则遇到超大规模的项目时,很容易消耗所有内存.

Table 4 Performance of static analysis for the SPEC CPU 2006 Benchmark表4 静态分析方法在SPEC CPU 2006 Benchmark 上的性能

此外,为了评估静态分析的误报率,如果warning(告警)数小于50,则人工验证所有warning;否则,随机抽取50 个进行人工验证.我们发现,静态分析的误报率约为32.657%.此外,为了评估静态分析的漏报率,我们使用Fortify SCA[23],Splint[24]以及Coverity[25]作为基准,共报告了106 处warning.具体来说,Fortify 报告了23 处warning,包括12 个double-free 和11 个use-after-free.Splint 和Coverity 分别报告了72 个和11 个warning.我们人工验证这些warning并发现DangDone 的静态分析部分也会报告所有warning,这说明DangDone 的静态分析的漏报率为0.

综上所述,表4 的结果可以回答RQ3,静态分析可以通过减少潜在垂悬指针数量来减少DangDone 的时间开销;这需要一定的时间开销以及内存开销;并且具有可接受的误报率和非常低的漏报率.

3.5 讨 论

· 漏报

据我们所知:当应用到现实项目中时,DangDone 会引入漏报,即无法防御指针计算生成的指针.因为DangDone 试图使所有别名指向中间指针,而指针算法不遵循此规则.例如,图8 中的转换程序创建一个生命周期与p2 相同的指针,然后将指针运算的结果赋给新创建的指针.最后,它将地址存储到p2,使所有转换的指针具有相同的级别.但是,第10 行的引用仍然是use-after-free.尽管DangDone 无法保护通过指针运算创建的潜在垂悬指针,但比起不转换,不如对其进行转换,因为我们仍然可以保护部分漏洞(例如指针的指针算法位于分支中时).在我们的实验中,尚未发现与指针运算相关的垂悬指针.

· 间接赋值

别名可以通过间接赋值引入.对于存储在模板中的原始指针(即用户定义的模板和容器),我们递归地转换模板中使用的所有指针(即模板的使用点).如果不转换所有指针,会导致程序语义发生变化.

· 类型转换

非指针类型变量之间的指针传递可能导致特殊别名,但是DangDone 可以识别这样的指针传递;这些类型的指向关系可以被它们对应的原始指针跟踪,它们的指针类型可以由DangDone 确定.

· 释放中间指针

虽然DangDone 通过替换内存分配函数分配了中间指针,但是不能通过替换内存释放函数释放中间指针.这可能导致内存泄漏.为了解决这个问题,我们通过静态分析得到的保守的指针分析结果,在某个潜在垂悬指针及其别名中分析出生命周期最长的变量,确定其最后一次使用点所在的函数.在该函数的返回之前,插入中间指针释放语句.

· CPS 系统中的防护结果

虽然DangDone 针对use-after-free 和double free 漏洞的防护结果是导致程序崩溃,而CPS 系统中的可用性是一个重要指标,直接导致程序崩溃会影响其可用性,但是值得注意的是:在借助于来自CVE 的漏洞的实验中,有一些程序能安全退出是通过检测被使用的指针是否为NULL.所以在CPS 系统中,虽然我们能够避免更严重的攻击后果(如权限提升、执行恶意程序等),但是依旧需要开发者有一定的容错机制.例如,在指针的使用点需要检测该指针是否为NULL:如果是,则进入错误处理.

Fig.8 Dangling pointer caused by pointer arithmetic图8 指针计算导致的垂悬指针

4 相关工作

4.1 改进内存分配函数

我们改进了内存分配函数来保护垂悬指针.Cling[26]替换了原来的内存分配函数,因为一些攻击手段依赖于释放内存的重复使用,它会避免重复使用同一类型的内存对象.DangDone 也用自定义的内存分配函数替换了内存分配函数,但是Cling 的内存分配函数侧重于只复用特定被释放的内存,这段内存的对象类型和释放之前的对象类型一致.Cling 的时间开销与DangDone 相似,但Cling 的内存开销略高一些.此外,Cling 不能处理由垂悬指针引起的所有类型的漏洞,例如无法防止由垂悬指针引起的信息泄漏.并且,该方法无法及时检测到出现了useafter-free 等漏洞,相比之下,DangDone 在预防这类漏洞的同时,也能够通过程序崩溃报告所出现的漏洞.Diehard[27]是一个运行时系统,可以通过随机化和复制来容忍内存错误.可以通过内存随机化和复制来容忍内存错误.它提供了一个近乎无限大的堆空间,使对象可以永远不被释放,并且各个对象可以无限远地被分配.以此消除了垂悬指针,并且不易受到缓冲区溢出攻击.但是空间消耗比传统的内存分配函数大,因为它需要至少两倍的额外内存空间去防止堆被破坏.Dieharder[28]通过改进随机化操作扩展了这种方法,这使得开发变得更困难,并且降低了性能开销.DieHarder 具有20%的几何平均性能影响,并且该方法可能面临能够控制内存分配的攻击者的攻击.相比之下,内存开销高于DangDone,少于DieHarder.

4.2 静态方法

与动态方法或运行时保护相比,检测垂悬指针的静态方法相对少见.大多数静态方法关注一组软件缺陷,垂悬指针只是其中的一个.Musuvati 等人[29]实现一种新的模型检查器,可以直接检查C/C++程序,无需人工抽象系统行为.自动抽象模型是通过捕获系统的所有行为来实现的[30].因为系统应该是一个面向行为的程序,这种技术的一个局限性是灵活性.Slayer[31]是一个验证工具,用来证明系统代码不存在内存安全错误,例如垂悬指针解引用和double-free 问题.但是与我们的方法相比,它是一个重量级的工具,因为slayer 的可扩展性受到SMT 解算器的限制.

4.3 动态方法

以检测是否出现垂悬指针或其漏洞的方法可以分为两种类型:一种关注use-after-free 漏洞;另一种是内存错误检测器,它还可以检测垂悬指针或use-after-free 漏洞.Valgrind Memcheck[32]是一种内存错误检测器,可以检测堆中的use-after-free 和double-free 漏洞.与purify[33]类似,这两种工具都会产生很高的内存和CPU 开销(代价是2 倍~25 倍的性能损失),并且无法检测到重新分配数据位置的垂悬指针.Mudflap[34]使用编译工具在指针使用(解引用)时插入判断内存区域是否被合法引用的断言.为了判断内存访问的合法性,它还需要检测来维护有效内存对象的列表.然而,Mudflap 在SPEC CPU Benchmark 上的额外开销范围从2x~41x,在复杂的C++代码中也有误报率.一种类似于Mudflap 的方法是AddressSanitizer[35],它是一个基于Clang 的内存错误检测器.它使用影子内存记录应用程序内存的每个字节是否可以安全访问,并使用检测工具检查每个应用程序加载或存储时的影子内存.但是,为了性能考虑,影子内存没有映射到所有内存分配函数.该优化导致漏报,如果访问重新分配的内存块,将不会检测到use-after-free.DangDone 会在释放对象时置空垂悬指针,使这些漏洞无法利用.此外,由AddressSanitizer 所引入的减速是2 倍,这也高于DangDone.DangSan[36]方法则是侧重于在多线程下高效率地通过日志检测垂悬指针.它主要优化了记录指针指向、释放信息所需要的时间,以此提高效率.

相比于RAII和C++11 及其之后版本中引入的智能指针(如shared_ptr),这两种方法都能更方便地管理指针.但是相比之下,DangDone 有着更广泛的适用性.这是因为一些系统如CPS 系统中,不能引入RAII 或智能指针.除此之外,DangDone 着重于预防和检测垂悬指针,这是RAII 和智能指针无法直接支持的,需要开发者自行避免垂悬指针及其使用.

4.4 基于程序转换的运行时防御方法

通过程序转换[10,11,37-40]确保空间安全错误的方法与我们的方法类似.为了减少空间安全误差、提高效率,不同的转换策略被提出.以前的方法存在一个或多个不足,很难被广泛采用,例如运行时开销高或需要对现有代码进行很大更改.

DangNull[10],FreeSentry[11]和pSweeper[39]是最近并且与DangDone 最相似的预防措施,通过动态运行时检查防止use-after-free 漏洞.DangNull 由两个主要组件组成:静态检测和运行时库.第1 个组件通过插入对跟踪例程的调用来标识指针传递,以跟踪指针和内存对象之间的指针指向关系.所有内存操作函数(如malloc和free)都会被替换,以跟踪内存对象和指针的生命周期.第2 个组件为静态检测函数提供运行时库,以便动态跟踪指针并将释放的指针及置空其别名.基于SPEC CPU Benchmark 的评估显示,DangNull 增加了80%的平均性能开销,这比DangDone 的开销高得多.FreeSentry 通过将对象链接回其指针来防止use-after-free 漏洞.在这些链接的帮助下,当对象容易受到垂悬指针的攻击时,FreeSentry 可以使链接到已释放对象的所有指针失效.为了实现这一点,freesentry 注册了创建或修改指向新对象的指针的地址.释放对象后,释放函数将查找该对象所驻留的内存区域的所有指针.如果这些指针仍然指向释放的对象,则将指针置空;如果某些指针试图对失效的内存地址进行解引用,将导致程序崩溃.SPEC CPU Benchmark[13,41]的评估显示:如果禁用栈空间保护,则平均开销为25%;否则,平均开销为 42%.pSweeper 则是借助另一个线程去扫描已有的指针,判断这些指针是否是垂悬指针,实验表明:pSweeper 的开销可低至12.5%.

综上所述,可以发现,三者的开销都比DangDone 高得多.但是DangDone 依赖于转换规则,而例如DangNull等方法的规则则相对更加通用.例如:如果存在嵌套的struct结构,DangDone则还需要增加对应转换规则.本文之前的工作[42]针对所有指针进行防护,但是实际程序它们不一定都是垂悬指针.所以为了减少不必要的转换,我们加入静态分析技术以减少所需要转换的垂悬指针数量.相比之下,前期工作只能够预防垂悬指针而不能通过静态分析检测垂悬指针.

4.5 针对CPS系统的攻击预防措施

CPS 系统在应用层面临的攻击主要通过软件系统漏洞发起的,而在这方面的攻击检测、预防技术主要通过专门模块(如入侵检测模块)实现[43,44].不同于常规软件,CPS 系统可以有独立的模块检测攻击者的恶意输入.如通过神经网络或支持向量机等分类器,采用数据挖掘、数据融合等方式对不同来源的数据进行分析.利用模式识别方法分析当前输入[43].在攻击检测中,由于恶意输入的特征一般会与常规输入有着较大的差异,该方法也能够在一定程度上检测并预防use-after-free 或double free 攻击.第2 类方法[44]则是侧重于身份认证,该方法基于的前提是攻击者无法获得证明用户身份的证据,如密码、认证证书等.相比于针对CPS 特征的攻击防御方法,DangDone 则是侧重于在代码层面加固系统本身,避免CPS 系统内部的缺陷、漏洞暴露出来.

5 总结

在本文中,我们针对CPS 系统的垂悬指针问题提出了基于中间指针的垂悬指针预防方法.它先通过静态和动态的方法检测潜在的垂悬指针,再在编译时通过程序转换防止这些指针被利用.我们通过测试11 个实际漏洞和5 个CPS 系统中人工植入的漏洞评估了它在预防use-after-free 和double-free 方面的有效性,并使用SPEC CPU Benchmark 测试评估了运行时开销.实验表明了该方法的有效性和可以忽略不计的运行时开销.在未来,我们计划进一步检测垂悬指针和未被发现的缺陷,如通过模糊测试的方式检测垂悬指针.此外,与大多数现有的预防措施类似,都可能将use-after-free 和double-free 问题被转换为空指针解引用问题,这也会影响CPS 系统的可用性,所以我们计划通过容忍空指针解引用来增强该方法.

猜你喜欢
指针指向静态
最新进展!中老铁路开始静态验收
科学备考新指向——不等式选讲篇
静态随机存储器在轨自检算法
动得多,还要坐得少——评WHO《身体活动与静态行为指南》
中年级“生本写作”教学的“三个指向”
猜猜他是谁
郊游
为什么表的指针都按照顺时针方向转动
浅析C语言指针