0x01. 漏洞简介
准备用业余时间学习一点浏览器漏洞利用相关的知识,刚好最近 Chrome 爆出来一个野外利用漏洞 CVE-2019-5786 ,而且网上也有两篇比较详细的分析报告(来自 Exodus Intelligence 和 McAfee Labs),刚好可以借鉴学习一下漏洞的原理以及利用技巧。
Chrome 72.0.3626.121 的 安全公告 显示,漏洞 CVE-2019-5786 由 Google Threat Analysis Group 的 Clement Lecigne 发现其被利用于野外攻击(配合 Windows 内核空指针漏洞 CVE-2019-0808 可实现 Windows 7 下的提权操作)。
0x02. 漏洞分析
2.1 FileReader
Chrome 的安全公告指明该 UAF 漏洞位于 FileReader 中,因此在进行具体的漏洞分析工作之前,有必要简单了解一下 FileReader 对象的用法。FileReader 可以实现对文件内容(File)或者缓冲区数据(Blob)的异步读取,其中几个重要的属性或者回调函数如下所示。
readyState
表示读取状态- EMPTY,0,尚未读取任何数据
- LOADING,1,正在读取数据
- DONE,2,数据读取完成
result
表示读取结果,具体的格式与数据的读取方式有关- readAsArrayBuffer
- readAsBinaryString
- readAsDataURL
- readAsText
onprogress
回调函数- 读取 Blob 时触发
- 当数据比较多时可能会周期性触发多次
onloadend
回调函数- 读取操作完成时触发,不管最终读取成功还是失败
下面是 FileReader 的一段简单示例代码(读取长度为 100MB 的字符串到 ArrayBuffer 中):
<script> |
因为待读取的内容比较大,因此可以看到 onprogress
回调函数被多次触发:
current read bytes: 69730304 |
2.2 漏洞分析
2.2.1 代码查看
由于众所周知的原因,在国内下载 Chromium 的源码是非常不方便的,另外如果电脑配置一般的话,在 Visual Studio 中查看和编译代码也是一件非常痛苦的事情,因此笔者推荐使用 Chromium 的在线浏览代码功能,因为支持查找和跳转,使用起来非常方便。查看代码的 URL 格式非常简单,指明文件路径、CL 版本(Change List 版本,即 GIT 提交时创建的 HASH 值)、代码行号即可。
2.2.2 补丁对比
从 Chrome 的安全公告可知漏洞的内部 ID 为 936448 ,尽管漏洞报告暂时不可访问,但是基于漏洞 ID 可以找到补丁的提交记录 150407e (上一版本为 1675c51 )。
补丁之前的代码如下所示(src/third_party/blink/renderer/core/fileapi/file_reader_loader.cc:137):
DOMArrayBuffer* FileReaderLoader::ArrayBufferResult() { |
补丁之后的代码如下所示(src/third_party/blink/renderer/core/fileapi/file_reader_loader.cc:137):
DOMArrayBuffer* FileReaderLoader::ArrayBufferResult() { |
在补丁之前,下面这行代码可能会被多次调用:
DOMArrayBuffer* result = DOMArrayBuffer::Create(raw_data_->ToArrayBuffer()); |
在补丁之后,上面的代码仅会被调用一次,取而代之的是下面这行代码可能会被多次调用:
DOMArrayBuffer::Create( |
所以这里关键的差异点在于 多次调用 下面两行代码的差异:
// before patch |
2.2.3 漏洞分析
如无特殊说明,本文所有代码均基于有漏洞的版本(1675c51b1d83160a8b7061f38bb722b2c43937b4)进行分析。
- 函数
FileReaderLoader::ArrayBufferResult
中raw_data_
的定义如下所示(src/third_party/blink/renderer/core/fileapi/file_reader_loader.h:157):
std::unique_ptr<ArrayBufferBuilder> raw_data_; |
ArrayBufferBuilder::ToArrayBuffer
的定义如下所示(src/third_party/blink/renderer/platform/wtf/typed_arrays/array_buffer_builder.cc:103):
scoped_refptr<ArrayBuffer> ArrayBufferBuilder::ToArrayBuffer() { |
- 先看
buffer_
的类型,后续再深入分析ArrayBufferBuilder::ToArrayBuffer
(src/third_party/blink/renderer/platform/wtf/typed_arrays/array_buffer_builder.h:94):
namespace WTF { |
ArrayBuffer::Slice
的定义如下所示(src/third_party/blink/renderer/platform/wtf/typed_arrays/array_buffer.h:259):
scoped_refptr<ArrayBuffer> ArrayBuffer::Slice(int begin, int end) const { |
ArrayBuffer::Create
的定义如下所示(src/third_party/blink/renderer/platform/wtf/typed_arrays/array_buffer.h:144):
scoped_refptr<ArrayBuffer> ArrayBuffer::Create(const void* source, |
现在回到第 2 步,在函数 ArrayBufferBuilder::ToArrayBuffer
中,如果 buffer_
的空间满了,则直接返回 buffer_
本身,否则调用 ArrayBuffer::Slice
返回一个 ArrayBuffer
副本。
最后看一下 DOMArrayBuffer::Create
的定义,注意该函数有多个重载,触发漏洞所用的定义如下所示(src/third_party/blink/renderer/core/typed_arrays/dom_array_buffer.h:20):
namespace blink { |
这个函数比较简单,就是基于 WTF::ArrayBuffer
创建 blink::DOMArrayBuffer
,不要被 std::move
所迷惑,这里只是针对函数形参 scoped_refptr<WTF::ArrayBuffer> buffer
进行 move ,不影响形参智能指针所指向的具体对象。
那么,这里的漏洞到底是如何产生的呢?回到有问题的 FileReaderLoader::ArrayBufferResult
函数,如果在数据读取完毕时刚好触发了 onprogress
回调函数(注意此时尚未触发 onloadend
函数),因为数据已经读取完毕,函数 ArrayBufferBuilder::ToArrayBuffer
直接返回 buffer_
,在此过程中,没副本产生!当回调函数 onloadend
触发时,返回的 ArrayBuffer 将会与 onprogress
返回的 ArrayBuffer 共享同一 backing store (即底层存储数据用的堆块)!当手上有两个这样的 ArrayBuffer 时,可以先释放其中一个,那么访问另一个时将导致 UAF 。
那么,你是否也会怀疑,在数据读取完毕的时候真的会出现先后触发 onprogress
和 onloadend
的情况吗?笔者也有这样的疑问,但是本文前面提供的 FileReader 测试代码的运行结果表明,确实就有这样的情况:最后一次触发 onprogress
回调函数时,读取的数据长度为 104857600
,表明数据已经读取完毕。
在补丁之后的代码中,如果 finished_loading_
尚未标记,那么总是调用 ArrayBuffer::Create
返回一个 ArrayBuffer 副本,这样就避免了两个 ArrayBuffer 共享同一 backing store 的情况的出现。
2.3 POC 构造
2.3.1 ArrayBuffer Neutering
在构造 POC 之前,先学习一下怎么释放一个 ArrayBuffer 的 backing store 。通常而言,可以通过转移 ArrayBuffer (比如转移给另一个线程)来实现底层堆块的释放,这称之为 Neuter 。在 V8 中,ArrayBuffer 提供了 Neuter
方法,代码如下所示(src/v8/include/v8.h:4819):
/** |
可以看到,调用 Neuter
时 ArrayBuffer 已经被 Externalized 了,此时 ArrayBuffer 的 backing store 已经被调用方所释放了。
Neuter 一个 ArrayBuffer 的常规做法是把它转移给一个工作者线程( Web Workers )。与桌面软件一样,JavaScript 默认的执行线程为 UI 线程,如果要执行复杂的计算工作,应当新建一个工作者线程来执行任务,以防止 UI 失去响应。
在 JavaScript 中,各线程之间通过 postMessage
实现数据的发送、通过 onmessage
回调函数实现消息的相应。线程之间的数据传递是通过复制(而不是共享)来实现的,因此传递对象时会经历序列化和反序列化的过程,即传出时进行序列化,传入时进行反序列化。大多数浏览器通过 Structured clone algorithm 来实现这一特性。
如果要传递的对象实现了 Transferable 接口,那么可以实现数据的高效转移,即并不复制数据,而是通过直接转移所有权来实现传递。对于这种传递方式,因为直接转移了所有权,因此原有线程不再享有对象数据的访问权限。ArrayBuffer 就是以这样的方式转移的,但这里笔者有一个 疑问 :实际情况中,原有 ArrayBuffer 的 backing store 会被释放,显然在接收线程中会有新的堆块的分配以及数据的复制,并不是简单的修改指针的指向,这和 MDN 的文档描述的高效理念是冲突的。
线程相关的两个重要概念定义如下:
postMessage
发送消息worker.postMessage(message, [transfer]);
- message 表示要传递的数据
- 如果有实现了
Transferable
的对象,可以以数组元素的方式放到第二个参数中,以提高传递效率,但是在第一个参数中需要指定一个引用,以方便目标线程接收
onmessage
响应消息myWorker.onmessage = function(e) { ... }
- 通过事件的
data
属性访问接收到的数据
- 通过事件的
一个简单的例子如下所示:
<!-- main.html 的代码 --> |
// worker.js 的代码 |
输出如下所示:
Main thread: before postMessage, ab.byteLength is 4096 |
McAfee Labs 的文章提到,使用 AudioContext.decodeAudioData 同样可以实现 ArrayBuffer 的 Neuter 。
<script> |
由测试结果可知,不管解码成功与否,ArrayBuffer 都会被转移:
Before decodeAudioData, ab.byteLength is 4096 |
2.3.2 POC 构造
分析清楚了漏洞的底层原理,写 POC 就是很简单的事情了,笔者构造的 POC 代码如下所示。
<!-- poc.html --> |
// worker.js |
这里向工作者线程 postMessage
的小技巧参考了 Exodus Intelligence 的代码。注意 postMessage
本身也是异步执行的,也就是调用之后会立刻返回,如果是下面这样的写法,可能无法触发 Crash ,因为主线程中 ArrayBuffer 的 backing store 可能还没有被释放。
worker.postMessage(ab1, [ab1]); |
而如果采用 worker.postMessage(ab1, [ab1, ab2])
这样的写法,当 ab1
传送完毕之后传送 ab2
时会抛出一个异常,此时主线程中 ArrayBuffer 的 backing store 已经被释放,可以非常稳定的触发 UAF 操作。
Uncaught DOMException: Failed to execute ‘postMessage’ on ‘Worker’: ArrayBuffer at index 1 could not be transferred.
查看 Chrome Stable 版本的发布历史,可以知道 72.0.3626.121
的上一个版本号为 72.0.3626.119
,因此可以下载该版本的 Chrome 进行测试。测试时注意屏蔽 Internet 访问,否则可能会自动升级。测试上面的 POC 代码可以发现,漏洞的触发非常稳定。
如果将 WinDbg 附加到 Chrome 的网页渲染进程(可以打开 Chrome 的任务管理器来查看进程 PID ),可以捕获到相关的异常信息。
(74c.2a4): Access violation - code c0000005 (first chance) |
0x03. 小结
这次 漏洞分析 尽管花费了一定的休息时间,但是作为第一个浏览器漏洞,在分析的过程中还是学到了不少东西,希望下次有时间可以学习一下这个漏洞的 Exploit 的编写。