APP下载

跨平台内存安全测试集设计①

2022-09-20沈思豪

计算机系统应用 2022年9期
关键词:完整性指针代码

沈思豪, 解 达, 宋 威

1(中国科学院 信息工程研究所 信息安全国家重点实验室, 北京 100093)

2(中国科学院大学网络空间安全学院, 北京 101408)

1 引言

内存安全问题一直是安全领域中经久不衰的问题[1]. 从缓冲区溢出漏洞的利用开始, 到现在返回导向编程技术(return oriented programming, ROP)[1,2]、数据导向编程技术(data oriented programming, DOP)[3]攻击的应用, 基于内存安全问题的攻击手段在不断地更新迭代. 相应的, 也不断有内存安全防御方案被提出. 早期的内存安全防御方案大多基于软件, 如栈帧守护者(stack canary)[4], Ccured[5]等, 这些方案虽然能够达到较好的防御效果, 但是性能开销较大. 所以这些方案鲜有被应用于商业处理器架构之上. 近几年来, 随着RISC-V等开源处理器架构的兴起, 陆续有新的硬件辅助内存安全方案被提出. 硬件支持使得防御方案的性能开销降低. 商业处理器架构逐渐产生了接纳硬件辅助安全方案的趋势.

然而目前, 无论是在工业界还是学术界, 都缺少对处理器内存安全性能进行评估的测试集. 现有的测试集, 如RIPE[6], CBench[7]等, 虽然能够在特定的内存安全性质上进行测试, 但是难以系统全面地反映处理器的内存安全状况; 此外, 对于x86系列以外的指令集架构的支持也不尽如人意.

因此, 设计一个系统的, 完善的, 跨平台的, 可拓展性强的硬件内存安全测试集就显得尤为重要. 为了解决这个问题, 我们提出了一个可拓展的内存安全测试集框架, 在该框架下给出了大小为160个测例的初始版本测试集. 受限于可用硬件资源, 我们仅在x86-64和RISC-V64两种指令集的不同平台上对测试集进行了测试. 初始版本的测试集涵盖了内存的时间、空间安全性, 内存访问控制, 指针完整性和控制流完整性等几个方面的安全特性.

本文工作可开源获取, 初始版本测试集可以在GitHub上找到: https://github.com/comparch-security/cpu-sec-bench.

2 研究背景

2.1 内存安全攻击防御技术概述

内存安全攻击和防御手段其实处于相互竞争和相互促进的关系中.

类似C/C++的编程语言在底层实现中缺乏内存安全支持, 导致使用C/C++语言编写的大量应用程序和动态库中包含缓冲区溢出、指针释放后使用(useafter-free, UAF)等内存时间和空间安全性漏洞.

代码注入攻击利用上述漏洞, 将预先构造好的恶意代码写入堆栈等用户数据区中, 将栈帧中保存的返回地址等代码指针的值覆盖为恶意代码起始地址, 从而实现恶意的代码执行.

代码注入攻击要求攻击者能够将恶意代码写入用户的可写数据区, 劫持程序的控制流指向恶意代码, 保证恶意代码能够执行. 针对这些前提条件, 一些经典的内存安全防御方案被提出: 栈帧守护者[8]在函数返回时检测栈帧是否被缓冲区溢出覆盖, 阻止攻击者劫持栈帧中保存的返回地址; 数据执行保护(data execution prevention, DEP)设置页面可写不可执行属性, 将可执行页和可写页分开, 保证攻击者即使成功将恶意代码植入用户可写数据区, 也无法将其作为代码执行; 地址空间随机化(address space layout randomization, ASLR)通过使用位置无关代码(place-independent code, PIC),在程序加载时将加载基地址随机化, 达到将程序执行期间的数据和代码地址隐藏的目的.

由于上述方案特别是数据执行保护与地址空间随机化的性能开销低, 防御效果显著, 所以得到了大范围部署. 直接的代码注入攻击几乎失效了. 但攻击者仍然能够访问和调用程序自身和动态库提供的数据和代码. 更复杂的内存安全攻击手段被提出, 来绕过上述防御方案, 如返回导向编程技术、跳转导向编程技术(jump-oriented programming, JOP)[9]、伪造对象导向编程技术(counterfeit-object oriented programming,COOP)[10]、数据导向编程技术等, 这些攻击手段都有一个共同的特征, 即不注入外部代码, 而是直接复用受害者程序和标准库中的代码完成攻击. 此类攻击称为代码复用攻击.

返回导向编程技术是典型的代码复用攻击[11], 常用于构造指令执行序列, 完成对系统函数mprotect等的调用, 关闭数据执行保护. 跳转导向编程技术类似于返回导向编程记录, 但在技术细节上存在不同.

返回导向编程技术攻击会改变程序的正常控制流,控制流完整性保护(control flow integrity, CFI)[12]通过在程序的间接跳转前插入验证代码, 保证程序的所有间接跳转都在程序编译时静态分析得到的控制流图的范围内. 根据控制流分析精度的不同, 控制流完整性保护具体可以分为细粒度和粗粒度两类. 前者使用不同的特征值区分不同的合法跳转地址; 后者则不对合法跳转地址进行区分.

传统的控制流完整性保护实现通过二进制分析完成, 重点保护间接跳转指令, 但是并不对类型进行检查,无法对多态类对象的虚函数表指针提供保护. 伪造对象导向编程技术利用了这一点, 通过在用户数据区伪造对象, 修改虚函数表指针, 复用其它多态类提供的虚函数表, 通过调用一系列虚函数完成攻击.

伪造对象导向编程技术攻击无法通过简单的二进制控制流分析实现的控制流完整性保护进行检测, 需要结合类型实现控制流完整性保护进行防御. 典型的防御方案如代码指针完整性保护(code pointer integrity,CPI)[13], 指针标记(pointer tagging), 指针审计(pointer authentication)等. 在x86架构下的GCC提供了虚函数表验证(vtable verification, VTV)[14]特性, 用于验证虚函数调用的正确性, 但是默认没有被GCC开启.

数据导向编程技术[3]通过修改控制程序分支执行的关键数据来进行攻击, 达到劫持程序控制流的目的.对于一些涉及敏感系统调用的程序, 数据导向编程技术攻击同样可以完成关闭数据执行保护的操作.

应对上述高级攻击的内存安全防御方案一般有两种实现方式. 一种使用纯软件方式实现, 主要在标准库、内核和编译器的层面进行安全增强, 很少或不依赖硬件提供支持, 导致性能开销过高, 难以被大范围部署. 例如, 代码指针完整性保护虽然能够防御大部分的代码复用攻击, 但是由于插入的验证代码过多, 导致性能开销过高, 一直没有被主流编译器(GCC, LLVM)采纳.

另一种方式使用硬件辅助的方法提供内存安全支持. 相较于纯软件的实现方式, 硬件辅助的实现方式能够对安全方案进行加速, 大幅降低安全方案实现的硬件开销. 典型的硬件辅助防御方案如Intel的Intel内存保护拓展(memory protection extension, MPX), Intel 控制流保护拓展(controlflow enforcement technology,CET), ARM公司在ARM v8.3-A中增加的Arm指针审计(pointer authentication, PA), ARM v8.5-A中增加的内存标签拓展(memory tagging extension, MTE).

2.2 学术研究中硬件安全测试现状

为了比较不同处理器提供的内存安全性, 我们需要一套测试集. 这套测试集: 1)相对完善, 能够量化处理器的内存安全性; 2)可移植性强, 能够在多个处理器平台上进行测试.

然而, 现在学术界普遍使用的测试集大多只关注内存安全的某个特定方面, 缺乏对内存安全的整体把握和评估. 以经典的内存安全测试集RIPE为例: RIPE测试集[6]是一系列缓冲区溢出攻击的集合. 它应用广泛, 常被用于测试控制流完整性保护防御方案. 每个测例都受5个变量控制: 缓冲区溢出位置、受攻击的代码指针类型、溢出攻击的类型、恶意代码和攻击使用的函数. RIPE使用枚举穷尽各个变量组合的方法对缓冲区溢出做了相对完善的测试, 但是对于缓冲区溢出以外的漏洞涉及不多, 所以不适合作为一个完善的处理器内存安全测试集.

在上述观察的基础上, 我们提出了一种处理器内存安全测试框架, 并开源了一个基于该框架的内存安全测试集. 内存安全测试集的初始版本包括160项测例, 覆盖了内存的时空安全性(spatial and temporal safety)、内存访问控制、指针完整性、控制流完整性等方面. 不同类型的漏洞及其相应的防御方案分别由对应的测例进行评测. 测试集已经在x86-64和RISCV64两种指令集架构的几个不同平台上进行了评估.

2.3 安全假设

为了将测试范围限制在内存安全上, 我们作如下假设: 1)攻击者能够控制用户程序的输入, 恶意利用程序的内存安全漏洞如注入恶意代码; 2)用户程序包含可以被攻击者所利用的内存漏洞, 攻击者可以利用漏洞实现对程序任意地址的读写; 3)我们还假设攻击者的目的是攻击用户程序空间内的数据, 而不是内核数据; 4)此外, 我们也不考虑侧信道攻击, 如缓存侧信道、瞬态执行攻击等.

3 测试框架设计

3.1 测试对象

处理器内存安全测试集以平台为对象进行测试.平台定义为测试集可以运行的环境. 它包括被测处理器以及运行于其上的操作系统. 操作系统包括一个内核以及若干运行时库.

3.2 测试范围

测试集假设: 内存安全是一系列内存安全性质的集合; 所有的内存漏洞和漏洞利用都是由于对某些内存安全性质缺少检查, 使得内存中的值被恶意泄露或篡改. 根据上述假设, 对内存安全的评估可以被细化为一系列对内存安全性质的测试, 测试这些内存安全性质是否能够被恶意利用. 如果一项测例成功完成执行,说明平台对该测例对应的内存安全性质缺少检查.通过的测例数和被安全检查拦截的测例分布, 可以反映系统整体的内存安全性.

测试集主要关注那些能够被硬件辅助安全方案保护的内存安全性质. 对于利用内存漏洞展开的攻击, 我们分析该攻击破坏或利用了哪些内存安全性质, 而不关注攻击本身.

以缓冲区溢出攻击为例. 缓冲区溢出攻击是一种越过缓冲区边界对值进行修改的行为. 该行为破坏了缓冲区不应该越界访问的内存安全性质. PUMP[15],AArch64 Address Sanitizer等硬件辅助的安全机制能够对缓冲区越界访问提供防护. 所以, 对于缓冲区溢出,测试集测试几类常见的访问越界行为, 这些行为可能不构成完整的攻击. 这些行为如果被拦截, 则说明平台保护了缓冲区不应越界访问的性质.

然而, 由于测试集本身也是软件, 也需要在系统环境下运行, 所以单凭测试集本身难以区分一个内存检查到底是通过硬件方式还是纯软件方式实现的. 所以需要保证测试覆盖的内存检查是由平台而不是第三方安全软件实现的. 例如, 二进制翻译技术和专用的内核安全补丁也能提供内存安全防护, 但由于它们使用纯软件方式实现, 所以应当排除在测试范围之外.

3.3 测例分布

测例主要覆盖内存的空间安全性、时间安全性、访问控制、指针完整性、控制流完整性等5个方面.

3.3.1 空间安全性

空间安全性指内存访问总是落在正确的数据边界和程序的可见域内的性质. 任何数据边界外或是可见域外的访问都是不安全的. 典型的破坏空间安全性的攻击是缓冲区溢出攻击. 它是对缓冲区的越界访问. 除了缓冲区以外, 栈帧、动态分配对象、全局变量、只读数据等均存在越界访问的风险.

缓冲区溢出按照溢出方向可以分为上溢和下溢;按照缓冲区位置可以分为堆上溢出和栈帧溢出. 喷射攻击是溢出攻击的一种特殊应用, 它将溢出位置和目标位置之间的全部内存都进行填充, 插入指向目标位置的跳转代码, 降低控制流劫持的难度.

对于缓冲区溢出的检测和防御方法包括内存检测器(address sanitizer, Asan)[16]、内存标记[17]和重型指针(fat pointer)[18]; 对于栈帧越界访问, 使用重型指针在栈帧粒度上保持数据完整性[19]、在栈帧之间填充字节[20]、在栈帧粒度进行数据隔离[21]都是有效的防御手段; 对于堆越界访问, 部分防御方案将陷阱数据填充在对象之间[22], 或者在对象粒度上进行边界检查.

测试集的空间安全性测例共98项, 主要使用两种方式构造缓冲区越界访问: 1)通过合法的缓冲区指针和越界的地址偏移; 2)修改合法的缓冲区指针指向界外位置. 测试集对栈上、堆上、全局变量、只读数据中的越界访问都进行了测试.

3.3.2 时间安全性

时间安全性指内存中的数据访问只发生在数据的生命周期之内. 任何发生在程序生命周期之前(未初始化数据)和之后(释放后使用)的访问都是不安全的. 时间安全性的测例只关心一个生命周期外的访问是否会发生, 不会针对具体的内存分配算法进行测试.

释放后使用相关的漏洞主要包括空悬指针(dangling pointer), 未初始化变量等. 在堆上和栈上的空悬指针都会为程序带来较大的安全隐患.

应对空悬指针的防御方案包含空悬指针归零[23]、解引用前检查空悬指针[24]、阻止分配器在被释放对象地址进行重分配[25]、阻止未初始化数据访问、细粒度栈空间随机化[26]、内存标记等.

与时间安全性相关的测试共有13项, 主要检测栈上或堆上的数据在栈帧或对象被释放后能否被空悬指针继续访问. 此外, 还检查平台是否具有保证相同类型的对象在相同的内存地址不被重新分配、函数每次调用时动态变化栈帧结构等性质.

3.3.3 访问控制

访问控制性质指限制了攻击者内存访问能力的性质. 主要用来防御信息泄露攻击. 程序的函数体代码、全局偏移量表(global offset table, GOT)都是潜在的攻击目标. 攻击者在运行时读取程序的函数体代码, 检索可能成为gadget的代码片段, 用以构造代码复用攻击.

全局偏移量表用于程序动态链接共享库时检索符号. 由于全局偏移量表表项在运行时动态更新, 所以需要存储在可写页上. 攻击者可以通过读取全局偏移量表表项获取动态库函数在内存中的地址, 造成信息泄露. 攻击者也可以通过修改全局偏移量表来劫持库函数.

针对上述攻击, 主要的防御技术包括地址空间随机化, 防止攻击者读取可执行页的代码随机化、可读不可执行[27]等. 测试集的访问控制测例共3项, 也围绕这些防御技术展开, 主要检查地址空间随机化是否有效, 函数体代码是否可读, 以及全局偏移量表特定表项是否可读等.

3.3.4 指针完整性

典型的控制流劫持攻击和防御手段经常围绕指针展开. 攻击的第1阶段修改保存敏感数据的指针, 破坏了指针完整性; 第2阶段使用被修改的指针劫持控制流, 破坏了控制流完整性.

指针完整性测例主要关注保存敏感数据的指针的安全性. 敏感数据指针包括函数指针、虚函数表指针和全局偏移量表.

函数指针通常可拷贝但不可修改, 进行算数运算的情况非常罕见. 函数指针一般通过指针审计和指针标记[13]进行保护. 虚函数表指针指向一张函数指针表,其中每个表项指向类型对应的虚函数. 对虚函数表指针的保护方案包括代码指针完整性保护[13]、GCC VTV等.

测试集的指针完整性测例共5项, 主要检测平台是否允许函数指针拷贝和算术运算, 是否允许对虚函数表指针进行读取和修改、是否允许对全局偏移量表进行修改等.

3.3.5 控制流完整性

控制流体现了程序动态执行时指令间逻辑上的先后顺序. 通过代码指针调用完成的控制流跳转称为前向控制流; 通过返回地址完成的控制流跳转称为后向控制流. 前向控制流完整性指保护代码指针解引用到合法的地址; 后向控制流完整性指保护返回地址不被恶意篡改.

控制流完整性相关的攻击方式包括代码注入攻击、代码复用攻击等, 对于使用多态的程序还包括虚函数表劫持攻击. 测试集的控制流完整性测例共41项,主要围绕这些攻击的典型防御方案如数据执行保护,控制流完整性保护等进行测试.

3.4 测例构造

测试集的整体架构如图1所示. 测试样例由两部分组成: 平台无关的测试逻辑, 与平台相关的支持库.

图1 内存安全测试集整体框架图

每个测例为测试特定内存安全性质的C++程序;若某条性质对应的安全检查缺失, 测例可以利用该漏洞完成测试逻辑并返回零值, 表示漏洞被成功利用; 否则测例返回非零值并提示测试失败.

整个测试集的运行通过测试驱动控制. 测试驱动使用指定的编译选项对测例进行编译, 运行测例并统计测例的运行结果, 得到测例通过数量的量化数据.

测例中利用漏洞的恶意行为常常以汇编代码的形式实现. 这是为了防止编译器优化掉恶意行为. 这些汇编代码在平台相关的支持库中. 测试逻辑是使用平台无关的方式编写的; 需要使用恶意代码的部分通过调用平台支持库来完成.

测例的可移植性通过测试逻辑与支持库的划分实现. 二者之间通过宏的定义和调用产生联系. 支持库中的汇编代码使用宏定义的方式组织; 测试逻辑通过引用宏定义完成调用. 不同平台的支持库对相同的宏名称提供定义, 使用平台特定的汇编代码实现相同的动作. 每个测例都会引用公共头文件(include/assembly.hpp), 该文件对不同平台支持库的头文件进行了包装,通过不同架构的预定义宏进行区分(如__x86_64、__riscv64), 编译测试集时编译器会根据预定义宏选择正确架构对应的支持库.

对于新增加的平台或指令集架构, 只需要和其他支持库对同样的宏名称进行定义即可, 不需要修改测试逻辑; 对于新增加的测例, 需要将测试使用支持库提供的宏实现, 如果需要新增宏定义, 则需要在所有的支持库中将对该宏进行定义. 对于新的指令集架构而言,目前只有约20个宏名称需要被实现. 综上所述, 测试集具有良好的可拓展性.

下面以控制流完整性的测例call-instruction-instack为例, 描述测例的代码结构和测试流程.

测例call-instruction-in-stack用来测试将栈上地址作为目标地址的情况下, 函数调用是否能够成功执行.测试逻辑的主要代码如代码清单1所示. 其中assembly.hpp和signal.hpp均为平台支持库的公共头文件. 头文件assembly.hpp负责提供FORCE_NOINLINE、CALL_DAT、FUNC_MACHINE_CODE等宏的宏定义. 头文件signal.hpp负责提供异常处理所需的接口代码. 部分平台在检测到违反内存安全规则的操作时会抛出异常,signal.hpp提供将这些异常捕获并产生特定返回值的代码.

代码清单 1. call-instruction-in-stack的测试逻辑#include "include/assembly.hpp"#include "include/signal.hpp"int gv = 1;int FORCE_NOINLINE helper(const unsigned char* m) {CALL_DAT(m);return gv;}int main(){unsigned char m[] = FUNC_MACHINE_CODE;… //异常处理初始化代码int rv = helper(m);… //异常处理收尾代码exit(rv);}

宏FORCE_NOINLINE的作用是使强制被修饰函数不生成内联代码, 原因是内联代码在部分平台上会影响测例测试逻辑的正确性; 宏CALL_DAT(addr)的作用是将addr作为目标地址, 进行函数调用; 宏FUNC_MACHINE_CODE的作用是模拟函数体代码, 使得CALL_DAT宏产生的函数调用一旦成功执行, 后续指令能够正常返回或是产生特定异常并被main函数中异常捕获逻辑捕获, 使得测例能够正常退出.

宏CALL_DAT的具体实现为C扩展内嵌汇编代码, 所以不同的指令集架构上, 实现各不相同. 在x86-64指令集架构上其实现如代码清单2所示, 在RISCV64指令集架构上其实现如代码清单3所示.

代码清单 2. CALL_DAT宏的RISC-V64架构实现#define CALL_DAT(ptr) asm volatile( "jalr ra, %0, 0;" : : "r"(ptr) : "ra" )代码清单 3. CALL_DAT宏的x86-64架构实现#define CALL_DAT(ptr) asm volatile( "call *%0;" : : "r" (ptr) )

如果需要将本测例移植到ARM AArch64架构, 则只需要在ARM AArch64指令集架构的平台支持库和头文件中实现对FORCE_NOINLINE、CALL_DAT和FUNC_MACHINE_CODE这3个宏的定义即可.

测例测试逻辑的核心部分在helper函数. 如果helper函数中的CALL_DAT宏成功执行, FUNC_MACHINE_CODE将会和main函数中的异常处理代码结合, 将rv的值设置为0. 测例将以返回值0退出,表示测试通过. 如果CALL_DAT宏的执行抛出异常,则main函数中的异常处理代码会将抛出的异常转换为非零返回值-1并退出程序.

4 测试结果及分析

4.1 测试平台

对于x86-64架构, 我们使用一台较旧的Intel i7-3770 CPU搭配Ubuntu 16.04操作系统, 和一台较新的Intel Xeon 8280 CPU搭配Ubuntu 18.04操作系统进行测试.

对于RISC-V64架构, 我们使用SiFive公司的HiFive Unleashed和HiFive Unmatched两款开发板进行测试, 两块开发板分别基于SiFive公司的u540和u740 CPU, 操作系统均为SiFive公司提供的预编译OpenEmbedded操作系统.

我们在x86-64和 RISC-V64两个ISA架构的4个平台上应用测试集进行了测试. 平台列表如表1所示.

表1 内存安全测试集运行平台参数

4.2 测试结果

4.2.1 不同平台间安全性对比

为了保持一致性, 我们在不同平台上统一使用操作系统提供的GNU g++编译器, 使用相同的编译选项“-O2 -std=c++11 -Wall”进行编译. 测试集结果的概要如表2所示.

表2 不同平台成功执行测试样例数

时间安全性: Intel Xeon 8280、HiFive Unleashed、HiFive Unmatched平台上, 部分堆上的释放后使用相关测例运行失败. Intel i7-3770平台全部测例运行成功.说明除了Intel i7-3770平台外, 其他各被测平台都具有一定的内存时间安全性防御能力. 通过调查原因发现,这些平台使用了较新版本的GLIBC. 后者采用了新的内存分配算法, 在同一片内存区域释放后和分配前插入了垃圾内容, 阻止了释放后信息泄露以及伪造未初始化变量对象攻击. 不过新的算法仍然能够被强制在同一块内存区域重新分配相同类型的对象, 一些使用空悬指针的释放后使用攻击仍然有效. 此外, 各个被测平台对栈上的释放后使用同样缺乏有效的安全检查.

指针完整性: 在各个被测平台上, 读写代码指针和虚函数表指针的测例全部成功执行. 虽然编译器在编译阶段给出了指针算数运算的警告, 但是指针算数运算测例在各个平台仍然能够成功执行. 修改全局偏移量表表项的测例在Intel Xeon 8280平台上执行失败,但在其他平台上成功执行. 说明4个被测平台中, 只有Intel Xeon 8280平台默认提供了部分指针完整性检查.该检查来自重定位只读保护(relocation read-only,RELRO), 大多数Linux发行版都默认提供了部分重定位只读保护. 但是在HiFive Unmatched和HiFive Unleashed两个平台上重定位只读保护并没能覆盖库函数的入口.

访问控制: 检测发现Intel i7-3770平台的地址空间随机化测例成功执行. 其他各被测平台除了地址空间随机化测例以外其余测例都成功执行. 说明被测平台中大部分都默认开启了地址空间随机化保护, 但是缺乏对信息泄露的进一步防御. 分析原因发现, Intel i7-3770平台编译器默认的编译选项不支持生成位置无关代码, 导致对用户程序的地址空间随机化无法使用. 增加“-pie -fPIE”选项后地址空间随机化相关测例执行失败, 地址空间随机化保护成功开启.

空间安全性: 在4个被测平台上, 所有98个测例都成功完成了测试. 说明被测平台默认提供的安全防护中缺乏对内存越界访问的安全检查. 软件上常使用address sanitizer来检测越界访问, 但是性能代价太高,只适合在开发阶段使用, 无法部署到产品中. 硬件拓展如CHERI[28]、PUMP[15]等虽然实现了对越界访问的检查, 但是是以修改系统ABI、增加硬件开销和性能开销为代价的.

控制流完整性: 对于后向控制流劫持相关的测例,与返回导向编程技术相关的测例都成功执行; 代码注入攻击的测例悉数被数据执行保护拦截. 对于前向控制流劫持, 除了代码注入攻击的测例被数据执行保护拦截, 其他类型攻击相关的测例都成功执行. 对于虚函数表保护, 替换虚函数表、伪造虚函数表的相关测例均成功执行. 对部分使用新的内存分配算法的平台, 虚函数指针复用攻击相关的测例执行失败, 调查原因发现, 新的内存分配算法在释放对象时清零了虚函数表指针. 上述被测平台都具有一定的控制流完整性防御能力.

总的来说, 在各个被测平台上, 由默认配置提供的安全防护并无太大区别. 各平台默认都没有对空间安全性提供有效的保护; 在HiFive Unleashed和HiFive Unmatched平台上由于配套的工具链和运行时库增加了安全防护, 所以提供了更好的时间安全性保护. 地址空间随机化和数据执行保护虽然为各平台提供了一定的内存安全防护能力, 但覆盖面较窄, 只能限制在特定的几项内存安全性质上.

4.2.2 不同编译器与编译选项间对比

编译器不同的编译选项也提供了部分安全防护.在Intel Xeon 8280平台上, 使用GCC 10.3.0和GLIBC 2.32对不同的编译选项进行了测试. 为了测试LLVM提供的控制流完整性保护防御机制, 也将LLVM13在Intel Xeon 8280平台上进行了测试.

很可惜, 由于工具链移植仍然不完整, RISC-V的GCC和LLVM没有提供对VTV和CFI的支持, RISCV架构的address sanitizer无法正常工作, 剩余的可用内存安全选项测试得到的结果差别不大, 对判断RISCV架构平台的内存安全性意义不大, 所以我们将只对Intel Xeon 8280平台的测试结果进行讨论.

我们按照功能将编译器提供的安全方面的编译选项分为几组, 如表3所示.

表3 内存安全相关不同编译选项组

下面对表3中的选项组进行解释.

默认选项: 只要求-O2优化, 其他为编译器默认选项; RELRO: 开启对全局偏移量表的全面保护; 栈保护:通过在栈中插入canary实现栈覆写保护(stack smashing protection); VTV: GCC支持的虚函数表验证特性,用于应对伪造对象导向编程技术攻击; CFI: LLVM支持的前向控制流攻击防御机制; 全部防护: 对编译器应用上述支持的所有编译选项; Asan: 开启动态address sanitizer; 无防护: 关闭包括数据执行保护在内的所有防护, 包括内核提供的地址空间随机化等.

在默认选项下, Intel Xeon 8280平台使用GCC 10.3的通过测例数为142, 与使用平台默认的编译工具相比, 全局偏移量表篡改可行性的测例通过, 但是有4个堆上释放后使用的测例失败, 原因是采用了新的GLIBC库. 使用LLVM通过的测例数同样为142, 不过由于LLVM生成的代码默认不开启PIE选项, 并且在编译时不允许代码指针算术运算, 所以具体成功执行的测例稍有区别.

在RELRO选项下, Intel Xeon 8280平台下GCC编译通过测例数减少了1, LLVM编译通过测例数减少了2. 可见开启RELRO选项对测试集涉及的内存安全漏洞并不敏感.

测试结果如表4所示.

表4 Intel Xeon 8280平台下不同编译选项组测试集编译运行通过测例数

开启栈保护选项下, Intel Xeon 8280平台下对测试集通过测例数几乎没有任何影响, 因为大多数返回导向编程技术攻击都可以定位返回地址保存位置, 并在不触碰canary的情况下能够修改返回地址. 失败的测例为伪造栈帧攻击相关的测例.

VTV选项下, Intel Xeon 8280平台下6项伪造对象导向编程技术相关测例都测试失败. 不过将虚函数表替换为子类、父类的行为仍然没有被拦截.

CFI选项下, Intel Xeon 8280平台下几乎没有提供任何安全增强. 可能的原因是LLVM CFI要求在链接期间对所有的类定义都可见. 这需要使用静态链接方式编译. 而为了应对编译器优化策略, 所有可执行文件均以动态链接方式链接. 这导致链接时分析将对虚函数表指针和函数指针的修改操作识别为了合法操作.

全部防护选项下, Intel Xeon 8280平台下GCC编译测试集共有26项测例失败; LLVM编译测试集共有22项测例失败.

开启Asan选项下, Intel Xeon 8280平台下GCC编译测试集通过的测例数减少为8. 通过的测例仅包括两项访问控制测试(read-func和read-GOT)以及6项栈上释放后使用攻击测例. LLVM编译测试集通过的测例数减少为21. LLVM编译测试集中, 返回导向编程技术和伪造对象导向编程技术攻击相关的测例都测试失败, 但是跳转导向编程技术攻击相关的测例仍然成功执行, 全局偏移量表表项修改可行性的测例也成功执行. 不过LLVM编译测试集中所有的释放后使用相关测例都测试失败, 包括被GCC Asan漏掉的栈上释放后使用攻击测例.

无防护选项下, Intel Xeon 8280平台下GCC编译测试集仅有5项测例失败. 失败测例均为堆上释放后使用攻击测例. LLVM编译测试集除了没有编译通过的代码指针算术操作测例之外, 结果与GCC编译测试集相同. 结合上述各点, GCC编译器与LLVM编译器安全特性提供的内存安全检查大致相近, 只是在使用动态链接类型定义时LLVM的CFI安全特性未能发挥有效作用, 相比于GCC稍逊一筹. 不过, LLVM测试集中关于代码指针算数运算的测例没有通过编译, 而GCC测试集中只是给出了警告, 这也说明两款编译器对于内存安全问题防护具有不同的侧重点. 两款编译器提供的address sanitizer拦截了绝大多数的内存安全恶意行为, 说明大多数的内存安全性质都依赖于内存的空间安全性.

5 讨论与展望

5.1 相关工作

关于测试集, 早期的测试集主要用于测试计算机的计算性能. 19世纪70年代的LINPACK测试集用于测量计算机进行线性代数数值计算的性能, 至今还用于超算的性能衡量中. Dhrystone[29]为衡量计算机普通整数运算提供了性能指标; CoreMark专注于微控制器的性能测量; SPEC测试集[30]则用于性能更强的通用计算机. PARSEC[31]则主要集中于衡量共享内存和多线程应用的性能.

在2005年, Kratkiewicz等[32]提出了使用构造的小型缓冲区溢出攻击测试现有的软件防御方案.2006年, BASS[33]吸收了SPEC的思想, 将7个包含有不同种类内存漏洞的测例综合进行安全性验证, 同时提供了一个框架用于自动生成利用内存漏洞攻击. 据我们所知, BASS是最早的尝试衡量计算机安全性的测试集; 然而该测试集的测试范围只限定在几个特殊的内存空间漏洞上. RIPE[6]是当前内存安全领域应用最为广泛的安全测试集. 通过枚举几种攻击方式的组合,RIPE能够覆盖850种缓冲区溢出攻击和返回导向编程技术攻击. 它也被用于衡量硬件辅助的控制流攻击防御方案. 但是按照RIPE的方法覆盖缓冲区溢出和返回导向编程技术攻击就需要850项测例, 要对内存安全进行较全面的覆盖可能难以实现.

最近几年出现了新的安全测试集设计. CONFIRM[34]是最近提出的用于衡量不同控制流完整性防御方案的兼容性和可用性的安全测试集, 但是缺少对于安全性的评估. CBench[7]对控制流完整性防御方案的实际效果进行评估, 采用与BASS类似的设计, 共使用7个大类共18个包含漏洞的程序. 与本文工作相比, CBench使用完整的攻击进行测试, 而且集中在被测防御机制本身, 而不是实现这些机制的平台, 另外, CBench也不支持跨平台, 只能在x86-64架构上运行.

5.2 对其他主流指令集的支持

由于目前我们可用的平台支持的指令集架构只包括Intel x86-64和RISC-V64, 测试集目前仅在这两个指令集上进行了测试, 对于其他指令集架构的支持正在进行中. 未来计划增加对ARM AArch64和龙芯/MIPS指令集架构的支持.

5.3 对测试环境的讨论

虽然主要测试目标是处理器及相应的指令集架构的内存安全水平, 但是测试集的执行并不能脱离测试环境. 这也导致在测例不通过时, 有时较难区分具体是处理器的硬件防御机制起了作用, 还是操作系统、编译器或标准库的软件防御机制起了作用.

上述问题向测试集引入了操作系统、编译器和标准库等无关变量. 一种消除这些无关变量的方法是将所有被测平台都强制安装特定的操作系统、编译器和相同版本的标准库. 这种方法虽然在理论上可行, 但是实践的难度很大. 如果将测试环境缩小到只包含内核与命令行工具的最小系统, 在嵌入式平台上比较容易实现, 但是在一般的服务器和PC机上安装最小环境则比较困难. 如果将测试环境规定为特定版本的操作系统发行版(如Ubuntu), 那么这一发行版并非能够被所有被测平台支持, 如Mac M1和其他众多嵌入式平台等.

基于上述原因, 我们不强制所有平台运行特定的操作系统、编译器和相同版本的标准库, 而是默认为某种发行版, 假定该发行版提供的测试环境足够小. 我们将被测目标的含义扩大为硬件平台及其支撑的运行环境. 受控变量除了处理器和硬件平台之外, 还包括平台上运行的操作系统、编译器和标准库.

为了消除增加受控变量带来的影响, 保证能够正确的分析测试的结果, 在测试集的构成上, 测例尽量使用不同的非零返回值去标注不同位置和不同原因造成的测试失败, 从而为判断生效的防御类型提供线索.

此外, 我们也使用现有的平台对编译器提供的内存安全标志选项进行了讨论, 分析了主流编译器提供的内存安全防护的有效性. 操作系统和标准库这些变量对内存安全防御的影响也可以通过配置不同的内核安全功能、标准库版本进行评估. 不过限于篇幅和工作量的关系, 这些评估现在还没有展开.

6 结论

我们设计了一套兼具综合性和可移植性的内存安全测试集框架. 初始的测试集包含160项测例, 覆盖了内存时空安全性、访问控制、指针完整性和控制流完整性等几个方面. 每一类漏洞及其相关的防御方案都被若干测例评估. 为验证可用性, 我们将测试集在Intel x86-64和RISC-V64指令集架构上进行了评估. 我们的评估结果显示, 虽然地址空间随机化和数据执行保护等防御方案对被测平台提供了部分内存安全保护,但大部分的内存漏洞在部分处理器的默认编译器配置下仍然能够被利用. 开启额外的编译器安全特性能够抵御特定类型的内存安全攻击. 尽管address sanitizer作为调试工具不能用于生产环境中, 它在捕获内存安全攻击上十分有效. 就相同平台上的编译器表现来看,LLVM和GCC能够提供相近的内存安全保护, 两者对内存安全保护的侧重各有不同.

致谢

感谢郭雄飞提供的HiFive Unleashed开发板以及中国科学院软件研究所PLCT团队赠与的HiFive Unmatched开发板. 两套硬件设施对我们在RISC-V架构平台上的测试起到了很大帮助.

猜你喜欢

完整性指针代码
酶可提高家禽的胃肠道完整性和生产性能
郊游
为什么表的指针都按照顺时针方向转动
神秘的代码
数学课堂教学完整性与实效性的思考
一周机构净增(减)仓股前20名
重要股东二级市场增、减持明细
近期连续上涨7天以上的股
浅析C语言指针
谈书法作品的完整性与用字的准确性