这篇文章将会探索一下 CVE-2016-9066,这是 Firefox 中一个简单却有趣(从实际操作的角度来看)的漏洞,可以利用该漏洞来获取代码执行权限。
一段负责加载脚本标签的代码中的一个整数溢出导致了对 mmap 结束块的越界写操作。一种利用方法是在缓冲区后放置一个 JavaScript 堆以便便溢出到它的元数据中来创建一个假的未使用堆单元。然后可以将一个 ArrayBuffer 实例放置在另一个ArrayBuffer 的内联数据中。ArrayBuffer 对象内部可以被任意修改,就产生了任意读/写原语。至此,实现代码执行就变的非常简单了。完整的 exploit 针对 macOS 10.11.6 平台上的 Firefox 48.0.1 进行了测试。
☋ 漏洞
以下代码用来加载外部脚本标签:
result
nsScriptLoadHandler::TryDecodeRawData(const uint8_t* aData,
uint32_t aDataLength,
bool aEndOfStream)
{
int32_t srcLen = aDataLength;
const char* src = reinterpret_cast(aData);
int32_t dstLen;
nsresult rv =
mDecoder->GetMaxLength(src, srcLen, &dstLen);
NS_ENSURE_SUCCESS(rv, rv);
uint32_t haveRead = mBuffer.length();
uint32_t capacity = haveRead + dstLen;
if (!mBuffer.reserve(capacity)) {
return NS_ERROR_OUT_OF_MEMORY;
}
rv = mDecoder->Convert(src,
&srcLen,
mBuffer.begin() + haveRead,
&dstLen);
NS_ENSURE_SUCCESS(rv, rv);
haveRead += dstLen;
MOZ_ASSERT(haveRead <= capacity, "mDecoder produced more data than expected");
MOZ_ALWAYS_TRUE(mBuffer.resizeUninitialized(haveRead));
return NS_OK;
}
当服务器中的新数据到达时这段代码将被 OnIncrementalData 调用。这是一个简单的整数溢出 Bug,当服务器发送超过 4GB 数据的时候即可发生。数据超过4GB的情况下,capacity 将会环回(wrap around),接下来的对 mBuffer.reserve 函数的调用并不会修改缓冲区。接下来 mDecode->Convert 函数将会把数据写入到8GB缓冲区的尾部(数据在浏览器中以 char16_t 的形式存储),这部分内存将会通过 mmap 块备份(对于一个非常大的块这是通用做法)。
修补也是相当简单:
int32_t haveRead = mBuffer.length();
- uint32_t capacity = haveRead + dstLen;
- if (!mBuffer.reserve(capacity)) {
+
+ CheckedInt capacity = haveRead;
+ capacity += dstLen;
+
+ if (!capacity.isValid() || !mBuffer.reserve(capacity.value())) {
return NS_ERROR_OUT_OF_MEMORY;
}
这个漏洞第一眼看上不并没有什么搞头。它有一个必要条件,需要发送和申请多大几个GB的数据。正如我们即将看到的,该漏洞在我的 2015
MacBook
Pro 上相当可靠的被利用,完成整个利用代码只需打开页面用时不到1分钟。我们接下来先来探索一下此漏洞为什么会在 macOS 上被利用并弹出一个计算器,然后我们来改进一下利用代码让它变的更可靠一些,并且占用更低的带宽(剧透:我们将会使用 HTTP 压缩)
☋ 操作
当超过 mmap 区的溢出发生时,我们首先关注的是有没有可能在溢出的内存之后可靠地申请一块空间。与一些堆分配器相反,mmap(可以看作是内核提供的内存分配器)是非常具有确定性的:如果没有合适的内存块,调用 mmap 两次将会导致两次连续的内存映射。你可以用下边的代码来尝试一下。注意,实验的结果的异同取决于代码是运行在Linux系统还是 macOS 系统上。mmap 内存区相较于 macOS 系统上由低向高增长,在 Linux 系统上,是由高向低增长。在本篇文章接下来的部分,我们将会专注于 macOS。Linux 或者 Windows 上应该也可能存在相似的利用代码。
#include
#include
const size_t MAP_SIZE = 0x100000; // 1 MB
int main()
{
char* chunk1 = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
char* chunk2 = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
printf("chunk1: %p - %p\n", chunk1, chunk1 + MAP_SIZE);
printf("chunk2: %p - %p\n", chunk2, chunk2 + MAP_SIZE);
return 0;
}
上边的程序向我们展示了通过简单的映射所有的内存页直到所有已经存在的分页被填满,然后通过 mmap 再申请一块内存块。为了验证这个过程,我们接下来将会这样做:
加载一个包含一段脚本(将会触发溢出的payload.js)的 HTML 文档并且异步执行一些 JavaScript 代码(code.js,实现第3步和第5步)。
当浏览器请求 payload.js 时,使服务器返回一个 Content-Length 为 0x100000001 但是只发送 0xffffffff 字节的数据。
接下来,让 JavaScript 代码申请多个巨大(1GB)的 ArrayBuffers(在缓冲区实际写入前,内存不一定被使用)。
让服务器发送 payload.js 剩下的 2 个字节。
检查所有 ArrayBuffer 对象的前几个字节,其中的某一个应该会包含服务器发送的数据。
为了实现这个过程,我们需要一些浏览器中的 JavaScript 代码和服务器之间的同步原语。为此,我在 python 的 asyncio 库智商写了一个小小的 webserver,它包含一个方便的 Event 对象,用来和协同程序同步。创建两个全局事件可使客户端代码完成当前任务等待 webserver 进行下一步的操作时通知服务器。/sync 的处理例程如下所示:
async def sync(request, response):
script_ready_event.set()
await server_done_event.wait()
server_done_event.clear()
response.send_header(200, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': '2'
})
response.write(b'OK')
await response.drain()
客户端中我是用了同步 XMLHttpRequests 来阻塞脚本的执行,直到服务器完成相关工作:
function synchronize() {
var xhr = new XMLHttpRequest();
xhr.open('GET', location.origin + '/sync', false);
// Server will block until the event has been fired
xhr.send();
}
这样,我们就可以实现上边的场景并且将会看到实际上有一个 ArrayBuffer 对象的开始处包含了我们的 payload 字节。不过还有一个小小的限制条件:我们只能通过有效的 UTF-16 来进行溢出,因为这是 Firefox 内部使用的。我们必须记住这一点。现在剩下的就是找到一些更有趣的事情,用内存分配来取代对 ArrayBuffer 的溢出。
☋ 寻找目标对象
因为 malloc(同样的 C++ 中的 new 操作)将在某些时候使用 mmap 请求更多的内存,所以像这些操作分配的内存可能是我们代码所感兴趣的。我走了一条不同的路线。最初我想检测一下是否有可能溢出到 JavaScript 对象中,比如说使数组的或者其他类似对象的长度腐败。为此,我开始围绕着 JavaScript 分配器深入发掘,来看 JSObject 被存储在哪里。Spidermonkey(Firefox 中的 JavaScript 引擎)把 JSObjet 存储在两个独立的区域中:
永久堆(The tenured heap)。长生命周期的对象,同时少数被选择的对象类型在此处分配。这是一个相当经典的堆,跟踪自由内存然后在未来重复使用。
托管区。这是一块包含短暂生命周期对象的内存区域。大多数的JSObject最初都被分配在此处,然后如果在下一个垃圾回收周期(
包括更新所有指向他们的指针,因此要求牢记收集器知道其对象的所有指针)中依然存在则被移动到永久堆中。托管区不需要释放列表或者相似的结构:在一个垃圾回收循环之后,托管区只是在所有的存活对象被移出之后简单的声明一下自己是可用的。
永久堆中存储对象的容器叫做 Arenas:
/*
* Arenas are the allocation units of the tenured heap in the GC. An arena
* is 4kiB in size and 4kiB-aligned. It starts with several header fields
* followed by some bytes of padding. The remainder of the arena is filled
* with GC things of a particular AllocKind. The padding ensures that the
* GC thing array ends exactly at the end of the arena:
*
* = ArenaSize bytes
* +---------------+---------+----+----+-----+----+
* | header fields | padding | T0 | T1 | ... | Tn |
* +---------------+---------+----+----+-----+----+
* = first thing offset
*/
class Arena
{
static JS_FRIEND_DATA(const uint32_t) ThingSizes[];
static JS_FRIEND_DATA(const uint32_t) FirstThingOffsets[];
static JS_FRIEND_DATA(const uint32_t) ThingsPerArena[];
/*
* The first span of free things in the arena. Most of these spans are
* stored as offsets in free regions of the data array, and most operations
* on FreeSpans take an Arena pointer for safety. However, the FreeSpans
* used for allocation are stored here, at the start of an Arena, and use
* their own address to grab the next span within the same Arena.
*/
FreeSpan firstFreeSpan;
// ...
注释已经给出了非常好的总结:Arenas 只是简单容器对象,其中分配了相同大小的 JavaScript 对象。它们位于容器对象内,这个块结构本身就是直接使用 mmap 来分配的。在Arena类中有趣的部分是它的 firstFreeSpan 成员:它是 Arena 对象(因此处于一个映射区的开始处)的第一个成员,并且本质上它指明了 Arena 中第一个未使用区块的索引。下边就是 FreeSpan 的大致结构:
class FreeSpan
{
uint16_t first;
uint16_t last;
// methods following
}
其中的 first 和 last 在 Arena 中都是按字节索引,用来指明未使用区块链的头部。那么这就开辟了一条有趣的道路来利用这个漏洞:通过对 Arena 中 firstFreeSpan 成员对象的溢出,我们有可能在另一个对象中分配一个对象,最好是在某些可访问的内联数据中分配。那么接下来我们就能任意的修改内部分配的对象。
这个技巧有一下几点好处:
我们将会看到能在 Arena 中使用指定的偏移分配一个 JavaScript 对象,这就产生了一条内存读写原语。
我们只需溢出接下来的区块的4字节,因此不会破坏任何的指针或是其他的敏感数据。
Arenas/Chunks 可以仅仅通过申请大量的 JavaScript 对象来可靠的产生。
事实证明,ArrayBuffer 对象中高达96字节的数组会被内联存储在该对象头部之后后。这将会跳过托管过程并且因此它将会在 Arena 中被分配。这使得它们成为我们漏洞利用的理想选择。我们会这样做:
申请大量存储 96 字节的 ArrayBuffer 对象。
溢出,并且在我们的缓冲区之后,在 Arena 内部创建一个假的未使用内存块。
申请更多的同样大小的 ArrayBuffer 对象,看是否其中的某一个会被放置在另一个 ArrayBuffer 的数据中(通过扫描所有先前的 ArrayBuffer 中的不为空的内容即可)
☋ 垃圾回收的要求
不幸的是,这并不是那样简单:为了让 Spidermonkey 在我们的目标 Arena(被破坏)中申请一个对象,那么这个 Arena 就必须在之前就被(部分)标记为可使用。这意味着,我们需要释放所有的 Arena 中至少一个存储块。我们可以通过删除每第 25个 ArrayBuffer(每个 Arena 有 25 个)来实现,然后强制进行垃圾回收。
Spidermonkey 因为各种各样的原因而触发垃圾回收。诸多方法中似乎使用TOO_MUCH_MALLOC 来触发是最简单的一种:只要通过 malloc 分配了一定数量的字节,它就会被简单的触发。因此,下边的代码足以用来触发垃圾回收:
function gc() {
const maxMallocBytes = 128 * MB;
for (var i = 0; i
在此之后,我们的目标 Arena 将会被放置在一个未使用标记链表中,随后的覆盖将会破坏该链表。下一次从被破坏的 Arena 中产生的分配将会返回一个假的处于一个 ArrayBuffer 对象的内联数据中内存块。
☋ (可选阅读)压缩GC
实际上,这有点复杂了。存在一种叫做压缩 GC 模式的垃圾回收,这种模式将会把多个部分填充的 Arena 移动去填满另一个 Arena。此举减少了内部碎片,并且协助释放整个内存区域以便系统回收。不管怎样,对于我们来讲,压缩 GC 着实是个麻烦,因为,它有可能填充了我们之前创建的目标Arena。以下的代码用来决定是否应该运行一个压缩 GC:
bool
GCRuntime::shouldCompact()
{
// Compact on shrinking GC if enabled, but skip compacting in incremental
// GCs if we are currently animating.
return invocationKind == GC_SHRINK && isCompactingGCEnabled() &&
(!isIncremental || rt->lastAnimationTime + PRMJ_USEC_PER_SEC
查看一下代码,应该有方法来阻止压缩 GC 来运行(比如说,展示一些动画)。看来我们很幸运:上文中提到的gc函数(译注:用来产生 ArrayBuffer 的 JS 代码片段)将会在 Spidermonkey 中触发下边的代码流程,因此,阻止压缩 GC 的调用形式将会从 GC_SHRINK 变为 GC_NORMAL。
bool
GCRuntime::gcIfRequested()
{
// This method returns whether a major GC was performed.
if (minorGCRequested())
minorGC(minorGCTriggerReason);
if (majorGCRequested()) {
if (!isIncrementalGCInProgress())
startGC(GC_NORMAL, majorGCTriggerReason); //
☋ 编写 Exploit
此刻,我们已经拼接了所有的碎片,可以实际动手写利用代码了。一旦我们创建了一个假的自由存储块并在其中创建一个 ArrayBuffer 对象,就能看见其中一个之前申请的 ArrayBuffer 中包含了我们的数据。ArrayBuffer 对象的结构大致如下:
// From JSObject
GCPtrObjectGroup group_;
// From ShapedObject
GCPtrShape shape_;
// From NativeObject
HeapSlots* slots_;
HeapSlots* elements_;
// Slot offsets from ArrayBufferObject
static const uint8_t DATA_SLOT = 0;
static const uint8_t BYTE_LENGTH_SLOT = 1;
static const uint8_t FIRST_VIEW_SLOT = 2;
static const uint8_t FLAGS_SLOT = 3;
常量 XXX_SLOT 确定对象的起始位置相应值的偏移量。这样一来,数据指针(DATA_SLOT)将会被存储在 addrof(ArrayBuffer) + sizeof(ArrayBuffer)。
现在,我们就可以构建以下的代码利用原语:
读取绝对内存地址:设置 DATA_SLOT 为所需的地址,然后从 ArrayBuffer 对象中读取。
写入绝对内存地址:和上边一样,不过此刻变成了写操作。
获取 JavaScript 对象的地址:为此,需要设置需要获取地址的对象为 ArrayBuffer 的内部属性,然后通过之前的读内存原语从 slots_指针处读取地址。
☋ 进程持续
为了避免浏览器进程在下一次垃圾回收中崩溃,我们必须修复一下几点:
在我们的利用代码中的 ArrayBuffer 之外的 ArrayBuffer,将会被代码内的 ArrayBuffer 数据破坏。可以通过简单的拷贝另一个 ArrayBuffer 对象来覆盖被破坏的数据即可修复。
我们原本在 Arena 中释放的区块此刻看起来应该是一个正在被使用的状态,同样回收器也是这样认为,这将会导致崩溃,因为这块数据已经被其他的数据(比如,FreeSpan 实例)覆盖了。我们可以通过重新载入我们 Arena 中原始 firstFreeSpan 域的值来标记该块为可使用状态。
以上几点就足以保证浏览器在跑完利用代码之后仍然存活了。
☋ 概要
插入一个脚本标签加载 payload,最终触发漏洞
等待服务器发送 2GB+1 字节的数据。浏览器此刻应该已经申请了我们最终要溢出的内存块。像我们最初的最简单的 POC 那样尝试使用 ArrayBuffer 对象来填充 mmap 中的区块。
申请包含大小为 96 字节(数据存储空间分配在对象之后的最大大小)的JavaScript
Arenas(内存区域),接下来期待其中之一被放置在了我们将要溢出的缓冲区之后。由于 mmap 分配连续的区域,所以如果我们不申请足够的空间或者有什么其它的东西已经被分配在此处则可能会导致失败。
让服务器尽情的发送 0xffffffff 字节的数据,完全填充当前块。
释放所有的 Arena 中的一个 ArrayBuffer,然后尝试触发垃圾回收,使 Arena 被插入到未使用块链表中。
使服务器发送剩下的数据。这将会触发溢出并破坏一个 Arenas 内部的一个未使用内存块链表。链表被改写使得其中之一的 ArrayBuffer 中第一个包含内联数据的自由块被包含在 Arena 中。
申请更多的 ArrayBuffer。如果一切正常,其中之一的 ArrayBuffer 将会被分配在另一个 ArrayBuffer 内联数据内部。找到那个 ArrayBuffer!。
如果找到了,构造一个任意内存读写原语。现在我们就可以修改 ArrayBuffer 内部的数据指针,所以这是非常容易做到的。
修复被破坏的对象,以便使我们的利用代码跑完之后浏览器进程依然存活。
☋ 弹出计算器
剩下的工作就是以某种方式弹出一个计算器。
一个简单的跑自己代码的方式是嘿咻 JIT 了,但是,这个技术(部分)在 Firefox 中被削弱了。考虑到我们开发的原语,可以绕过被削弱的部分(比如,使用 ROP 来转移控制),但对于简单的 PoC 来说,似乎有点复杂。
有一些其他的 Firefox 相关的技巧来通过滥用特权的 JavaScript 获取代码执行权限,但是这些需要对浏览器状态进行不必要的修改(比如, 关闭所有安全性,以便病毒可以接管这台电脑)。
我最终使用了一些标准的 CTF 技巧来完成利用代码:寻找对 libc 中第一个参数为字符串的函数的交叉引用(此例中,选用strcmp),我发现Date.toLocalFormat的执行并且注意到了该函数将第一个参数从JSString转换为C-String,它的第一个参数被用来做strcmp。因此,我们可以简单的 strcmp 的 GOT 换成 system,然后执行data_obj.toLocaleFormat("open -a /Applications/Calculator.app");。搞定:).
☋ 改进Exploit
这时,基本的利用代码已经完成了, 接下来我们将会描述如何使它变的更可靠一些以及占用更少的带宽。
☋ 增强鲁棒性
此时此刻我们的利用代码知识申请了一些非常大的 ArrayBuffer 实例(每个1GB)用来填充 mmap 空间,然后再分配一大堆 js
:: Arena 实例用来溢出。因此,该操作假设浏览器的堆操作在利用期间多多少少是确定的。既然这不一定是这样,我们希望让我们的漏洞更加强大一些。
快速浏览一下 mozilla::Vector 类(用来保留脚本缓冲区)接下来的操作向我们展示了它使用了 realloc 在其需要的时候来倍增自己的空间。由于 jemalloc 直接使用 mmap 来申请较大的区块,这就给了我们以下的分配模式:
mmap 1MB
mmap 2MB, munmap previous chunk
mmap 4MB, munmap previous chunk
mmap 8MB, munmap previous chunk
…
mmap 8GB, munmap previous chunk
因为当前区块的大小总是会大于之前所有区块的大小,这将导致我们的最终缓冲区之前存在大量的可用空间。理论上,我们可以计算空闲空间之和,然后申请一个大的ArrayBuffer。实际上,这行不通,因为当服务器发送数据到来时,在浏览器完成解压缩最后一块数据完成之前有一些其他的申请空间的操作。此外 jemalloc 还保留了一部分被释放的内存以供以后使用。相反,我们会尽快在浏览器中申请被释放的空间,理由如下:
JavaScript代码使用sync来等待服务器
服务器发送到下一个 2 的幂(MB)的所有数据从而在最后触发一次对 realloc 的调用。浏览器此时将会释放一个大小已知的空间。
服务器设置 server_done_event 信号,使 JavaScript 代码继续执行。
JavaScript 代码申请一个与之前空间大小相同的 ArrayBuffer 实例来填充该空间。
重复上述步骤直到我们发送完 0x80000001 字节数据(强制申请最后一块空间)。
这个简单的算法服务器端代码在这里,客户端第一步的代码在这里。使用这个算法,我们可以通过仅喷射几兆的 ArrayBuffer 实例而不是多个千兆字节来相当可靠地获得分配在目标缓冲区之后的空间。
☋ 减少网络负载
目前,我们的利用代码需要通过服务器发送4GB的数据。可以简单的通过下述方法来改进:使用HTTP压缩。zlib有一个好处,支持流式压缩,它可以逐步压缩 payload。有了这个,我们只需将 payload 的每个部分添加到在 zlib 流中,然后调用 flush 来获取 payload 的下一个压缩块,并将其发送到服务器(译注:服务器发送到浏览器,笔误?)。服务器(译注:浏览器,笔误?)在收到该块后解压缩该文件,并执行所需要的操作(比如,执行一次 realloc 操作)。
poc.py 中的 construct_payload 方法执行了该过程,并且将 payload 的大小减少到大约18MB。
☋ 资源使用
至少在理论上,exploit 需要非常大量的内存:
不管怎么说,因为许多的缓冲区并没有被写入,所以不一定得消耗如此多的物理空间。更多的,在最终 realloc 中,只有 4GB新的缓冲区会被写入之前释放的旧的缓冲区中,因此真正需要的仅仅是 8GB 而已。
不过这还是非常的占内存。然而,如果物理内存降低后,还是有一些技术来帮助减少内存占用:
内存压缩(macOS):大量的内存区域可以被压缩然后交换至交换分区。这对于我们的案例来讲是完美的,因为8GB的缓冲区将完全被0填充。这个效果可以在Activity
Monitor.app中观察到,在利用代码运行期间的某些时候会显示超过6GB的内存为“压缩”。
页重复数据删除(Windows,Linux):具有相同内容的的内存页会被映射为具有写时复制(COW,copy-on-write)属性的相同物理页面(基本上将内存使用减少到4KB)。
CPU 使用率在峰值期间(解压缩数据)也是相当的高。然而可以通过延迟发送较小的块之间的时间(这显然会增加漏洞利用的时间)来进一步降低 CPU 的压力。这也将给 OS 更多的时间来压缩和或删除大的重复数据内存缓冲区。
☋ 进一步可能的改进
目前的漏洞利用有几个不可靠的因素,主要是处理时机:
在发送 payload 数据期间,如果 JavaScript 在浏览器完全处理下一个块之前运行分配,则分配是不同步的。这可能导致利用失败。理想情况下,JavaScript 将在接收和处理下一个块后立即执行分配,这个或许可以通过观察 CPU 使用情况来确定。
如果垃圾回收在我们已经破坏 FreeSpan 之后但在我们修复之前运行,崩溃!
如果在我们释放了一些数组缓冲区之后,在触发了溢出之前,如果一个压缩的垃圾回收循环运行,那么漏洞利用就会失败,因为 Arena 将再次被填满。
如果假的单元块恰好放在释放的 ArrayBuffer 的单元块内,那么我们的漏洞利用将失败,浏览器会在下一个垃圾回收周期中崩溃。每个 Arena 有 25 个单元块,则在理论上有 1/25 失败的几率。然而,在我的实验中,未使用单元块总是位于相同的偏移处(Arena 的 1216 字节处),表明在开发阶段引擎的状态是相当确定的(至少关于 Arena 所持有的 160 字节的对象是来说是这样的)。
从我的经验来看,如果浏览器没有大量的处理任务,这个漏洞利用率非常可靠(>95%)。如果 10 个以上的其他选项卡是打开的,则漏洞利用仍然有效,但如果大型 Web 应用程序正在加载,则可能会失败。
☋ 结论
虽然从攻击者的角度来看,这个漏洞并不理想,但它仍然可以相当可靠地在低带宽下利用。过程中有趣的是使用的各种技术来使得这个漏洞更加简单的被利用。
考虑到如何防止这种错误的可利用性,有几点是我想说明的。一个相当通用的缓解措施是使用保护页面(无论用什么来访问保护页面都会产生段错误)。保护页面必须在每个 mmap 分配区域之前或之后分配,并且此举将防止对这种线性溢出的利用。但是,它们不会防止非线性溢出,比如说这个漏洞。另一种可能性是引入内部 mmap 随机化来分散整个地址空间中的分配区域(可能仅在64位系统上有效)。这最好由内核执行,当然也可以在用户空间中完成。
本文由 看雪翻译小组 zplusplus 编译,来源root@saelo
热 门 阅 读:
《走进企业看安全.易宝支付》第9站 报名倒计时!
AutoIt 脚本反混淆
攻击 Western Digital NAS 个人云存储设备
渗透测试 Node.js 应用
UPDATE 查询中的 SQL 注入
......
更多优秀文章点击左下角阅读原文查看!
看雪论坛:http://bbs.pediy.com
微信公众号 ID:ikanxue
微博:看雪安全
投稿、合作:www.kanxue.com