在2018年的最后一天,我在Internet Explorer
中发现了一个类型混淆漏洞,可以利用它产生一个write-what-where
原语。它于今年四月得到修复,编号为CVE-2019-0752。作为练习,我使用原始的开发技术为此漏洞编写了一个完整的exp。即使漏洞本身仅产生受控写入并且无法触发以产生信息泄漏,但是仍然存在直接且高度可靠的代码执行路径
此外值得注意的一点是,该利用过程不需要使用shellcode
在IE == 8
或更低的仿真级别,Internet Explorer
通过该IDispatchEx
机制执行DOM
方法和属性。尽管这是最自然的实现选择,但在性能方面还有很多不足。为了提高性能,IE
为DOM
属性和方法的子集实现了快速路径,这些是通过位于静态表mshtml!_FastInvokeTable
中的函数指针调用的。当可用时,快速路径通过避免使用某些常规的调度机制来实现加速。以下反编译代码来自mshtml!CBase::ContextInvokeEx
中的IDispatchEx::InvokeEx
:
如上述的代码片段显示,如果请求的是put
操作,则不会使用加速调用机制
原因很显然,对于给定的方法或属性,_FastInvokeTable
只能包含一个条目,并且从属性的角度来讲,它将指向更频繁调用的属性getter
而不是setter
上述代码中漏洞的根源在于IDispatchEx
允许两种不同的属性放置于同一处的事实。典型属性put
将例如整数或字符串的标量值分配给属性,此操作类型由标志DISPATCH_PROPERTYPUT
指出,其值为0x4
。另一种属性的put
是将对象引用分配给属性,在使用时,需要在调用时提供标志值DISPATCH_PROPERTYPUTREF
,其值为0x8
。
有点令人困惑的是,标志值被定义为这两个看似不相关的操作类型,因此无法通过DISPATCH_PROPERTYPUT
位检测到putref
类型的操作。因此,在上述代码中,类型的操作DISPATCH_PROPERTYPUTREF
将被错误地路由到_FastInvokeTable
属性的条目,而其中存放的属性是get
方法的指针。get
方法和put
方法的函数签名必然是不同的,因此,这里传递的、用于给属性赋值的值就会出现类型混淆。
接下来发生的事情,取决于与被调用的特定属性相对应的混淆的get/put
函数的签名。
我找到了三个可能的函数签名子句,如下所示:
在每种情况下,我们都能够调用get
方法来代替put
方法。
对于Case 1
来说,并没有安全隐患,会调用get_className_direct
函数,并且对于其out参数(类型为BSTR
*
),传递的是不兼容类型BSTR
的值。当get_className_direct
执行时,它会实例化一个新的BSTR
来保存get
操作的结果,并在BSTR* value
参数指定的地址处写入一个指向这个新字符串的指针。在我们的例子中,这会覆盖所提供的BSTR
的字符数据的前四个字节。除了覆盖它们外,不会发生其他的内存损坏。注意,4字节指针值实在太短了,绝不可能溢出BSTR分配的字符数据部分并覆盖相邻的内存空间。此外,脚本无法访问损坏的字符串数据以进行信息泄漏,因为传递给get_className_direct
的BSTR
是临时的。之后,脚本所访问的BSTR
的内存空间,和一开始的是不同的。因此,案例1是无法利用的。
对于Case 2
来说,被利用的可能性更大一点,通过属性的put操作赋值的对象将作为struct tagVARIANT
的值进行传递,但由于将调用get
方法,因此,tagVARIANT
结构的前4个字节将被解释为VARIANTARG *
——一个指向将被结果值填充的VARIANTARG
结构的指针。当然,我们能够对tagVARIANT
的前4个字节施加部分控制,使其等于指向我们希望破坏的数据的地址。然而这种情况下,混淆的get
和put
函数具有不同的堆栈参数总长度,因此这里很难加以利用。当getter
返回时,堆栈指针将无法进行适当的调整。函数调用方会立即检测到这种差异,并且安全地关闭该进程。
相比之下,Case 3
提供了出色的可利用性。设置属性时传入的值将传递给CElement::get_scrollLeft
,后者会将这些值解释为int*
指针写入结果的位置。因此,scrollLeft
的当前值将按照我们选择的地址写入内存。之后,控制权将返回给这个脚本。这为攻击者提供了一个write-what-where
原语。唯一的限制似乎是scrollLeft
的值不能设置为大于0x001767dd
的值,所以,这个值就是我们可以写入的最大的DWORD
值,然而这也不会造成很大的障碍。
以下PoC
演示了如上所述的write-what-where
原语。注意,这里使用的是VBScript
。据我所知,这是生成所需DISPATCH_PROPERTYPUTREF
的唯一方法。
为了触发该漏洞,我们可以将一个MyClass
实例赋给scrollLeft
这样,系统会生成一个带有标志DISPATCH_PROPERTYPUTREF
的调用。由于mshtml!CBase::ContextInvokeEx
中存在安全漏洞,故被调用的将是CElement::get_scrollLeft
,而不是CElement::put_scrollLeft
我们知道,CElement::put_scrollLeft
具有一个整型参数,因此,调度机制会将MyClass
实例强制转换为整型,当CElement::get_scrollLeft
接收这个整数后,后面的函数会将该它解释为指向内存位置的指针
总而言之,值0x1234
将写入0xdeadbeef
。由于实现细节的原因,这里首先会对0xdeadbeef
进行一些无关的读写操作。为了查看整体效果,最简单的方法是使用已知的有效地址替换PoC
中的0xdeadbeef
利用该漏洞的主要障碍在于,它虽然提供了写入原语,却没有读取原语或信息泄漏功能。因此,攻击者首先面临的问题是,不知道任何安全或有用的地址。
但是,只需分配一个非常大的数组,使得所选的常量地址几乎总是位于该数组的内存空间中,就能轻松搞定这个问题:
创建ar1
时,会在内存中为VARIANT
结构分配一段地址连续的缓冲区,总长度为0x30000000
字节。如果是从一个干净的进程开始的话,这段内存空间肯定会包括我们选择的地址0x28281000
最初,ar1
中的所有VARIANT
结构的内容都为0
,因此,每个元素的类型都为VT_EMPTY
。如果我们在0x28281000
处写入一个新值,比如说0x4003 (VT_BYREF | VT_I4)
,那么,它将改变ar1
的一个元素的类型,使其不再是空值。
通过遍历数组,我们可以找出损坏的元素。这里,我们将这个元素称为gremlin
,因为gremlin
叫起来很气派。在我们的漏洞利用代码中,变量gremlin
用于索引,因此,gremlin
本身被引用为ar1(gremlin)。
注意,数组的起始地址的可变性是受约束的,因为该地址总是位于内存页的边界处,也就是说,是0x1000
的倍数。因此,查找gremlin
时,我们不必检查每个数组元素,我们可以检查每个第0x100(0x1000除以VARIANT的大小)处的元素即可。通过这种方法,可以快速完成对gremlin
的搜索,通常不到一秒钟。顺便提一下,这种对地址可变性的约束也是我们可以确定0x28281000必定位于一个VARIANT
元素的开头而不是VARIANT
中间某处的原因。
现在,为什么我选择给
gremlin
类型为VT_BYREF | VT_I4
?
因为通过这种类型的VARIANT
能够间接获取一个针对整数值的读取原语。
换句话说,假设我们按如下方式对gremlin
的内存空间执行写操作:
然后,当读取gremlin
的值时ar1(gremlin)
,它将对地址0x12345678进行
dereference(*addr)`操作,并返回从那里找到的4字节整数。也就是说,我们终于获取了读原语。
实际上,我掩饰了一个优点
要构造以上所示的gremlin
,我们要将目标地址写入位置0x28281008
。但是,如前所述,我们的write原语
有一个限制,即它不能写大于的值0x001767dd
我使用的解决方案是一次写一个小值,每个值在范围内0x00-0xff
,每个值从后续地址开始。通过重复这个过程4次,我们可以在内存中建立一个任意的4字节值,但需要注意的是,后面的3个字节最终会被零覆盖。在VARIANT
如上图所示的结构中,在字段后面有一个未使用的4字节字段0x28281008
,所以不需要的零不会造成伤害(更重要的是,它们都是零开始)。下图显示了如何把0x12345678
通过四个单独的受限DWORD写入来构建任意DWORD
。
应对下一个挑战:我们仍然不知道任何有趣的地址。
同样,这很快就得到了补救。由于我们知道数组元素ar1(gremlin)
位于0x28281000
,因此数组元素ar1(gremlin+1)
位于0x28281010
。我们可以放置一个任意对象ar1(gremlin+1)
,然后使用gremlin
作为read原语
来泄露目标对象的地址:
上图展示了我如何将gremlin
与后续数组元素一起使用。布置好后,ar1(gremlin)`上会放置目标对象的地址。
传统上,此时的下一步是利用我们的内存读写功能来进行ROP
,最终导致本机的代码执行。但是,这些方法已经得到了很好的探索。我很想尝试一些更具创新性的东西。
我受到了tombkeeper在2014年描述的Vital Point Strike [PDF幻灯片]技术的启发。该攻击的基本思想是使用内存读/写功能来定位和更改内存中的数据结构,从而关闭SafeMode
。完成后,脚本可以简单地实例化任意ActiveX
对象,如WScript.Shell
,并利用它提供的丰富功能。
自2014年blackhat
演讲以来,微软已经为tombkeeper
的原始演示文稿的“生命点”添加了强大的篡改保护,所以我不相信这是一种可行的技术。但问题仍然是关于可以找到其他“关键点”的问题。
我猜想,一旦攻击者对进程的地址空间进行任意读/写访问,总会有一些方法在内存中构造危险对象,从而简化代码执行。考虑到这一点,我开始寻找一种简洁的新技术,可以在今天用于Internet Explorer
,轻松实现代码执行,而无需使用任何ROP
或shellcode
。
我决定采用的想法是颠覆调度机制。在对象上调用方法或属性时,调度机制打包脚本提供的参数,将它们转换为基于本机堆栈的参数,最后调用 实现所需方法或属性的 本机函数。因此,调度机制完成了从脚本到本机函数过程中所需的所有繁重工作。我们可以颠覆它以调用我们选择的本机代码吗?
事实上,更改调度的本机目标地址是很容易的部分。通常,在调度期间,可以通过在vtable
中查找来找到目标函数。通过读写内存的能力,我们可以创建一个虚假的vtable,其中一些条目已被更改为指向我们选择的本机API
。我认为这WinExec
是一个可以最容易用于代码执行的API。通过将vtable
条目更改为指向WinExec
,我们实际上可以通过脚本调度来调用此API。
但是,该计划存在一个主要问题:功能签名并不完全正确。每当通过dispatch
调用一个函数时,第一个参数将是一个指向调用该方法的COM
对象的指针(this
参数)。这对我们来说是个坏消息,因为我们通常需要完全控制传递给目标API
的第一个堆栈参数。当然,情况就是如此WinExec
,其中第一个堆栈参数是指向要执行的命令字符串的指针。
我对这个问题的解决方案是正面的:我在内存中准备的COM
对象需要同时是可用的,也是一个有效的ANSI
命令字符串——一种内存中的多语言。这比听起来简单得多。考虑一下:当我们准备WinExec
通过伪造的vtable
调用时,我们不再需要COM
对象处于运行状态。不会调用COM
对象的任何方法,正是因为WinExec
将执行代替对象的原始方法。因此,我们可以随意覆盖内存中COM
对象的所有字段。我们必须小心保持COM
对象的唯一部分是调度机制本身正常运行所需的那些字段。
我选择了ActiveX
对象Scripting.Dictionary
。我认为它是一个很好的选择,因为它简单,特别是由于它有一个相对简单的实现IDispatch
。
尝试Scripting.Dictionary
实例的内存布局会显示以下内容:
上图为Scripting.Dictionary的Dispatch-critical字段
整个对象的大小为0x40
字节,只有三个DWORD
大小的字段对于调度机制至关重要。
第一个,以红色显示,是主要的vtable
指针。我们将用指向伪造vtable的指针替换它,其中一个函数指针已被替换为WinExec
。
第二个,以蓝色显示,是参考计数器。在调度调用的持续时间内,这将增加1。它的精确值并不重要。
最后一个字段以绿色显示,是一个指向小结构(大小为0xc
)的指针,它似乎被称为Pld
。采取有根据的猜测,我认为这代表Per-LCID Dispatch
。
总的来说,这表明我们处于相当不错的状态。我们可以用我们选择的几乎任何东西覆盖整个对象,除了第一个和最后一个字段,它们必须分别指向可用(伪造)vtable
和完整的pld
结构。回想一下,为了进行攻击,此COM
对象的内存也必须是要传递给WinExec
的有效ANSI命令字符串
。
我们的第一个挑战是:在第一个字段中,我们如何编写一个同时是vtable
指针的4字节值以及ANSI命令字符串
的前四个字符?
我的解决方案是编写对象的前8个字节,如下所示:
看看我在那里做了什么?前四个字节可以作为指针值0x28282828
读取,我们可以将伪造的vtable
放在该位置。但是,当读作ANSI字符
时,它们代表字符串((((
。这是一个有效的Win32路径组件。之后,我们\..\
使用路径遍历放置字符串以取消虚假路径组件((((
。
请注意,磁盘上不需要存在名为((((
的文件夹。我推荐读者阅读James Forshaw 撰写的这篇文章,以便对Windows中路径处理的细微处理进行出色的处理。
要清除的下一个障碍是引用计数,如上图的蓝色所示,但由于它确实是一个低位。我们放在那里的任何值都是可以接受的,只要我们记住DWORD
将在调用之前递增WinExec
。因此,我们将预先缩小的数据放在那里,以便将其增加到我们想要的值。我决定要运行一些PowerShell
,因此我们到目前为止所做的是:
其中.ewe
将递增,以便读取.exe
(字节0x77
是字符w
,这是在上面所示的DWORD
的低位字节199e3fd4
)。
在此之后,我们开始放置PowerShell脚本。不幸的是,到现在为止我们的空间已经不多了。在我们达到第三个障碍(即pld
指针)之前,只有0x1c
可用字节。我们如何防止pld
指针的出现破坏PowerShell
脚本的文本?我通过打开PowerShell Comment
解决了这个问题:
之后,我们可以关闭PowerShell
命令并编写所需的PowerShell
脚本,而不受任何进一步的限制。那时我们将会在Scripting.Dictionary
内存的末尾之后再写入一些值,但只要我们正确地准备堆,这就不会造成任何问题。
确实出现的一个问题是pld
指针有时会包含一个字节,如0x00
或0x22
(双引号),这会过早地终止PowerShell
命令。为了防止这种情况,我写了一些脚本来复制pld
结构并在0x28281020的固定位置重写它。然后我将0x28281020
作为pld
指针放入Scripting.Dictionary
。
在完成这个细节之后,当从一个最原始的进程开始时,该漏洞利用完全可靠。
我在Windows 7
开发了这个漏洞利用,因为在Windows 10
上不允许使用VBScript
。不久之后,James Forshaw 发布了他发现同样允许VBScript
在Windows 10
上运行的研究成果。这让我可以在Windows 10
上为IE编写一个漏洞利用版本。微软已经修补了由CVE-2019-0768引发的漏洞,但我们仍然可以用它进行此演示。
在Windows 10
上,代码执行前有一条最后的防线:CFG。CFG
会阻止试图WinExec
从vtable
进行函数调用吗?很可惜它并没有,似乎微软认为不适合使用CFG
来限制WinExec
调用GetProcAddress
以及一些用于开发的API。我不会因为这个决定而对他们提出错误。一旦攻击者对进程的内存空间具有完全读/写访问权限,尝试锁定代码执行的所有可能途径就不值得冒险。
此处显示的是2019年2月补丁级别的Windows 10 1809
上Internet Explorer
的完整漏洞。此PoC
也可以在我们的GitHub存储库中找到。从最初始的进程开始,它非常可靠。增强保护模式可以关闭或打开(但不是在具有64位渲染器进程的增强保护模式下)。启用增强保护模式后,生成的代码执行将受到IE EPM AppContainer
的约束。
我感觉我们只是通过使用对地址空间的读/写访问来解决可能实现的问题。这种访问级别使得可以任意破坏数据结构,甚至可以预先手动创建内存中不存在的新对象实例。攻击者可以使用它来实现他们的目标,而无需执行任何单一的机器级指令。