Ikoct的饮冰室

你愿意和我学一辈子二进制吗?

0%

2025腾讯游戏安全 PC端决赛题解+复现

省流: 被算法卡脖子了 学到虚脱

(2)能在双机环境运行驱动并调试(1分)

直接加载驱动会报错:

DriverEntry failed 0xc0000001 for driver \REGISTRY\MACHINE\SYSTEM\ControlSet001\Services\ACE2025

代码高度混淆 静态也看不出东西 这里尝试patch驱动强制下断让windbg接管:

QQ_1744350475400

QQ_1744350458831

但是调着调着会进到KeBugCheckEx()然后蓝屏:

QQ_1744354520570

估计就是这里检测双机调试或程序完整性的 写个驱动hook这个函数 同时观察.text段中需要解密的字节是否在此时已经解密了:

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
#include <string.h>
#include <ntifs.h>
#include <ntdef.h>
#include <ntstatus.h>
#include <ntddk.h>
#define MAX_BACKTRACE_DEPTH 0x20
#define SYM L"\\??\\Hook_API"
#define kprintf(format, ...) DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, format, __VA_ARGS__)
typedef UINT64 u64;
typedef UINT32 u32;
typedef UINT16 u16;
typedef UINT8 u8;

u8 mov_jmp_rax1[12] = {
0x48,0xB8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, //mov rax,xxx
0xFF,0xE0 //jmp rax
}, mov_jmp_rax2[12] = {
0x48,0xB8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, //mov rax,xxx
0xFF,0xE0 //jmp rax
}, mov_jmp_rax3[12] = {
0x48,0xB8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, //mov rax,xxx
0xFF,0xE0 //jmp rax
}, mov_jmp_rax4[12] = {
0x48,0xB8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, //mov rax,xxx
0xFF,0xE0 //jmp rax
}, origin1[12] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00
}, origin2[12] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00
}, origin3[12] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00
}, origin4[12] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00
};

u8* HookAddr1 = NULL, * HookAddr2 = NULL, * HookAddr3 = NULL, * HookAddr4 = NULL, Hooked1 = FALSE, Hooked2 = FALSE, Hooked3 = FALSE, Hooked4 = FALSE;

KIRQL writeProtectOFFx64() {
KIRQL OldIrql;
OldIrql = KeRaiseIrqlToDpcLevel();
__writecr0(__readcr0() & ~0x10000);
_disable();
return OldIrql;
}

void writeProtectONx64(KIRQL OldIrql) {
_enable();
__writecr0(__readcr0() | 0x10000);
KeLowerIrql(OldIrql);
}

NTSTATUS dohook3(u8 HOOK) {
Hooked3 = HOOK;
KIRQL irql = writeProtectOFFx64();
if (HookAddr3) {
if (!HOOK) {
memcpy(HookAddr3, origin3, sizeof(origin3));
}
else {
memcpy(HookAddr3, mov_jmp_rax3, sizeof(mov_jmp_rax3));
}
}
writeProtectONx64(irql);
return STATUS_SUCCESS;
}
typedef NTSTATUS(*kebugcheck_ptr) (
_In_ ULONG BugCheckCode,
_In_ ULONG_PTR BugCheckParameter1,
_In_ ULONG_PTR BugCheckParameter2,
_In_ ULONG_PTR BugCheckParameter3,
_In_ ULONG_PTR BugCheckParameter4
);

ULONG myKeBugCheckEx(_In_ ULONG BugCheckCode,
_In_ ULONG_PTR BugCheckParameter1,
_In_ ULONG_PTR BugCheckParameter2,
_In_ ULONG_PTR BugCheckParameter3,
_In_ ULONG_PTR BugCheckParameter4
) {
dohook3(FALSE);
kebugcheck_ptr func = (kebugcheck_ptr)HookAddr3;

PVOID backtrace[MAX_BACKTRACE_DEPTH];
USHORT capturedFrames = RtlCaptureStackBackTrace(0, MAX_BACKTRACE_DEPTH, backtrace, NULL);

kprintf(("Line %d: calling KeBugCheckEx(%p,%p,%p,%p,%p)\n"), __LINE__, BugCheckCode, BugCheckParameter1, BugCheckParameter2, BugCheckParameter3, BugCheckParameter4);
if (BugCheckCode == 0x414345) {
dohook3(TRUE);
return STATUS_SUCCESS;
}
ULONG64 s = func(BugCheckCode, BugCheckParameter1, BugCheckParameter2, BugCheckParameter3, BugCheckParameter4);
dohook3(TRUE);
return s;
}

void DeleteDevice(PDRIVER_OBJECT pDriver) {
kprintf("Line %d: Deleting Driver\n", __LINE__);
if (pDriver->DeviceObject) {
UNICODE_STRING DeviceName;
RtlInitUnicodeString(&DeviceName, SYM);
IoDeleteSymbolicLink(&DeviceName);
IoDeleteDevice(pDriver->DeviceObject);
}
kprintf("Line %d: Driver Deleted\n", __LINE__);
}

void DriverUnload(PDRIVER_OBJECT pDriver) {
kprintf("Line %d: Unloading Driver\n", __LINE__);
if (Hooked3) {
dohook3(FALSE);
}
DeleteDevice(pDriver);
kprintf("Line %d: Driver Unloaded\n", __LINE__);
}

NTSTATUS DriverEntry(
_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath
) {
DriverObject->DriverUnload = DriverUnload;
kprintf("Line %d: Driver Loaded, RegistryPath : %S\n", __LINE__, RegistryPath->Buffer);
HookAddr3 = (u64)KeBugCheckEx;
kprintf("Line %d: HookAddr1 : %p, HookAddr3 : %p\n", __LINE__, HookAddr1, HookAddr3);
memcpy(origin3, HookAddr3, sizeof(origin3));
*((u64*)(mov_jmp_rax3 + 2)) = (u64)myKeBugCheckEx;
dohook3(TRUE);
return STATUS_SUCCESS;
}

到加载驱动的入口时这段还是待解密的:

QQ_1744440737644

到达hook到的kebugcheck里之后已经解密过了:

QQ_1744440985810

现在dump出来方便静态分析 .writemem "D:\dump.bin" ACEDriver L012CE000 用CFF重建PE头:

QQ_1744454198485

在IDA数据库中rebase为0方便分析 找到了可能可以指示真正入口点的字符串 但是交叉引用没有结果 应该是隐藏了 用bn试试能不能找到在哪里引用了这个字符串:

QQ_1744454895747

QQ_1744455889702

找到DEFB处进行了引用 去除几处花指令就在IDA中发现了可能为程序入口点的位置 在此处下硬件断点(ba e1 ACEDriver+0xde2c)看看Windbg能不能断在此处:

img

成功断下:

QQ_1744462321480

调试到后面发现进入了这个函数:

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
__int64 __fastcall sub_E304(__int64 a1)
{
int v3; // edi
__int64 v4; // rcx

qword_6B548 = a1;
if ( !(unsigned __int8)sub_E450() )
RtlFailFast(7u);
if ( (unsigned int)sub_E408(&unk_10218, &unk_10220) )
return 0xC0000365LL;
sub_E3D0(&unk_10208, &unk_10210);
v3 = sub_808C(a1);
if ( v3 < 0 )
{
sub_E6A0();
LOBYTE(v4) = 1;
sub_E574(v4, 0LL);
}
else if ( *(_QWORD *)(a1 + 104) )
{
qword_6B550 = *(_QWORD *)(a1 + 104);
*(_QWORD *)(a1 + 104) = sub_E2D0;
}
return (unsigned int)v3;
}

其中的RtlFailFast()启发了我主动造成蓝屏的原因 如果要造成蓝屏且错误代码是0x414345那么就一定会将这个参数计算或者直接传入寄存器作为参数 先尝试搜索:

QQ_1744464225957

直接搜到了两个结果 在这两个函数开头下断看看会不会到这里 结果直接断下来了 并且还可以发现是先清除了栈然后调用的是KeBugCheckEx():

QQ_1744464319344

QQ_1744464366243

在IDA中向上交叉引用还能发现反调试使用的函数:

QQ_1744514945922

QQ_1744514962483

hook这个函数看看是否能正常进行双机调试了:

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
203
204
#include <string.h>
#include <ntifs.h>
#include <ntdef.h>
#include <ntstatus.h>
#include <ntddk.h>
#define MAX_BACKTRACE_DEPTH 0x20
#define SYM L"\\??\\Hook_API"
#define kprintf(format, ...) DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, format, __VA_ARGS__)
typedef UINT64 u64;
typedef UINT32 u32;
typedef UINT16 u16;
typedef UINT8 u8;

u8 mov_jmp_rax1[12] = {
0x48,0xB8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, //mov rax,xxx
0xFF,0xE0 //jmp rax
}, mov_jmp_rax2[12] = {
0x48,0xB8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, //mov rax,xxx
0xFF,0xE0 //jmp rax
}, mov_jmp_rax3[12] = {
0x48,0xB8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, //mov rax,xxx
0xFF,0xE0 //jmp rax
}, mov_jmp_rax4[12] = {
0x48,0xB8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, //mov rax,xxx
0xFF,0xE0 //jmp rax
}, origin1[12] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00
}, origin2[12] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00
}, origin3[12] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00
}, origin4[12] = {
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00
};

u8* HookAddr1 = NULL, * HookAddr2 = NULL, * HookAddr3 = NULL, * HookAddr4 = NULL, Hooked1 = FALSE, Hooked2 = FALSE, Hooked3 = FALSE, Hooked4 = FALSE;

KIRQL writeProtectOFFx64() {
KIRQL OldIrql;
OldIrql = KeRaiseIrqlToDpcLevel();
__writecr0(__readcr0() & ~0x10000);
_disable();
return OldIrql;
}

void writeProtectONx64(KIRQL OldIrql) {
_enable();
__writecr0(__readcr0() | 0x10000);
KeLowerIrql(OldIrql);
}

NTSTATUS dohook1(u8 HOOK) {
Hooked1 = HOOK;
KIRQL irql = writeProtectOFFx64();
if (HookAddr1) {
if (!HOOK) {
memcpy(HookAddr1, origin1, sizeof(origin1));
}
else {
memcpy(HookAddr1, mov_jmp_rax1, sizeof(mov_jmp_rax1));
}
}
writeProtectONx64(irql);
return STATUS_SUCCESS;
}

NTSTATUS dohook2(u8 HOOK) {
Hooked2 = HOOK;
KIRQL irql = writeProtectOFFx64();
if (HookAddr2) {
if (!HOOK) {
memcpy(HookAddr2, origin2, sizeof(origin2));
}
else {
memcpy(HookAddr2, mov_jmp_rax2, sizeof(mov_jmp_rax2));
}
}
writeProtectONx64(irql);
return STATUS_SUCCESS;
}

NTSTATUS dohook3(u8 HOOK) {
Hooked3 = HOOK;
KIRQL irql = writeProtectOFFx64();
if (HookAddr3) {
if (!HOOK) {
memcpy(HookAddr3, origin3, sizeof(origin3));
}
else {
memcpy(HookAddr3, mov_jmp_rax3, sizeof(mov_jmp_rax3));
}
}
writeProtectONx64(irql);
return STATUS_SUCCESS;
}

typedef u8(*kdrefreshdebugnotpresent_ptr)();

u8 myKdRefreshDebugNotPresent() {
kprintf(("Line %d: calling KdRefreshDebuggerNotPresent()\n"), __LINE__);
return TRUE;
}

typedef NTSTATUS(*queryinformation_ptr) (
_In_ ULONG SystemInformationClass,
_Out_writes_bytes_opt_(SystemInformationLength) PVOID SystemInformation,
_In_ ULONG SystemInformationLength,
_Out_opt_ PULONG ReturnLength
);

NTSTATUS myHalQuerySystemInformation(
_In_ ULONG SystemInformationClass,
_Out_writes_bytes_opt_(SystemInformationLength) PVOID SystemInformation,
_In_ ULONG SystemInformationLength,
_Out_opt_ PULONG ReturnLength
) {
dohook1(FALSE);
queryinformation_ptr func = (queryinformation_ptr)HookAddr1;
if (SystemInformationClass == 0x23 || SystemInformationClass == 0x95
|| SystemInformationClass == 0xA3 || SystemInformationClass == 0xBA) {
kprintf(("Line %d: calling HalQuerySystemInformation(%p,%p,%p,%p)\n"), __LINE__, SystemInformationClass, SystemInformation, SystemInformationLength, ReturnLength);
dohook1(TRUE);
return STATUS_UNSUCCESSFUL;
}
ULONG64 s = func(SystemInformationClass, SystemInformation, SystemInformationLength, ReturnLength);
dohook1(TRUE);
return s;
}

typedef NTSTATUS(*kebugcheck_ptr) (
_In_ ULONG BugCheckCode,
_In_ ULONG_PTR BugCheckParameter1,
_In_ ULONG_PTR BugCheckParameter2,
_In_ ULONG_PTR BugCheckParameter3,
_In_ ULONG_PTR BugCheckParameter4
);

ULONG myKeBugCheckEx(_In_ ULONG BugCheckCode,
_In_ ULONG_PTR BugCheckParameter1,
_In_ ULONG_PTR BugCheckParameter2,
_In_ ULONG_PTR BugCheckParameter3,
_In_ ULONG_PTR BugCheckParameter4
) {
dohook3(FALSE);
kebugcheck_ptr func = (kebugcheck_ptr)HookAddr3;

PVOID backtrace[MAX_BACKTRACE_DEPTH];
USHORT capturedFrames = RtlCaptureStackBackTrace(0, MAX_BACKTRACE_DEPTH, backtrace, NULL);

kprintf(("Line %d: calling KeBugCheckEx(%p,%p,%p,%p,%p)\n"), __LINE__, BugCheckCode, BugCheckParameter1, BugCheckParameter2, BugCheckParameter3, BugCheckParameter4);
if (BugCheckCode == 0x414345) {
dohook3(TRUE);
return STATUS_SUCCESS;
}
ULONG64 s = func(BugCheckCode, BugCheckParameter1, BugCheckParameter2, BugCheckParameter3, BugCheckParameter4);
dohook3(TRUE);
return s;
}

void DeleteDevice(PDRIVER_OBJECT pDriver) {
kprintf("Line %d: Deleting Driver\n", __LINE__);
if (pDriver->DeviceObject) {
UNICODE_STRING DeviceName;
RtlInitUnicodeString(&DeviceName, SYM);
IoDeleteSymbolicLink(&DeviceName);
IoDeleteDevice(pDriver->DeviceObject);
}
kprintf("Line %d: Driver Deleted\n", __LINE__);
}

void DriverUnload(PDRIVER_OBJECT pDriver) {
kprintf("Line %d: Unloading Driver\n", __LINE__);
if (Hooked3) {
dohook3(FALSE);
}
DeleteDevice(pDriver);
kprintf("Line %d: Driver Unloaded\n", __LINE__);
}

NTSTATUS DriverEntry(
_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath
) {
DriverObject->DriverUnload = DriverUnload;
kprintf("Line %d: Driver Loaded, RegistryPath : %S\n", __LINE__, RegistryPath->Buffer);

HookAddr1 = (u64)HalQuerySystemInformation;
HookAddr2 = (u64)KdRefreshDebuggerNotPresent;
HookAddr3 = (u64)KeBugCheckEx;
memcpy(origin3, HookAddr3, sizeof(origin3));
memcpy(origin2, HookAddr2, sizeof(origin2));
memcpy(origin1, HookAddr1, sizeof(origin1));
*((u64*)(mov_jmp_rax3 + 2)) = (u64)myKeBugCheckEx;
*((u64*)(mov_jmp_rax2 + 2)) = (u64)myKdRefreshDebugNotPresent;
*((u64*)(mov_jmp_rax1 + 2)) = (u64)myHalQuerySystemInformation;
dohook3(TRUE);
dohook2(TRUE);
dohook1(TRUE);
return STATUS_SUCCESS;
}

先运行hook驱动再运行题目驱动成功让调试器正常附加到内核上了:

QQ_1744522409303

(1)在intel CPU/64位Windows10系统上运行sys 成功加载驱动(0.5分)

能够调试驱动过后找到导致驱动加载失败的位置 现在IDA中静态分析:

QQ_1744522533947

E3B8的返回值应该就是驱动加载成功的关键 最终定位到这个函数:

QQ_1744523799960

如果要正确加载驱动这个函数应该返回0 也就是需要一直执行到important()中间没有跳转 调试到此处通过修改标志位来让它返回0 最后成功加载驱动:

img

(3)优化驱动中的耗时算法 并给出demo能快速计算得出正确的key(1分)

在上面的调试过程中发现important()调用了位于0x9930处的函数 而这个函数中间提示了它应该就是需要改进的耗时算法:

QQ_1744524570828

在进入耗时函数之前还执行了位于0x670C处的函数 开始就先解密了一个字符串:

QQ_1744526708788

dump出解密结果得到是\Registry\Machine\System\CurrentControlSet\Services\ACEDriver\2025ACECTF:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
text = """5C 00 52 00 65 00 67 00 69 00 73 00 74 00 72 00
79 00 5C 00 4D 00 61 00 63 00 68 00 69 00 6E 00
65 00 5C 00 53 00 79 00 73 00 74 00 65 00 6D 00
5C 00 43 00 75 00 72 00 72 00 65 00 6E 00 74 00
43 00 6F 00 6E 00 74 00 72 00 6F 00 6C 00 53 00
65 00 74 00 5C 00 53 00 65 00 72 00 76 00 69 00
63 00 65 00 73 00 5C 00 41 00 43 00 45 00 44 00
72 00 69 00 76 00 65 00 72 00 5C 00 32 00 30 00
32 00 35 00 41 00 43 00 45 00 43 00 54 00 46 00
"""

for line in text.split('\n')[:-1]:
line = line.replace(' 00', '')
print(bytes.fromhex(line).decode(), end='')

那驱动大概率通过注册表来获取了一些值 在ZwOpenKeyZwQueryValueKey下断点发现在进入程序时就读取了\Registry\Machine\System\CurrentControlSet\Services\ACEDriver\2025ACECTF的内容:

QQ_1744529032944

先随便创建一个对应的注册表键值对在下一次调试时看看具体读取的是哪个键:

reg add "HKLM\System\CurrentControlSet\Services\ACEDriver\2025ACECTF" /v "Flag" /t REG_SZ /d "flag{testflaggggggggggggg}" /f

ZwQueryValueKey处的断点还得知了它要读取的键名为Key 并且第2次调用时可以得知大小至少为8字节 应该是一个QWORD:

QQ_1744529599453

那么先创建这个键看看下一次如果读取成功的话会对key进行什么操作:

reg add "HKLM\System\CurrentControlSet\Services\ACEDriver\2025ACECTF" /v "Key" /t REG_QWORD /d 0xACE6ACE6ACE6ACE6 /f

后续又读取了Flag:

QQ_1744537836977

0x6a61处读取到了flag:

QQ_1744538422996

再执行时发现驱动就启动成功了 且不需要修改返回值:

QQ_1744538684000

也就是说驱动加载成功的关键是注册表中有Key和Flag两项

后续调试发现如果要让程序自己计算正确的Key就需要不传入Key 手动修改跳转条件使其进入待改进的算法 :

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
__int64 __fastcall to_promote(__int32 _0x2c, unsigned int _0x16)
{
_QWORD *v4; // rax
__int64 v5; // rdi
__int64 v6; // rdx
_QWORD *seq; // r8
unsigned int v8; // r10d
int v9; // r9d
int v10; // ecx
int v11; // ecx
__int64 v12; // rdx
__int64 v13; // r8
__int64 v14; // rbx
__int64 v15; // rcx
__int64 v16; // rdx
__int64 v17; // rax
__int64 v18; // rcx
__int128 vector; // [rsp+20h] [rbp-50h] BYREF
__int64 v21; // [rsp+30h] [rbp-40h]
__int64 v22; // [rsp+38h] [rbp-38h]
__int64 v23; // [rsp+40h] [rbp-30h]
__int32 now_1; // [rsp+48h] [rbp-28h] BYREF
unsigned int next; // [rsp+4Ch] [rbp-24h]
__int64 v26; // [rsp+50h] [rbp-20h]
__int64 v27; // [rsp+58h] [rbp-18h]
int v28; // [rsp+60h] [rbp-10h]

vector = 0LL;
v21 = 0LL;
v22 = 0LL;
v23 = 0LL;
v4 = (_QWORD *)sub_B0F4(16LL);
v4[1] = 0LL;
*(_QWORD *)&vector = v4;
*v4 = &vector;
v5 = 0LL;
if ( _0x2c )
{
now_1 = _0x2c;
next = _0x16;
LABEL_4:
v26 = 0LL;
v27 = 0LL;
v28 = 0;
append(&vector, &now_1);
v6 = v23;
while ( v6 )
{
seq = *(_QWORD **)(*((_QWORD *)&vector + 1) + 8 * ((v21 - 1) & (v6 + v22 - 1)));// start with 0x2c 0x16
v8 = *((_DWORD *)seq + 1);
if ( !v8 || (v9 = *(_DWORD *)seq, v8 == *(_DWORD *)seq) )
{
v5 = 1LL;
v23 = --v6;
v22 &= -(__int64)(v6 != 0);
}
else
{
v10 = *((_DWORD *)seq + 6);
if ( !v10 )
{
*((_DWORD *)seq + 6) = 1;
now_1 = v9 - 1;
next = v8;
goto LABEL_4;
}
v11 = v10 - 1;
if ( !v11 )
{
seq[1] = v5;
*((_DWORD *)seq + 6) = 2;
now_1 = v9 - 1;
next = v8 - 1;
goto LABEL_4;
}
if ( v11 == 1 )
{
seq[2] = v5;
v5 += seq[1] + v9 % 5;
v6 = --v23;
if ( !v23 )
v22 = 0LL;
}
}
}
}
else
{
sub_DDAD("This step will take a long time to run, maybe 12 hours (depending on your machine), maybe you have a better way?");
}
...
return v5;
}

后面对最后返回的v5 即key没有影响 只用分析上面的 初步分析可以定义出下面几个结构体 不知道含义的先随便用xyz之类的顶着:

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
00000000 struct data // sizeof=0x20
00000000 {
00000000 _DWORD x;
00000004 _DWORD y;
00000008 _QWORD last;
00000010 _QWORD now;
00000018 _DWORD step;
0000001C // padding byte
0000001D // padding byte
0000001E // padding byte
0000001F // padding byte
00000020 };

00000000 struct Vector // sizeof=0x28
00000000 { // XREF: to_promote/r
00000000 _QWORD *self; // XREF: to_promote+30/w
00000000 // to_promote+51/w ...
00000008 data **data; // XREF: to_promote+B0/r
00000008 // to_promote+1AD/r ...
00000010 _QWORD align1; // XREF: to_promote+38/w
00000010 // to_promote+A6/r ...
00000018 _QWORD align2; // XREF: to_promote+3C/w
00000018 // to_promote:loc_99CC/r ...
00000020 _QWORD have;
00000028 };

00000000 struct KeyPair // sizeof=0x10
00000000 { // XREF: to_promote/r
00000000 _DWORD x; // XREF: to_promote:loc_99A4/w
00000000 // to_promote+130/w ...
00000004 _DWORD y; // XREF: to_promote+77/w
00000004 // to_promote+137/w ...
00000008 _QWORD z; // XREF: to_promote:loc_99AA/w
00000010 };

逻辑清晰了一点:

QQ_1744807550117

接下来就用调试来分析了

但是算法这块还是太史了 比赛的时候就做到这里 下面是根据网上的wp进行复现的

重点应该是now的赋值(99EF)以及那个不是对step进行判断的特殊的分支(9AA0) 分别下断点观察一下 取出的data结构体放在r8中 经过几轮调试观察取出来的dataxy 可以大致发现规律 下面是一个辅助理解的python程序:

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
import matplotlib.pyplot as plt
import numpy as np
from time import sleep

colors = {0: 'green', 1: 'blue', 2: 'red'}
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
self.weight = 0
self.stage = 0

points = [Point(44, 22)]
sum = 0
plt.ion()

while len(points):
if points[-1].x == points[-1].y or points[-1].y == 0:
sum = 1
points.pop()
continue
match points[-1].stage:
case 0:
points[-1].stage = 1
points += [Point(points[-1].x - 1, points[-1].y)]
case 1:
points[-1].weight = sum
points[-1].stage = 2
points += [Point(points[-1].x - 1, points[-1].y - 1)]
case 2:
sum += points[-1].weight + (points[-1].x % 5)
points.pop()

_max = 0
plt.clf()
for p in points:
plt.scatter(p.x, p.y, color=colors[p.stage])
if p.weight != 0:
plt.text(p.x + 0.1, p.y + 0.1, f'{p.weight}', fontsize=8, color='blue')
_max = max(_max, p.x, p.y)
x_line = np.arange(0, _max + 1, 1)
y_line = x_line
plt.plot(x_line, y_line, label="x=y", color='red', linestyle='-')
plt.xticks(np.arange(0, _max + 1, 1))
plt.yticks(np.arange(0, _max + 1, 1))
plt.axis([0, _max + 1, 0, _max + 1])
plt.legend()
plt.title(f'Sum = {sum}')
plt.grid(True)
plt.pause(1)

print(f'Final sum = {hex(sum)}')
plt.ioff()
plt.show()

可以发现是一个求路径和的算法 计算从(44, 22)(0, 0)的一个特殊路径和 只能往左或者左下走走一格 用公式来表示大概是W(x, y) = W(x - 1, y) + W(x - 1, y - 1) + (x % 5) 往左走优先于往左下走 在y = 0y = x两条曲线上的点权重为0

题目给的驱动里面用的是类似递归的算法 可以直接正向求解 时间复杂度可以降至O(n):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
prev_row = [1]

for x in range(1, 45):
y_max = min(x, 22)
current_row = [0] * (y_max + 1)
for y in range(y_max + 1):
if y == 0 or y == x:
current_row[y] = 1
else:
term1 = prev_row[y] if y < len(prev_row) else 0
term2 = prev_row[y-1] if (y-1 >= 0 and y-1 < len(prev_row)) else 0
current_row[y] = term1 + term2 + (x % 5)
prev_row = current_row

print(hex(prev_row[22]))
# 0x66711265fd2

正确的Key为0x66711265fd2

(4)分析并给出flag的计算执行流程(1.5分) 能准确说明其串联逻辑(0.5分)

在注册表中填充正确的key:reg add "HKLM\System\CurrentControlSet\Services\ACEDriver\2025ACECTF" /v "Key" /t REG_QWORD /d 0x66711265fd2 /f

后续跟进Key的处理发现了第一步加密:

QQ_1744544994801

QQ_1744545022126

也就是将flag和上面得到的key(d2 5f 26 11 67 06)进行异或 但是从内存中([r15+RCX])取出flag时会发现和用WinDbg查到的不一样 第1位应该是0x66 但是经过0x95df的mov al后得到的却是0x95 合理怀疑特意为flag分配了一个page是为了进行EPThook 查找vmresume(0F 01 C3)指令看看能不能找到 VT对虚拟机退出进行异常处理的位置 然后查找到了0x1220处的ExitHandler 其中调用的0x5150应该是就分发器 其中_RCX应该就是从VMCS中取出的Exit Reason:

QQ_1746684048947

根据Intel手册(或者直接看linux的vmx.h) EPT violation对应数值0x30 进行运算后为0x460d 找对对应的handler0x2B78 下面大概是取出了Exit qualification看是不是mov al那条指令导致的 那么就可以确定其中的就是handler:

QQ_1746682900777

QQ_1746685322531

中间取的大概是Guest的寄存器 按照x86_64的寄存器顺序大概可以判断这几个变量的含义 接下来handler根据取出的当前flag位和其下标进行了一个校验值计算:

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
char __fastcall ept_violation_handler(char now, char idx)
{
char res; // r10
bool v3; // zf
unsigned __int8 v4; // r11
unsigned __int8 v5; // dl
unsigned __int8 v6; // cl
__int64 v7; // rbx
__int64 v8; // rcx
unsigned int v9; // edi
char v10; // r9
__int64 v11; // rdx
int v12; // eax
_BYTE *v13; // r8
char v14; // dl
char v15; // dl
_BYTE v17[8]; // [rsp+0h] [rbp-18h] BYREF

res = 0;
v4 = idx ^ now;
v3 = idx == now;
v5 = 0;
v6 = v4;
if ( !v3 )
{
do
{
v5 += v6 & 1;
v6 >>= 1;
}
while ( v6 );
}
v7 = 7LL;
v8 = v5;
*(_DWORD *)v17 = 0x3020100;
*(_DWORD *)&v17[4] = 0x7060504;
v9 = 8;
do
{
v10 = v17[v7];
v8 = 0x3F5713FCCC7C79AALL * v8 + 0x3A7D9E5B36F498B2LL;
v11 = HIDWORD(v8) % v9--;
v17[v7--] = v17[v11];
v17[v11] = v10;
}
while ( v7 > 0 );
v12 = 0;
v13 = v17;
do
{
v14 = v4 >> *v13++;
v15 = (v14 & 1) << v12++;
res |= v15;
}
while ( v12 < 8 );
return res;
}

大概是根据当前的字节和下标生成一个乱序的set(0~7)并根据这个序列调换当前字节的每个位 这个过程明显是不可逆的 但是可以打印出所有下标和值进行计算的结果最后取表

接下来就是经典TEA(0x93B8):

QQ_1747018663779

但是连初赛都上了inline hook 决赛的TEA绝对不是看上去那么简单 果然调试的时候就能发现给delta赋值这一步执行的指令和读到的就已经不一样了:

QQ_1747020775137

这里肯定是上了ept hook 但是在处理EPT violation的handler中能明显看出来根据指令hook的只有上面用key和flag异或那一步根据是否是mov al进行操作 既然没有根据指令hook 那应该就是直接hook了一整个页 这里识别出VT框架就很重要了 看网上的wp说是hv框架的 该框架在处理EPT violation时会将安装了ept hook的假页面返回 交叉引用install_ept_hook可以发现该框架可以通过guest执行vmcall主动向host发送安装ept hook的请求

再看看hc::install_ept_hook:

1
2
3
4
5
6
7
8
9
10
// install an EPT hook for the CURRENT logical processor ONLY
void install_ept_hook(vcpu* const cpu) {
// arguments
auto const orig_page_pfn = cpu->ctx->rcx;
auto const exec_page_pfn = cpu->ctx->rdx;

cpu->ctx->rax = install_ept_hook(cpu->ept, orig_page_pfn, exec_page_pfn);

skip_instruction();
}

要hook的页面和实际执行的页面分别在执行vmcall时存放在RCX和RDX中 在驱动中唯一一个vmcall(0x1557)下断应该就能得到实际执行的函数了:

QQ_1747027684894

dump出这个页(4kb)后缝到之前dump出来的驱动上方便用IDA分析:

1
2
3
4
5
origin = bytearray(open('.\dumpsys.sys', 'rb').read())
patch = bytearray(open('.\eptdump.bin', 'rb').read())
origin[0x1000:0x2000] = patch
with open('.\eptsys.sys', 'wb') as f:
f.write(origin)

得到实际执行的TEA:

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
__int64 __fastcall TEA(unsigned int *flag, _DWORD *key)
{
__int64 result; // rax
int v3; // [rsp+0h] [rbp-58h]
unsigned int v0; // [rsp+4h] [rbp-54h]
unsigned int v1; // [rsp+8h] [rbp-50h]
unsigned int v6; // [rsp+Ch] [rbp-4Ch]

v3 = 0;
v6 = 0;
v0 = *flag;
v1 = flag[1];
do
{
v0 += (v3 + key[v3 & 3]) ^ (v1 + ((v1 >> 5) ^ (16 * v1)));
v3 -= 0x788EEF63;
v1 = v1 + 0x7C77AF7C + ((v3 + v0) ^ v0 ^ (key[2] + 16 * v0) ^ (key[3] + (v0 >> 5)) ^ 0x4321) - 0x5901181B;
++v6;
}
while ( v6 < 0x3C );
result = v0;
*flag = v0;
flag[1] = v1;
return result;
}

继续调试可以发现执行完TEA后与密文进行比较前flag又发生了变动 0x94A1处的rdmsr指令执行完后flag就发生了变化 read msr导致的退出Exit Reason为0x1f 计算完后为0x690d 对应handler在0x2608 根据某个和0xE8对比的变量可以推测a1在0x2740B8偏移位置的应该就是guest的寄存器指针:

img

找到hv框架中对应的guset-context 在IDA中定义寄存器结构体 方便后续逆向 后面可以发现就是拆成byte进行异或:

QQ_1747049152013

验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main(){
vector<uint8_t> test_enc = {0x42, 0x7b, 0x50, 0xb1, 0x12, 0xf8, 0x3d, 0xdc};
int round = 0, count = 0;
uint8_t key1[5], key2[7];
*(uint32_t *)key1 = 0x12CDAD89;
key1[4] = 0x56;
*(uint32_t *)key2 = 0xEF586958;
*(uint16_t *)&key2[4] = 0x46B3;
key2[6] = 0x23;
do{
uint32_t xor_key = round++ + 0x1A + key1[count % 5] + key2[count % 7];
test_enc[count++] ^= xor_key;
}
while ( round < 8 );
for(auto byte : test_enc){
cout << hex << (int)byte << " ";
}
cout << endl;
return 0;
}
// b9 4a 11 af 35 16 cd 9a

成功验证:

img

到这里就能给出flag计算中的串联逻辑了 即:

  1. 通过ept hook在读取flag内容时触发EPT Violation根据flag中每个字节和其下标交换字节中的位
  2. 与Key循环异或
  3. 变异TEA加密
  4. 通过读MSR触发Read MSR对flag进行异或

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
#include <iostream>
#include <vector>
#include <string>
#include <cstdint>

using namespace std;
using std::vector;

uint32_t enc[] = {0x199354C3,0xB1FD7BE6,0x73205B55,0xDE5C4D43,0xA4EF9954,0xA97651D4,0xEFBA6B6A,0xC6E221DE,0x8FA342FE,0x4C1C63BE,0xD0AEE4C6,0xC6F63D4B,0x3807EBDA,0x2ADF5814,0x7A83C42E,0x9E348D33,0x782779E4,0xC4A55FC0,0xDC0B64D0,0x7EE36C5D,0xE43BE42C,0xD5E405CA,0xB772C9A7,0x30CDC7,0x2F09B31C,0xFA839DD7,0x57547B88,0xF754B5AE,0x231F7B75,0x13160770,0x6EB71579,0xFA28BBD,0x6103E890,0xEF604E1D};
uint8_t *bpenc = (uint8_t *)enc;

int main(){
/* XOR1 decrypt */
uint8_t key1[5], key2[7];
*(uint32_t *)key1 = 0x12CDAD89;
key1[4] = 0x56;
*(uint32_t *)key2 = 0xEF586958;
*(uint16_t *)&key2[4] = 0x46B3;
key2[6] = 0x23;
for(int _ = 0; _ < ((34 - 1) >> 1) + 1; _++){
int round = 0, count = 0;
do{
uint8_t xor_key = round++ + 34 + key1[count % 5] + key2[count % 7];
bpenc[count++] ^= xor_key;
}
while ( round < 8 );
bpenc += 8;
}

/* TEA decrypt */
uint32_t key[6] = {0x89, 0xfe, 0x76, 0xa0};
for(int _ = 0; _ < 17; _++){
uint32_t v0 = enc[2 * _], v1 = enc[2 * _ + 1], delta = 0x788EEF63, sum = 0x3c * -delta, round = 0;
do{
round++;
v1 -= 0x7C77AF7C + ((sum + v0) ^ v0 ^ (key[2] + 16 * v0) ^ (key[3] + (v0 >> 5)) ^ 0x4321) - 0x5901181B;
sum += delta;
v0 -= (sum + key[sum & 3]) ^ (v1 + ((v1 >> 5) ^ (16 * v1)));
}while(round < 0x3c);
enc[2 * _] = v0;
enc[2 * _ + 1] = v1;
cout << hex << v0 << " " << hex << v1 << " ";
}
cout << endl;

/* XOR2 decrypt */
uint8_t xor_key[6] = {0xd2, 0x5f, 0x26, 0x11, 0x67, 0x6};
for(int i = 0; i < 34; i++){
enc[i] ^= xor_key[i % 6];
}

/* Brute force */
for(int idx = 0; idx < 34; idx++){
for(int byte = 0; byte < 0x100; byte++){
uint64_t seed = 0, r1 = 7, r2 = 8;
uint8_t rand_key[8] = {0};
*(uint32_t *)rand_key = 0x3020100;
*(uint32_t *)&rand_key[4] = 0x7060504;
uint8_t tmp = byte ^ idx, new_seed = 0;
do{
seed += tmp & 1;
tmp >>= 1;
}while(tmp > 0);
do{
uint8_t tmp = rand_key[r1];
seed = 0x3F5713FCCC7C79AALL * seed + 0x3A7D9E5B36F498B2LL;
uint64_t new_idx = (seed >> 0x20) % r2--;
rand_key[r1--] = rand_key[new_idx];
rand_key[new_idx] = tmp;
}while(r1 > 0);
r1 = 0;
uint8_t res = 0;
do{
uint8_t tmp1 = (idx ^ byte) >> rand_key[r1], tmp2 = (tmp1 & 1) << (r1++);
res |= tmp2;
}while(r1 < 8);
if(res == enc[idx]){
cout << (char)byte;
break;
}
}
}

return 0;
}

(6)该题目使用了一种外挂常用的隐藏手段 请给出多种检测方法 要求demo程序能在题目驱动运行的环境下进行精确检测 方法越多分数越高(3分)

从上面的分析能看出要检验的应该就是是否开启VT

方法1: cpuid执行的cpu周期

cpuid作为一条特权指令 在VT环境下执行会触发VM-exit交由host处理 即使题目的VT框架没有特殊处理这条特权指令 但是下面这些代码都是会执行的:

QQ_1747316790832

这肯定比执行一条cpuid所需的时间长 所以可以通过执行和cpuid所需时间差不多长的指令来找到一个基准 如果执行cpuid的时间远超这个值基本上就可以确定当前处于VT环境下 先测一下非嵌套VT环境下执行cpuid所需的时间:

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
#include <string.h>
#include <ntifs.h>
#include <ntdef.h>
#include <ntstatus.h>
#include <ntddk.h>
#define MAX_BACKTRACE_DEPTH 0x20
#define SYM L"\\??\\VTcheck1"
#define kprintf(format, ...) DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, format, __VA_ARGS__)
typedef UINT64 u64;
typedef UINT32 u32;
typedef UINT16 u16;
typedef UINT8 u8;

void PerformTimingTest() {
const u32 iterations = 1000000;
u64 start = 0, end = 0, total = 0;
int cpuinfo[4] = { 0 };
// Bind current thread to CPU 0
KAFFINITY oldAffinity = KeSetSystemAffinityThreadEx((KAFFINITY)1);
KIRQL oldIrql = KeRaiseIrqlToDpcLevel();
for (u32 i = 0; i < iterations; i++) {
start = __rdtsc();

__cpuid(cpuinfo, 0);

end = __rdtsc();
total += (end - start);
}
u64 avg = total / iterations;
KeLowerIrql(oldIrql);
KeRevertToUserAffinityThreadEx(oldAffinity);
kprintf("Line %d: Average CPU cycles for __cpuid: %llu\n", __LINE__, avg);
}

void DeleteDevice(PDRIVER_OBJECT pDriver) {
kprintf("Line %d: Deleting Driver\n", __LINE__);
if (pDriver->DeviceObject) {
UNICODE_STRING DeviceName;
RtlInitUnicodeString(&DeviceName, SYM);
IoDeleteSymbolicLink(&DeviceName);
IoDeleteDevice(pDriver->DeviceObject);
}
kprintf("Line %d: Driver Deleted\n", __LINE__);
}

void DriverUnload(PDRIVER_OBJECT pDriver) {
kprintf("Line %d: Unloading Driver\n", __LINE__);
DeleteDevice(pDriver);
kprintf("Line %d: Driver Unloaded\n", __LINE__);
}

NTSTATUS DriverEntry(
_In_ PDRIVER_OBJECT DriverObject,
_In_ PUNICODE_STRING RegistryPath
) {
DriverObject->DriverUnload = DriverUnload;
kprintf("Line %d: Driver Loaded, RegistryPath : %S\n", __LINE__, RegistryPath->Buffer);

PerformTimingTest();

return STATUS_SUCCESS;
}

可以看到在非嵌套VT环境下cpuid执行的时间大致为800左右:

QQ_1747624003674

启动了题目驱动后执行时间翻了十倍有余:

QQ_1747626371632

接下来就是找到不被VT影响的指令来对比cpuid进行是否在VT环境的判断:

QQ_1747627899501

最后得到检测VT环境的函数:

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
void PerformTimingTest() {
const u32 iterations = 10;
u64 start = 0, end = 0, total = 0;
int cpuinfo[4] = { 0 };
// Bind current thread to CPU 0
KAFFINITY oldAffinity = KeSetSystemAffinityThreadEx((KAFFINITY)1);
KIRQL oldIrql = KeRaiseIrqlToDpcLevel();

for (u32 i = 0; i < iterations; i++) {
start = __rdtsc();
int j = 0x300;
while (TRUE) {
if (j < 0)
break;
j -= 1;
}
end = __rdtsc();
total += (end - start);
}
u64 avg_before_VT = total / iterations;
total = 0;

for (u32 i = 0; i < iterations; i++) {
start = __rdtsc();
__cpuidex(cpuinfo, 0, 0);
end = __rdtsc();
total += (end - start);
}
u64 avg_cpuid = total / iterations;

KeLowerIrql(oldIrql);
KeRevertToUserAffinityThreadEx(oldAffinity);
if (avg_cpuid / avg_before_VT > 10) {
kprintf("Line %d: VT is enabled\n", __LINE__);
}
else {
kprintf("Line %d: VT is disabled\n", __LINE__);
}
}

未开启题目驱动时:

QQ_1747628423947

开启题目驱动后:

QQ_1747628528091

成功检测到VT环境

方法2: 检测IA32_APERF_MSR

IA32_APERF_MSR(MSR[0xE8])用于记录cpu在运行期间的实际执行周期数 在两次读取之间如果cpu执行了一些任务那么理论上第二次读取到的数值会大于第一次读到的 但是上面解flag的时候已经知道了题目驱动hook了读取msr[0xE8]的指令 返回的永远都是0 可以利用这一点检测是否开启了题目驱动 但是因为我的VMware开了虚拟化CPU性能计数器就报错开不了机 所以这里只给出可能可行的代码:

QQ_1747632879661

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void PerformAPERFTest() {
int cpuinfo[4] = { 0 };
// Bind current thread to CPU 0
KAFFINITY oldAffinity = KeSetSystemAffinityThreadEx((KAFFINITY)1);
KIRQL oldIrql = KeRaiseIrqlToDpcLevel();

u64 start = __readmsr(0xe8);
__cpuid(cpuinfo, 0);
u64 end = __readmsr(0xe8);

KeLowerIrql(oldIrql);
KeRevertToUserAffinityThreadEx(oldAffinity);
if (end - start == 0) {
kprintf("Line %d: VT enabled\n", __LINE__);
}
else {
kprintf("Line %d: VT disabled\n", __LINE__);
}
}

参考

腾讯2025游戏安全PC方向决赛题解 - moshuiD https://bbs.kanxue.com/thread-286462.htm

腾讯游戏安全竞赛2025决赛题解 - xia0ji233 https://xia0ji233.pro/2025/04/14/tencent-race-2025-final/

hv github项目 https://github.com/jonomango/hv