GoogleCTF 2022 d8 Write Up
有一位大佬分享了当时的wp思路,网上似乎没找到中文版的wp,在此做翻译记录和学习。
概览
题目目录文件:
1 | base ❯ tree -L 1 |
根据v8.patch文件可知定义了/srv/challenge/runner.cc
文件作为题目。
1 | -DEFINE_BOOL(wasm_write_protect_code_memory, true, |
主要逻辑为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 caching
- Snapshots and Code Caching
总结了中文文档:Code-caching
该功能是对javascript编译执行的优化。
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掉。作者想到了三种攻击方式,不过只有最后一个利用成功:
- 使用字节码泄漏JavaScript的Hole,之前使用过的一个攻击原语。然而在当前版本可能已经被修复了。
- v8字节码访问参数寄存器时,在指令字节序列中存在一个索引值字节。通过修改该字节,可以导致OOB。然而通过进一步调查,发现OOB发生在栈上,并且后面的数据不容易控制。另外参数数组不是以压缩指针的形式保存的而是64位指针形式,因此难以简单的通过写入Smi数组到栈上。
- 我们发现
CreateArrayLiteral
也有一个索引值可以用于去访问OldSpace
的FixedArray
对象。该值是指向ArrayBoilerplateDescription
的指针,描述了数组的初始化。通过控制FixedArray
后面的内容,我们可以伪造ArrayBoilerplateDescription
实例,从而获取对象伪造原语。
CreateArrayLiteral
CreateArrayLiteral
是用于创建JavaScript数组的字节码。
gpt给出的解释:
1 | 在 V8 的字节码中,`CreateArrayLiteral [0], [0], #4` 是一个字节码指令,用于创建一个数组字面量(Array Literal)。我们需要分析这条指令的含义,特别是第一个 `[0]` 是如何控制指向 `ArrayBoilerplateDescription` 的偏移的。 |
以下是测试代码:
1 | function foo() |
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 elements
为FixedDoubleArray
类型,其表明unboxed double
存放在内存中,因此我们可以完全控制FixedArray后的内存内容。
注:auto box,unbox为Java的语言特性,auto box即将基础数据类型(eg:int, double)自动装箱为Integer,Double对象。unbox则与之相反
对应的OOB index是通过调试尝试来计算得到的。开始时我们设置unboxed double
为AAAA...
,然后在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 | function victim() |
Final Exploit
具体步骤如下:
- 准备一个足够大的double数组,并且其低32位的元素地址需要是固定的(V8指针压缩)。该数组用于提供伪造实例例如
ArrayBoilerplateDescription
,FixedArray
,Uint32Array
…(让其指向了constant number) - 喷射低32位元素地址令其作为位于
FixedArray
后的越界读FixedDoubleArray
。注意对于内置类型Map值是固定的。 - 调用victim函数,其
CreateArrayLiteral
指令已被修改来进行越界读,然后将其返回获取到伪造对象。 - 后续常规做法即可
创建了big_array,其内容地址为0x1c2149,同时在foo函数后定义foo2创建内容为0x1c2149
的FixedDoubleArray
数组,其内容会位于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
