谨慎共享:利用共享数组缓冲区实现Firefox UAF漏洞利用
2017年6月21日
• 作者:bkth, eboda
本文探讨了结构化克隆算法在处理共享数组缓冲区时发生的引用泄漏漏洞。结合缺失的溢出检查,可利用此漏洞实现任意代码执行。这两个问题均由saelo发现,相应的错误报告可在Bugzilla上获取。
文档分为以下部分:
- 背景
- 漏洞
- 利用
- 结论
我们的漏洞利用将针对Linux上的Firefox Beta 53。需要注意的是,Firefox的发布版本从未受此漏洞影响,因为共享数组缓冲区在Firefox 52之前被禁用,并在Firefox 53中由于此漏洞默认禁用。
完整漏洞利用可在此处获取。
背景
漏洞和利用需要对结构化克隆算法(以下简称SCA)和共享数组缓冲区有基本了解,本节将介绍这些内容。
结构化克隆算法
Mozilla开发者网络上的文档指出:
结构化克隆算法是HTML5规范定义的一种新算法,用于序列化复杂的JavaScript对象。
SCA用于Spidermonkey内部的序列化,以便在不同上下文之间传递对象。与JSON相反,它能够解析循环引用。在浏览器中,序列化和反序列化功能由postMessage()使用:
postMessage()函数可在两种情况下使用:
- 通过window.postMessage()进行(可能跨源的)通信。
- 与Web Workers通信,这是并行执行JavaScript代码的便捷方式。
与Worker的简单工作流程如下:
|
|
相应的Worker脚本worker_script.js可以通过注册onmessage监听器来接收obj:
|
|
不同窗口之间的通信工作流程类似。
在这两种情况下,接收脚本在不同的全局上下文中执行,因此无法访问发送者上下文中的对象。因此,对象需要以某种方式传输并在接收脚本的上下文中重新创建。为实现这一点,SCA将在发送者的上下文中序列化obj,并在接收者的上下文中再次反序列化,从而创建其副本。
SCA的代码可在文件js/src/vm/StructuredClone.cpp中找到。定义了两个主要结构:JSStructuredCloneReader和JSStructuredCloneWriter。JSStructuredCloneReader的方法处理接收线程上下文中的对象反序列化,而JSStructuredCloneWriter的方法处理发送线程上下文中的对象序列化。
负责序列化对象的主要函数是JSStructuredCloneWriter::startWrite():
|
|
根据对象的类型,如果它是原始类型,它将直接序列化,或者根据对象类型调用函数来处理进一步的序列化。这些函数确保任何属性或数组元素也被递归序列化。感兴趣的情况是当obj是SharedArrayBufferObject时,执行最终会调用JSStructuredCloneWriter::writeSharedArrayBuffer()(在[[ 1 ]]处)。
最后,如果提供的值既不是原始类型也不是可序列化的对象,它将简单地抛出错误。反序列化的工作方式基本相同,它将序列化作为输入并创建新对象并为它们分配内存。
共享数组缓冲区
共享数组缓冲区提供了一种创建共享内存的方法,可以在上下文之间传递和访问。它们由SharedArrayBufferObject C++类实现,并继承自NativeObject,这是表示大多数JavaScript对象的基类。它们具有以下抽象表示(如果您自己查看源代码,您会发现它不是明确定义这样的,但这将有助于理解本文后面描述的内存布局):
|
|
rawbuf是指向SharedArrayRawBuffer对象的指针,该对象持有底层内存缓冲区。当通过postMessage()发送时,SharedArrayBufferObjects将在接收Worker的上下文中重新创建为新对象。另一方面,SharedArrayRawBuffers在不同上下文之间共享。因此,单个SharedArrayBufferObject的所有副本的rawbuf属性都指向同一个SharedArrayRawBuffer对象。出于内存管理目的,SharedArrayRawBuffer包含一个引用计数器refcount_:
|
|
引用计数器refcount_跟踪有多少SharedArrayBufferObjects指向它。它在序列化SharedArrayBufferObject时在JSStructuredCloneWriter::writeSharedArrayBuffer()函数中递增,并在SharedArrayBufferObject的终结器中递减:
|
|
然后,SharedArrayRawBuffer::dropReference()将检查是否存在更多引用,并在这种情况下释放底层内存。
漏洞
有两个不同的错误,它们单独可能无法利用,但结合在一起允许代码执行。
SharedArrayRawBuffer::refcount_的整数溢出
SharedArrayRawBuffer的refcount_属性不受整数溢出保护:
|
|
此函数在JSStructeredCloneWriter::writeSharedArrayBuffer中的序列化期间调用:
|
|
代码简单地递增refcount_,而SharedArrayRawBuffer::addReference()未能验证它不会溢出并变为0。回想一下,refcount_被定义为uint32_t整数,这意味着上述代码路径必须触发2³²次才能溢出它。这里的主要问题是每次调用postMessage()都会创建SharedArrayBufferObject的副本,从而分配0x20字节的内存。Firefox当前的堆限制为4GB,但如上所述的溢出需要128GB,使其无法利用。
SCA内部的引用泄漏
不幸的是,还有另一个错误允许我们绕过内存要求。回想一下,postMessage()首先序列化然后反序列化对象。对象的副本是在反序列化过程中创建的,但refcount_的递增实际上在序列化期间就已经发生了!如果postMessage()在序列化SharedArrayBufferObject之后但在反序列化之前失败,则不会创建SharedArrayBufferObject的副本,即使refcount_已递增。
回顾序列化,有一种简单的方法可以让它失败:
|
|
如果要序列化的对象既不是原始类型也不是SCA支持的对象,序列化将简单地抛出JS_SCERR_UNSUPPORTED_TYPE错误,并且反序列化(包括内存分配)永远不会发生!这是一个简单的PoC,它将增加refcount_而不实际复制SharedArrayBuffer:
|
|
一个包含一个SharedArrayBuffer和一个函数的数组被序列化。SCA将首先序列化数组,然后递归序列化SharedArrayBuffer(从而递增其原始缓冲区的refcount_),最后序列化函数。但是,函数序列化不受支持,并抛出错误,不允许反序列化过程创建对象的副本。现在refcount_为2,但实际上只有一个SharedArrayBuffer指向原始缓冲区。
使用此引用泄漏,可以在不实际分配任何额外内存的情况下溢出refcount_。
利用
虽然内存要求已解决,但触发错误仍然需要2³²次调用postMessage()。这在现代机器上可能需要几个小时才能执行。为了实现合理的漏洞利用执行时间,需要更快地触发错误。
提高性能
减少postMessage()调用次数的一种简单方法是在每次调用时序列化多个sab:
|
|
不幸的是(对我们来说),SCA支持反向引用,它实际上不会多次递增refcount_,而是将每个sab序列化为第一个的反向引用。因此,此方法需要sab的不同副本。实际上,它们也可以通过使用postMessage()创建:
|
|
包含单个sab的数组发送到脚本本身,当它(确切地说是它的副本!)被接收时,它被添加到现有的copies数组中。现在copies中有两个不同的对象指向同一个SharedArrayRawBuffer。通过重复复制copies数组,我们可以有效地获得大量副本。在我们的漏洞利用中,我们创建了0x10000个副本(这需要16次调用postMessage())。然后我们使用这些副本来进行引用泄漏,将所需的postMessage()调用次数减少到2³² / 0x10000 = 65536。
通过使用多个Web Workers并行利用引用泄漏,可以利用所有CPU核心进一步提高性能。每个Worker接收0x10000个共享数组缓冲区的副本,然后将在简单循环中执行引用泄漏:
|
|
一旦执行了how_many次,它将向主脚本报告已完成。如果所有Worker都已完成,refcount_应该已经溢出并保持值1。通过删除一个sab,refcount_将为0,共享原始缓冲区将在下一次垃圾回收时被释放。漏洞利用中会发生的情况是,一个SharedArrayBufferObject将被垃圾回收,这将依次调用dropReference()。这将有效地将引用计数递减到0,这将触发原始缓冲区的释放:
|
|
do_gc()的可能实现可在此处找到。
此时,SharedArrayRawBuffer被释放,但对它的引用仍然存储在sabs中,允许对已释放内存进行读/写访问,并可能导致释放后使用情况。
将释放后使用转化为读/写原语
由于我们现在持有对已释放内存的引用,我们可以分配大量对象,以便在仍然有引用的内存中分配目标对象。在某个时刻,分配器将通过mmap请求更多内存,并且来自SharedArrayRawBuffer的munmaped内存将被返回。为了将其转化为任意读/写原语,可以使用ArrayBuffer对象。这些对象包含一个指向实际数组内容所在内存区域的指针。如果ArrayBuffer分配在先前释放的内存中,则可以覆盖此指针以指向我们想要的任何内存。
为此,我们分配大量大小为0x60的ArrayBuffer。这是底层缓冲区仍然内联存储在ArrayBuffer标头之后的最大大小。通过用魔术值0x13371337标记每个,然后稍后查找该值的第一次出现,我们将能够找到ArrayBuffer的确切位置:
|
|
此时,这些缓冲区中的一些应该分配在先前从SharedArrayRawBuffer释放的内存中,我们仍然持有对其的引用。使用该引用,我们查找魔术值0x13371337。一旦找到它,我们用不同的魔术值0x13381338标记它并保存其偏移量:
|
|
我们最后一次迭代所有分配的ArrayBuffer,并查找魔术值0x13381338,以找到与上面刚刚找到的偏移量相对应的确切ArrayBuffer:
|
|
最后,buffers[ptr_access_idx]是ArrayBuffer,其内存可以通过修改sab_view[ptr_overwrite_idx](加上/减去一些偏移量)来控制。
回想一下,数组内容内联在标头之后,这意味着标头从sab_view[ptr_overwrite_idx-16]开始。因此,指向数组缓冲区的指针可以通过写入sab_view[ptr_overwrite_idx-8]和sab_view[ptr_overwrite_idx-7]来覆盖(将64位指针写为两个32位值)。一旦该指针被覆盖,buffers[ptr_access_idx][0]允许在所选位置读取或写入32位值。
实现任意代码执行
一旦对内存具有任意读/写访问权限,我们需要一种方法来控制RIP。由于libxul.so – 包含大多数浏览器代码(包括Spidermonkey)的共享对象 – 未使用完整RELRO编译,因此可以覆盖全局偏移表(GOT)条目以重定向代码流。
首先,我们需要泄漏libxul.so在内存中的位置。为此,我们可以简单地泄漏任何本机函数的函数指针,例如Date.now()。函数在内部用JSFunction对象表示,并存储其本机实现的地址。为了泄漏该指针,可以将函数设置为ArrayBuffer的属性,该ArrayBuffer迄今为止已用于内存读/写。遵循一个简短的指针链,最终可以泄漏到libxul.so的本机指针。我们不会详细讨论对象属性在内存中的组织方式,因为argp已经就此主题写了一篇优秀的Phrack论文。现在我们有了libxul.so中Date.now()的地址,我们可以使用预编译的Firefox Beta 53附带的libxul.so的硬编码偏移量来获取GOT的地址。
最后,我们用system()的libc地址覆盖GOT中的一个函数(也从libxul.so泄漏,详见我们的漏洞利用)。在漏洞利用中,我们使用Uint8Array.copyWithin(),