Files
pyhumor/README.md
2025-04-15 10:38:51 +08:00

10 KiB
Raw Blame History

永信至诚春秋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 的要求

[题目制作过程]

  1. 使用 C 语言编写混淆器运行时,源码位于 obfuscator/pyhumor_runtime.c,此文件与 Flag 内容无关
  2. 编译此运行时:以 obfuscator/ 为工作目录,使用 Python 3.10 运行 python setup.py build(可能会遇到编译器找不到 Windows Kits 路径的问题,需要结合实际环境解决)
  3. 使用 Python 编写 Flag 检查程序,源码位于 task-src/plain.py,其中包含 Flag 校验逻辑(如果修改此文件,与 Flag 无关的部分需保持代码不变)
  4. 生成混淆脚本,即题目附件:以 task-src/ 为工作目录,使用 Python 3.10 运行 python get-obfuscated.py,在此目录下生成的 pyhumor_runtime.cp310-win_amd64.pydpyhumor.py 即题目附件
  5. (可选)使用 .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}

注意事项