Sekaictf2025 Alchamy_master复现
在闲逛一位大佬的博客的时候,偶然发现这道题有点新颖,遂来复现一下,参考了两篇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 Pathfrom tempfile import TemporaryDirectoryimport subprocessimport syslaunch_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
第6个参数值为4,进程只挂起不运行
注入部分
image-20251229081608147
plugin.dll 查询到字符串
image-20251230112659302
这里一个逻辑是
往上回溯到函数sub_180002010(),获取了CL.exe的模块基址
image-20251229092254565
分析sub_180003230得知是hook函数,hook了CL.exe基地址偏移0xD5AA0位置的函数以及LoadLibrary,
image-20251229130417205
保留原指令
image-20251229130456767
然后还hook了c1.dll和c2.dll的同一个函数,hook的调用函数也是同一个,就是我们通过字符串查找输出验证信息的函数
image-20260112211044078
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; __int64 v2; __int64 *v3; __int64 v4; _DWORD *v5; int v6; char v7; __int64 v8; __int64 v11; unsigned __int64 v12; 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 = 0 i64; 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 = 0 i64; 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 = 8 i64 * _EAX + 170672 ; v12 = *(_QWORD *)((char *)&unk_7FF93CB40000 + v11) + 1 i64; if ( v12 > 0x4268 ) v12 = 0x4268 i64; *(_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(0 i64); JUMPOUT(0x7FF93CB41FBB i64); } } } } } }
v3是一个链表,大概结构如图
1 2 3 4 5 6 struct Node { int * next; uint32_t key; uint8_t type; };
大体逻辑为先遍历整个链表,找到type值为23的节点开始往下分析,将v3中key成员的值与v5中的某个偏移值进行比较,再来进行对全局数组的7个值进行操作计算,最后再与目标值进行比较。
这是初始值
image-20260113160536912
这是目标值
image-20260113160606727
看大佬的博客,他的一个做法是,发现了v3和v5的数据结构后,判断我们输入的cpp代码分别都对应着不同的类型,而不同类型的代码分别又对应着一个不同的key值(该值是被保存到全局数组dword_7FFE52FA5D20中的),每个key值也有对应的操作数,分别为key值后面的两个int16值,用来控制最终验证数组的加减的
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 ; } int a () { int b = 0 ; return 0 ; } int a () { return 0 ; return 0 ; } int a () { { } { } } int a () { throw ; }
最终虽然没有把所有key值对应的代码都找全,但是找到了对应索引0-6的所有类型,所以理论上就可以通过爆破出我们需要的cpp代码来完成对全局数组的计算至target数组
1 2 3 4 5 6 7 8 9 10 11 {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_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]) 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: file.write(code_map[0x933 ][0 ]+'\n' ) int_count = results[0x852 ] for i in range (int_count): file.write(code_map[0x852 ][0 ]+str (i)+' = 0;\n' ) throw_count = results[0x8a2 ] file.write((code_map[0x8a2 ][0 ]+'\n' ) * (throw_count)) ret_count = results[0x86c ] file.write((code_map[0x86c ][0 ]+'\n' ) * (ret_count)) call_count = results[0x899 ] file.write((code_map[0x899 ][0 ]+'\n' ) * (call_count - throw_count)) trunk_count = results[0x93a ] file.write((code_map[0x93a ][0 ]+'\n' ) * (trunk_count-1 )) 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
其中的err_num就是一开始提到的那个v3里的key,也就是之前所说的所谓的代码类型对应的编码
一共是这些值
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
加操作(cap常量0x4268是最大值,当大于等于cap常量时取该值)
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