CVE-2023-4427:ReduceJSLoadPropertyWithEnumeratedKey 中的越界访问

CVE-2023-4427:ReduceJSLoadPropertyWithEnumeratedKey 中的越界访问

XiaozaYa 看雪学苑 2024-05-05 17:59

之前分析调试漏洞时,几乎都是对着别人的poc/exp
调试,感觉对自己的提升不是很大,所以后面分析漏洞时尽可能全面分析,从漏洞产生原理、如何稳定触发进行探索,并尝试自己写poc/exp。

diff.patch
如下:

最初接触enum cache
是在V8
的官方博客
Fast for-in in V8(https://v8.dev/blog/fast-for-in)
中,其介绍了V8
是如何实现快速的for-in
语句的,详细的内容可以参考上述官方博客。

总的来说for-in
语句用于遍历对象的可枚举属性(包括原型链),在V8
中其设计大概如下:

可以看到,其首要的工作就是迭代遍历对象及原型链上的可枚举属性从而收集所有的可枚举keys
。那么V8
为了优化这一过程,配合V8
的隐藏类机制提出了enum cache
。我们知道V8
通过隐藏类或所谓的Map
来跟踪对象的结构。具有相同Map
的对象具有相同的结构。此外,每个Map
都有一个共享数据结构——描述符数组,其中包含有关每个属性的详细信息,例如属性存储在对象上的位置,属性名称以及是否可枚举等属性信息。为了避免反复的访问描述符数组和检测相关属性,V8
将可枚举对象内属性和快属性的key
和位置index
保存在了enum cache

注:enum cache
保存在描述符数组中,而字典模式是不具有描述符数组的,而对于具有描述符数组的element
其也默认就是可枚举的,而对于elements
的键查找是非常简单的。所以这里enum cache
主要就是针对快属性和对象内属性的所以如果对象只要快属性或对象内属性,那么在执行for-in
时,只需要访问一次描述符数组,从描述符数组中拿到enum cache
即可找到所有的可枚举属性,然后遍历原型链,取原型链的enum cache
(如果有的话)。当然如果对象中还有elements
呢?这时也会取enum cache
,但是会进行一些其它的操作,大致流程如下:

对于for-in语句,V8会将其转换成一个循环,其主要使用 3 个关键的操作:ForInEnumerate、ForInPrepare、ForInNext,其中ForInEnumerate/ForInPrepare主要就是收集对象所有的可枚举属性,然后ForInNext用来遍历这些收集的可枚举属性,对于对象属性的访问会调用JSLoadProperty:

而如果对象存在enum_cache
,则在InliningPhase
阶段会对JSLoadProperty
进行优化:

在InliningPhase
存在一个native_context_specialization
裁剪器:

该裁剪器会对一些JS
原生操作进行优化:

可以看到这里会调用ReduceJSLoadProperty
对JSLoadProperty
节点进行优化:

对于for-in
中的属性加载会调用ReduceJSLoadPropertyWithEnumeratedKey
进行优化:

这里建议读者自己好好看下这个函数中本身的注释,其写的很清楚。

总的来说对于将for-in
中的快属性访问,会将JSLoadProperty
节点优化成obj map check
+LoadFieldByIndex
节点。

接下来我们去看下经过trubofan
优化后的代码的具体执行逻辑。
获取map

执行完Builtins_ForInEnumerate
后,返回值rax
就是map
的值:

获取描述符数组:

获取EnumCache

获取EnumCache.keys

获取map.enum_length

将enum_length
、enum_cache
、map
保存在栈上:

每次执行完callback
后,都会检测obj2
的map
是否被改变,如果被改变,则直接去优化;否则通过保存在栈上的map
获取描述符数组,从而获取enum_cache
,进而获取enum_cache.indices
,这里会检测enum_cache.indices
是否为空,如果为空则直接去优化。

但是这里的enum_length
并没有更新,使用的还是之前保存在栈上的值:

这里debug
版本有检测,所以没办法展示,而release
版本又用不了job
命令,有点难调试,所以这里得两个对着调(说实话,挺麻烦的,这里其实可以直接在release
中设置一下的)

poc
如下:

调试可以看到,在执行完callback
后,map->descriptor_array->enum_cache
已经被修改,其中enum_cache.keys/indices
数组的大小都为 1(这里存在指针压缩,所以要右移一位),但是这里栈中保存的enum_length
却还是 2。

这里是真不知道map
上的enum length
的偏移是多少

POC
输出如下:

可以看到这里明显存在问题,本来应该输出 2,但是最后却输出为 0,这里简单调试一下。

在经过callback
后,obj2
的enum_cache.indices
数组如下:

上面说了,这里的使用的enum_length
仍然是栈上保存的 2,所以这里会执行第二次遍历:

这时取出的indice
为0x6a5
,跟上面是吻合的,然后这里存在指针压缩,所以还要经过右移处理,这里的r8
指向的是对象起始地址,r8+0xb
是对象内属性存储的起始位置,r12
是经过右移后的indice

这里按理说应该是[r8 + r12*4 + 0xb]
的,但是调试发现该POC
就是走的这条路径……

这里可以看下[r8 + r12*2 + 0xb]
处的值:

可以看到其值为 0,我们尝试修改一下值为 0xdea4:

最后输出如下:

根据输出可以知道,我们之前的分析是对的(这里存在指针压缩,所以要右移一位,说了很多次了,后面就不多说了)

根据上面的漏洞分析,我们可以知道其存在一个似乎不可控的越界读的漏洞。为啥说似乎不可控呢?通过上面的分析,我们可以知道这里越界读是由indice
决定的,在上面的例子中就是enum_cache.indices[1]
,所以如果想实现精确的越界读,则需要控制enum_cache.indices[1]
。所以接下来我们得去研究下enum_cache
的内存布局。

调试代码如下:

这里主要关注obj2
的enum_cache
(其实都一样,毕竟是共享的),其布局如下:

可以看到这里enum_cache
是在old_space
分配的,从源码中也可以得知:

所以如果我们可以找到一些对象,其部分内容可控并且也使用AllocationType::kOld
进行分配,其就会紧跟在enum_cache
后面分配,这时我们调整obj2
的初始大小即可实现精确越界读。所以问题转换到了如何在old space
上分配内容可控对象。

笔者做了如下尝试:

◆最开始本想利用gc
来实现,但是其也是不可控的,因为你不能保障对象晋升时,目标对象总是与indices
有固定偏移(测试可以知道,几乎不固定,并且gc
会打乱之前的堆布局,主要是其它对象可能跑前面去了);

◆然后笔者想着直接利用enum_cache.keys/indices
的length
字段去进行控制不就完了,但是测试发现在笔者机器上,当属性个数超出 0x13 时就会切换成字典模式,字典模式是不存在enum_cache
的(毕竟描述符数组都没了);

◆最后笔者尝试在V8
源码中全局搜索AllocationType::kOld
,看看哪些代码逻辑会利用其进行堆分配,但是也没有找到合适的对象。

所以为什么我是菜鸡?因为我只会看官方WP
,官方提供了一个POC
,其中提供一个函数可以在紧接着indices
后面分配内容。

其实我发现了字符串是直接分配在old_space
上的,但是其始终在indices
的上方,不知道为啥

但是我思考了一下,其实我们没必要实现精确控制,根据V8
指针压缩堆分配特性,obj2
的enum_cache
的map
偏移始终是0x6a5
。然后由于只有一个越界读,所以这里直接用固定的map
去尝试,所以exp
不具备通用性。

这里固定的map
其实就是上面说的指针压缩堆分配特性,但是非常不稳定

所以我们尝试写针对特定版本的利用,这里感觉不太好说,所以直接画了一张图:

理论上victim_array
的addr/map/element
偏移都是固定的,所以这里我们可以直接用而无需泄漏,所以我们可以在victim_array
中伪造一个Array
(其就是victim_array
本身,这里伪造的len
可以改大一些),然后在obj2
后面放置一个fake_object_array
数组,其内容全是伪造的Array
的地址偏移(由于指针标记,所以记得地址要加1)。

这里在越界读的时候,越界的地址为obj2_addr + 0xb + 0x6a4
,其有很大的概率会落在fake_object_array_element
中,而其保存的内容为一个地址,所以会对其进行解析,查看指向位置的map
发现是个对象,所以这里就会返回一个伪造的对象fake_object
。我们可以通过victim_array
修改fake_object
的elemet
和len
,修改其len
可以实现越界读写,修改其element
可以实现 4GB 内范围的任意地址读写。然后就可以直接劫持函数对象的code_entry
进行利用。问题:这种利用方式理论上可行,笔者遇到过两次,但是每次都没有成功完成利用,主要有以下问题:

◆1、由于gc
从而导致victim_array
的addr/map/element
发生改变;

◆2、测试发现没有触发gc
,但是victim_array
却发生了重新分配,导致之前的内存被释放,然后写上了无效数据;

◆3、写利用非常麻烦,不知道为啥,只要添加一些代码就会使得victim_array
每次的map/element
发生变化(可能是由于代码触发的内存分配,比如print
也存在着内存的分配)。

所以笔者并没有成功完成利用,最后夭折的exp
如下:

后面糊了一个利用脚本,我的环境有沙箱,TypeArray/DataView
不存在未压缩指针,最后劫持的wasm
的jump_table_start
,然后打的立即数shellcode

效果如下:

这个漏洞笔者没有独立写出poc/exp
,但是自己也经过了大量的思考和调试,并不是像之前一样直接拿着poc
就开始搞。调试分析这个漏洞花了接近两天的时间,其中漏洞分析花了一天,漏洞利用花了一天,但是最后还是没有成功写出exp
,后面看看针对该类漏洞有没有什么好的利用办法。成功完成利用,nice!

参考(具体链接见“阅读原文”原帖)

原作者对漏洞原理的分析
CVE-2023-4427 PoC : Out of bounds memory access in V8.强网杯2023Final-D8利用分析——从越界读到任意代码执行(CVE-2023-4427)

Fast for-in in V8

看雪ID:XiaozaYa

https://bbs.kanxue.com/user-home-965217.htm

*本文为看雪论坛精华文章,由 XiaozaYa 原创,转载请注明来自看雪社区

#往期推荐

1、怎么让 IDA 的 F5 支持一种新指令集?

2、2024腾讯游戏安全大赛-安卓赛道决赛VM分析与还原

3、Windows主机入侵检测与防御内核技术深入解析

4、系统总结ARM基础

5、AliyunCTF 2024 – BadApple

球分享

球点赞

球在看

点击阅读原文查看更多