有一位大佬分享了当时的wp思路,网上似乎没找到中文版的wp,在此做翻译记录和学习。

概览

题目目录文件:

1
2
3
4
5
6
7
8
9
10
base ❯ tree -L 1
.
├── build.Dockerfile
├── challenge
├── launcher.py
├── Makefile.txt
├── snapshot_blob.bin
└── v8.patch

0 directories, 6 files

根据v8.patch文件可知定义了/srv/challenge/runner.cc文件作为题目。

1
2
3
4
5
-DEFINE_BOOL(wasm_write_protect_code_memory, true,

+DEFINE_BOOL(wasm_write_protect_code_memory, false,

             "write protect code memory on the wasm native heap with mprotect")

主要逻辑为challenge二进制程序接收用户输入(大小不超过65536)binary data到v8::ScriptCompiler::CachedData,后续会调用script->Run()函数执行目标。即可以利用challenge执行任意 v8 bytecode。由于 v8 对于字节码为信任输入,字节码执行存在很多的越界读原语。最终通过CreateArrayLiteral的越界读来获取到 faked ArrayBoilerplateDescription,从而触发 fake object 原语,然后获取到任意代码执行。

0x01 V8 Code Caching

code-caching相关文档:

Code Cache生成

根据以上文档,需要使用v8::ScriptCompiler::kProduceCodeCache或者v8::ScriptCompiler::GetCodeCache来产生cache。然而在给定的v8版本中均没有以上api。通过检查test-api.cc文件代码,发现使用了v8::ScriptCompiler::CreateCodeCache来为script生成cache data。需要注意必须在script->Run(context)后调用CreateCodeCache,否则一些懒编译函数不会生成cache。
另外,可以使用v8::V8::SetFlagsFromCommandLine来允许script来使用%DebugPrint等参数,便于调试。需要注意%DebugPrint会被编译进code cache,因此runner.cc中cache执行时,仍然会展示%DebugPrint的内容。
另外一个需要注意的点是runner.cc装载cache时,会提供一个空的script string。cache loader会检查binary cache的hash与script的hash是否相同。通过调试发现,空script的hash值为0,并且binary cache的hash位于+8偏移处,占四字节大小。因此如果要允许cache执行需要控制该域为0。

debug调试

生成的cache在debug/release版本之间是不能共享的。在debug版本,会设置Flag_verify_snapshot_checksum标志位来进行额外的校验和检查,需要手动通过SerializedCodeData::SanityCheckWithoutSource函数来禁用该标志位。

在flag_definitions.h文件中修改该flag定义为false,然后禁用DCHECK以及CSA_CHECK后即可使用DEBUG调试。
通过runner.cc,我们可以提供JavaScript代码生成Code cache。完整代码为:gen.cc。可以使用./gen exp.js --allow-native-syntax --print-bytecode来编译JavaScript进cache并存储二进制cache到./blob.bin。使用--print-bytecode可以看到生成的v8 bytecode,并且这些bytecode可以在生成的cache中查看。

0x02 v8字节码漏洞分析利用

一开始我们思考的是JIT产生的raw machine code是否也能够保存到cache中,如果可以我们就可以通过修改cache直接执行shellcode。然而经过数次尝试无法实现。因此我们需要使用v8 bytecode来实现攻击。
通过任意修改bytecode,v8会直接crash掉。作者想到了三种攻击方式,不过只有最后一个利用成功:

  1. 使用字节码泄漏JavaScript的Hole,之前使用过的一个攻击原语。然而在当前版本可能已经被修复了。
  2. v8字节码访问参数寄存器时,在指令字节序列中存在一个索引值字节。通过修改该字节,可以导致OOB。然而通过进一步调查,发现OOB发生在栈上,并且后面的数据不容易控制。另外参数数组不是以压缩指针的形式保存的而是64位指针形式,因此难以简单的通过写入Smi数组到栈上。
  3. 我们发现CreateArrayLiteral也有一个索引值可以用于去访问OldSpaceFixedArray对象。该值是指向ArrayBoilerplateDescription的指针,描述了数组的初始化。通过控制FixedArray后面的内容,我们可以伪造ArrayBoilerplateDescription实例,从而获取对象伪造原语。

CreateArrayLiteral

CreateArrayLiteral是用于创建JavaScript数组的字节码。
gpt给出的解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在 V8 的字节码中,`CreateArrayLiteral [0], [0], #4` 是一个字节码指令,用于创建一个数组字面量(Array Literal)。我们需要分析这条指令的含义,特别是第一个 `[0]` 是如何控制指向 `ArrayBoilerplateDescription` 的偏移的。

---

### **字节码指令解释**

#### **1. CreateArrayLiteral**

- 这是 V8 中的一条字节码指令,用于创建一个数组字面量(`ArrayLiteral`)。
- 它的格式通常是:

`CreateArrayLiteral [index_in_constant_pool], [flags], [length]`

- **`[index_in_constant_pool]`**: 指向常量池(Constant Pool)中的索引,用于获取 `ArrayBoilerplateDescription`。
- **`[flags]`**: 一些标志位,用于描述数组的特性(如是否为浅拷贝等)。
- **`[length]`**: 数组的长度。

以下是测试代码:

1
2
3
4
5
6
7
8
function foo()
{
const o = [[], 1.1, 0x123];
return o[0];
}
foo();
readline();
// ./d8 test.js --print-bytecode

foo函数生成字节码如下:

CreateArrayLiteral指令负责数组创建。放入数组的元素由[0]控制。该值为用于访问OldSpace的FixedArray的索引。
索引0访问了ArrayBoilerplateDescription实例,该实例用于描述数组元素如何初始化。

ArrayBoilerplateDescription示例包含了一个constant elements字段指向另一个FixedArray[0]。该FixedArray包含了新创建数组的初始化元素。另外一个有趣的点是第一个元素是另一个ArrayBoilerplateDescription指针而非JSArray指针,不过可以理解:每次创建一个数组时,我们都会新建一个数组实例,而非沿用老的JSArray的引用。
主要关注的点是是否可以伪造CreateArrayLiteral对象中的ArrayBoilerplateDescription,从而实现对象伪造原语。通过手动修改constant elements使其指向另外一个已存在的JavaScript对象指针即可。因此只需要关注如何通过CreateArrayLiteral越界读来伪造ArrayBilerplateDescription

控制FixedArray后的内存

由于FixedArray位于OldSpace中,需要创建一个存放double元素Array并触发内存回收来将其放入OldSpace,然后要确定其数组元素要位于FixedArray后。然而该元素地址一般距离FixedArray较远。
然后我们发现constant elements的内容是接近FixedArray的,但是其位于FixedArray前。然而,如果我们创建另外一个也包含CreatArrayLiteral指令的函数(需要满足函数声明位于目标函数后),其constatn elements则会位于目标FixedArray后。另外如果数组只包含double类型,则其constant elementsFixedDoubleArray类型,其表明unboxed double存放在内存中,因此我们可以完全控制FixedArray后的内存内容。

注:auto box,unbox为Java的语言特性,auto box即将基础数据类型(eg:int, double)自动装箱为Integer,Double对象。unbox则与之相反

对应的OOB index是通过调试尝试来计算得到的。开始时我们设置unboxed doubleAAAA...,然后在gen.cc进程中检查FixedArray后内存。注意我们不能在run.cc中完成以上任务(由于无法打印字节码),但是两个内存布局很相似。然后可以将其作为CreateArrayLiteral的索引值,然后运行./challenge程序。如果发生0x????41414141的crash,则表明发生了OOB。

计算偏移:(0x1b6a00253c39 - 0x1b6a00253b99) = 0x14,修改生成的cache二进制文件的对应字节码即可,以下为生成字节码的对应CreateArrayLiteral

Debug与Release的差异
debug版本的challenge会直接reject掉patch的cache,但是release不会;
debug版本与release对应的偏移可能不同

在debug版本调试,发现执行到CreateArrayLiteral函数时其rsi值即ArrayBiolerplateDescription。

在release版本中调试,发现两个ArrayBoilerplateDescription1相差0x14c,其索引值是以4字节为一个单位的,因此可以修改为0x53,从而使其指向要伪造的目标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function victim()
{
const o = [[], 1.1, 0x123];
%DebugPrint(o);
// console.log(o);
return o[0];
}
function target()
{
const target_arr = [0x4141414141414141, 0x4141414141414141, 0x4141414141414141, 0x4141414141414141];
%DebugPrint(target_arr);
// console.log[target_arr];
return target_arr;
}
victim();
target();
console.log("[*] Finished!")
// readline();
// ./d8 test.js --print-bytecode

Final Exploit

具体步骤如下:

  1. 准备一个足够大的double数组,并且其低32位的元素地址需要是固定的(V8指针压缩)。该数组用于提供伪造实例例如ArrayBoilerplateDescription, FixedArray, Uint32Array…(让其指向了constant number)
  2. 喷射低32位元素地址令其作为位于FixedArray后的越界读FixedDoubleArray。注意对于内置类型Map值是固定的。
  3. 调用victim函数,其CreateArrayLiteral指令已被修改来进行越界读,然后将其返回获取到伪造对象。
  4. 后续常规做法即可

创建了big_array,其内容地址为0x1c2149,同时在foo函数后定义foo2创建内容为0x1c2149FixedDoubleArray数组,其内容会位于FixedArray后,因此设置victim函数创建的数组可以溢出ArrayBoilerplateDescription到foo2定义的数组,然后指向0x1c2149。
foo2定义数组的constant elements地址

victim定义数组的ArrayBoilerplateDescription地址

通过伪造ArrayBoilerplateDescription使其指向受控的big_array后,目标数组定义为[{}, 0x1337],指定第一个对象为{a: wmain},即定义的wasm函数,然后定义fake_object = arr[0]获取其引用,然后其内容fake_object.a定义位于big_array中,因此可以控制其内容
然后定义了wasm函数并篡改其为shellcode并执行。
直接用作者提供的exp,发现会出现segmentation fault,界面如下:

可以发现执行到最后的写入shellcode时出现问题
似乎是这里的问题,现代cpu开了pku保护

关闭该保护对应的的flag标识即可

成功执行注入shellcode,执行了/catflag

参考链接