本文首发于先知社区:https://xz.aliyun.com/news/18274
前言
又是很久没有写文章了,终于一两天有空补一下前面几个月的坑。
本文是关于UMDCTF2025的两道V8-CTF题目的解题思路分享。由于1984本身存在非预期解, 1985本质只是1984的临时修复版。如果只考虑从patch出发的解题思路,其实并没有什么区别。
literally-1984的非预期解
既然存在非预期,我们就稍微提一下。如果对比一下两个题目的patch文件,就能发现一些特点
其实就是注释掉了一些全局的JS接口,这里我一个非预期exp仅供参考
1 | let content = read('/flag'); |
literally-1985 Patch 分析
首先来看一下patch的核心部分,其实也就是1984的patch内容
Part 1
1 | diff --git a/src/compiler/machine-operator-reducer.cc b/src/compiler/machine-operator-reducer.cc |
首先修改了machine-operator-reducer.cc
中的Reduction MachineOperatorReducer::ReduceInt32Add(Node* node){}
。查看v8的源代码内容结合函数名称,我们大概能明白,这个部分是用来优化简化int32类型的算术加法
也就是说,patch本质上是加了一条2 + 2 => 5的优化操作,当左右两个节点的值都为2时,直接被替换为数字5.
Part 2
1 | diff --git a/src/compiler/simplified-lowering.cc b/src/compiler/simplified-lowering.cc |
Patch的第二个部分修改了对于index越界访问的校验。从注释我们也能明白
The bounds check is redundant if we already know that the index is within the bounds of [0.0, length[.
如果我们已经知道index访问的范围在0.0 - length之间,那么边界检查就比较多余了
重点在于if (v8_flags.turbo_typer_hardening) {
==> if (v8_flags.turbo_typer_hardening && 2 + 2 == 5) {
由于第一个patch影响的是turbofan优化的Javascript层面的2+2,而这里源代码的2+2=5是不受影响的。也就是说,这个if判断变为了一个恒为False的情况,使得new_flags永远不会拥有CheckBoundsFlag::kAbortOnOutOfBounds标志位。
CheckBoundsFlag::kAbortOnOutOfBounds:告诉编译器在访问数组时如果发现索引越界,应立即“强制终止”(abort)程序,而不是继续执行或返回默认值。
具体作用流程:
需要对某个数组访问加一个边界检查;
如果发现数组索引超出了范围;
那就 触发一个安全终止(abort),而不是容忍它或 fallback 到解释器。
总体来说,这段patch就是防止了程序从越界访问时触发错误终止。
2+2=5?
分析完了Patch,我们很显然能够意识到,出题人为我们提供了一个数组越界的风险。那么接下来就需要尝试构造并验证2+2=5,最终实现数组越界的效果。
直观来想,我构造的第一个poc函数如下:
1 | function foo() { |
并通过在外层主流程循环调用来触发Turbofan优化
1 | for(let i = 0; i< 10000; i++) { |
如下图:
能够成功的泄露内存值,为了更进一步查看泄露的内存值,略微修改函数返回值
1 | ... |
关注红框的内容,可以看到最下面的输出时idx = 5 以及leak的内存信息
同时由于elements的内存布局,这里泄露的值恰好为JSArray对象的map和properties域。到此为止,我们便可以正式开始漏洞利用的过程。
错误的AAR & AAW
我们知道,V8对于数组越界的判断是基于计算一个索引属于[x, y]这样的集合上下界来判断的。我们已经有了能够让2+2 => 5的能力,从而实现短距越界的效果。如果想要任意地址读或者任意地址写,最好的办法便是扩大越界范围,以修改elements导向,从而实现任意地址读写。
1 | function foo() { |
只需简单的借助乘法便可以实现对于elements的越界访问
如果此时覆盖elements到指定地址,便可轻易实现AAW与AAR,如下
1 | //前面的其余数据处理函数省略掉 |
写到这里时,我天真得以为这样就可以轻松实现任意地址读写,然而实际是这样的
本质是由于增添了一个CHECK,用来校验elements对象的map是否符合预期。如果顺利的话我会在后续的文章中具体分析,并尝试提出新的通用利用链。(因为elements的校验会影响fake_array的伪造,使得常用的基于fake_array的任意地址读写手法无法正常运行)
From JSTypedArray to AAR & AAW
我在Discord上看到了一个比较巧妙的优化方式来自@white701
1 | function foo(x) { |
通过越界更改corrupted_array的length和corrupted_array.elements的length,而并非更改elements指针的指向。从而可以使得corrupting数组稳定的越界buf_array对象数组。另外,在外层循环通过if (o[0].length != 3) break;
来定位优化完成的时刻。
因为如果test没有正确进行Turbofan优化,其中的赋值语句显然不会更改corrupted_array的长度。
自然的,有了这样o[0]和o[1]这样稳定的越界读写对象数组,GetAddressOf()
和GetFakeObject()
的实现就显的很轻松了
1 | function addrof(obj) { |
在无法正常使用fake_array的情况下,我们应当如何实现AAW或者AAR原语,这里就要用到标题中提到的JSTypedArray,先来看看对象结构:
1 | let leakArr = new Float64Array(1); |
简要信息如下:
1 | DebugPrint: 0x24c10007a331: [JSTypedArray] |
其中data_ptr指向的便是数据存储的直接存储空间,目标地址无需满足特定对象结构,且data_ptr = base_pointer + external_pointer。
具体来讲data_ptr有时与elements相关,有时与buffer相关,取决于当前的存储是inline还是external。如果顺利的话这段具体的详情,我们也放到下一篇文章内。
小小吐槽:写到这里突然发现欠了很多文章没有完成,只能慢慢补了
且base_pointer和external_poniter的完整信息都存储在对象内存结构内,那么我们只要利用越界读写,覆盖这两个域。便能够控制data_ptr指向任意原始指针范围,从而实现64bit的任意地址读写。
那么AAW和AAR也就呼之欲出了:
1 | let leakArr = new Float64Array(1); |
这里的58偏移需要具体通过%DebugPrint()
来计算具体偏移值是多少即可。这里的-7n
就是为了和external_pointer做累加实现堆内的任意地址读写。因为后面只需要更改wasm.Instance对象的跳转表,所以堆内任意地址读写就已经足够。
控制执行流
这里我对原作者的EXP做了一些更改和优化,因为利用并不需要他写的这么复杂,在文末我会放出优化后的完整EXP和原EXP以供参考
这里使用的shellcode是return浮点数利用Turbofan优化后的汇编码,可以看这条:JIT-Spary
1 | let shell_wasm_code = new Uint8Array([ |
我们来看看这个版本下的Instance结构
进一步查看trusted_data
其实与RWX段有关的代码只有jump_table_start,查看对应的汇编代码就能看到熟悉的东西了。即函数的lazy binding结构,类似于glibc的plt-got结构。
注:下图地址与上图不太一样,因为不是一次gdb的截图,但是都是对应默认的jump_table_start
可以看到,jump_start_table最终索引到了WasmCompileLazy()
函数(可以理解为类似glibc的libc_runtime_resolve()
),关于这个结构的具体运作机理,我会在下一篇文章中详细介绍,这里我只说明利用方法。
1. 寻找真实汇编地址
首先需要正常调用一次函数,然后利用%DebugPrint()
找到函数的真实代码地址(也就是懒加载后的地址)
1 | shell_func(); |
经过懒加载后,代码的start_jump_table内容就会直接跳转真实代码地址,如图:
上图中的0x8cb91817000
便是WasmTrustedInstanceData.jump_table_start,可以明显的看到代码此时已经变为了我们的浮点shellcode
2. 记录shellcode偏移
计算浮点shellcode距离原始jump_table_start的偏移,例如这里便是0x89c
1 | pwndbg> p/x 0x8cb9181789c - 0x8cb91817000 |
利用addrof()
和arb_read()
进行对应的内存泄露
1 | let shell_wasm_instance_addr = addrof(shell_wasm_instance); |
3. 覆盖start_jump_table
此时需要覆盖start_jump_table为真实shellcode地址.
但是需要注意,也是这一步最重要的信息: 需要在调用之前就需要将其中内容覆盖为真实shellcode地址,一旦函数不是初次调用,那么这里的覆盖将不会起到引导程序流的作用。具体逻辑将在下一篇文章详细讨论,也就是说,整体JS逻辑应该如下,第一次调用wasm函数就应当是已经进行过地址覆盖的情况:
1 | let shell_wasm_code = new Uint8Array([ |
不出意外这样就可以getshell了,如下图
EXP
经过我个人优化过的EXP如下:
1 | function foo(x) { |
原作者的EXP也放置在这里,可以用作对比:
其实主要区别就是原作者的覆盖放在了新的函数,但这里其实没必要大费周章的新建函数,只要保证是初次调用函数即可
1 | function foo(x) { |