TPCTF 2023 maze

  • main → sub_403E50 完全是标准 PyInstaller 启动流程,负责读取 _MEIPASS2/_PYI_PROCNAME 环境变量、检查外部归档、并在必要时把内嵌归档解包到临时目录。
  • sub_406890 / sub_406A90 / sub_406CD0 等函数做的都是解析 CArchive/PKG、加载 PYZ 模块、并把内嵌的 .pyc 解出来交给 PyEval_EvalCode 执行。
  • sub_403C00 明确调用 PyImport_AddModule(“main“)、PyMarshal_ReadObjectFromString 等接口执行内嵌脚本,真正的谜题与校验逻辑全部在打包进去的 Python 代码里,C 端没有额外的校验算法或显式字符串可以利用。

其实就是解pyc了,非exe的还是第一次见

揭开来发现几个pyc没啥东西,maze.so中发现

image-20250918211443958

交叉引用可以主逻辑

image-20250918231251728

这种base64解码init_secret,有很多这种字符串

这种一般找不到逻辑可以对xor或者compare做交叉引用,但是compare太多了,看看xor定位到

image-20250918231843366

image-20250919102007546

其实可以这里下断点然后dump出来,但是非常麻烦,因为我没有ubuntu的ida,好像动调maze.so是需要attach的,比较麻烦

中间有

1
Attr = (PyObject *)tp_getattro(BuiltinName, _pyx_mstate_global_static.__pyx_n_s_b64decode); else

对base64解码的操作

长度校验

image-20250919104356114

  1. 导入base64并取b64decode
  2. 取模块名UJ9mxXxeoS并b64decode后.decode得到真实模块名(“MazeLang”),这个模块就是下面的一大段base64
  3. 从该模块取函数cnVuX3RpbGxfb3V0cHV0(即”run_till_output”),这是一个可调用/迭代器,每次调用产出一个整数掩码
  4. 如果len(输入)!=33,直接返回1(像是反调或迷惑分支)
  5. 调用aW5pdF9zZWNyZXQ(“init_secret”)初始化全局表c2VjcmV0(“secret”)
1
0x1b2c0 函数,run_till_output:初始化 self.time=0,循环检测 self.cars 是否存在并调用 self.step() 推进状态;每轮产出一个数值(在 maze_solve 中被取用参与异或比较)。从代码结构看,该序列仅依赖 self 的内部状态(cars/step/time),与用户输入字符本身无关。
1
2
3
s = "IyMgIyMgIyMgIyMgIyMgIyMgIyMKIyMgIyMgIyMgXl4gIyMgXl4gIyMKIyMgIyMgIyMgLi4gIyMgSVogIyMgIyMgIyMgIyMKIyMgJVIgLi4gJUQgIyMgJUQgLi4gLi4gJUwgIyMKIyMgPj4gIyMgLi4gIyMgRUEgKiogUFAgJVUgIyMKIyMgJVUgSUEgVEEgIyMgRUIgKiogUFAgJVUgIyMKIyMgJVUgSUIgVEIgIyMgRUMgKiogUFAgJVUgIyMKIyMgJVUgSUMgVEMgIyMgRUQgKiogUFAgJVUgIyMKIyMgJVUgSUQgVEQgIyMgRUUgKiogUFAgJVUgIyMKIyMgJVUgSUUgVEUgIyMgRUYgKiogUFAgJVUgIyMKIyMgJVUgSUYgVEYgIyMgJVIgKiogSVogJVUgIyMKIyMgJVUgSUcgJUwgIyMgIyMgIyMgIyMgIyMgIyMKIyMgIyMgIyMgIyMgIyMgIyMKClBQIC0+ICs9MQpNTSAtPiAtPTEKSVogLT4gPTAKRUEgLT4gSUYgPT0wIFRIRU4gJVIgRUxTRSAlRApFQiAtPiBJRiA9PTEgVEhFTiAlUiBFTFNFICVECkVDIC0+IElGID09MiBUSEVOICVSIEVMU0UgJUQKRUQgLT4gSUYgPT0zIFRIRU4gJVIgRUxTRSAlRApFRSAtPiBJRiA9PTQgVEhFTiAlUiBFTFNFICVECkVGIC0+IElGID09NSBUSEVOICVSIEVMU0UgJUQKVEEgLT4gSUYgKiogVEhFTiAlTCBFTFNFICVECklBIC0+ID03MgpUQiAtPiBJRiAqKiBUSEVOICVMIEVMU0UgJUQKSUIgLT4gPTczClRDIC0+IElGICoqIFRIRU4gJUwgRUxTRSAlRApJQyAtPiA9ODQKVEQgLT4gSUYgKiogVEhFTiAlTCBFTFNFICVECklEIC0+ID04MApURSAtPiBJRiAqKiBUSEVOICVMIEVMU0UgJUQKSUUgLT4gPTY3ClRGIC0+IElGICoqIFRIRU4gJUwgRUxTRSAlRApJRiAtPiA9ODQKSUcgLT4gPTcwCkxUIC0+IElGID09NiBUSEVOICVEIEVMU0UgJUwK"

print(base64.b64decode(s).decode())

运行输出

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
## ## ## ## ## ## ##
## ## ## ^^ ## ^^ ##
## ## ## .. ## IZ ## ## ## ##
## %R .. %D ## %D .. .. %L ##
## >> ## .. ## EA ** PP %U ##
## %U IA TA ## EB ** PP %U ##
## %U IB TB ## EC ** PP %U ##
## %U IC TC ## ED ** PP %U ##
## %U ID TD ## EE ** PP %U ##
## %U IE TE ## EF ** PP %U ##
## %U IF TF ## %R ** IZ %U ##
## %U IG %L ## ## ## ## ## ##
## ## ## ## ## ##

PP -> +=1
MM -> -=1
IZ -> =0
EA -> IF ==0 THEN %R ELSE %D
EB -> IF ==1 THEN %R ELSE %D
EC -> IF ==2 THEN %R ELSE %D
ED -> IF ==3 THEN %R ELSE %D
EE -> IF ==4 THEN %R ELSE %D
EF -> IF ==5 THEN %R ELSE %D
TA -> IF ** THEN %L ELSE %D
IA -> =72
TB -> IF ** THEN %L ELSE %D
IB -> =73
TC -> IF ** THEN %L ELSE %D
IC -> =84
TD -> IF ** THEN %L ELSE %D
ID -> =80
TE -> IF ** THEN %L ELSE %D
IE -> =67
TF -> IF ** THEN %L ELSE %D
IF -> =84
IG -> =70
LT -> IF ==6 THEN %D ELSE %L

这是GPT的解释

image-20250918215206369

secret 是在模块初始化时构造的。maze.so 的 __pyx_pymod_exec_maze 里,会把字符串 UJ9mxXxeoS 之类的短 base64 段解码成整型,再依次 push 到全局列表 __pyx_n_s_secret。IDA 看伪代码(大约在 maze.so:0xc93b 一带)

ord(S[i]) ^ G[i] == T[perm[i]]

这里可以看到两个数据

image-20250918224844093

1
2
3
4
5
6
7
8
9
secret = [7, 47, 60, 28, 39, 11, 23, 5, 49, 49, 26, 11, 63, 4, 9, 2, 25, 61, 36, 112, 25, 15, 62, 25, 3, 16, 102, 38, 14, 7, 37, 4, 40]
pos = [18, 17, 15, 0, 27, 31, 10, 19, 14, 21, 25, 22, 6, 3, 30, 8, 24, 5, 7, 4, 13, 29, 9, 26, 1, 2, 28, 16, 20, 32, 12, 23, 11]
key = b"HITPCTF"

for i in range(33):
secret[i], secret[pos[i]] = secret[pos[i]], secret[i]

flag = "".join([chr(secret[i] ^ key[i % len(key)]) for i in range(len(secret))])
print(flag)

TPCTF{yOu_@re_m@sT3r_OF_mAZElaN6}

怎么看出来的是位置表?

  1. 从值域一眼看出是“索引置换”:maze.EqdU3uQNCi 的列表有 33 个整数,且刚好是 0..32 每个各一次(不重复、不缺失)。这类列表在校验逻辑里只能当“位置索引的置换表”用,比如把第 i 轮要比较的字符位置映射成 perm[i]。

  2. 与 T 的数值分布完全不同:打印的 maze.c2VjcmV0 列表里有很多大于 32 的值(如 49、112、102 等),显然不可能是“索引”(索引必须在 0..32 之间)。这类列表才符合“目标常量序列 T(与 G[i] 异或的比较值)”的特征。再加上名字 c2VjcmV0 是 base64(‘secret’),语义上也指向“目标常量”。

校验需要三样东西:G(每轮生成的字节)、T(目标常量)、以及“这一轮用哪个输入字符来比”(是否打乱顺序)。

G 来自运行时序列;T 是固定常量;而“打乱顺序”只能由一个取值范围在 0..32 的排列来提供。你的 EqdU3uQNCi 正好就是这样的排列,因此判断它是“索引置换表”。

这是出题人:TPCTF2023 Maze WP | Yasar’s Blog

记录一下

Py_mod_exec: 指定一个供调用以执行模块的函数。 这造价于执行一个 Python 模块的代码:通常,此函数会向模块添加类和常量。根据文档中的说明,Py_mod_exec会绑定对应的函数,向模块中添加类和常量。可以在__pyx_pymod_exec_maze函数中看到非常多的常量定义,同时其中调用了_Pyx_CreateStringTabAndInitStrings函数。

_Pyx_CreateStringTabAndInitStrings函数将所有Python代码使用到的字符串插入到__pyx_string_tab中,并于__pyx_mstate_global_static中对应的变量绑定。

__pyx_n_s_c29sdmU对应的_pyx_pw_4maze_3c29sdmU的逻辑:

  1. 解析传入的参数,并赋给__pyx_n_s_SvL6VEBRwx
  2. 调用base64.b64decode解密__pyx_n_s_UJ9mxXxeoS
  3. 使用解密后的值初始化了一个__pyx_n_s_TWF6ZUxhbmc类。
  4. 调用了__pyx_n_s_aW5pdF9zZWNyZXQ函数。
  5. 将传入参数第i位取ord后与__pyx_n_s_TWF6ZUxhbmc__pyx_n_s_cnVuX3RpbGxfb3V0cHV0函数的结果异或,然后与__pyx_n_s_c2VjcmV0的第i位比较。

分析__pyx_n_s_aW5pdF9zZWNyZXQ对应的_pyx_pw_4maze_1aW5pdF9zZWNyZXQ逻辑:

  1. for i in range(33)
  2. 取出__pyx_n_s_c2VjcmV0[__pyx_n_s_EqdU3uQNCi[i]]
  3. 取出__pyx_n_s_c2VjcmV0[i]
  4. __pyx_n_s_c2VjcmV0[__pyx_n_s_EqdU3uQNCi[i]]赋给__pyx_n_s_c2VjcmV0[i]
  5. __pyx_n_s_c2VjcmV0[i]赋给__pyx_n_s_c2VjcmV0[__pyx_n_s_EqdU3uQNCi[i]]