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