1:写在前面:
最近无意刷到看到一篇v8CTF的文章,原本想看一下学习下v8沙箱绕过的姿势,看了作者的slides却是第一时间 ![](https://bbs.kanxue.com/upload/attach/202406/808412_ESHCVTNK5VMNNPX.webp)
图 1.1
被作者圈起来的PromiseAllResolveElementClosure这个函数吸引,查了下是v8处理Promise对象相关的torque。
https://kaist-hacking.github.io/pubs/2024/lee:v8-ctf-slides.pdf
印象中这个builtin有关联的漏洞不少,随手查了下就看到
1):CVE-2020-6537
2):chrome issue 40068417
还有这个CTF slide所用的
3):CVE-2023-6702
由于之前没怎么对v8 Promise相关的函数分析过,这次索性一起调试一遍,顺便尝试通过CVE-2023-6702的POC构造另外两个issue的POC。
2:CVE-2023-6702
故事的源头从一个补丁开始:
![](https://bbs.kanxue.com/upload/attach/202406/808412_ZRFQSK9V6NGX5HR.webp)
图 2.1
2.1:这里的补丁说的是,在闭包指向NativeContext作为marker调用,async stack trace堆栈中出现了错误,这边红线还有提示是在Promise.all()发生的。
这里解释一下作者构造的POC
var closure;
function Constructor(executor){//1:按照ECMAScript标准创建Promise构造器
executor(v=>v,e=>e);
}
Constructor.resolve=function(v){//2:定义构造器resolve方法,这里只是简单的return参数。
return v;
}
let p1={//3:创建参数对象,对象内的then方法将onFul内部对象传递给closure
then(onFul,obRej){
closure=onFul;
closure(1);//4:将closure赋值为onFul对象,然后对closure闭包调用,也就是补丁标注达到这个代码路径的提示。
}
};
async function foo(){
await Promise.all.call(Constructor,[p1]);//5:将构造器和p1对象传入到Promise.all.call,为了确保closure能够调用完成,这里用了await等待调用结束。
await bar(1);
}
async function bar(x){
await x;
throw new Error();//6抛出异常,让之后的catch能够捕获到异常,然后输出错误堆栈。
}
foo().then(a).catch(e=>console.log(e.stack))//7:接收异常,访问异常的堆栈e.stack,触发漏洞。
漏洞原因:e是在closure调用之后捕获到的异常,v8设计的时候没有处理好这种闭包调用onFul这个Builtin的情况,导致了出现了通过e.stack将当前GlobalContex和closure里面的NativeContext出现混淆的使用。
2.2: 根据paper POC和调试内容可以知道。
2.2.1:onFul对象使用的是v8内部的builtin对象,然后使用的context为NativeContext。
2.2.2:使用onFul这个内部对象的时候会调用PromiseAllResolveElementClosure这个builtin。
2.2.3:在PromiseAllResolveElementClosure这个torque函数中,期待处理的数据结构为context,如果使用NativeContex,会出现数据结构混淆,造成内存破坏。
3.从CVE-2023-6702到CVE-2020-6537
3.1 CVE-2020-6537的root case
从2023年的POC倒推2020年的POC,现实工作和学习肯定不会有这种需求,听起来有点无厘头。但是最近漏洞研究经历让我感觉,很多时候找漏洞只是在不同的地方反反复复的找相似而又遗漏掉的情况,前人会找到这个东西作为研究,肯定是这个点集合了很多,有鸡有篮球,有中分也有背带裤,你如果能发现新的要素就又可以玩之前的烂梗。由于我先看到的是这个v8CTF的paper,从没看过CVE-2020-6537的POC,尝试一下由这个paper作者构造的POC基础上推测CVE-2020-6537的POC一下。
这里查看一下CVE-2020-6537的bug issue里面的描述,
https://issues.chromium.org/issues/40052834
In function PromiseAllResolveElementClosure
, it will read {remainingElementsCount} from {context}'s slot firstly, subtract 1, and save it back to {context}. {remainingElementsCount} represents the number of pending promise and when it becomes to zero, the function will return an array of objects that each describes the outcome of each promise.
貌似这个漏洞和remainingElementCount,这个变量有关,使用切换到漏洞版本的commit以后,利用关键字搜索一下v8源码,发现这个变量remainingElementCount是表示计数,
出现在PerformPromiseAll和PromiseAllResolveElementClosure这两个torque函数里面,这里对这两个torque函数打了补丁,Print出remainingElementCount的数量。
![](http://resources.yxfzedu.com/images/article_images/808412_5JZYZHJFEJX7FWB.webp)
图 3.1.1
![](http://resources.yxfzedu.com/images/article_images/808412_ZP7FDP2BDNPJGCS.webp)
图 3.1.2
如图3.1.1,3.1.2,重新编译以后,接下来尝试由v8CTF小修改下的以下POC
function Constructor(executor)
{
executor(v=>v,e=>e);
}
Constructor.resolve=function(v){
return v;
}
let p1={
then(onFul,onRej){
onFul();
}
}
async funcrion foo()
{
await Promise.all.call(Constructor,[p1]);
}
![](http://resources.yxfzedu.com/images/article_images/808412_2RPFYTDNSJWQ478.webp)
图 3.1.3
如图3.1.3所示,成功进入PerformPromiseAll和PromiseAllResolveElementClosure这两个torque函数并且使用了计数变量remainingElementCount。通过控制台的输出信息我们可以推测:
v8执行这个POC的时候,调用了PerformPromiseAll,增加了一次remainingElementCount,然后调用了PromiseAllResolveElementClosure,减少了一次remainingElementCount。
根据漏洞描述:
Normally, either resolveElementFun
or rejectElementFun
should only be called once at most, which means turning a pending promise to fulfilled or rejected state. However, user can get resolveElementFun
and rejectElementFun
through some user defined js functions. Once both of them are called, {remainingElementsCount} will be substracted twice but only one promise has been processed. So the result of Promise.allSettled will be returned to user in advance. An attacker may use this vulnerability to cause type confusion, and achieve arbitrary code execution.
通常resolveElementFun
和 rejectElementFun情况是只调用1个,然后在用户JS劫持的情况下可以调用2次,但具体的怎么调用2次目前还不知道。
3.2.2到这里再总结一次:
3.2.1:按照v8CTF slide给我们的信息,如果重写了then方法,并且将其中内置的onFul对象当成函数调用,就会进入PromiseAllResolveElementClosure这个builtin,通过输出日志,我们能看到可以走到PerformPromiseAll这个torque,并且会把remainingElementCount这个计数+1,
同时接下来会走到PromiseAllResolveElementClosure这个torque,然后把remainingElementCount这个计数-1,
这里可以直观的认为,每次对象出现一次then(){}函数调用,会先使用PerformPromiseAll来增加一次remainingElementCount计数,然后使用PromiseAllResolveElementClosure,减少一次remainingElementCount计数,验证的方式是注释掉onFul();然后再运行一次,输出的log没有查看到出现上述调用。
3.2.2:那么如何让remainingElementCount计数出现错误呢,
本人第一次尝试,重复调用onFul(),结果只对remainingElementCount计数改变了一次。事实是尝试多次onFul调用,结果也只是触发一次PromiseAllResolveElementClosure。
第二次尝试,分别重写onFul和onRej,然后一起调用,结果还是无功而返。
仔细的研究了下Promise和then的资料,发现ECMAScript标准下的then接管以后,无论调用onFul和onRej这情况本质都是相同的,简单来说,结果有两种情况:
第一种情况是全部处理正确,这时候由内部的resolve,也就是这里的onFul处理,
第二种是出现了错误,就让内部的reject,返回第一个错误,交给reject,也就是这里的onRej处理,这样的话在返回then的时候同时调用onFul,onRej是不会成功的,两者是只有其中一个是有意义的。
3.3:通过一段时间对标准的研究和尝试,我发现如果我们直接重写注册器Constructor的resolve方法,在这个注册器方法中获得调用then方法,就能第一时间接管到onFul,onRej,这个时候其实v8还没有对结果进行判断,onFul和onRej都还是”有意义的“的对象,这时候劫持那很就能直接调用onFul()和onRej(),
触发两次的PerformPromiseAll和PromiseAllResolveElementClosure调用,将remainingElementCount减1两次。
把POC改为
function Constructor(executor)
{
executor(v=>{console.log('haha')},e=>console.log('hehe'));
}
Constructor.resolve=function(){
then(onFul,onRej){
onFul();
onRej();
}
}
async funcrion foo()
{
await Promise.all.call(Constructor,[1]);
}
foo();
![](http://resources.yxfzedu.com/images/article_images/808412_J3YWASCC48DXD6Y.webp)
图 3.3.1
成功在控制台打出了hehe haha,说明onFul,onRej都成功调用了
但是remainingElementCount结果计算并没有没有错误,虽然remainingElementCount加了2次,但只减了1次,甚至还是正的。
按照这个漏洞的是描述和我之前的理解,应该是没有错误的。正当我疑惑的时候,
随手把Promise.all.call修改为原issue描述的Promise.allSettled.call,
神奇的事情发生了
如同中分和背带裤一样出现神奇的反应。
function Constructor(executor)
{
executor(v=>{console.log('haha')},e=>console.log('hehe'));
}
Constructor.resolve=function(){
return {
then(onFul,onRej){
onFul();
onRej();
}
}
}
async funcrion foo()
{
await Promise.allSettled.call(Constructor,[1]);
}
foo();
![](http://resources.yxfzedu.com/images/article_images/808412_9H226DN3XJV92Y9.webp)
图 3.3.2
触发了漏洞,remainElement变量先加了2次,然后连续减了2次,最终命中了remainingElementsCount==0的这个assert,触发了崩溃。
至此由v8CTF的POC反推测构造出CVE-2020-6537的POC就成功了。
3.4简单的总结一下:
3.4.1:我们可以直接修改构造器的resolve方法,实现同时调用onFul和onRej,触发PromiseAllResolveElementClosure这个builtin两次的效果。
3.4.2:Promise.all.call和Promise.allSettled.call虽然都会调用PerformPromiseAll和PromiseAllResolveElementClosure这两个builtin,但是其实底层的逻辑并不相同,可以推测出PerformPromiseAll和PromiseAllResolveElementClosure这两个torque
都只是v8实现Promise.all.call和Promise.allSettled.call逻辑的一部分,显然之前的先入为主,存在巨大问题的。
到了这一步成功触发了remainingElementCount结果结果为0的情况,命中了remainingElementsCount==0的这个assert,引发了Debug版本下面的崩溃,但是release版本下并没有这个DebugCheck,所以后续能够在错误逻辑下继续运行。
那这个remainingElementCount计数的问题怎么会变成v8的安全漏洞呢?原因就是Promise.allSettled.call会将处理的结果作为FixArray数组,传递回给用户JS环境,因为remainingElementCount的计数错误,
用户JS会因为remainingElementCount计数为0,从而进入remainingElementCount==0的判断,在v8处理完Promise.allSettled.call这个逻辑之前,提前获得这个FixedArray数组,如图 3.3.3
![](http://resources.yxfzedu.com/images/article_images/808412_W2ZJ88R8AGFFKDG.webp)
图 3.3.3
这时候如果我们把这个FixArray改为
dictionary Array,然而此时v8其实并没有完成Promise.allSettled.call的执行,之后依旧会执行到这个torque函数,并始终用FixArray的结构
![](http://resources.yxfzedu.com/images/article_images/808412_W5SAATP4DRU29EX.webp)
图 3.3.4
来操作这个数组,如图3.3.4,之后依旧会把这个数组当成FixedArray的数据结构来操作直到流程结束,这就会造成数组的数据结构混淆,导致最后越界的写入。
4:issue 40068417
让我们仔细的看一下这段代码,
![](http://resources.yxfzedu.com/images/article_images/808412_N4PF4G84AQPPQRQ.webp)
图 4.1.1
4.1 torque源码:
v8进入PromiseAllResolveElementClosure这个builtin中先是取出传进来的数组的values,然后将这个数组的values作为FixedArray,再将这个FixedA数组和Context作为参数创建1个arrayMap,接下来使用这个arrayMap来创建1个NewJSArray,并且调用途中的这个Call,这时候这个Call栈地址就会保留着对这个数组的引用,接着就轮到用户接管JS。
那如果我们想办法在JS层面触发垃圾回收,然后把这个引用指向的堆释放,会出现什么情况呢,答案是什么都不可能出现,v8会标记这个这个内存,然后把内存相关的引用都放在一起,集中处理,过程非常的复杂。
https://blog.exodusintel.com/2023/05/16/google-chrome-v8-arrayshift-race-condition-remote-code-execution/
但这里有一个和这篇文章提到相似的点,都是在同步过程中维持着一个FixedArray的引用
如果这时候我们对这个数组进行.shift()操作,v8会创建一个新的FixedArray,
同时会将原本的对象标记为Fillerobject,这个Fillerobject是垃圾回收中的一个特殊的类型,v8垃圾回收的过程中会认为这个地址后续还会被v8使用。
所以这时候垃圾回收的话,v8会新开辟一块内存,然后将新创建的FixedArray以及其他对象放到新的内存空间,将原本的内存释放掉,而这个call栈里的对这个数组因为被标记为Fillerobject,所以这个索引并不会被处理,还会指着原本Fillerobject的地址,这样就形成了了悬垂的指针。
如果再次进行垃圾回收的话,v8就会尝试对这个call的栈里面数组索引指向的空间进行回收,访问到已经释放的堆块,造成内存访问错误。
关于shift在v8的操作,可以参考这个文章。
![](http://resources.yxfzedu.com/images/article_images/808412_YXK8JY6HZX36WR7.webp)
图 4.1.1
4.2 不过虽然看了这个issue的介绍,觉得也明白了这个root case,也想到了发生的问题跟
https://blog.exodusintel.com/2023/05/16/google-chrome-v8-arrayshift-race-condition-remote-code-execution/
里面描述的场景非常相似,不过忙活了好久始终没能写出POC,估计是自己对CG和这个issue的触发的理解还是差了点意思,看了下原作者给出的构造中,使用的数组必须大于JSArray::kMaxCopyElements,才会让这个call的栈上的context保留着这个数组的引用,感觉这个具体细节可以留着后续研究吧。
最终POC变为:
var gc=function(){
try{new ArrayBuffer(0x7fe00000)}
}
var resolve=null;
function Constructor(executor){
executor(v=>{v.shift();gc();gc()},e=>{e});
}
Constructor.resolve=function(){
return {
then(onFul,onRej){
if(null==resolve)
{
resolve=onFul;
}
else{
onFul();
onRej();
}
}
}
}
async function foo()
{
var array=Array(102);
await Promise.all.call(Constructor,array);
resolve();
}
foo();
最终都忘记了一开始是想研究v8沙箱的,哈哈
4:参考
https://v8.dev/docs/torque
https://issues.chromium.org/issues/40052834
https://issues.chromium.org/issues/40068417
https://blog.exodusintel.com/2023/05/16/google-chrome-v8-arrayshift-race-condition-remote-code-execution/
https://kaist-hacking.github.io/pubs/2024/lee:v8-ctf-slides.pdf
最后于 8分钟前
被苏啊树编辑
,原因: