本文是自己对McAfee安全实验室Hardik Shah发现CVE-2021-1665过程文章的翻译(原文点击这里),Hardik在Windows fuzz方面研究的内容颇多,在Youtube上也有自己的系列视频教授别人如何进行模糊测试,通过他分享的这篇文章可以学习一些漏洞挖掘流程和利用补丁对比回溯漏洞成因的思路。

背景介绍

Microsoft Windows 的图形设备接口简称 GDI+,允许各种应用层的程序调用打印机或视频显示器的图形功能。Windows 应用程序并不直接访问设备驱动程序等图形硬件,而是通过与 GDI 接口来与设备驱动程序交互。这样,就存在了一个 Windows 应用程序的抽象层和一组可供所有用户调用的通用 API。

因为 GDI+ 很复杂,在网上GDI+ 也有各种漏洞的已知历史。McAfee 持续的对各种开源和闭源软件进行模糊测试,其中就包括 了Windows GDI+。在过去几年中,McAfee向 微软也报告了各种 Windows 组件(包括 GDI+)中的问题,并收到了相应的 CVE编号。

在这篇文章中,详细介绍了 CVE-2021-1665:GDI+ 远程代码执行漏洞的发现过程与漏洞分析,这是使用 WinAFL 发现的漏洞之一。该漏洞已在 2021 年 1 月在 Microsoft 公布的补丁中解决。

什么是Winafl?

WinAFL 是流行的 Linux模糊器 AFL 的 Windows 版本,是由 Google Project Zero 的 Ivan Fratric 发布并维护的。WinAFL 通过 DynamoRIO 进行二进制动态插桩与覆盖率反馈,想要针对应用程序进行模糊测试需要一个名为 Harness 的程序(部分文章中称其为Fuzz Driver),Harness 只不过是一个简单的程序,它可以调用我们想要 fuzz 的 API函数。

以下是我们所使用的Harness小程序,用于模糊测试 GDI+ 图像和 GetThumbnailImage API函数。

image-20211004160401285

如您所见,这段代码只是从提供的输入文件中创建一个新的图像对象,然后调用另一个函数来生成缩略图。这是一个极好的攻击媒介,如果使用缩略图,可能会影响各种 Windows 应用程序。此外,这个过程需要很少的用户交互,因此使用 GDI+ 并调用 GetThumbnailImage API 的软件容易受到攻击。

收集语料库

一个好的语料库能够为模糊测试提供坚实的基础。您可以使用 Google 或 Github 来广泛的收集语料信息,并在针对不同漏洞发布的各种公共 EMP 文件中获得额外的测试语料库。本文更改了 Microsoft 站点上提供的示例代码来自主的生成一些测试文件,比如以下代码生成一个包含 EMFPlusDrawString 和其它记录的 EMF 文件。

image-20211004160831893

该部分内容可以参考微软的官方文档:https : //docs.microsoft.com/en-us/openspecs/windows_protocols/ms-emfplus/07bda2af-7a5d-4c0b-b996-30326a41fa57

最小化语料库

收集初始语料库文件后,应将其最小化。为此,您可以使用名为 winafl-cmin.py 的实用程序,命令如下所示:

winafl-cmin.py -D D:\\\\work\\\\winafl\\\\DynamoRIO\\\\bin32 -t 10000 -i inCorpus -o minCorpus -covtype edge -coverage_module gdiplus.dll -target_module gdiplus_hardik.exe -target_method fuzzMe -nargs 2 — gdiplus_hardik.exe @@

Winafl是如何工作的?

WinAFL 使用了内存模糊测试的概念,我们需要为 WinAFL 提供一个函数名称,Winafl将在函数开始时保存程序状态,并从语料库中获取一个输入文件对其进行变异操作,并将其提供给该函数。

Winafl将监视任何新出现的代码路径或崩溃。如果它找到一个新的代码路径,它会将该输入文件视为一个有趣的测试用例,并将其添加到队列中以进行进一步的修改(变异);如果发现任何崩溃,它会将崩溃文件保存在 crashes 文件夹中。

下图显示了模糊测试流程:

image-20211004161529149

使用 WinAFL 进行模糊测试

编译Harness程序、收集语料库并最小化后,您可以运行以下命令使用WinAFL进行漏洞挖掘了!

afl-fuzz.exe -i minCorpus -o out -D D:\\work\\winafl\\DynamoRIO\\bin32 -t 20000 —coverage_module gdiplus.dll -fuzz_iterations 5000 -target_module gdiplus_hardik.exe -target_offset 0x16e0 -nargs 2 — gdiplus_hardik.exe @@

结果

在经过一段时间的模糊测试后,发现了不少的Crash,经过复现发现在“ gdiplus!BuiltLine::GetBaselineOffset ”中发现了一个问题,它在调用的堆栈中如下所示:

image-20211004161915453

如上图所示,程序在尝试读取 edx+8 指向的内存地址处的数据时崩溃。可以看到 ebx、ecx 和 edx 包含 c0c0c0c0,这也就是说为二进制启用了页堆(Page Heap),我们还可以看到 c0c0c0c0 作为参数传递给“ gdiplus!FullTextImager::RenderLine ”函数。

利用补丁比较进一步探索原因

为了找出根本原因,您可以使用 IDA BinDiff 插件来识别补丁文件中的更改。如果幸运的话,只需查看更改的代码,就可以轻松找到根本原因。因此,我们可以生成 gdiplus.dll 打补丁和未打补丁版本的两种 IDB 文件,然后运行 IDA BinDiff插件来查看它们有哪些不同。

我们可以看到在补丁文件中添加了一个新函数,这似乎是BuiltLine 对象的析构函数:

image-20211004162536761

您可以看到一些函数的相似度分数 < 1,其中之一是FullTextImager::BuildAllLines,如下所示:

image-20211004162729903

为了验证这个函数是否真的被打过补丁,我们可以在windbg中运行我们的测试程序(harness)和POC(crash),并在这个函数上设置一个断点。我们观察到断点被击中时,程序不再崩溃:

image-20211004163003533

接下来,我们需要确定此函数中进行了哪些更改以修复此漏洞。我们可以切换函数的流程图对比来进行查找:不幸的是,有如此多的变化,仅通过查看差异无法识别出漏洞位置。

image-20211004163348840

左侧展示了一个未打补丁的 dll,而右侧展示了一个打过补丁的 dll:

  • 绿色表示补丁和未补丁的相同块。
  • 黄色表示未补丁和已补丁的 dll 之间发生了一些变化。
  • 红色表示 dll 中补丁前后出现比较明显的差异。

如果我们放大黄色块,我们可以看到以下内容:

image-20211004163548595

我们可以看到一些变化,由于已从补丁后的 DLL 中删除了一些块,因此仅靠补丁差异不足以确定漏洞的根本原因。但是,它确实提供了有关在使用其他调试方法(如 windbg)时在哪里查找内容的宝贵提示。我们可以在上面的 BinDiff 输出中看到一些观察结果:

  • 在未打补丁的 DLL 中,如果我们仔细检查,我们可以看到调用了“ GetuntrimmedCharacterCount ”函数,随后又调用了另一个函数“ SetSpan::SpanVector
  • 在修补后的 DLL 中,我们可以看到调用了“ GetuntrimmedCharacterCount ”,其中检查了存储在EAX寄存器中的返回值。如果是零,则控制跳转到另一个位置的(BuiltLine 对象的析构函数),这是新添加的代码:

image-20211004163934672

所以我们可以假设这是修复漏洞的地方。现在我们需要弄清楚以下几点:

  1. 为什么我们的程序会因提供的 POC 文件而崩溃?
  2. 文件中的哪个字段导致此崩溃?
  3. 该字段的值是什么?
  4. 导致此崩溃的程序条件是什么?
  5. 漏洞是如何解决的?

EMF 文件格式

EMF 也称为增强型元文件格式,用于独立存储图形图像设备。EMF 文件由各种长度可变的记录组成。它可以包含各种图形对象的定义、绘图命令和其他图形属性。

image-20211004164324662

通常,一个 EMF 文件包含以下记录:

  1. EMF 标头 - 包含有关 EMF 结构的信息。
  2. EMF 记录 - 这可以是各种长度可变的记录,其中包含有关图形细节、绘图顺序等的信息。
  3. EMFEOF 记录 - EMF 文件中的最后一条记录。
  4. 可以在位于以下 URL 的 Microsoft 站点上找到 EMF 文件格式的详细规范:

https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-emf/91c257d7-c39d-4a36-9b1f-63e3f73d30ca

在 EMF 文件中定位漏洞记录

通常,EMF 中的大多数问题都是由于格式错误或损坏的记录造成的。我们需要找出导致这次崩溃的记录类型。为此,如果我们查看调用堆栈,我们可以看到以下内容:

image-20211004164439912

我们可以注意到对函数“ gdiplus!GdipPlayMetafileRecordCallback ”的调用

image-20211004164502396

通过在该函数上设置断点并检查参数,我们可以看到以下内容:

image-20211004164540535

我们可以看到EDX包含一些内存地址,我们可以看到给这个函数的参数是:00x00401c、0x00000000 和 0x00000044。

除此之外,在检查EDX指向的位置时,我们可以看到以下内容:

image-20211004164613679

如果我们检查我们的 POC EMF 文件,我们将看到该数据属于文件中的偏移量 0x15c。

image-20211004164635282

通过EMF规范,手动解析记录,我们可以很容易地看出这是一个“ EmfPlusDrawString”记录,该记录的格式如下。

image-20211004164743038

在我们的例子中:

Record Type = 0x401c EmfPlusDrawString record

Flags = 0x0000

Size = 0x50

Data size = 0x44

Brushid = 0x02

Format id = 0x01

Length = 0x14

Layoutrect = 00 00 00 00 00 00 00 00 FC FF C7 42 00 00 80 FF

String data =

image-20211004164856788

现在我们似乎已经找到了导致崩溃的记录,接下来就是找出程序崩溃的原因。如果我们调试和检查代码,我们可以看到控制到达了一个函数“ gdiplus!FullTextImager::BuildAllLines ”。当我们反编译这段代码时,我们可以看到如下内容:

image-20211004165020772

下图显示了函数调用的层次结构。

image-20211004165049682

执行流程总结

  1. 在“ Builtline::BuildAllLines”函数内部,有一个while循环,程序在其中分配了0x60字节的内存。然后它调用“ Builtline::BuiltLine”
  2. “Builtline :: BuiltLine”函数将数据迁移到新分配的内存,然后将其称之为“ BuiltLine :: GetUntrimmedCharacterCount ”。
  3. BuiltLine::GetUntrimmedCharacterCount ”的返回值被添加到循环计数器中,即ECX。这个过程会一直重复,直到循环计数器(ECX)< 字符串长度(EAX),这里是0x14。
  4. 循环从 0 开始,所以它应该在0x13 处终止,或者应该在“ GetUntrimmedCharacterCount”的返回值为0时终止。
  5. 但是在易受攻击的DLL 中,由于循环计数器增加的方式,程序不会终止。这里,“ BuiltLine::GetUntrimmedCharacterCount”返回0,它被添加到循环计数器(ECX)并且不会增加ECX值。它分配 0x60 字节的内存并创建另一行破坏数据导致程序崩溃。循环执行了21次而不是20

详情

  1. 在“ Builtline::BuildAllLines”内部将分配0x60或96字节的内存,在调试器中如下所示:

image-20211004165336100

  1. 然后调用“ BuiltLine::BuiltLine ”函数并将数据移动到新分配的内存中:

image-20211004165406705

  1. 这发生在 while 循环中,并且有一个对“ BuiltLine::GetUntrimmedCharacterCount ”的函数调用。
  2. BuiltLine::GetUntrimmedCharacterCount ”的返回值存储在0x12ff2ec位置。该值将为 1,如下所示:

image-20211004165449904

  1. 此值被添加到 ECX:

image-20211004165532361

  1. 然后检查确定是否ecx< eax。如果为真,它将继续循环,否则将跳转到另一个位置:

image-20211004165606361

7、现在在易受攻击的版本中,如果“ BuiltLine::GetUntrimmedCharacterCount ”的返回值为0,则循环不存在,这意味着将 0 添加到 ECX 并且 ECX 值不增加。所以需要运行20次的循环一共执行了21次,这就是根本原因。

同样经过一些调试,我们可以弄清楚为什么EAX包含 14。它是从 POC 文件中的偏移量0x174 处读取的:

image-20211004165850321

如果我们还记得,这是EmfPlusDrawString记录,0x14是我们之前提到的长度。

稍后,程序使用“ FullTextImager::Render ”函数破坏 EAX 的值,因为它读取未使用的内存:

image-20211004165920282

这将作为参数传递给“ FullTextImager::RenderLine ”函数:

image-20211004165943368

稍后,程序将在尝试访问此位置时崩溃。

image-20211004170004122

我们的程序在处理EMF 文件中的EmfPlusDrawString记录时崩溃,同时访问了无效的内存位置和处理字符串数据字段。总结来说,程序没有验证“ gdiplus!BuiltLine::GetUntrimmedCharacterCount ”函数的返回值,这导致采用不同的程序路径破坏寄存器和各种内存值,最终导致崩溃。

这个问题如何解决?

正如我们通过查看上面的补丁差异发现的那样,添加了一个检查来确定“ gdiplus!BuiltLine::GetUntrimmedCharacterCount ”函数的返回值。

image-20211004170125822

如果返回的值为 0,则对包含计数器的xor 的EBX进行编程,并跳转到为内置对象调用析构函数的位置:

image-20211004170143828

这是防止问题的析构函数:

image-20211004170202997

结论

GDI+ 是一个非常常用的 Windows 组件,此类漏洞可能会影响全球数十亿个系统。我们建议您对自己电脑上的应用定时更新,并使您的 Windows 部署保持最新。

McAfee 不断对各种开源和闭源库进行模糊测试,并通过与供应商合作负责任地披露这些问题,为他们提供适当的时间来解决问题并根据需要发布更新。

感谢 Microsoft 解决此问题并发布更新。