KOOBE: Towards Facilitating Exploit Generation of Kernel Out-Of-Bounds Write Vulnerabilities

参考

KOOBE: Towards Facilitating Exploit Generation of Kernel Out-Of-Bounds Write Vulnerabilities
论文阅读之KOOBE

概括

这篇论文探讨了Linux内核中另一种重要的内存漏洞——溢出(Out-of-Bounds, OOB)写入,并提出了一种名为KOOBE的系统来辅助分析此类漏洞。其研究背景是现代操作系统内核的单一性导致了持续不断的漏洞发现,而这些漏洞中只有部分可能严重到足以导致安全接管(例如权限提升)。因此,研究人员开始开发自动化漏洞利用生成技术(特别是针对Use-After-Free, UAF漏洞)以帮助漏洞分类过程。然而,对于OOB漏洞,目前还缺乏有效的分析工具。

KOOBE的设计理念

KOOBE的设计基于两个观察:

  1. 不同的OOB漏洞实例往往表现出广泛的能力范围。
  2. 内核漏洞利用通常是多交互性质的(即,一个漏洞利用涉及多个系统调用),这允许漏洞利用构建过程具有模块化特性。

KOOBE的工作方式

  • 能力导向的模糊测试:KOOBE引入了一种新的能力导向的模糊测试方法,旨在揭示隐藏的能力。这种模糊测试不是盲目地生成输入,而是有目的地寻找可以暴露特定漏洞能力的输入。
  • 能力组合:KOOBE还提供了一种机制,将不同的能力组合起来,以增加成功利用漏洞的可能性。通过这种方式,即使是那些看似难以利用的漏洞,也有可能找到有效的攻击路径。

实验结果

在评估阶段,KOOBE对最近的17个Linux内核OOB漏洞进行了全面分析,其中只有5个漏洞有公开的利用代码。KOOBE成功为11个漏洞生成了候选的利用策略,包括5个尚未分配CVE编号的漏洞。从这些策略出发,研究人员能够为所有11个漏洞构建完全可用的利用程序。

意义

这项工作对于提高操作系统内核的安全性具有重要意义。它不仅有助于发现和评估OOB漏洞的影响,还能促进更快速地修复这些问题,从而减少潜在的安全威胁。此外,KOOBE的技术和方法论也可以为其他类型的安全研究提供参考。

实例和自动化思路

CVE-2018-5703

1. struct Type1 {; };
2. struct Type2 { Type1 sk; uint64_t option;; };
3. struct Type3 { int (*ptr)();; };
4. struct Type4 { uint64_t state; Type3 *sk;; };
5. struct Type5 { atomic_t refcnt;; };

6. Type2 gsock = {, .option = 0x08080000000000, };
7. Type1 * vul = NULL; Type3 * tgt = NULL;

8. void sys_socket() //sizeof(Type1) == sizeof(Type3)
9. vul = kmalloc(sizeof(Type1))

10. void sys_accept()
11. vul = (Type2*)vul; //type confusion
12. vul->option = gsock.option; //Vulnerability Point

13. void sys_setsockopt(val) //not invoked in given PoC
14. if (val == -1) return;
15. gsock.option = val;

16. void sys_create_tgt()
17. tgt = kmalloc(sizeof(Type3));
18. tgt->ptr = NULL; //init ptr

19. void sys_deref() { if (tgt->ptr) tgt->ptr(); }
  1. 漏洞概述:

    • 漏洞点在第12行,存在类型混淆越界写原来的对象(OOB write)。
    • 涉及两种对象:脆弱对象(vul)和目标对象(tgt)。
    • 第11行的类型混淆导致第12行的越界写。
  2. 漏洞特性:

    • 初看似乎只能写入固定值0x08080000000000。
    • 实际上,如果先调用sys_setsockopt,覆盖的内容是可控的。
  3. 利用过程(图:

    • 使用堆喷(heap feng shui)技术操纵堆布局。
    • 创建目标对象使其与脆弱对象相邻。
    • 利用漏洞修改目标对象的指针。
    • 触发指针解引用来劫持控制流。
1. for (i = 0; i < N; i++)
2. sys_create_tgt(); // cache exhaustion
3. sys_socket(); // vuln obj
4. sys_create_tgt(); // target obj
5. sys_setsockopt(0xdeadbeef);
6. sys_accept(); // tgt->ptr = 0xdeadbeef
7. sys_deref();

关键点:

  • 原始PoC中缺少sys_setsockopt调用,限制了漏洞的利用价值。

在这里插入图片描述

分析能力(可利用性)

对于通过模糊测试发现的大多数漏洞,相应的PoC通常能够破坏一些数据,但不一定会导致可利用的状态。例如,基于随机突变的模糊测试派生的PoC可能会用一些随机值覆盖目标对象中的指针,从而导致不可利用的页面错误,或者损坏一些导致崩溃的系统数据。

为了评估其可利用性,安全分析人员通常需要检查易受攻击代码的逻辑,然后仔细调整系统调用的参数,将额外的系统调用插入PoC(如示例中所述),甚至重复触发覆盖

堆分水

当前一代的Linux内核堆分配器根据内存的大小组织动态分配的内存。相同大小的对象由一个专用缓存(也称为slab)管理,该缓存从系统中预留一个或多个页面,然后预先将它们分割成相同大小的块,以提高效率。例如,图1a中的Type1或Type3对象总是从同一个缓存中分配,因为它们的大小相同。每当一个缓存耗尽时,它会获取新的页面,并将它们分割成具有连续地址的块。最重要的是,这些新的块会按顺序(从低地址到高地址)分配(例如,通过 kmalloc())。这个过程在图2中的堆风水步骤中进行了说明。通过利用这一知识,我们可以耗尽当前缓存(图1b中的第1行和第2行),以确保在后续分配中会请求新的页面,从而使由同一缓存管理的易受攻击对象和目标对象相邻分配(图1b中的第3行和第4行分别)。请注意,没有必要利用易受攻击对象或目标对象来耗尽缓存,事实上,目前已经找到了一些不同大小的一般对象(例如,msgbuf)用于此目的 [41]。

一般来说,每个系统调用可能一次创建多个对象,这会使堆风水复杂化。然而,鉴于堆分配器是确定性的,以及攻击者可以通过一系列系统调用来提前设置堆布局的事实,几乎总是可以安排内存以促进越界写漏洞的利用(例如,使易受攻击对象和目标对象相邻)。

选择利用目标

在总结了能力和预安排的内存布局之后,我们需要仔细选择一个目标对象,其关键字段可以用所需的负载覆盖。通常,我们可以将关键字段分为(函数/数据)指针和非指针。

(i) 函数指针(例如,图1a中的Type3)是最理想的,因为控制它们的值可以在它们被解引用后立即导致控制流劫持。

(ii) 数据指针,可以用于构造任意写操作(如果它们指向稍后会被写入的结构体),或者仍然可以用于任意代码执行(如果它们指向另一个包含函数指针的结构体,例如Type4)。值得注意的是,堆元数据是一个特殊的目标对象,其中包含一个指向缓存中下一个可用对象的数据指针 [1]。

(iii) 非指针字段需要逐个案例评估。例如,在Linux中,struct cred 中的 uid 是一个常用的目标特殊字段,它控制用户ID。如果攻击者可以将自身进程的 uid 覆盖为0,就可以将进程的权限提升到root。另一个不太常见的例子是广泛用于Linux内核对象的引用计数器(例如,图1a中Type5的第一个字段)。如果引用计数器可以强行覆盖(例如,为0),目标对象将被提前释放,导致UAF(Use-After-Free)漏洞 [4]。(在Linux内核中,许多对象使用引用计数器来管理其生命周期。引用计数器(refcnt)是一个原子变量,用于跟踪有多少个引用指向该对象。当引用计数器减为0时,表示没有引用指向该对象,通常会触发对象的释放操作。)攻击者可以利用已研究得很好的基于UAF的利用技术 [57, 58]。

如图1b所示,我们选择Type3作为目标对象,因为它与易受攻击对象具有相同的大小(更容易进行堆风水),并且在前8字节中有一个函数指针。这与能力匹配,即总共8个可控字节可以覆盖在易受攻击对象旁边。另一方面,Type4不适合利用,因为其关键字段(即数据指针 sk)不在开头。

在特定越界写漏洞的能力有限的情况下,收集包含关键字段的多种对象是非常重要的。例如,图3a所示的CVE-2016-6187只能溢出一个字节的零,不足以构造指针。然而,选择一个引用计数器作为第一个字段的目标对象(例如,图1a中的Type5)是有意义的。这是因为将引用计数器的最低有效字节覆盖为零相当于减少其值,最终将其转换为UAF漏洞。实际上,在Linux内核中,有超过2,000个对象可能成为合适攻击的目标。

void example1(size) {
    vul = kmalloc(size);
    vul[size] = '\0';
}
// (a) CVE-2016-6187

void example2(i) {
    vul = (char*)kmalloc(sizeof(TYPE));
    // 省略路径上的其他越界点
    vul[i/8] |= 1 << (i & 0x7); // 设置1位
}
// (b) CVE-2017-7184

上面是两个简化的CVE。左边的一个允许溢出一个字节的零,而右边的一个只能在可控偏移处设置一个位。

综合利用

最后,根据我们之前选择的目标对象,我们需要相应地调整PoC(概念验证)。通常,目标对象是事先已知的(因为Linux内核是开源的)。具体来说,我们需要知道如何分配每个对象以及触发相应指针的解引用。从那里,我们可以结合这些知识来合成一个完整的漏洞利用。

然后是绕过高级防御并实现任意代码执行,现代防御措施通常包括KASLR(内核地址空间布局随机化)、SMEP(监督模式执行保护)和SMAP(监督模式访问保护)。虽然这些防御措施使攻击变得更加复杂,但并不一定能阻止它们。我们简要概述了一些常见的绕过这些防御的策略如下:

  • 绕过KASLR:实践中通常使用单独的信息泄露漏洞;或者,最近的CPU侧信道攻击如Meltdown [36]、Spectre [34]、RIDL [54]和ZombieLand [46]都可以实现这一目标。
  • 绕过SMEP:可以简单地将控制流引导到内核地址空间(ROP/JOP),这不是一个大的障碍(无需在用户空间执行代码)。
  • 绕过SMAP:可以将一个被破坏的数据指针指向内核的物理映射区域(physmap),在那里使用物理映射喷射技术 [33, 58] 伪造一个可控对象。
  • 最后,转向将IP劫持转化为任意代码执行和权限提升:最近的研究 [56] 可以在启用SMEP和SMAP的情况下自动化这一过程。

设计

漏洞分析

给定一个PoC(概念验证),我们的系统首先尝试发现所有的漏洞点(即越界访问点)并识别对应的易受攻击对象(详见附录B中的图10)。不幸的是,仅靠KASAN [5] 无法提供完整的漏洞点或准确的易受攻击对象报告。KASAN以其可能遗漏的越界访问而闻名,因为它依赖于影子内存和红区 [51],对于那些没有溢出到红区的越界访问(例如,仅覆盖附近对象的情况)无效。实际上,我们发现在某些情况下,KASAN只能报告多个越界访问中的一个。此外,KASAN无法准确地定位易受攻击的对象,因为它只报告离访问的红区最近的对象。

为此,在执行PoC时,除了基本的KASAN之外,我们还进行符号跟踪以监控更详细的内存操作(每条PoC的离线步骤),例如 kmalloc() 和单个内存访问。具体来说,我们的系统利用符号跟踪来跟踪每个对象,当对象创建时为其分配一个唯一的符号值。因此,对于每次内存访问,如果其中包含符号表达式,我们可以直接提取目标对象。此外,通过查询指针的符号表达式的可能范围,即使给定的PoC未触发溢出,我们也能检测到潜在的溢出。在动机示例中,如果我们为从 kmalloc() 函数返回的易受攻击对象分配一个符号值(第9行),我们可以得到第12行指针的以下符号表达式:vul + offsetof(Type2, option),其中 vul 是我们分配的符号值。通过分析图3b中指针的符号表达式(即 vul + i/8,其中 vuli 都是符号值——i 从系统调用参数传递而来),我们可以断定这必定是一个越界漏洞点,因为偏移量可能大于易受攻击对象的大小,因为 i 没有任何约束(即使PoC未使用足够大的 i)。

能力总结

相关定义1 OOB写入表示

有点抽象

符号表达式集合 ( E )

  • 符号表达式集合 ( E ):这是符号执行引擎支持的所有可能的表达式的集合。这些表达式可以是变量、常量、运算符等的组合。例如,x + 5y * 2z - 1 都是符号表达式。

路径集合 ( P )

  • 路径集合 ( P ):这是程序中所有可能的执行路径的集合。每条路径代表程序的一种执行方式。例如,一个简单的if-else语句会产生两条路径:一条是if条件为真的路径,另一条是if条件为假的路径。

路径 ( p ) 上的漏洞点集合 ( N_p )

  • 路径 ( p ) 上的漏洞点集合 ( N_p ):这是在特定路径 ( p ) 上所有可能的漏洞点的集合。漏洞点是指程序中可能导致安全问题的地方,例如缓冲区溢出(Buffer Overflow)或越界写入(Out-of-Bounds Write)。

例子

假设有一个简单的C程序:

void vulnerable_function(int x) {
    int buffer[10];
    if (x > 0) {
        buffer[x] = 1;  // 可能的漏洞点
    } else {
        buffer[0] = 1;  // 不是漏洞点
    }
}

在这个程序中,我们可以有两条路径:

  1. 路径1x > 0

    • 在这条路径上,buffer[x] = 1 是一个可能的漏洞点,因为如果 x 大于10,就会发生越界写入。
    • 因此,路径1上的漏洞点集合 ( N_{p_1} ) 包含一个元素:buffer[x] = 1
  2. 路径2x <= 0

    • 在这条路径上,buffer[0] = 1 不是漏洞点,因为 buffer[0] 是合法的。
    • 因此,路径2上的漏洞点集合 ( N_{p_2} ) 是空的。

数学表示

  • 路径 ( p ) 上的漏洞点集合 ( N_p ):这是路径 ( p ) 上所有可能的漏洞点的集合。
  • ( i \in N_p ):表示 ( i ) 是路径 ( p ) 上的一个漏洞点。
  • ( off, len, val \in E ):表示偏移量 ( off )、长度 ( len ) 和值 ( val ) 都是符号表达式,属于集合 ( E )。

通俗解释

  • 路径 ( p ):就是程序的一种执行方式。
  • 漏洞点 ( i ):是路径 ( p ) 上可能导致安全问题的具体位置。
  • 偏移量 ( off ):是从易受攻击对象的起始地址开始的偏移量。
  • 长度 ( len ):是可以写入的字节数。
  • 值 ( val ):是写入的值。

例子解释

假设我们有一条路径 ( p ),在这条路径上有两个漏洞点 ( i_1 ) 和 ( i_2 ):

  • 漏洞点 ( i_1 )buffer[x] = 1,其中 x 是一个符号表达式。

    • 偏移量 ( off_{p_1} )x(相对于 buffer 的起始地址)
    • 长度 ( len_{p_1} ):1(写入1个字节)
    • 值 ( val_{p_1} ):1(写入的值)
  • 漏洞点 ( i_2 )buffer[y] = 2,其中 y 是一个符号表达式。

    • 偏移量 ( off_{p_2} )y(相对于 buffer 的起始地址)
    • 长度 ( len_{p_2} ):1(写入1个字节)
    • 值 ( val_{p_2} ):2(写入的值)

因此,路径 ( p ) 上的OOB写集 ( T_p ) 可以表示为:

[ T_p = {(x, 1, 1), (y, 1, 2)} ]

相关定义2能力提取

路径 ( p ) 的能力表示为 Cp = {sizep,Tp, f§ | sizep ∈ E},其中:

  • ( size_p ):表示易受攻击对象的大小。
  • ( T_p ):表示路径 ( p ) 上的OOB写集。
  • ( f§ ):表示在执行路径 ( p ) 时收集的路径约束的集合。
  • ( size_p \in E ):表示 ( size_p ) 是符号表达式集合 ( E ) 中的一个元素。

详细解释

  1. 易受攻击对象的大小 ( size_p )

    • 定义:( size_p ) 表示在路径 ( p ) 上易受攻击对象的大小。
    • 重要性:在某些情况下,易受攻击对象的大小是可变的,这会影响OOB写入的可能性和范围。例如,如果对象的大小可以通过用户输入控制,那么这会增加找到合适目标对象的搜索空间。
    • 符号表达式:( size_p ) 是一个符号表达式,属于集合 ( E )。
  2. OOB写集 ( T_p )

    • 定义:( T_p ) 表示路径 ( p ) 上所有OOB写入的集合。
    • 结构:每个OOB写入可以用三元组 ( (off, len, val) ) 表示,其中:
      • ( off ):表示相对于易受攻击对象起始地址的偏移量。
      • ( len ):表示可以写入的字节数。
      • ( val ):表示写入的值。
    • 例子:假设路径 ( p ) 上有两个OOB写入点,分别为 ( (10, 2, 0x41) ) 和 ( (15, 1, 0x42) ),则 ( T_p = {(10, 2, 0x41), (15, 1, 0x42)} )。
  3. 路径约束 ( f§ )

    • 定义:( f§ ) 表示在执行路径 ( p ) 时收集的路径约束的集合。
    • 重要性:路径约束限制了OOB写入的实际可能性。例如,某个值必须满足特定条件才能到达某个代码行。
    • 例子:假设路径 ( p ) 上有一个条件检查 val != -1,那么 ( f§ ) 中包含这个约束。

例子解释

假设有一个C程序,其中包含一个易受攻击的函数:

void vulnerable_function(char *data, int len, int obj_size) {
    char *buffer = malloc(obj_size);
    if (obj_size > 0 && obj_size < 20) {
        memcpy(buffer, data, len);
    }
    free(buffer);
}

在这个例子中,我们可以通过符号执行发现两条路径:

  1. 路径1len = 16obj_size = 15

    • 路径约束obj_size > 0 && obj_size < 20
    • 漏洞点memcpy(buffer, data, 16),在 buffer 之后的1个字节处写入。
    • OOB写集:( T_{p_1} = {(15, 1, data[15])} )
    • 能力:( C_{p_1} = {15, T_{p_1}, {obj_size > 0, obj_size < 20}} )
  2. 路径2len = 18obj_size = 15

    • 路径约束obj_size > 0 && obj_size < 20
    • 漏洞点memcpy(buffer, data, 18),在 buffer 之后的3个字节处写入。
    • OOB写集:( T_{p_2} = {(15, 3, data[15])} )
    • 能力:( C_{p_2} = {15, T_{p_2}, {obj_size > 0, obj_size < 20}} )

为什么路径约束重要

路径约束限制了OOB写入的实际可能性。例如,在上面的例子中,obj_size 必须在1到19之间,否则 mallocmemcpy 不会被执行。因此,路径约束 ( f§ ) 是确定OOB写入是否可行的重要因素。

  • 易受攻击对象的大小 ( size_p ):表示对象的大小,可能影响OOB写入的范围。
  • OOB写集 ( T_p ):表示路径 ( p ) 上所有OOB写入的集合,每个写入由偏移量、长度和值组成。
  • 路径约束 ( f§ ):表示在执行路径 ( p ) 时收集的路径约束,限制了OOB写入的实际可能性。

相关定义3能力比较

  • 定义:对于所有 ( e1, e2 \in E ),如果 ( e1 ) 与 ( e2 ) 相同,或者 ( e1 ) 是一个常量且其值可以在 ( e2 ) 中取到,则 ( e1 \leq e2 )。
  • 解释:这里定义了符号表达式之间的比较关系。如果两个表达式相同,或者其中一个表达式是常量且其值在另一个表达式中可以取到,则认为前者小于或等于后者。
OOB写集比较
  • 定义:对于所有 ( p1, p2 \in P ),如果 ( off_{p1_i} \leq off_{p2_i} ) 且 ( len_{p1_i} \leq len_{p2_i} ) 且 ( val_{p1_i} \leq val_{p2_i} ),则 ( T_{p1_i} \leq T_{p2_i} )。
  • 解释:这里定义了两个OOB写集之间的比较关系。如果一个OOB写入的偏移量、长度和值都小于或等于另一个OOB写入的相应值,则认为前者小于或等于后者。
能力比较
  • 定义:对于所有 ( p1, p2 \in P ),如果 ( size_{p1} \leq size_{p2} ) 且对于所有 ( i \in N_{p1} ),( T_{p1_i} \leq T_{p2_i} ),则 ( C_{p1} \leq C_{p2} )。
  • 解释:这里定义了两个路径的能力之间的比较关系。如果一个路径的易受攻击对象的大小小于或等于另一个路径的易受攻击对象的大小,并且该路径上的所有OOB写入都小于或等于另一个路径上的相应OOB写入,则认为前者的能力小于或等于后者的能力。

观察

直接比较符号表达式可能很棘手,因为它们之间存在内在关系,尤其是当与路径约束结合时。因此,我们保守地认为,只有当两个表达式相同,或者前者是一个常量且其值可以在后者中取到时,才认为前者等于或劣于后者。基于这一点,我们可以通过比较每个元素来定义OOB写入和能力的部分顺序。从上面的例子中可以看出,第二个能力是优越的,因为 ( C_{orig} \leq C_{comp} )。

1. 符号表达式之间的内在关系

符号表达式之间的内在关系指的是这些表达式之间存在某种依赖、等价或逻辑联系,这些联系使得它们不能被简单地独立对待。

例子 1:依赖关系

假设我们有两个符号表达式:

  • ( e1 = x + 5 )
  • ( e2 = y + 5 )

如果 ( y = x + 10 ),那么 ( e2 = (x + 10) + 5 = x + 15 )。在这种情况下,虽然 ( e1 ) 和 ( e2 ) 形式相似,但它们的值是不同的,因为 ( y ) 依赖于 ( x )。

2. 路径约束的影响

路径约束是在程序执行过程中收集的条件,这些条件决定了程序的执行路径。路径约束限制了符号表达式可以取的值,使得符号表达式之间的关系更加复杂。

例子 2:路径约束

假设我们有一个程序片段:

int x, y;

if (x > 0) {
    y = x + 5;
} else {
    y = x - 5;
}

在这个例子中,路径1的路径约束是 ( x > 0 ),路径2的路径约束是 ( x \leq 0 )。

3. 符号表达式与路径约束的结合

当符号表达式与路径约束结合时,情况变得更加复杂。路径约束限制了符号表达式可以取的值,因此在比较符号表达式时,必须考虑这些约束。

例子 3:符号表达式与路径约束

假设我们有两个符号表达式:

  • ( e1 = x + 5 )
  • ( e2 = y + 5 )

并且有路径约束:

  • 路径1的约束:( x > 0 )
  • 路径2的约束:( y > 10 )

在这种情况下,即使 ( e1 ) 和 ( e2 ) 形式相似,它们的值范围是不同的。路径1的 ( x ) 可以取任何大于0的值,而路径2的 ( y ) 可以取任何大于10的值。因此,直接比较 ( e1 ) 和 ( e2 ) 并不简单,需要考虑路径约束。

具体例子

假设我们有一个更复杂的C程序,其中包含多个条件分支和路径约束:

int x, y, z;

if (x > 10) {
    y = x + 5;
    if (y > 15) {
        z = y + 10;
    } else {
        z = y - 10;
    }
} else {
    y = x - 5;
    if (y < -10) {
        z = y + 10;
    } else {
        z = y - 10;
    }
}
符号表达式
  • 路径1x > 10y > 15
    • 符号表达式
      • y = x + 5
      • z = y + 10
  • 路径2x > 10y <= 15
    • 符号表达式
      • y = x + 5
      • z = y - 10
  • 路径3x <= 10y < -10
    • 符号表达式
      • y = x - 5
      • z = y + 10
  • 路径4x <= 10y >= -10
    • 符号表达式
      • y = x - 5
      • z = y - 10
路径约束
  • 路径1的约束x > 10y > 15
  • 路径2的约束x > 10y <= 15
  • 路径3的约束x <= 10y < -10
  • 路径4的约束x <= 10y >= -10
内在关系
  • 路径1:当 x > 10y > 15 时,y = x + 5z = (x + 5) + 10 = x + 15。这里,yz 的值取决于 x 的值,并且 x 必须大于10,y 必须大于15。
  • 路径2:当 x > 10y <= 15 时,y = x + 5z = (x + 5) - 10 = x - 5。这里,yz 的值取决于 x 的值,并且 x 必须大于10,y 必须小于或等于15。
  • 路径3:当 x <= 10y < -10 时,y = x - 5z = (x - 5) + 10 = x + 5。这里,yz 的值取决于 x 的值,并且 x 必须小于或等于10,y 必须小于-10。
  • 路径4:当 x <= 10y >= -10 时,y = x - 5z = (x - 5) - 10 = x - 15。这里,yz 的值取决于 x 的值,并且 x 必须小于或等于10,y 必须大于或等于-10。

能力生成

分类漏洞点
  • 分类:我们通常将前一步识别的漏洞点分为两类:函数调用和内存访问指令。
    • 函数调用:如果OOB访问是由内存复制函数(如 memcpy())触发的,则相应的漏洞点是调用该函数的指令。
    • 内存访问指令:否则,引起OOB写入的指令直接被视为漏洞点。
Modeling memory copy functions
  • 目的:Modeling memory copy functions可以简化能力的提取(因为它避免了我们将详细说明如何处理的循环分析)
  • 方法
    • 偏移量:通过符号跟踪,可以从 memcpy() 的第一个参数(目标地址)中提取写入的偏移量。
    • :可以从 memcpy() 的第二个参数(源地址)中提取写入的值。
    • 长度:可以从 memcpy() 的第三个参数中提取写入的长度。

例子解释

假设我们有一个程序,其中包含一个易受攻击的函数:

void vulnerable_function(char *data, int len, int obj_size) {
    char *buffer = malloc(obj_size);
    if (obj_size > 0 && obj_size < 20) {
        memcpy(buffer, data, len);
    }
    free(buffer);
}

在这个例子中,我们可以通过符号执行发现两条路径:

  1. 路径1len = 16obj_size = 15

    • 路径约束obj_size > 0 && obj_size < 20
    • 漏洞点memcpy(buffer, data, 16),在 buffer 之后的1个字节处写入。
    • OOB写集:( T_{p1} = {(15, 1, data[15])} )
    • 能力:( C_{p1} = {15, T_{p1}, {obj_size > 0, obj_size < 20}} )
  2. 路径2len = 18obj_size = 15

    • 路径约束obj_size > 0 && obj_size < 20
    • 漏洞点memcpy(buffer, data, 18),在 buffer 之后的3个字节处写入。
    • OOB写集:( T_{p2} = {(15, 3, data[15])} )
    • 能力:( C_{p2} = {15, T_{p2}, {obj_size > 0, obj_size < 20}} )

比较能力

  • 偏移量off_{p1} = 15off_{p2} = 15,相等。
  • 长度len_{p1} = 1len_{p2} = 3len_{p1} < len_{p2}
  • val_{p1} = data[15]val_{p2} = data[15],相等。

因此,根据定义,( C_{p1} \leq C_{p2} ),即路径2的能力优于路径1的能力。

在这里插入图片描述

能力探索

通常,一个漏洞会导致在不同路径上出现不同的漏洞点,每个漏洞点可能表现出一种独特的能力。(该漏洞可能利用会导致该漏洞所在的不同路径出现新的漏洞点)此外,即使是同一个漏洞点,不同的路径和相关的路径约束也可能导致不同的能力,如图1a所示。不幸的是,给定的PoC(概念验证)通常只覆盖一条路径,这可能限制我们对漏洞完整能力的理解。因此,如图4所示,如果我们的系统使用已发现的能力未能找到解决方案(未能定位合适的攻击目标对象),它会探索新的PoC,这些PoC要么扩展现有的能力,要么发现新的能力,然后重复能力总结和可利用性评估的过程,直到成功或达到预设的超时时间。为此,我们的系统采用了一种新颖的能力引导模糊测试解决方案来探索额外的能力。

能力引导模糊测试

模糊测试是探索不同可利用状态的一种自然解决方案。然而,现有的内核模糊测试工具(如Syzkaller)主要是覆盖率引导的,对探索OOB(超出边界)能力的效果不佳。这是因为最大化分支覆盖率只是发现更多OOB能力的非常宽松的近似——它通常优先选择那些实现新覆盖率的测试程序(这些程序甚至可能不会触发OOB),并且对实际发现的OOB能力不敏感。这促使我们设计了一种结合覆盖率引导的能力引导模糊测试策略。给定一个PoC及其对应的OOB能力,我们对其进行变异,并收集能力反馈(每当触发OOB时)以及覆盖率反馈。最终,我们将具有新能力的种子输入符号跟踪引擎进行进一步总结。与现有能力 ( C_{p1} ) 相比,新提取的能力 ( C_{p2} ) 如果 ( C_{p2} \leq C_{p1} ) 为假,则被认为是新的能力。

具体来说,每当执行一个新的测试程序时,我们在运行时收集OOB写集的具体值作为能力反馈(例如,写入了多少字节以及写入了什么值)。需要注意的是,与使用符号跟踪进行的重量级能力总结不同,我们在模糊测试组件中使用轻量级动态插桩来收集OOB写集(关于插桩的更多细节在第5节开头描述)。然而,权衡在于,如果我们仅通过比较具体值来确定是否发现了新的能力,一些测试用例可能是重复的,因为稍后我们可以通过能力总结来泛化它们。例如,如果我们知道被覆盖的值可以是任意的,那么在模糊测试过程中保留仅仅在被覆盖值上不同的测试用例是多余的。为了解决这个问题,KOOBE会在每次发现新的漏洞点时进行能力总结,然后向模糊测试引擎提供OOB写集中值的范围,以过滤测试用例。因此,它可以通过检查具体值与其通过符号跟踪收集的范围来检测“重复”输入,而不是比较符号值。需要注意的是,如图4所示,漏洞分析(见第4.1节)总是在能力总结之前进行,以避免错过KASAN未能检测到的任何OOB站点。

在我们的设计中,我们在语料库中的测试程序之间保持平衡。鉴于通常更容易提高覆盖率而不是发现新的能力,语料库中新测试程序的分布可能会严重偏向于那些增加覆盖率的程序。我们通过维护两个队列来改变种子选择的策略:一个队列用于增加覆盖率的程序,另一个队列用于扩展能力的程序,并以相等的概率从两个队列中选择种子。这种配置在我们的实验中产生了良好的结果(将在第6.4节中展示),我们将不同概率配置的探索留作未来的工作(见第7节的更多讨论)。

利用评估

考虑到从前面步骤中获得的功能,我们的系统现在尝试在Linux内核中搜索一个或多个合适的目标对象。如果找到匹配项,则产生解决方案(具体示例参见附录B中的图13)。

我们首先引入目标约束的概念,这些约束代表了目标对象可以被覆盖以导致潜在漏洞利用的条件。它们描述了哪些字段需要被覆盖(例如,函数指针、数据指针、引用计数器或任何自定义数据),以及这些字段的预期值范围。例如,对于一个指针来说,它必须指向一个有效的用户空间或内核空间地址。此外,由于堆风水的要求,我们要求目标对象的大小与脆弱对象相同。但这一要求可以被移除,因为高级的堆风水策略即使在目标对象和脆弱对象大小不同的情况下也能将目标对象放置在脆弱对象的相邻位置。然而,这样做稳定性较差,因此我们更倾向于选择大小相同的目标对象。

然后,我们将目标约束叠加在我们之前推导出的能力之上,并将它们传递给求解器以寻找解决方案。如果没有找到解决方案,我们就转向下一个对象。

图5a描绘了一个通用模型,其中一个或多个内存访问覆盖了相邻于脆弱对象的目标对象,假设堆风水可以按需操纵堆布局(我们以只有一个越界写入的情况为例,但这可以推广到多个越界写入)。
在这里插入图片描述

更具体地说,我们的系统构建了一个内存对象 M,以建模脆弱对象和目标对象的内存区域,允许使用符号索引、值和长度更新其内容(详情见第5节)。在使用能力提供的符号数据/索引/偏移量初始化内存对象 M 后,可以通过在内存对象 M 上添加目标约束并检查相对于从能力总结中检索到的路径约束的可满足性来评估候选对象是否合适。

在这里插入图片描述

图5b说明了动机示例中的过程,其中考虑了两个目标对象(Type3 和 Type4)。第一行简单地声明脆弱对象的大小必须与目标对象的大小匹配。第二行和第三行关于越界偏移量和越界长度(两者都是常量)用于更新内存对象,第四行表示越界值(一个8字节的符号值)。最后,最后一行包括路径约束(作为能力的一部分收集)和负载所需的值范围(作为目标约束的一部分)。在这种情况下,Type3 类型的目标对象期望第一个字段(从索引0到7的8字节函数指针)被覆盖为有效的用户空间或内核空间地址,这确实是可以满足的。另一方面,由于有限的越界偏移量和越界长度,Type4 类型的目标对象的第二个字段不能被覆盖

能力组合。当单次使用某项能力——这可能已经包含多次越界访问——不能满足给定目标对象的要求时,并不一定意味着它是无用的,因为有可能该能力一次只能修改目标对象的一部分(例如,单个位)。因此,如果我们重复使用相同的能力(即,重新触发相同的越界写入路径)来操作剩余部分,我们可以达到所需的值。例如,图3b中展示的CVE-2017-7184即使每次只设置一个位,也可以将空指针修改为任意值。在脆弱对象的分配和溢出发生在不同系统调用的情况下,我们可以通过多次调用相应的系统调用序列来多次触发来自同一脆弱对象的越界写入。此外,不仅仅是重复使用相同的能力,某些漏洞需要组合不同的能力才能被利用(例如,那些具有不同越界值的能力)。为此,我们提出了一种高效的贪心算法来评估给定不同能力的可利用性,如附录A所示。
在这里插入图片描述

与其通过暴力穷举每个可能的能力组合,关键思想是每次迭代只用一个能力来接近期望的目标字段值,直到结果不再变化。然后,我们检查最终结果是否满意,即是否得到了一个解决方案。因此,根据目标字段的类型(例如,数据或函数指针),我们定义一个对应的距离函数作为目标函数,指导我们在每次迭代中选择最佳能力来最小化距离。注意,每次选择都会将其结果写回到内存对象中,以便下一次迭代可以继续减少距离。表1描述了第3节提到的三种目标字段类型的距离函数。对于函数指针和非指针类型,通常提供负载(例如,重定向地址 就是写个地址),而数据指针类型需要修改后的值位于有效的内存区域内(无论是内核空间还是用户空间)。因此,这些对应目标类型的距离函数具有以下两个属性:

  1. 若满足条件则返回零:例如,数据指针类型的距离函数只有在值位于有效范围内时(即,[MIN_POINTER, MAX_POINTER])才返回零;否则,返回一个正距离。
  2. 可微性:它使我们的贪婪算法能够区分哪个能力能帮助我们更接近期望的负载。

注意,鉴于上述两个属性,为包含多个关键字段的目标对象推导距离函数并不困难。例如,两个目标字段的合取(conjunction)的距离函数是个体距离的总和,而析取(disjunction)的距离函数是两个个体距离中的最小值。对于超过两个目标字段的联合距离函数,推广起来也是直截了当的。
在这里插入图片描述

利用原语合成

一旦我们的系统成功找到一个解决方案,实际上就是之前标记为符号的具体系统调用参数,下一步显然是执行堆风水(heap feng shui),以构建前一步假设的布局,并触发损坏的字段(例如,函数指针)被解引用。对于堆风水,我们编码了一些众所周知的策略,如第3节所述,这些策略足以应对我们遇到的所有情况。
具体来说,我们通过三种不同的系统调用——add_key()、msgsnd() 和 sendmsg()——进行堆喷射,采用[58]中介绍的技术实现缓存耗尽,并在适当的位置插入所选目标的分配和解引用函数(详见附录B中的图2)。我们手动收集了我们在网上找到的所有公开利用中使用的靶标对象,并制作了一个数据库,指定了它们的使用方式(如图12所示)。此外,我们在评估中选择性地采样了一些有前景的对象来协助这一步骤(见第5节)。

在这里插入图片描述

如前所述,由于我们的目标是实现IP劫持原语,而不是实现任意代码执行的端到端解决方案(这可能涉及ROP/JOP以绕过SMEP),我们明确将这些现代防御措施(例如KASLR、SMEP)排除在外。然而,在特殊情况下,当我们需要伪造一个可控的内核对象时,我们利用物理映射喷射(physmap spray)[33],以避免违反SMAP(详见附录B中的图14)。
在这里插入图片描述
上面那段话实在是无法理解其逻辑。下面的改良版

  1. 目标:攻击者的目标是实现IP(指令指针)劫持。这意味着攻击者想要控制程序的执行流,而不是直接实现任意代码执行。IP劫持可以被用作更复杂攻击的一部分。

  2. 现代防御措施

    • KASLR(内核地址空间布局随机化):通过随机化内核在内存中的位置,增加攻击者猜测内核地址的难度。
    • SMEP(超级用户模式执行保护):防止内核从用户空间执行代码,增加了攻击难度。
    • SMAP(超级用户模式访问保护):防止内核访问用户空间的数据。
  3. 不考虑绕过KASLR和SMEP:由于目标仅为实现IP劫持,而不是完整的任意代码执行方案,文中明确表示不考虑如何绕过这些防御措施(KASLR和SMEP)。

  4. 特殊情况和physmap spray技术

    • 在某些特殊情况下,攻击者需要创建一个可控的内核对象。
    • 物理映射喷射(physmap spray):这是一种技术,攻击者通过大量分配内存来填充物理地址空间。这样可以在某些情况下将攻击者控制的数据放入内核的物理地址空间。
    • 使用这种技术可以避免违反SMAP的限制,因为攻击者通过这种方式在内核内创造了一个受控的对象。

总结来说,这段文字描述了一个特定的攻击策略,专注于实现IP劫持,通过物理映射喷射技术,在不违反SMAP的情况下实现对内核对象的控制。KASLR和SMEP等防御措施被视为不在讨论范围内,因为目标不是实现任意代码执行。

实现

我们已经在流行的内核模糊测试工具 Syzkaller、二进制符号执行框架 S2E [21] 和二进制分析引擎 angr [50] 的基础上实现了系统的原型。该系统包括:

  • 7,510 行 C++ 代码,用于在 S2E 上进行能力总结和可利用性评估,
  • 2,271 行基于 angr 的 Python 代码,用于分析漏洞,
  • 1,106 行 Go 代码,用于探索分歧路径并合成漏洞利用。

动态 instrumentation 支持能力引导的模糊测试。除了使用 S2E 进行符号跟踪和生成能力的符号表示外,我们还通过 S2E 提供的 QEMU 将 S2E 与 Syzkaller 集成,利用其强大的二进制级 instrumentation 支持能力引导的模糊测试(如第4.3节所述)。此外,通过动态 instrumentation,Syzkaller 可以检查内核的内部状态并进行非崩溃模糊测试。具体来说,由于我们的初始种子(即给定的 PoC)已经能够导致系统崩溃,我们希望变异后的程序能够触发相同的崩溃。因此,如果每次运行测试用例时都需要重启系统,效率极低。为了应对这一点,我们在内核中插入 instrumentation 代码,跳过那些导致 OOB 写入的指令(同时记录每次 OOB 访问的操作数以检查它们是否是新的),避免任何 KASAN 警告,以保持模糊测试会话的进行。缺点是这可能会导致系统状态的不一致,从而产生潜在的误报(即错误报告新的漏洞点或新的能力)。然而,我们的观察是,我们只跳过了与动态分配的堆对象访问相关的漏洞点。每个测试程序执行完毕后,这些堆对象会被释放,因此不会干扰未来的测试程序运行。尽管如此,我们可以通过在标准的 Syzkaller 环境中重复运行这些程序来过滤掉生成不可重现漏洞的程序。可重现就是两个都能跑通的


支持符号化长度:在漏洞利用评估的关键步骤中,我们需要更新内存模型 M[offset: offset+length-1] = value[0: length-1](参见第4.4节),此时 offsetlengthvalue 都可能是符号化的。然而,符号化长度在符号执行引擎中通常支持得非常差,不像符号化索引和值那样容易处理。通常,我们需要指定符号数据的具体长度 [2, 7],因此无法使用符号化长度来更新内存对象中的越界(OOB)值。不幸的是,具体化长度会导致能力的低估(实际上我们应该能够写入更多的或更少的字节)。这个问题在进行能力引导的模糊测试时会有所缓解,因为这种测试可以生成产生不同具体化 OOB 长度的 PoC(概念验证)。然而,依赖模糊测试来生成所有可能的具体化 OOB 长度并不实际。在实践中,我们有多个理由需要在一系列 OOB 长度中寻找解决方案,这在能够处理符号化长度时得到了最好的支持:(1) 我们通常更喜欢具有最小 OOB 长度的解决方案,以避免破坏系统数据(可能导致崩溃)。(2) 我们可能需要根据脆弱对象的大小要求来约束 OOB 长度,特别是在它们耦合(就是不仅溢出到目标对象还溢出到了下一个相邻对象)的情况下。

1. void loop(n) // n = 64
2. vul = (char*)kmalloc(32);
3. for (i = 0; i < n; i++)
4. vul[i] = 0; // OOB Point

图6:带有循环的溢出示例

我们的解决方案直观上与枚举不同的可能 OOB 长度没有区别,但我们以一种更高效的方式进行,这种方式与现有的内存模型和求解器兼容。具体来说,对于总结的 OOB 写操作 (off, len, val),其中所有元素都是符号化的,具体长度为 10,我们的系统逐字节更新内存对象 M,如下所示:

for i in [0, 10]:
    M[ite(i < len, i + off, offsetdummy)] = val[i]

其中 ite 表示由 KLEE 和 Z3 [10] 支持的 if-then-else 表达式,offsetdummy 表示我们引入的一个虚拟字节的偏移量,用于取消特定字节的内存更新。(offsetdummy 是一个虚拟的内存地址,并不会指向实际的内存区域。通过使用 offsetdummy,程序不会真的去更新任何有效的内存地址。这种做法的目的是当条件不满足时(即 i >= len 时),避免对真实的内存进行不必要或错误的写操作。) 本质上,求解器可以在 0 到 10 之间的长度范围内搜索可行的解决方案,并适当更新内存模型。

正如我们在示例中看到的,我们仅保守地从具体化 OOB 长度(0 到 10)反向搜索。这是因为无法预测较大索引处的字节值,而在较小索引处进行预测是安全的(如果长度较小的话),因为我们已经看到它们被赋值,并且我们知道路径何时不会改变(通过遵守 OOB 长度的路径约束)。请注意,我们依赖于能力引导的模糊测试来找到更大的长度。当较低的字节基于较高的字节计算时(OOB 值是符号化的),例如在加密和压缩的上下文中,这一假设可能会失效。然而,我们认为这种情况在 Linux 内核中很少见,而且符号执行/跟踪在遇到此类过程时已经在求解器中卡住了(比较难求出满号要求的对应的符号的具体值)。在我们的实验中,我们没有遇到任何这样的情况,假设始终成立。

符号化长度在符号执行引擎中通常支持得非常差,不像符号化索引和值那样容易处理

1. void loop(int n) // n 是符号化的
2. {
3.     char *vul = (char *)malloc(32); // 分配 32 字节的内存
4.     for (int i = 0; i < n; i++)
5.     {
6.         vul[i] = 0; // OOB Point
7.     }
8.     free(vul);
9. }
  • 生成路径约束:0 <= i < n。
    每次 i 增加时,路径约束 0 <= i < n 都会被重新评估。
  • 每个路径约束都独立地描述了程序到达某个状态的条件。
    因此,每个 n 的值都会生成一条新的路径,因为每个路径约束都是唯一的。
  • 如果 n 是符号化的,每个可能的 n 值都会生成一条新的路径,导致路径数量急剧增加。
    符号化长度会引入更复杂的约束,导致约束求解器的求解过程更加复杂和耗时。例如,路径约束 0 <= i < n 需要对每个可能的 n 值进行求解。

循环中的能力提取:正如图6第3-4行所示,溢出数据的长度由已被符号化的输入n决定。然而,当涉及循环时,现有的符号执行技术存在局限性——符号值n不会传播到索引i。这种输入相关的循环问题是符号执行中一个常见的问题,尚未被完全解决。为了解决这一问题,我们借鉴了SAGE的方法,该方法利用一些简单的循环守卫模式匹配规则来动态推断索引的公式。我们遵循相同的假设,即归纳变量(例如索引i)与其守卫条件是线性关系。由于我们只需关注涉及脆弱性点的特定循环,我们决定使用Angr进行静态分析(而不是原论文中建议的动态分析)。如前文§4.2所述,处理循环的能力对于能力总结至关重要。

void process(char* input, int n) {
    char buffer[10];
    for (int i = 0; i < n; i++) {
        buffer[i] = input[i];
    }
}

在传统的符号执行中,符号值(如n)在进入循环后,通常不会直接影响到循环变量(如i)的具体值。此时每次循环都会产生新的路径约束,而且是嵌套的,这样会造成路径约束剧增。对于循环,这可能导致路径爆炸(path explosion),因为每个路径约束都有可能产生一个新的路径。而对应的新路径正好可以认为是n的值的一一尝试。此外,符号执行器可能无法精确地确定i如何随着每次迭代而变化,因为i的变化逻辑是明确的(每次加1),但其最终值依赖于符号值n

大概流程

  1. 循环展开
    有限展开:符号执行器可能会选择将循环展开有限的次数,比如展开3次,那么无论n的值是多少,循环只会执行0到3次,每次展开都会产生新的路径分支。
  2. 路径分支
    分支点:在每次循环迭代结束时,符号执行器面临一个分支点:继续循环还是退出循环。对于每个可能的n值,这个决策会产生不同的路径:
    继续循环:如果i < n为真,继续下一次迭代。
    退出循环:如果i >= n,则退出循环。
  3. 符号约束
    约束生成:在每次迭代中,符号执行器会生成或更新路径约束。对于i < n,如果n是符号的,那么每次迭代都会添加或更新这个约束。
    例如,第一次迭代后,约束是i < n && i == 1。
    第二次迭代后,约束可能更新为i < n && i == 2。
  4. 约束求解
    求解与分支:符号执行器使用约束求解器来决定是否存在一个n的值使得当前路径可行。如果可行,则继续沿这条路径;如果不可行,则回溯到上一个决策点去探索其他路径。

解决方法 - 循环守卫模式匹配

  • 动态推断索引公式:SAGE(以及类似的方法)通过观察循环的结构来解决这个问题。它们使用模式匹配来识别常见的循环模式,比如for (int i = 0; i < n; i++),并假设这种循环中的索引变量i与循环的边界条件(即n)之间存在线性关系。

  • 为什么能解决问题

    • 模式识别:通过识别出i < n这样的守卫条件,工具可以推断出i的上限是n,即使n是符号的。这允许工具在符号执行时为i创建一个与n相关的表达式,而不是简单地尝试每个可能的i值。
    • 线性关系假设:假设in之间是线性关系,工具可以构造一个公式,比如i = k where k goes from 0 to n-1。这简化了对循环的分析,因为现在工具可以用这个公式来模拟i的变化,而不是对每个可能的n值都进行完整的路径探索。(在不实际执行循环的每一步的情况下,来达到循环的行为。)

处理符号索引和循环边界以解决路径冲突。路径冲突发生在尝试超越在给定PoC(概念验证)的符号追踪期间收集的路径约束时(例如,尝试少写一个字节以调整符号长度)。问题在于,PoC 只能具体地遍历一条特定的路径,任何偏离(例如,不同的数组索引和不同数量的循环迭代)都会创建与之前收集的约束不兼容的新约束。这是一个类似的问题,之前的工作 [13] 中也遇到了,他们试图通过所谓的‘路径揉捏’来解决这些冲突,即暂时偏离路径并在某个点合并回原来的路径,以达到相同的关键点。这种分析非常耗时,平均需要2.62小时,这对于Linux内核这样的大型代码库来说是不现实的(并且在能力探索后,我们可能需要评估每个漏洞的数百个PoC)。
好的,让我们通过一个具体的例子来解释路径冲突以及如何处理它们。

问题描述

路径冲突发生在尝试超越给定PoC(概念验证)期间收集的路径约束时。例如,假设我们有一个代码片段:

void example_function(int n) {
    char buffer[64];
    for (int i = 0; i < n; i++) {
        buffer[i] = 'A';
    }
}

假设我们在符号执行过程中,n 被具体化为 64。那么,路径约束会是:

  1. 0 < n
  2. 1 < n
  3. 2 < n
  4. 63 < n
  5. 64 >= n

这些约束实际上将 n 限定为 64,因为只有在 n 为 64 时,这些约束才能全部满足。

如果我们现在尝试生成一个新的PoC,通过少写一个字节(例如,让 n = 63),这会创建与之前收集的路径约束不兼容的新约束:

  1. 0 < 63
  2. 1 < 63
  3. 2 < 63
  4. 62 < 63
  5. 63 >= 63

这些新约束显然与原始约束不同,导致路径冲突。

我们的观察是,由于数组索引和循环边界的具象化导致的过度约束可以通过简单地去除这些约束来轻松处理。其基本原理是,内存索引在每次运行时都会变化,因为动态分配对象的地址不太可能保持不变,因此在内存访问操作中通过添加约束将符号索引具象化会禁止求解器改变索引,从而不必要的过度限制搜索空间。所以会多了些过度约束。例如,当在图3b中的地址 ‘vul[i/8]’ 发生写入时,S2E 引入了一个约束,将相应的符号地址具象化为一个值,以减少建模符号索引的开销。由于我们已经抽象/建模了易受攻击的对象(如§4.4所述),可以自动检测这些约束并简单地消除它们。同样,假设在图6中,参数 n 是一个符号值(在符号追踪期间),其具体值为64,循环 i 增加64次,导致65个路径约束 0 < n, 1 < n, …, 63 < n 和 64 >= n,这些约束实际上强制 n 为64。直观上,在提取循环的能力时,我们已经建模了循环守卫(例如 n)和循环体执行次数之间的关系,因此丢弃这些约束不会使我们高估能力。在我们的解决方案中,我们简单地去除这些不必要的约束,这使得求解器可以在符号索引和循环边界的有效范围内搜索,生成不同于之前使用的PoC(即系统调用参数将发生变化,以使越界写入和越界长度适应覆盖关键字段的目标对象)。在我们的评估中,确实发现这种放松约束的方法从未导致任何问题(例如,错误的解决方案)。"

void loop(int n) { // 假设 n = 64
    char *vul = (char*)kmalloc(32);
    for (int i = 0; i < n; i++) {
        vul[i] = 0; // OOB Point
    }
}

现有解决方案及其问题

在之前的工作[13]中,他们尝试通过“路径揉捏”来解决这些冲突。路径揉捏的基本思想是通过暂时偏离路径并在某个点合并回原来的路径,以达到相同的关键点。这种方法非常耗时,因为需要大量的计算来找到合适的偏离点和合并点,平均需要2.62小时。

我们的方法

我们的观察是,由于数组索引和循环边界的具体化导致的过度约束可以通过简单地去除这些约束来轻松处理。

实例解释

假设我们有如下代码:

void loop(int n) { // 假设 n = 64
    char *vul = (char*)kmalloc(32);
    for (int i = 0; i < n; i++) {
        vul[i] = 0; // OOB Point
    }
}

在符号执行过程中,假设 n 被具体化为 64,我们会得到以下路径约束:

  1. 0 < n
  2. 1 < n
  3. 2 < n
  4. 63 < n
  5. 64 >= n

这些约束强制 n 为 64,从而导致过度具体化。

为了处理这种过度具体化,我们可以简单地移除这些约束。这意味着我们不再强制 n 为 64,而是允许 n 保持符号状态,使得求解器可以探索不同的 n 值及其对应的路径。

通过移除这些具体化的约束,求解器可以探索如下情况:

  • n 可以是 63 或其他值
  • 循环次数不再固定为 64 次

这样一来,我们就能生成一个不同的 PoC,其中 n 不再被限制为 64,而是可以取其他值。这种方法使得我们能够在不影响漏洞利用能力的情况下探索更多的路径。


消除不必要的约束:由于内核的复杂性,我们收集的路径约束可能过于复杂,以至于在有限的时间预算内无法解决。为了解决这个问题,一些由函数(如 printk())引入的复杂约束与我们的目标无关,可以直接忽略。另一种特殊情况是竞争条件,其中具有相同参数的系统调用被反复调用,累积了重复的约束。我们的系统识别每个线程中的这些重复约束,并保留最后一个(当发生越界访问时)。由于竞争条件线程通常在一个循环中反复执行一系列系统调用,我们在每个循环的开始处注释PoC(概念验证代码),以告知我们的系统线程即将重新执行其系统调用序列。正如第6.4节所示,所提出的优化措施显著提高了可利用性评估的效率。


目标收集:我们解析了Linux内核的调试信息,以检索所有结构体,并仅保留那些包含关键数据(如指针或引用计数器)的结构体,总计2615个。除了关键数据的类型外,我们还收集了其偏移量和目标对象的大小,因为这些构成了目标约束。理想情况下,我们还应该获取有关目标对象使用的信息,例如如何分配它、如何触发其关键数据的引用等。为此,我们实现了一个LLVM pass来构建整个内核的调用图,以便我们可以搜索从系统调用可达的分配和引用位置。然而,由于调用图不准确且静态分析不提供具体的输入,我们仍然对每个结构体进行可利用性评估,但依赖调用图来优先排序候选检查的顺序。同时,我们在分析过程中编码新目标对象的知识。此外,我们从公开可用的漏洞利用中收集了常用对象(如 keypacket_sockip_mc_socklist),这些对象可以满足我们构建的大多数漏洞利用。SLAKE [20] 是最近发表的一项同期工作,用于相同目的,利用模糊测试自动系统地生成所需的输入,这些输入可以导致更完整的一组内核对象的分配和引用。KOOBE 可以直接从这样的系统输出中受益。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

看星猩的柴狗

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值