Sekaictf2025 Alchamy_master复现

w1n9 Lv1

在闲逛一位大佬的博客的时候,偶然发现这道题有点新颖,遂来复现一下,参考了两篇wp

sekaictf-2025/reverse/alchemy-master/solution/README.md at main · project-sekai-ctf/sekaictf-2025(官方wp)

SekaiCTF 2025 Reverse WP | Liv’s blog

题目为SekaiCTF2025 的一道题,题目名为Alchamy master

初步分析

一共四个附件,分别来看一下

server.py

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
from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess
import sys


launch_exe = Path(__file__).parent / 'launch.exe'


def main() -> None:
if not launch_exe.exists():
print('something is wrong! contact admins')
return

print('hi! please enter your cpp code line by line, then end it with a __END__ line')

lines = []
while True:
l = input()
if l == '__END__':
break
lines.append(l)

code = '\n'.join(lines)

print('gotcha, lets compile this!')
with TemporaryDirectory() as tmpdir:
cwd = str(Path(tmpdir).absolute())
file_path = Path(tmpdir) / 'solution.cpp'
file_path.write_text(code)

completed = subprocess.run(
[str(launch_exe.resolve()), str(file_path.resolve())],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=cwd,
)

sys.stdout.write('=== cl.exe ===\n')
sys.stdout.write(completed.stdout.replace(cwd + '\\', ''))
sys.stdout.flush()


if __name__ == '__main__':
main()

主要作用是读取用户输入的C++代码然后在一个临时目录将其写入一个新建的solution.cpp里,然后将solution.cpp的目录作为参数传入launch.exe

launch.exe

cl.exe的启动器,然后就是把plugin.dll注入进去

image-20251229081541891
image-20251229081541891

第6个参数值为4,进程只挂起不运行

注入部分

image-20251229081608147
image-20251229081608147

plugin.dll

查询到字符串

image-20251230112659302
image-20251230112659302

这里一个逻辑是

往上回溯到函数sub_180002010(),获取了CL.exe的模块基址

image-20251229092254565
image-20251229092254565

分析sub_180003230得知是hook函数,hook了CL.exe基地址偏移0xD5AA0位置的函数以及LoadLibrary,

image-20251229130417205
image-20251229130417205

保留原指令

image-20251229130456767
image-20251229130456767

然后还hook了c1.dll和c2.dll的同一个函数,hook的调用函数也是同一个,就是我们通过字符串查找输出验证信息的函数

image-20260112211044078
image-20260112211044078

image-20260112211101561
image-20260112211101561

来看一下验证函数的主要逻辑

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
void __fastcall sub_7FF93CB41E00(__int64 a1)
{
__int64 *v1; // r9
__int64 v2; // r9
__int64 *v3; // r9
__int64 v4; // r11
_DWORD *v5; // rax
int v6; // r10d
char v7; // cl
__int64 v8; // r8
__int64 v11; // rcx
unsigned __int64 v12; // rax

v1 = *(__int64 **)(a1 + 16);
if ( v1 )
{
v2 = *v1;
if ( v2 )
{
v3 = *(__int64 **)(v2 + 56);
if ( v3 )
{
while ( *((_BYTE *)v3 + 12) != 23 )
{
v3 = (__int64 *)*v3;
if ( !v3 )
return;
}
v4 = 0i64;
do
{
v5 = &unk_7FF93CB65D20;
while ( *v5 != *((_DWORD *)v3 + 2) )
{
v5 += 2;
if ( v5 == (_DWORD *)&unk_7FF93CB65D68 )
goto LABEL_36;
}
v6 = *((unsigned __int16 *)v5 + 2);
v7 = 0;
v8 = 0i64;
while ( ((1 << v7) & v6) == 0 || (&data1)[v8] )
{
++v7;
if ( (unsigned __int64)++v8 >= 7 )
{
if ( (v6 & 1) != 0 )
data1 = (_QWORD *)((char *)data1 - 1);
if ( (v6 & 2) != 0 )
--data2;
if ( (v6 & 4) != 0 )
--data3;
if ( (v6 & 8) != 0 )
--data4;
if ( (v6 & 0x10) != 0 )
--data5;
if ( (v6 & 0x20) != 0 )
--data6;
if ( (v6 & 0x40) != 0 )
--data7;
_ECX = *((unsigned __int16 *)v5 + 3) | 0xFFFF0000;
if ( dword_7FF93CB69010 < 5 )
_BitScanForward((unsigned int *)&_EAX, _ECX);
else
__asm { tzcnt eax, ecx }
v11 = 8i64 * _EAX + 170672;
v12 = *(_QWORD *)((char *)&unk_7FF93CB40000 + v11) + 1i64;
if ( v12 > 0x4268 )
v12 = 0x4268i64;
*(_QWORD *)((char *)&unk_7FF93CB40000 + v11) = v12;
break;
}
}
LABEL_36:
v3 = (__int64 *)*v3;
}
while ( v3 );
while ( (&data1)[v4] == (&target)[v4] )
{
if ( (unsigned __int64)++v4 >= 7 )
{
sub_7FF93CB41DB0("Good job! Here is your flag: SEKAI{real_flag_is_on_remote}\n");
sub_7FF93CB4A020(0i64);
JUMPOUT(0x7FF93CB41FBBi64);
}
}
}
}
}
}

v3是一个链表,大概结构如图

1
2
3
4
5
6
struct Node {
int* next; // +0x00 (因为 v3 = (int*)v3->next)
uint32_t key; // +0x08 (因为 *((DWORD*)v3 + 2))
uint8_t type; // +0x0C (因为 *((BYTE*)v3 + 12))
// 其他字段未知(+0x04 / +0x10...)
};

大体逻辑为先遍历整个链表,找到type值为23的节点开始往下分析,将v3中key成员的值与v5中的某个偏移值进行比较,再来进行对全局数组的7个值进行操作计算,最后再与目标值进行比较。

这是初始值

image-20260113160536912
image-20260113160536912

这是目标值

image-20260113160606727
image-20260113160606727

看大佬的博客,他的一个做法是,发现了v3和v5的数据结构后,判断我们输入的cpp代码分别都对应着不同的类型,而不同类型的代码分别又对应着一个不同的key值(该值是被保存到全局数组dword_7FFE52FA5D20中的),每个key值也有对应的操作数,分别为key值后面的两个int16值,用来控制最终验证数组的加减的

image-20260113155702965
image-20260113155702965

然后他通过传入不同的cpp代码来发现不同的代码类型来对应的相应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
94
95
96
97
98
99
100
int a()
{
reinterpret_cast<void (*)()>(nullptr)();
return 0;
}
/*
933 -> function def
939

899 -> call()

86c -> return 0;
89b

93a
931
934
*/
int a()
{
int b = 0;
return 0;
}

/*
933
939

852 -> int b;

86c -> return 0;
89b

93a
931
934
*/

int a()
{
return 0;
return 0;
}
/*
933
939

86c -> return 0;
89b

86c -> return 0;
89b

93a
931
934
*/

int a()
{
{
}
{
}
}
/*
933
939

939
93a
89b

93a -> end of {}
931
934
*/

int a()
{
throw;
}
/*
933
939

84f
84f

899 -> call

8a2 -> throw
89b

93a
931
931
934
*/

最终虽然没有把所有key值对应的代码都找全,但是找到了对应索引0-6的所有类型,所以理论上就可以通过爆破出我们需要的cpp代码来完成对全局数组的计算至target数组

1
2
3
4
5
6
7
8
9
10
11
//{code, add_index, dec_index}

{0x91e, {6}, {0,4}},

{0x852, {2}, {4,6}},
{0x8a2, {1}, {2,4,6}},
{0x899, {3}, {0,6}},
{0x86c, {5}, {0,4}},
{0x933, {5}, {0,6}},
{0x93a, {5}, {0,4}},

爆破代码

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
from z3 import *

# 数值代码对应的字符串,以及加一的下标和减一的下标
# code (string, add_index, dec_index)
code_map = {
0x852: ("int b", {2}, {4, 6}),
0x8a2: ("throw;", {1}, {2, 4, 6}),
0x899: ("reinterpret_cast<void(*)()>(nullptr)();", {3}, {0, 6}),
0x86c: ("return 0;", {5}, {0, 4}),
0x933: ("int a(){", {5}, {0, 6}),
0x93a: ("{}", {5}, {0, 4}),
}

# 初始值
Original = [0x734, 0x0, 0x0, 0x0, 0xBBC, 0x0, 0xB63]
# 目标值
Dest = [0x14D, 0x2D7, 0x161, 0x2EA, 0x1B1, 0x2FD, 0x169]

counts = {code: Int(f"c_{code:x}") for code in code_map.keys()}

solver = Solver()

for code_variable in counts.values():
solver.add(code_variable >= 0)

# 给对应下标的全局数组数据进行++或--操作
for i in range(len(Original)):
equation = Original[i]

for code, (_, inc_indices, dec_indices) in code_map.items():
if i in inc_indices:
equation += counts[code]
if i in dec_indices:
equation -= counts[code]

solver.add(equation == Dest[i])

# 函数声明开头数量只能1
solver.add(counts[0x933] == 1)

if solver.check() == sat:
model = solver.model()

results = {}

for code, var in counts.items():
results[code] = model.evaluate(var).as_long()

with open(r"solution.cpp","w") as file:

# int a(){
file.write(code_map[0x933][0]+'\n')

# int b = 0;
int_count = results[0x852]
for i in range(int_count):
file.write(code_map[0x852][0]+str(i)+' = 0;\n')

# throw;
throw_count = results[0x8a2]
file.write((code_map[0x8a2][0]+'\n') * (throw_count))

# return 0;
ret_count = results[0x86c]
file.write((code_map[0x86c][0]+'\n') * (ret_count))

# reinterpret_cast<void(*)()>(nullptr)();
# throw命令包含一次Call命令,所以减去throw的数量
call_count = results[0x899]
file.write((code_map[0x899][0]+'\n') * (call_count - throw_count))

# {}
# 数量-1,因为整个函数的结尾}也占一个0x93a
trunk_count = results[0x93a]
file.write((code_map[0x93a][0]+'\n') * (trunk_count-1))

# last }
file.write("}\n")
else:
print("No solution found.")

梳理一下总体逻辑,写入solution.cpp传入launch.exe作为参数启动 -> launch.exe挂起cl.exe进程等待注入 -> plugin.dll远程线程注入cl.exe进程 -> hook了cl.exe和其模块的某个函数,调用了验证函数 -> 验证函数有一组全局数组需要我们构造cpp代码来得到一组操作对其进行加减,最后将其和我们的目标值进行比较,最后输出占位flag,当然最后还需要远程上传代码获取远端flag

自己的思考

这道题像大佬博客里那样做应该就是比较完整且规范的解法了,不过这道题应该还是有值得深挖的地方的,比如那些key和操作数到底有什么含义,整个的计算流程是怎么样的

首先v5的数据结构恢复大致如下

image-20260113214751297
image-20260113214751297

其中的err_num就是一开始提到的那个v3里的key,也就是之前所说的所谓的代码类型对应的编码

一共是这些值

image-20260113232107420
image-20260113232107420

至于为什么叫他err_num,其实在恢复这些数据后,像什么2334,2130这些问了ai之后发现这些数据其实是MSVC的错误编号,也就是我们在编译的时候遇到的一些报错他都对应这一个编号,我让ai帮我总结了一下这些错误编号

错误号 官方错误名 简要含义 人话
C2130 illegal use of type 非法使用某个类型 “类型不完整 / 错位使用”
C2156 pragma must be outside function #pragma 位置非法 “编译指令位置错误”
C2201 invalid declaration 声明非法 “结构/声明破坏”
C2210 illegal parameter 非法函数参数 “函数签名破坏”
C2211 illegal use of type 非法使用类型(变体) “类型语义错误”
C2327 illegal cast 非法类型转换 “强制类型转换错误”
C2334 unexpected token 意外的 token “语法被打断”
C2355 illegal conversion 非法类型转换 “类型系统冲突”
C2362 initialization requires constant 初始化语义错误 “初始化需要常量”

那么其实所谓的代码类型,倒不如说是错误类型,这道题考的就是,我们需要构造的cpp代码包含了这些编译错误类型的代码,cl.exe在编译的时候输出的错误编号即被我们的plugin.dll给hook拦截下来作为v3的key来使用,key来和这里的err_num来比对去相应的mask操作

下面就是来重点分析每个错误编号对应的操作

首先整个流程是通过Controller结构体的后两个成员dec_mask和add_mask(前者控制减的操作,后者控制加的操作)来实现

减操作

image-20260114191434536
image-20260114191434536

加操作(cap常量0x4268是最大值,当大于等于cap常量时取该值)

image-20260114191455802
image-20260114191455802

提取所有的mask值,总结出了以下的规律

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
先定义原数组为src[7]

C2334: dec_mask = 0x11 -> src[0]--, src[4]--;
add_mask = 0x40 -> src[6]++;

C2130: dec_mask = 0x50 -> src[4]--,ssrc[6]--;
add_mask = 0x04 -> src[2]++;

C2210: dec_mask = 0x54 -> src[2]--, src[4]--, src[6]--;
add_mask = 0x02 -> src[1]++;

C2211: dec_mask = 0x09 -> src[0]--, src[3]--;
add_mask = 0x10 -> src[4]++;

C2201: dec_mask = 0x41 -> src[0]--, src[6]--;
add_mask = 0x08 -> src[3]++;

C2327: dec_mask = 0x0C -> src[2]--, src[3]--;
add_mask = 0x02 -> src[1]++;

C2156: dec_mask = 0x11 -> src[0]--, src[4]--;
add_mask = 0x20 -> src[5]++;

C2355: dec_mask = 0x41 -> src[0]--, src[6]--;
add_mask = 0x20 -> src[5]++;

C2362: dec_mask = 0x11 -> src[0]--, src[4]--;
add_mask = 0x40 -> src[5]++;

此时便可和题目名称中的Alchamy_master相呼应了,可以看到每一个错误编号对应着某些索引的值减,某些索引的值加,那么便可以将减的值看成消耗的材料,加的值看成生成的材料,整个过程就可以看作一个化学反应的过程

key (node->key) consume_mask 含义(消耗) produce_mask 含义(产出)
0x091E 0x0011 消耗资源 #0 和 #4 0x0040 产出资源 #6
0x0852 0x0050 消耗 #4 和 #6 0x0004 产出 #2
0x08A2 0x0054 消耗 #2,#4,#6 0x0002 产出 #1
0x08A3 0x0009 消耗 #0,#3 0x0010 产出 #4
0x0899 0x0041 消耗 #0,#6 0x0008 产出 #3
0x0917 0x000C 消耗 #2,#3 0x0002 产出 #1
0x086C 0x0011 消耗 #0,#4 0x0020 产出 #5
0x0933 0x0041 消耗 #0,#6 0x0020 产出 #5
0x093A 0x0011 消耗 #0,#4 0x0020 产出 #5

一开始没看懂官方wp在干嘛,现在这样梳理一遍之后倒回去看就发现差不多这就是官方的做法

最后完整的cpp代码就不展示了有点长,主要就是构造相应的错误代码,关键就是看如何能够通过这些反应过程来达到最终目的

复现差不多就到这,远端环境估计关了,就在本地验证一下

image-20260114194302550
image-20260114194302550

  • 标题: Sekaictf2025 Alchamy_master复现
  • 作者: w1n9
  • 创建于 : 2026-01-14 19:47:32
  • 更新于 : 2026-01-14 19:50:08
  • 链接: https://vv1n9.github.io/2026/01/14/Alchamy-master复现/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论
目录
Sekaictf2025 Alchamy_master复现