这是内核漏洞挖掘技术系列的第三篇。
第一篇:内核漏洞挖掘技术系列(1)——trinity
第二篇:内核漏洞挖掘技术系列(2)——bochspwn

前言

上一篇文章我们讲解了内核double fetch漏洞和Project Zero的j00ru开源的这种漏洞的挖掘工具bochspwn(https://github.com/googleprojectzero/bochspwn)的原理。这一篇文章我们继续讲解内核中未初始化导致的信息泄露漏洞和Project Zero的j00ru开源的同样基于bochs的插桩API实现的这种漏洞的挖掘工具bochspwn-reloaded(https://github.com/googleprojectzero/bochspwn-reloaded)的原理。环境搭建可以直接参考github上的文档,可能会遇到的问题也在上一篇文章中有说明,所以就不再赘述环境搭建的步骤了。由于内容比较多,所以文章分成两篇,这一篇基于论文讲解信息泄露漏洞和bochspwn-reloaded的设计,下一篇讲解bochspwn-reloaded的代码和信息泄露漏洞的其它挖掘方法。

为什么会造成信息泄露漏洞

内核中信息泄露漏洞的产生主要有两方面的原因,一是C语言带来的问题,二是内核设计和编程模式带来的问题。

C语言带来的问题

未初始化变量

在C语言中未初始化变量会继承其相应内存区域的旧值。比如下面这样的系统调用每次就会泄露栈上的四个字节。

但是这样单个变量的信息泄露其实并不是很常见,因为编译器通常会警告,也较容易在开发和测试的过程中发现。在下面这样的系统调用中结构体中的Reserved成员没有被用到,但是也会被拷贝回用户态,同样会泄露栈上的四个字节。

结构体对齐和填充

结构体中的所有成员和整个结构体都是对齐的。在必要的地方,填充字节被人为地插入到结构体中。虽然不能在源代码中直接访问,但是这些字节仍然继承了相应内存区域的旧值。下面的系统调用中结构体中的每个成员都被正确初始化,但是由于存在填充字节,所以仍然会造成信息泄露。


对于这个问题,有下面这几种解决方案:

  1. 在代码中声明填充字节并初始化。
  2. 设置__attribute__((packed))禁止结构体对齐。
  3. 将结构体序列化为字节缓冲区并在另一端反序列化。

1需要花费程序员大量额外的精力;3可能会造成内核性能下降;我猜测2可能是内核开发实践中比较好的一个解决方案。

联合体中不同大小的成员

如果联合体由不同大小的成员组成并且只设置了较小的成员,为较大的成员分配的剩余字节就没有初始化。下面的系统调用中联合体是8个字节,但是只设置了较小的成员的值,因此剩下四个字节未初始化,造成信息泄露。

总结

在前面的例子中我们可以看到,sizeof操作符的使用和内核信息泄露有直接的关系,因为它导致最终复制的数据比初始化的数据更多。更深层次的原因在于C语言缺少在不同层次之间安全传输数据的功能,如果不采取编译器/操作系统/语言规范这样级别的解决方案,这样的漏洞可能长期存在。

内核设计和编程模式带来的问题

动态分配中的内存重用

前面几个例子都是重用了栈上的值,在windows/linux中也存在动态分配的内存重用机制,同样会导致信息泄露的风险。

固定长度的数组

大的缓冲区很少被全部使用,剩下的空间通常不会被重置,这可能导致特别长的连续的内核内存泄露。下面的系统调用中RtlGetSystemPath函数加载系统路径到一个本地缓冲区,如果调用成功,所有260个字节都传递给调用者而不考虑字符串的实际长度。

分配缓冲区未全部初始化

大多数系统调用接受指向用户态缓冲区的指针以及缓冲区的大小。在大多数情况下,关于大小的信息应该只用于确定缓冲区是否足够大以接收系统调用的输出数据,但不应该影响复制了多少内存。但是内核中存在试图填充用户态缓冲区的每个字节而不考虑要复制的实际数据量的情况。下面的系统调用目的是提供给用户态3个32位的值,一共12个字节。在检查用户态缓冲区大小足够之后,内核本来可以分配一个12字节的缓冲区然后拷贝到用户态,但是内核根据用户态传入的OutputLength分配缓冲区并全部拷贝到了用户态。用户态只需要12个字节的数据,剩下的字节没有初始化并且泄露给了用户态。

bochspwn-reloaded设计原理

shadow memory

bochspwn-reloaded的原理是污点追踪,污点信息记录在bochs进程的shadow memory内存区域中,将每个字节映射到相应的元数据。在32位系统中,除了污点标记之外,元数据还包括下面这些信息。

  • size:内存分配的大小
  • base address:内存分配的起始地址
  • tag/flags:windows系统上是池分配的tag,linux系统上是堆分配的flag
  • origin:请求分配的指令的地址

为了节约空间,除了taint之外其它信息的粒度由1个byte增加到8个byte。类型、粒度和内存使用情况如下。

在64位的系统中,元数据还按照32位系统中的结构就不太现实了,所以size,base address和tag/flags就不要了,但是origin还是需要保留的,毕竟需要跟踪控制流定位漏洞点。origin不再静态分配,改用std::unordered_map<uint64_t, uint64_t> origins表示。就算这样taint占用的内存也不可能一次分配成功。在windows系统上不支持Memory overcommitment(分配给虚拟设备的内存大于它们所在的物理设备的内存),但是可以使用异常分配机制实现。
1.使用VirtualAlloc和MEM_RESERVE标志保留shadow memory。
2.使用AddVectoredExceptionHandler函数设置异常处理程序。
3.在异常处理程序中,检查被访问的地址是否位于shadow memory中,并且异常代码等于EXCEPTION_ACCESS_VIOLATION。如果是这种情况,使用VirtualAlloc和MEM_COMMIT标志提交shadow memory并返回EXCEPTION_CONTINUE_EXECUTION。
除了设置shadow memory之外,新分配的堆/池用0xaa填充,栈用0xbb填充。这不仅能够帮助调试,还有助于发现隐藏的未初始化漏洞。

栈污点标记

用伪代码表示的栈污点标记的大致逻辑如下。指令执行前的回调函数标志指令是否会修改栈指针,指令执行后的回调函数检查标志,如果栈指针被修改并且ESP变小了,对ESP(new)-ESP(old)做污点标记。

对于32位的windows系统还有两个问题。
1.在32位的windows系统中有一个内置的chkstk函数,它等价于alloca,相关代码如下所示。高亮的xchg eax, esp将更新后的esp保存到eax中。为此我们需要读取eax寄存器中的值作为origin。

2.在32位的windows系统中__SEH_prolog4和__SEH_prolog4_GS用来为含有异常处理程序的函数创建栈帧,为此我们需要读取[EBP-8]的值作为origin。

池/堆污点标记

windows x86

在32位的windows系统中函数遵循stdcall调用约定,即参数存储在栈上,返回值存储在EAX寄存器。windows内核中几乎所有的内存分配函数最终都会调用ExAllocatePoolWithTag函数,hook这个函数就可以获得内核中几乎所有内存分配的信息。

windows x64

通过RCX,RDX和R8寄存器将参数传递给ExAllocatePoolWithTag函数的参数,当函数返回时可能其中的值已经被更改。所以需要hook函数两次,第一次读取origin和size,第二次读取base address,根据这些信息做污点标记。
需要特别注意的一个情况是内核中特定子系统使用的内核分配器。比如下面是win32k!AllocFreeTmpBuffer函数的伪代码。内存区域只分配一次,而且永远不会释放,但是图形子系统的多个部分会重用它,这对我们的检测不利。我们可以patch代码让它每次都会调用AllocThreadBufferWithTag函数。

linux x86

在linux x86系统中hook了更多的函数,但是原理类似。下一篇文章讲解代码时再详细提。

污点清除

内存被除了memcpy外的指令覆写时将清除污点。污点也可以在内存被释放时清除,比如add esp指令或者调用ExFreePoolWithTag/kfree等函数时。在bochspwn-reloaded中只对32位的linux/windows实现了内存被释放时清除污点。

污点传播

当检测到内核到内核的数据拷贝时进行污点传播。

windows x86

在windows内核中存在下面这些拷贝数据的函数:memcpy/memmove/RtlCopyMemory和RtlMoveMemory。在32位的windows系统上,RtlCopyMemory相当于memcpy的宏;RtlMoveMemory基本基于rep movs指令;除了下面这几种情况外,memcpy和memmove基本上也是基于rep movs指令的:
1.源缓冲区和目标缓冲区重叠
2.目标指针不是4字节对齐的
3.复制长度小于32字节
4.复制长度不是4字节对齐的
1,2,4这几种情况都比较罕见,比较麻烦的是3,它可能会导致内核地址空间中大量较小对象没有被标记污点。解决方法仍然是patch代码,将下面cmp ecx, 8改成cmp ecx, 0使得rep movsd指令永远都会被调用(同样也要patch memmove函数)。

windows x64

在64位windows上有两个处理逻辑。
1.内存拷贝函数不再使用rep movs指令,而是被优化成mov指令和等价的SSE指令的形式。因为它们都有共同的实现,所以在配置文件中设置了memcpy_signature,匹配上了就调用handle_memcpy函数进行污点传播。

2.更高的windows版本上越来越多固定长度的memcpy被编译为内联的mov指令序列,而不是rep movs指令或者直接调用库函数。

linux x86

设置CONFIG_X86_GENERIC=y和CONFIG_X86_USE_3DNOW=n然后如下图所示patch代码,使得所有的memcpy都调用rep movsd指令和rep movsb指令实现的__memcpy。

漏洞检测

当检测到内核到用户的数据拷贝时插桩指令检测到含有未初始化的字节就报告漏洞。
在linux中,有两个接口可以方便地将数据从内核态写入用户态:copy_to_user和put_user。copy_to_user等价于memcpy,符合我们漏洞检测的逻辑;put_user允许内核将字符或整数等简单的数据类型直接写入用户态,在二进制层面数据是通过寄存器传递的。这种类型的信息泄露我们前面通用的漏洞检测逻辑就检测不到了。
在代码中我们可以看到传递给用户态的数据被存储在名为__x的本地辅助变量中。

我们修改这个宏,添加下面两个指令。

在bochs中prefetcht1和prefetcht2指令由BX_CPU_C::NOP方法处理。这使得它们成为用作hypercall的主要候选对象——对guest的执行没有任何影响,而是用于与模拟器通信的特殊操作码。在这种情况下,插桩指令检测prefetcht1的执行,把它当作一个信号。如果此时读取了未初始化内存,以同样的方式报告潜在的漏洞。prefetcht2指令禁用了该信号。

点击收藏 | 0 关注 | 1 打赏
  • 动动手指,沙发就是你的了!
登录 后跟帖