强网杯2023Final-D8利用分析——从越界读到任意代码执行(CVE-2023-4427)
Last Update:
前言 Introduction
在2024年1月举行的强网杯决赛中遇到了以 CVE-2023-4427 为题所出的一道题目,该漏洞相关信息均以披露,在比赛期间我也完成了本地的利用测试,但可惜的是,我的利用只在我本地的设备上成功运行,尽管在官方提供的虚拟机下也能成功利用,但却没能完成展示机的利用,尽管目前笔者并不清楚其原因,但斗胆在此讨论该漏洞的相关利用思路,如果有师傅解决了该问题,还请务必与我联系,感激不尽:(附一张本地虚拟机成功的图片)

这是一台全新的、刚刚通过 ovf 导入的虚拟机,它在我本地成功了,但奇怪的是,它在其他队友们的设备,以及展示机上都未能成功,目前笔者暂时不清楚确切的原因。
如果您了解相关信息,欢迎联系:tokameine@gmail.com,感激不尽!
顺便,久违的撰写了 V8 相关的内容,姑且将其作为Chaos-me-JavaScript-V8的第八章吧QWQ
调试环境搭建 Environment
在题目的目录下可以找到相关的版本:
1 | |
得到版本以后,按照标准流程构建即可,详细内容请参考 Chapter1-环境配置,本文仅做简述。
配置depot_tools
1 | |
获取源代码
1 | |
安装依赖
1 | |
补丁
1 | |
这里把补丁一起贴一下:
1 | |
编译
1 | |
注意事项
这里简单解释一下为什么笔者另外编译了一个调试版本的二进制文件。
在我们调试 d8 的时候往往都需要使用到官方提供的 gdb 插件,这个插件能够帮助我们解析某个地址处的对象的所有相关成员,由于笔者一般记不清各种对象的成员偏移,所以往往会使用这种方法来查找对象的各个成员的偏移地址。而该插件的原理是直接调用了调试版二进制文件下的一个函数,该函数在 is_debug=true 的时候才会被编译出来,因此特地编译一个调试版用来对照,方便我们在调试 Release 版的时候清楚我们需要哪些数据。
漏洞成因分析
前置知识 Prerequisite Knowledge
其实各大分析文章都讲的差不多了,但这里为了内容完整,笔者还是再次引述或依托一部分自己的理解造一次轮子吧。
In-object property 对象内属性
在构造对象时,一个对象的属性会被储存在对象本身的 property 中,如图:

我们通过如下代码来验证:
1 | |
得到对应对象:
1 | |
1 | |
可以发现,各属性对应的值均被储存在对象的特定偏移处。
不过请注意,如果属性的数量超过了 4 个,多出来的部分并不会再储存在内存连续的位置,而是为 properties 成员创建额外的数组对象,将多余的部分储存在该对象中,例如:
1 | |
1 | |
1 | |
可以注意到,数量少于等于 4 时,properties 对应的类型是 FixedArray,而超过之后则变成PropertyArray ,并储存多余的部分。
DescriptorArray 描述符数组
对于对象的每个属性,都需要有一个地方用于存储该属性相关的信息,该对象即为 DescriptorArray ,他位于对象的 map 成员下的 instance descriptors :
1 | |
当有不同的对象据由相似的属性时,他们会共用这些描述符,例如:
1 | |
1 | |
可以看到,obj1 和 obj2 的 a 和 b 属性共享了同一个描述符。
而描述符的布局如下:
1 | |
我们暂且还不太关心具体的结构。
enum cache 枚举缓存
通过上文方式声明的属性称之为 fast property,这里笔者就以 快速属性 代称。在查找快速属性时,如果对象没有被更改,那么就会为其生成枚举缓存,例如通过 Object.getOwnPropertyNames 或者 for-in 语法。

这里的缓存只记录了相关的 key 和索引,不记录具体的值。
1 | |
1 | |
查一下相关对象:
1 | |
在对代码完成 ReduceJSLoadPropertyWithEnumeratedKey 优化以后,将会使用该缓存来访问相应属性。对应 SON 中的表现为,将 JSLoadProperty 节点优化为 LoadFieldByIndex。
相关代码:
1 | |
1 | |
1 | |
漏洞成因
修复该漏洞的补丁内容如下:
1 | |
根据注释,补丁的大致用意是:如果某个对象需要更新描述符,那么如果这个被更新的描述符已经构建了枚举缓存,那么就要为新的描述符也重新构建枚举缓存。
反过来说,这意味着对于补丁以前的解释器来说,在它对某个对象更新其描述符时,不会为新描述符重新生成枚举缓存,也就是说,新的描述符会使用旧的枚举缓存。
其中的一个补丁:
1 | |
1 | |
大致意思就是说,如果新对象没有快速属性,不要为它创建空的枚举缓存。
不妨用一个简单的例子来看看具体的现象是什么:
1 | |
首先创建了三个对象,其中 object2 和 object3 能够共享描述符:
1 | |
1 | |
可以看到,两个对象共享了 instance descriptors ,因此会据有相同的描述符。
而当对 object3 的属性做额外的赋值时,将会改变这一现状,因为类型的变化,因此需要重新更新这两个描述符:
1 | |
1 | |
新的描述符中不再拥有之前的缓存,不过旧的描述符里还保留着:
1 | |
再然后是涉及具体的缓存如何被使用:
1 | |
通过跟踪 SON,我们可以找到大致如下内容:
- 获取对象的
map成员

获取描述符

获取枚举缓存

获得
keys
获得长度

储存在栈上

不知道您读到这里是否意识到了问题所在,这里存在一个非常巧妙的问题:假设代码在加载了长度以后重新更新了这份缓存,而新缓存的长度小于旧缓存,由于更大的长度已经事先被加载到栈上了,因此以它作为基准将有可能出现 OOB。
该漏洞相关的最简 POC :
1 | |
在 Debug 版本下,该代码将会导致崩溃,而在 Release 下,它的结果如下:

可以看到,该操作导致了预料之外的问题,本该输出 3 的代码却在优化后导出了 1 。
不妨大致跟一下:
- 获取
map

- 获取描述符


- 获得枚举缓存


- 获取长度并储存


地址不一定一样,前后为了调试方便分别用了 Debug 和 Release 版,优化等级有所不同。
但是长度由旧的缓存决定,而在修改 object3 以后生成了新的描述符和缓存,此时将会使用新的描述符和缓存来获得实际的内容:

此处获取的缓存是来自于如下代码生成的内容:
1 | |
由于 object1 只有一个成员,因此缓存的数组长度也仅为 1,但上限却事先被设定为 2,因此在循环中将会导致 OOB 从而越界将数组外的数据作为偏移进行访问。
相关代码如下:

在访问时会从 rbp-0x28 读出先前写入的 map 然后按照上述的顺序重新获得缓存,不过新的 map 下的缓存已经更新了,而新缓存没有索引为 1 的项,那么其索引为 1 处的值就会被作为偏移以获得对应的属性。
漏洞利用
任意偏移读
根据上文的这些分析,我们获得了一个不确定的越界读能力,接下来我们希望完成的是如何让它能够稳定的去内存里访问特定数据呢?也就是说能否构造成任意地址读。
这个问题在该漏洞发现的 issue 中已被提及:
1 | |
运行该 POC 会导致内存崩溃,相应的位置如下:

显然此处的 r9 作为索引去获得属性值,而该索引过大导致了崩溃,而这个 0x10909090 是从 Utils.AllocateInOldSpace(0x42, 0x42, 0x42, 0x42) 而来的,公式为 ([buf]/2-1)/2 ,也就是 (0x424242/2-1)/2==0x10909090。因此在这个 POC 的基础上做一些修改就可以直接完成指定偏移读了。
这里姑且提一下这份 POC 为什么能够准确的使用到 0x42424242。
这是一份标准的描述符下的偏移数组 indices :
1 | |
在这里标记了该数组位于 OldSpace ,而该内存空间相对于 NewSpace 来说是相对稳定且不经常变动的,如下是内存视图:
1 | |
对于尚未被使用的内存,均使用 beadbeef 填充,因此可以看出,在我们为它创建了描述符以后,被放在了该空间的高地址处,也就意味着如果有办法往该空间创建额外的内容,那么就必定在内存连续的特定偏移下。
而通过代码中的 AllocateInOldSpace 能够实现这个操作,从而在特定偏移处输入用户定义的内容,在发生越界以后将输入内容作为偏移。
准备工作
为了完成完整的利用,我们事先准备一些将要用到的函数。这里读者可以直接复制使用:(此处的 shellcode 为 Ubuntu 下弹出计算器的相关代码)
1 | |
伪造对象数组
既然我们能够在数组中越界读了,而我们知道,读取到的数据是什么类型完全取决于读到的值为单数还是双数,对于单数的情况,又取决于其 0 偏移处的 map,因此我们可以大致构造如下结构:
1 | |
当其越界访问到了 doubleArr[1] 处的地址,那么将会以它为地址去读取对应的值,而如果 doubleArr[1] 的低 4 字节正好指向了 doubleArr[2] ,而我们又将此处伪造成了一个对象数组,那么最终就会返回一个对象数组:

这里因为 Debug 版的检查会导致崩溃,但是这个问题在 Release 下不会发生,可被直接视为一个正常对象。
在 Release 下会是这样的:

代码有改动,因此调试的时候地址可能会有微妙的变化
代码中需要改动的部分包括:
AllocateInOldSpace的参数,用于确定偏移con_buf:值为element对象的地址
构造 AddressOf 原语
有了上一步构造出的数组,这一节的工作就非常轻松了:
1 | |
由于我们在设定 vul 的 element 指针正好为 doubleArr[4] ,因此直接写入并读取即可。
构造任意地址读
在本题中笔者的方法相对朴素,通过直接修改 element 字段来实现 4G 空间内的地址读写:
1 | |
构造任意地址写
1 | |
地址笼逃逸/shellcode 写入
我们知道,在 V8 下一直使用地址压缩的技术,这使得我们无法在其他内存段下进行数据读写。在本题所提供的版本下,可以使用 DataView 来解决该问题,关于详细的利用方法,笔者已经在 该文 有过描述,本文就不再赘述了。
大致的原理是 DataView 在进行数据读写时使用 8 字节指针指向缓冲区,通过修改该指针的值来完成全内存读写。
1 | |
另外,在该版本下使用 wasm 会生成 rwx 段,因此使用 该文 的方法已经能够完成所有利用了。
最后调用一下对应函数触发 shellcode 即可弹出计算器。
从 d8 到 chrome
在我们完成了对 d8 的利用以后,接下来就是将其适配到 chrome 去了。在过去的几篇文章中,笔者一直未曾介绍过如何调试 chrome,正好本文需要,因此在这里一并做个介绍。
从本质上说,chrome 和 d8 的调试方法是一样的,但是由于 chrome 本身是多进程,因此为了找到相应的程序也花了点时间。
附加到 V8
首先按照题目的方式启动 chrome :
1 | |
此时的进程列表中会出现多个进程:
1 | |
其中,带有 --type=renderer 的进程则为 JavaScript 解释器。不过如您所见,这里有两个符合条件的进程,具体是哪一个需要读者自行确定,在笔者的环境下一般是占用率较高的那个。
在 chrome 下使用调试代码
在 d8 下我们可以使用诸如 %DebugPrint 这样的函数来帮助我们进行调试,而在 chrome 下,如果需要使用同样的调试函数,那么需要在启动浏览器时添加参数 --js-flags="--allow-natives-syntax"。
不过需要注意的是,本题并不提供这样的启动参数,因此在完成调试以后需要去掉这些函数,其中包括了 %PrepareFunctionForOptimization 和 %OptimizeFunctionOnNextCall ,因此后续的调整中,需要使用大循环来让其优化 trigger 函数,这里贴一份笔者的代码:
1 | |
不知道是不是笔者本地独有的问题,对于只调用 0x10000 次并不会让
trigger被足够多的降级优化,因此笔者这里反复调用了两次。
但是即便如此,在浏览器第一次解析该脚本时也不会触发优化,需要笔者主动刷新一次页面才会发生优化降级。
最终的利用与其他顾虑
最后贴一份只在我本地可用的利用吧:
1 | |
该代码是笔者写于比赛期间的,而在本文撰写时已经时隔多日了,笔者重新开始考虑是否有更加稳定的方式去完成同样的工作。
其中笔者也尝试了通过将对象置于 OldSpace 的方式来试图令其稳定,但并没有解决一旦修改代码就会令偏移改变的问题。因此整个利用的编写前前后后调试了无数遍,相当的痛苦。
如果您在阅读本文后得出了更加精巧的方案,欢迎交流:tokameine@gmail.com。
总结
最后总结一下本文的利用手段,一般来说大概能够通用
- 条件:越界读
- 构造一个浮点数数组
DoubleArr,将其内容布置为对象数组的内容ObjArr - 通过越界读将其内容视作对象数组读出
- 构造AddressOf:向
ObjArr放入对象,再从DoubleArr读出即可 - 构造任意地址读:将
DoubleArr中对应位设置为DoubleArr map,将其element对应位设为地址,此时ObjArr被视为浮点数数组,读出内容即可 - 构造任意地址写:同
构造任意地址读一样,将最后的读取改为写入即可
在完成以上原语以后,后续操作根据实际情况可以考虑写入 shellcode 或者 JOP 或是其他方式进行逃逸即可。
不过似乎最新的版本中已经不再支持这两个方法了,不过又出现了新的逃逸方案,假以时日希望有机会另写一文吧。
参考文章
https://paper.seebug.org/3081/
https://cwresearchlab.co.kr/entry/CVE-2023-4427-PoC-Out-of-bounds-memory-access-in-V8
https://rycbar77.github.io/2023/12/01/CVE-2023-4427%E5%88%86%E6%9E%90%E4%B8%8E%E5%A4%8D%E7%8E%B0/
https://bugs.chromium.org/p/chromium/issues/detail?id=1470668