永信至诚春秋Game CTF赛题设计说明
[题目信息]:
出题人 | 出题时间 | 题目名字 | 题目类型 | 难度等级 | 题目分值 |
---|---|---|---|---|---|
LilRan | 20241226 | Pyhumor | Reverse | 6 | 500 |
[题目描述]:
对一款常见加密混淆器的简化和模仿。真是很幽默啊。
[题目考点]:
考察 SMC、动态调试获取数据的知识点。本题用自己实现的一个简化版 Pyarmor 来体现其基本原理,选手可以了解到 CPython pyd 扩展模块和 CPython PyCodeObject 结构。
[是否原创]:
是
[Flag]:
flag{9bc74ce3-a56d-467f-eb52-d5f3d8923c6f}
[题目环境]:
只分发附件
[特别注意]:
(此项不需要向选手说明)出题和做题所有用到 Python 的步骤,版本都需要是 Python 3.10,高一点或低一点都不行。这是 CPython ABI 的要求
[题目制作过程]:
- 使用 C 语言编写混淆器运行时,源码位于
obfuscator/pyhumor_runtime.c
,此文件与 Flag 内容无关 - 编译此运行时:以
obfuscator/
为工作目录,使用 Python 3.10 运行python setup.py build
(可能会遇到编译器找不到 Windows Kits 路径的问题,需要结合实际环境解决) - 使用 Python 编写 Flag 检查程序,源码位于
task-src/plain.py
,其中包含 Flag 校验逻辑(如果修改此文件,与 Flag 无关的部分需保持代码不变) - 生成混淆脚本,即题目附件:以
task-src/
为工作目录,使用 Python 3.10 运行python get-obfuscated.py
,在此目录下生成的pyhumor_runtime.cp310-win_amd64.pyd
和pyhumor.py
即题目附件 - (可选)使用
.pyc
而不是.py
作为题目附件:以task-src/
为工作目录,使用 Python 3.10 运行python get-task-pyc.py
[题目writeup]:
题目给了 pyc 和 pyd,这两种文件都是与 Python 版本相关的,本题是 3.10。起手用 pycdc 反编译 pyc 得到:
# Source Generated with Decompyle++
# File: pyhumor.pyc (Python 3.10)
from pyhumor_runtime import __pyhumor__
__pyhumor__(__name__, __file__, b'PYHUMOR\x00\x00\x03\n...') # 省略
看这段代码与常见加密混淆工具 Pyarmor 很相似,都是通过 CPython 扩展模块,从一段字节串中解密原程序运行。
用 IDA 反编译 pyhumor_runtime.cp310-win_amd64.pyd,从字符串 pyhumor_runtime
或 __pyhumor__
的交叉引用,定位到 sub_180002A40 为主要逻辑。
要实现将字节串转换为符合 CPython 解释器 ABI 的结构并与之交互,无非这两种可能:
- 在 C 层面遍历数据并构造 PyObject,相当于另外实现一套 CPython
- 先将字节串解密为 PyCodeObject 或源码字符串,然后由 Python 反射运行
从实现的难度来看显然后者可能性更大,从 sub_180002A40 后几行的 PyImport_ExecCodeModuleObject
也能知道。
sub_180002A40 依次进行了以下操作:
- 解析函数传参,
PyArg_ParseTuple_SizeT
的后四个参数从左到右依次是(PyObject* __name__, PyObject* __file__, char* input_code, Py_ssize_t input_code_len)
- 检查字节串指明的 Python 版本号、长度
- malloc 一段内存来存放解密后的字节串
- 通过很长的算法来解密(其实是 libtomcrypt 的 AES_ECB)
- 将字节串 unmarshal 成 PyObject
- 往 globals 里注册
__humor_enter__
函数为 sub_180002950 - Eval 上面的 PyObject
- 从 globals 里删除
__humor_enter__
注意到字节串会被 unmarshal 成可执行的 PyObject,而实际上 pyc 文件格式就是 marshal 然后前面加个 Magic Number 得到的。所以可以在 PyMarshal_ReadObjectFromString
处打断点,调试获得解密后的字节串。(此函数两个参数分别为字节数组和长度)
感兴趣的可在 Python 安装目录下 include 文件夹中找到 PyCodeObject 结构体的定义,并将其导入到 IDA,观察内存中的实例
为了调试 pyd,我们可以把调用者改成下面这样,然后趁着 sleep 的时候将调试器附加到进程:
import os
import time
from pyhumor_runtime import __pyhumor__
print(os.getpid())
time.sleep(20)
__pyhumor__(__name__, __file__, b'PYHUMOR\x00\x00\x03\n...') # 省略
拿到解密后 unmarshal 前的 PyCodeObject,我们既可以将它传递给 dis.dis,也可以补上与附件 pyc 相同的文件头,然后用 pycdas 反汇编。dis 得到:
>>> import dis
>>> import marshal
>>>
>>> code = marshal.loads(bytes.fromhex('''E300000000000000000000000000000000070000004000000073140100006500830001007A7873A805686DEFF01C36058A753A614B72CEF927609136F0E0AEC31293A93659474E8E26093235E694EA9C2D0F783A23D92C7ED02D4A25C07E852FE2D6390BD6D21DAA0238AF09DCCF120AC1CDF261C3F55E9DFDCCB61517EF8F008F0BC9188365B085DBD5B055F4B3CA57EC415C89DFC55461B3AA68A26316B234B84F949A27B40E1216FFCBF29530971A3C919EBF9EF2105FDFDEEB0E28A596123FACF53C928C4CDDDE31617009569E8A99F6546D34BB24DA3A0EDA096A4015CBA7B44929184C9DCD2E798EDA2E5D750BE8F4DB3642819C7D84877E365CDCC6C50A355E4862B08F766429C8C50DA130E83FD40FF4948930716A435CF95BABCCD18587C1A0B3746404830101005900640553002916E9000000002901DA067368613235362901DA067265647563657A11456E74657220796F757220666C61673A20E9010000004EE9030000005A03626967690100803B696ED1590969F0FFFFFF5A306463663536343736343537383830626635623339623239353431366632363762376136333633323462616561653166646302000000000000000000000002000000020000004300000073080000007C007C014100530029014EA9002902DA0178DA017972060000007206000000FA083C66726F7A656E3EDA083C6C616D6264613E0E00000073020000000800720A000000E9020000005A0557726F6E67E955000000E907000000E9080000006C160000000028B3313666676CED18E619E018F833E01B30783362E330F85831303478CC60D8428959DD307311434B9B015A0552696768747A2E53746F70207265766572736520656E67696E656572696E67206D652C20656E6A6F7920796F757220646179203A2929145A0F5F5F68756D6F725F656E7465725F5F5A07686173686C69627202000000DA0966756E63746F6F6C737203000000DA05696E707574DA04666C6167DA03696E74DA0A66726F6D5F6279746573DA06656E636F6465DA09686578646967657374DA08656E647377697468DA036D6170DA036F72645A0767726F75705F31DA03616C6CDA057072696E74DA0465786974DA03616363DA01697206000000720600000072060000007209000000DA083C6D6F64756C653E010000007330000000060202020C010C01080220031C01160104FD080608010801040208010C010801160108020E0108020E01060208010E010B0B0B0B0B0B0B0B0B0B0B'''))
>>> dis.dis(code)
3 0 LOAD_NAME 0 (__humor_enter__)
2 CALL_FUNCTION 0
4 POP_TOP
5 6 SETUP_FINALLY 120 (to 248)
6 8 POP_JUMP_IF_TRUE 168 (to 336)
10 DUP_TOP_TWO
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "...\lib\dis.py", line 79, in dis
_disassemble_recursive(x, file=file, depth=depth)
File "...\lib\dis.py", line 376, in _disassemble_recursive
disassemble(co, file=file)
File "...\lib\dis.py", line 372, in disassemble
_disassemble_bytes(co.co_code, lasti, co.co_varnames, co.co_names,
File "...\lib\dis.py", line 404, in _disassemble_bytes
for instr in _get_instructions_bytes(code, varnames, names,
File "...\lib\dis.py", line 340, in _get_instructions_bytes
argval, argrepr = _get_name_info(arg, names)
File "...\lib\dis.py", line 304, in _get_name_info
argval = name_list[name_index]
IndexError: tuple index out of range
>>>
可以发现,最前面对 __humor_enter__
的调用已经反汇编出来了,且字节串中可以看到 Stop reverse engineering me
等常量(pycdas 可以列出全部常量),但是后面的字节码仍然是错乱的。然而程序又可以正常运行,所以需要继续分析 __humor_enter__
即 sub_180002950。
sub_180002950 是一个 SMC 解密函数。通过 PyEval_GetFrame()
获得了当前 Python 函数调用栈帧(调用 C 函数不会增加一个栈帧),从中获得了正在运行的 PyCodeObject 指针,从中获得了指令字节码(co_code)的字节数组,并再次解密。这样的话,在 sub_180002950 返回后,下一条指令恰好已被解密,使得 CPython 能够运行下去。
注:Python 中的 bytes 是不可变的,但我们是在用 C 语言操纵内存;自 Python 3.11 起此方法失效,因为获得的可能是字节码的一个副本。
所以可以用同样的方法,在再次解密前记录下 co_code 内存地址和密文,在 return Py_NoneStruct;
前导出完全解密的 co_code 数组,并用它替换掉之前导出的字节串相应位置,补上 pyc 文件头然后用 pycdc 反编译:
# Source Generated with Decompyle++
# File: final.pyc (Python 3.10)
__humor_enter__()
try:
from hashlib import sha256
from functools import reduce
flag = input('Enter your flag: ')
group_1 = [
int.from_bytes(flag[1::3].encode(), 'big') % 998244353 == 156881262,
sha256(flag[-16:].encode()).hexdigest().endswith('dcf56476457880bf5b39b295416f267b7a636324baeae1fd'),
reduce((lambda x, y: x ^ y), map(ord, flag)) == 2]
if not all(group_1):
print('Wrong')
exit(1)
acc = 0
for i in flag:
i = ord(i) ^ 85
acc |= i
acc <<= 7 if i & 1 else 8
if acc == 0xCDCB4322E6C376CC4C2D8C199E0D1818D8F861C788CFC181BE067F06380CF318EDD8CF98D98D9A800L:
print('Right')
finally:
return None
print('Wrong')
exit(1)
return None
print('Stop reverse engineering me, enjoy your day :)')
exit(1)
return None
可以看到主体部分已经还原了。group_1 的三个不可逆校验是为了防止通过构造具有魔术方法的类来追踪打印计算过程“一把梭”。
由于前面分析过程较繁琐,算法上就不设置得太复杂了,只通过后面的或运算和左移运算就可以反推出 flag:
target = 0xCDCB4322E6C376CC4C2D8C199E0D1818D8F861C788CFC181BE067F06380CF318EDD8CF98D98D9A800
flag = []
while target != 0:
target >>= (7 if target & 0x80 else 8)
if target != 0:
flag.append((target & 0x7F) ^ 85)
flag = bytes(flag[::-1]).decode()
print(flag)
# flag{9bc74ce3-a56d-467f-eb52-d5f3d8923c6f}
注意事项
- 无