前言
建议先看《SoK: Eternal War in Memory》(笔记)
简介
该文强调sanitizer(动态程序分析工具)的重要性。分类已有的sanitizer,分析其发现安全问题的方法,分析其如何权衡性能、兼容性和其它细节。总结sanitizer可行的部署方法和研究方向。
(漏洞利用)防御方案和sanitizer的区别
(如表1所示。FP=False Positive,即误报。)
它们都会使用Inlined Reference Monitor (IRM)监控指针解引用情况并部署安全策略以发现安全问题。
防御方案为了产品上线后能有效抵御攻击,需要检测控制流数据流的偏差,并在攻击各个环节施以干扰,甚至终止程序以避免攻击达成,最终保障数码财产安全等。防御方案并不特别关注攻击触发语句,这对防御攻击而言意义并不必要。防御方案得到广泛部署的前提包括低开销和高兼容性。防御方案无法接受误报的存在,面对良性错误应能保障程序继续正常运行。防御方案的例子如《SoK: Eternal War in Memory》也提及的CFI、DFI和WIT。
Sanitizer为了在产品上线前找到Bug,需要精确定位代码漏洞位置。Sanitizer能接受误报,因为后续还紧跟人工分析,但面对任何良性错误也应及时警报,避免漏网之鱼。Sanitizer例子如边界检查工具需要及时发现发生越界操作的代码语句。
Low-level漏洞
Low-level漏洞类型 | 说明 |
---|---|
内存安全破坏 | 若指针未访问预期的 有效对象 ,则内存安全遭到破坏。内存安全破坏是许多攻击的基础。 |
使用未初始化变量 | 可能导致信息泄露等。 |
指针类型错误(类型安全破坏) | 错误使用(数据、数据指针、函数指针的)类型转换(如downcast、union场景)导致数据错误使用。 |
可变函数误用 | 程序无法静态验证可变参数有效性及类型。 |
其它漏洞 | 整数溢出,编译器极端的优化措施忽略程序可能存在的未定义行为。 |
Bug发现技巧
针对内存安全破坏的Sanitizer根据检测方法可分为Location-based Access Checker和Identity-based Access Checker。
检测方法 | 说明 | 优点 | 缺点 |
---|---|---|---|
Location-based Access Checker | 从内存位置的角度出发,记录其有效性并在访存时验证。如red-zone insertion、guard page检测空间安全破坏,结合memory-reuse delay检测时间安全破坏。 | 时间开销低,兼容性强 | 不精准,只能确定访存目标有效性而非是否是预期的,内存开销大 |
Identity-based Access Checker | 记录内存对象的元数据并维护机制用于验证指针是否指向预期的有效的目标。如对象、指针粒度边界检测空间安全破坏,结合lock-and-key checking和dangling pointer tagging检测时间安全破坏。 | 精准检测指针是否指向预期目标 | 时间开销大,兼容性差,误报多 |
针对内存安全破坏的Sanitizer根据检测目标可分为 空间安全破坏检测 方法和 时间安全破坏检测 方法。
空间安全破坏检测 | 说明 | 优点 | 缺点 |
---|---|---|---|
Red-zone Insertion | 访存时查询元数据对任何指向 Red-zone 的指针进行警报。 | 对每个load/store指令插桩 | |
Guard Page | 对象首尾插入不可访问页,越界操作将触发页错误并被检测 | 无需对每个load/store指令插桩。(PageHeap用模板填充guard page避免时间开销大) | 错误处理时间开销大。(PageHeap释放对象时才检测模板是否篡改) |
指针粒度边界跟踪 | 内存分配及对象取址时记录指针边界元数据,赋值/计算操作时传播元数据,指针访存时验证是否越界、目标是否预期 | 兼容性差,开销大 | |
对象粒度边界跟踪 | 记录内存对象边界元数据,访存时判断指针是否越界。为确保指针指向预期的对象,需避免指针计算过程越界或指向非预期但有效的对象。 | 不检查对象(如结构体)成员溢出,兼容性差,无法处理OOB指针使用前已还原为有效指针的情况 |
- Valgrind Memcheck使用Red-zone Insertion,在影子内存记录目标内存及寄存器有效性(bit-wise),操作内存及寄存器时输入数据有效性会影响输出数据有效性。
- Purify早于Memcheck记录字节粒度有效性。
- Light-weight Bounds Checking用随机模板填充 Red-zone 以快速检查空间安全破坏(若读数据与随机模板匹配则说明读操作指向 Red-zone )。
时间安全破坏检测 | 说明 | 优点 | 缺点 |
---|---|---|---|
内存重用延迟 | 使用 年龄 来标记已释放内存,避免其被立即用于分配内存。如何设置 年龄 引出了许多工作。 | ||
Lock-and-key | 内存对象具有唯一ID(key),有效对象的key存于lock区。指针也保存其可访问对象的key,并在解引用时验证其key是否存于lock区(所指内存是否有效),得以检测dangling指针。 | 可兼容其它使用指针元数据的 指针粒度边界跟踪 方案 | 兼容性差,开销大 |
悬空 指针标记 | 标记已释放的指针(及其别名)的边界/值。使用(污点传播、运行时注册函数等)记录所有指针创建pointer map,对象释放时将相关指针无效化,在无效指针解引用前对象释放时检测dangling指针。 | 形成pointer map时,非污点传播方法需要源码,无法记录不安全 类型转化 后指针元数据,只能记录存于内存(非寄存器)的指针;污点传播方法无此问题,但性能及内存开销大。 |
其它Sanitizer如下。
针对使用未初始化变量的Sanitizer | 说明 | 优点 | 缺点 |
---|---|---|---|
未初始化内存的读检测 | 将新分配对象标记未初始化,写操作移除未初始化标记,读操作报警(而red-zone对读写操作均报警) | 存在误报 | |
未初始化内存的使用检测 | 使用影子状态记录内存未初始化字节,使用时再检测未初始化数据(lazy策略)。 | 检测策略需尽可能完备,存在漏报(如未初始化内存的非法使用)和较少误报(如未初始化内存的合法使用。对此二进制级插桩存在少量误报,IR级插桩无误报) |
针对指针类型错误的Sanitizer | 说明 | 优点 | 缺点 |
---|---|---|---|
指针转换监视器 | 基于Run-Time Type Information或自定义构建类型信息表检查downcast(如static_cast)是否合法 | 相比dynamic_cast更自定义化 | |
指针使用监视器 | 扩展 指针转换监视器 检测C Cast、C++ reinterpret_cast、union type等绕过编译时类型检测的转换会导致误报。对此在指针解引用时检查更为有效。使用影子内存记录内存类型,load/store/间接函数调用时检查类型是否匹配 |
针对变参函数误用的Sanitizer | 说明 |
---|---|
危险的格式化字符串检测 | 针对(暴露问题多的)printf 专门检查(如%n )。 |
变参误用检测 | 检查变参使用时的个数(和类型)与传参时的个数(和类型)是否匹配。 |
针对其它漏洞的Sanitizer | 说明 | 优点 | 缺点 |
---|---|---|---|
Stateless Monitoring | 检测有符号整数溢出等未定义行为和无符号整数溢出等非预期行为。 | 各方法检测特征是无状态的,互不干扰 |
程序插桩
程序插桩层次 | 说明 | 优点 | 缺点 |
---|---|---|---|
语言级插桩 | 在语言/抽象语法树层面插桩以获取更多语义类型等信息(这对指针类型分析非常重要) | 需要代码开源,编译器会假设程序无未定义行为而优化掉看似无关紧要的安全检查 | |
IR级插桩 | 当抽象语法树转化成IR代码时进行插桩 | 可不关心上层语言,可使用各类静态分析、优化方法 | 不支持闭源库,无法很好支持内联汇编 |
二进制级插桩 | 包括动态二进制翻译DBI(在运行过程中动态插桩)和静态二进制翻译SBI | 两者均无法得到语义、类型、栈帧、全局数据布局的信息,无法实现指针类型分析及精确的空间内存安全保护 | |
Library Interposition | 调用库函数前插入特殊的动态库以执行所需的插桩内容,随后将控制流转向预期的库函数(如malloc和free) | 能兼容二进制,开销低 | 只能插桩库间函数调用,对平台及目标依赖性强 |
元数据管理
如何高效存储和查询元数据非常重要。使用相同元数据管理方式的Sanitizer能彼此兼容。
对象元数据
对象元数据记录方法 | 说明 | 优点 | 缺点 |
---|---|---|---|
内嵌元数据 | 在内存对象前或后嵌入元数据 | ||
直接映射Shadow | 将内存元数据按其地址线性映射到影子内存 | 查询操作只需一个读操作 | 会恶化内存碎片和空间局部性,需要巨大的连续空间存放影子内存来映射整个内存空间 |
多级Shadow | 使用多级目录表+元数据表存储元数据(类似页表中页目录表+页表) | 不需要连续存储空间,且按需存储元数据,支持不同大小的对象压缩到固定大小的元数据 | 查询操作需多次读操作,开销大 |
自定义数据结构 | 如部分边界检查器使用的伸展树、用来缓存最近类型检查结果的哈希表、用来维护对象间关系的线程安全的红黑树 |
指针元数据
指针元数据记录方法 | 说明 | 优点 | 缺点 |
---|---|---|---|
Fat Pointers | 将原指针及其元数据用数据结构来表示 | 无额外数据结构带来缓存开销 | 改变程序调用传统,无法兼容传统库(无法确保Fat Pointer的使用和更新) |
Tagged Pointers | 使用64位地址中(目前不使用的)高位地址存放tag(即元数据) | 不影响调用传统(指针传参依然只占用一个寄存器) | 指针使用及传递给库前需要mask高位tag |
Low-fat Pointer | 修改(堆)布局来体现指针属性 | 不存在兼容问题 | |
Disjoint Metadata | 将指针元数据单独存放(于影子内存等处)。由于元数据相比指针非常占空间,可用多级表存储以减少内存开销(使用指针位置作为key存储内存对象ID和lock区地址) | 含指针的结构体拷贝等场景中,指针拷贝到其它位置时需要显式传递元数据,这是因为指针存储位置变了(指针内含元数据的方法无需考虑该问题) | |
Static Metadata | 由于程序编译丢失语义信息、变参函数的参数信息不可知等情况,一些方案将静态元数据嵌入到编译后的程序中 |
使用Sanitizer
场景 | 检测策略载体(发现bug处) | 测例提供者 | 优点 | 缺点 |
---|---|---|---|---|
单元测试和集成测试 | 手写的检测代码 | 手写的测例或自动测例生成器(如Concolic) | 关注有效输入,代码覆盖率低 | |
模糊测试 | sanitizer | fuzzer | 提供有效/无效输入,一旦集成可自动运行测试 | |
Beta测试 | sanitizer或手写的检测代码 | beta测试员(如消费者)的使用 | 能快速分发测试负载 | 往往只测了主要的使用场景,sanitizer的性能开销会让测试员放弃彻底测试程序 |
总结分析
(每个工具漏洞发现技术、插桩方式、元数据管理方式、所能检测的漏洞信息如表2所示。)
- 由于测试人员往往需要面临大量的漏洞报告,误报(误报常见原因)会极大地增加他们的工作量,如何减少误报比减少漏报(漏报常见原因)更为重要。
- 源码级插桩不适用JIT代码、外部库,IR级插桩不适用内联汇编,会导致插桩不完整,元数据无法更新或传播。动态二进制插桩可以解决此问题,但缺失语义信息,无法进行类型检查。
- 某些工具无法线程安全地原子性地更新指针或对象的元数据。
- Sanitizer 时间开销主要源于检查操作、元数据存储和传播(、运行时插桩)。相比漏洞防御方案性能开销低于5%才可能广泛部署,Sanitizer性能开销低于3倍(某些场景20倍以内)即可能广泛部署,越低的性能开销能使fuzz工具测试地更快。
- Sanitizer 空间开销平均而言小于3倍(但对于32位系统而言往往难以承受)。
部署情况
AddressSanitizer是目前最广泛部署的Sanitizer。发现bug能力好,兼容性好,误报少,已集成于主流编译器,扩展性好,但有漏报。Memcheck是另一个较为流行的工具。其它基于LLVM的Sanitizer(如MSan和UBSan)很少被使用。误报多,需要插桩整个程序(及依赖库)。好的Sanitizer应该易用、误报少、性能开销少。
后续研究发展方向
- 继续使用和研究 指针使用监视器(记录了每个存储位置的有效类型)实现类型错误检测更为有效。
- 提高对 事实标准 和部分插桩程序(如不支持源码插桩的外部库)的兼容性。
- 设计元数据存储方式使各类Sanitizer彼此兼容。
- 使用硬件提升速度和兼容性。
- 将Sanitizer应用于low-level的内核和Hypervisor