Python
Python 逆向是指对 Python 编写的程序(包括脚本、.pyc 字节码、打包的可执行文件等)进行分析,以理解其逻辑、提取算法、恢复源码或寻找漏洞
Python 程序的编译与运行机制
源码 .py 被编译为 字节码 .pyc,存储在 __pycache__ 目录(Python 3.2+)
.pyc 文件结构:
前 4 字节:magic number(标识 Python 版本,如 Python 3.8 为 550D)
后 4 或 12 字节:时间戳 + 源码大小(可选)
剩余部分:
marshal序列化后的 code object
逆向的核心就是:获取 .pyc → 反编译为近似源码或直接分析字节码
实战案例
pydis2ctf(Python 反汇编)

C1cipher.txt 里面核心反汇编是
1 | LOAD_CONST 1 ('') |
翻译成看的 Python 代码就是
1 | def C1(text): |
所以 C1 的加密公式是
1 | cipher_char = chr(2 * ord(plain_char) - len(text)) |
反过来就是
1 | plain_char = chr((ord(cipher_char) + len(text)) / 2) |
C2cipher.txt 还原出来是
1 | def C2(inpString): |
也就是每个字符和 'S' 异或
但是实际解题时,对 encodedflag.txt 直接反 C1 就能得到 flag;C2 更像是干扰项
1 | import ast |
得到 flag:csictf{T#a+_wA5_g0oD_d155aSe^^bLy}
ICS TRITON(Python 反编译 + .pyz 恢复 + RC4 + 协议逆向)

分析 triton.exe 发现它是 PyInstaller 打包的 Python 2.7 程序
1 | strings triton.exe | grep -i "py" |
使用 pyinstxtractor.py 提取

在目录找到 PYZ-00.pyz,它本身是一个 Python 模块压缩包
PyInstaller 将 Python 依赖模块的字节码 .pyc(去掉 8~16 字节头部)进行 zlib 压缩,然后打包成一个 PYZ 存档,其物理结构如下
1 | +--------+----------+----------+-----------------------------+ |
TOC(Table of Contents,目录表) 是一个 Python 对象的序列化形式,位于 TOC 偏移指定的位置,一直延伸到文件末尾
TOC 描述了每个模块的名称、存储位置、长度以及是否为包
ispkg:表示模块是否为一个包(package),1 则为包
pos:该模块的压缩数据在 PYZ 文件中的起始偏移
length:压缩数据占用的字节数
编写脚本通过读取 TOC 获得所有模块的压缩数据所在位置,然后逐个提取
1 | import os # 操作系统接口,用于路径操作和创建目录 |
这里报错是因为 Python 导入模块时会优先搜索当前工作目录
脚本第一行 import struct 本来想导入标准库的 struct,但实际上却找到了目录下的 struct.pyc
这个 struct.pyc 是用 Python 2.7 编译的(magic 为 b'\x03\xf3\r\n'),而当前的 Python 环境是 3,两者不兼容,于是抛出 bad magic number 错误

直接删除这个文件就好了

重点反编译下面四个文件
1 | TsHi.pyc |
首先是 TsHi.py,定义了一个 TsHi 类(继承自 TsBase.TsBase),用于与某工业控制系统(ICS)设备进行交互
它实现了程序/函数的上传下载、内存任意读写、代码执行,以及一个基于文本模式匹配的远程许可证校验
重点是下面三个函数
1 | RemoteLicenseCheck() |
1 | def TransDummyData(self, s): |
功能:将字符串
s的每个字符转换为 8 位二进制字符串(带前导零),并全部拼接示例:
s = "AB" → ord('A') = 65 → "01000001",ord('B') = 66 → "01000010",结果"0100000101000010"用途:将变换后的中间密钥字符串展开为一串
'0'/'1'字符,作为控制空格替换的掩码
1 | def UpdateDataFormat(self, string, p, c): |
功能:将字符串
string位置p的字符替换为c,返回新字符串注意:Python 字符串不可变,所以采用列表转换方式实现单字符替换
用途:在 License 文本的指定空格位置,将空格改为制表符
\t
1 | def RemoteLicenseCheck(self, remoteLicenseKey): |
完整流程如下
1 | remoteLicenseKey (hex) |
恢复 key 脚本
1 | import base64 # Base64 编解码库 |
最后得到 77d975244bf3e642eac8b56aebed97595920a08b5a194eeb055d72cea621819329bf3863f9565a2529713f7d3e7412f59b31d94c481863ba14bc7d425a715334
接着反编译 aes.pyc,结果是 RC4 加密算法

最后是 sh.pyc
1 | def chend(data): |
每 4 字节为一组,反转组内字节顺序
等价于小端序 ↔ 大端序转换(对 32 位整数而言)
反编译最后的 TsBase.pyc,在 TsBase 类中,所有通信都通过 self.ts_exec() 发送,其第一个参数是一个元组,即协议命令标识。例如
1 | def AllocateProgram(self, id, next, full_chunks=0, offset=0, data=''): |
(55, 153)→ 55 即 0x37,153 是 0x99(可能是子命令或校验和参数)这正是
AllocateProgram函数使用的命令字
AllocateProgram 的功能是向设备分配(或覆盖)一个程序条目,参数包括:
id:程序 ID
next:下一个程序的 ID(链表指针)
full_chunks:总数据块数(每块 4 字节)
offset:写入偏移块号
data:实际写入的字节码数据
| 功能 | 命令元组 | 说明 |
|---|---|---|
| GetCpStatus | (19, 108) |
获取项目状态 |
| UploadProgram | (65, 162) |
上传程序数据 |
| AllocateProgram | (55, 153) |
分配/写入程序 (0x37) |
| StartDownloadChange | (1, 102) |
开始下载修改 |
| EndDownloadChange | (11, 103) |
结束下载修改 |
| CancelDownload | (12, 104) |
取消下载 |
| RunProgram | (20, 109) |
运行程序 |
| HaltProgram | (21, 110) |
暂停程序 |
| ExecuteExploit | (29, 150) |
漏洞利用接口 |
| GetModuleVersions | (54, 151) |
获取模块版本 |
| UploadFunction | (66, 163) |
上传函数数据 |
| AllocateFunction | (56, 154) |
分配/写入函数 |
| StartDownloadAll | (59, 101) |
开始全部下载 |
| EndDownloadAll | (10, 105) |
结束全部下载 |
打开流量包发现大量 UDP 协议的目标端口都是 1502

观察数据发现主要以 05003400000037 开头,推测是 AllocateProgram 函数

编写脚本提取流量
1 | import struct # 用于解析二进制数据 |

接下来只要获取 total_words == 1003 的数据即可
1 | import struct # 用于解析二进制数据 |

它的结构是
1 | uploaded_raw.bin |
程序上传前做了两步
1 | 原始数据 |
解密脚本
1 | import struct # 用于二进制数据打包/解包 |

inject_decrypted.bin 文件是一个缺了文件头的 GIF 图片
它少了前 4 个字节 GIF89a,修补后拿到 flag

最好的语言(Python 字节码)

下载附加识别字符串发现是 Python2 字节码文件
1 | su2es2@Suc2es2:/mnt/d/CTF/213_79e9610e96eb501c77b8d1cae8536dfcc64f58c0/最好的语言$ strings re |
根据字节码还原脚本
1 | import base64 |
程序把 flag 分成三段
1 | f[:12] |
加密方式为
1 | e = _(f[:12]) + ____(f[12:19]) + _(f[19:]) |
其中 _() 是 XOR 加密
key 是 4 位互不重复的数字
1 | key = ''.join(random.sample(string.digits, 4)) |
然后循环异或
1 | cipher[i] = plain[i] ^ key[i % 4] |
而 ____() 是 MD5
1 | md5(flag[12:19]).digest() |
所以密文结构为
1 | Base64( |
先解码目标字符串结果长度为 38
因为中间 MD5 digest 固定是 16 字节,所以可以切成
1 | c1 = data[:12] |
分别是
1 | c1 长度:12 |
这说明原始 flag 长度为 12 + 7 + 10 = 29
第一段是 XOR(flag[:12], key1),而 flag 开头固定为 flag{,密文是 535550534e6948776a08426b
计算
1 | key[0] = c1[0] ^ ord('f') |
得到结果 59145,第 5 位再次得到 5,说明 key 是循环的四位随机数
解密第一段密文得到 flag{PyC_1s_
爆破第二段密文 MD5 得到 613u21i
最后第三段密文的 Key 可以直接爆破,因为最后一位一定是 }
密钥是 4813,最后得到 _N0t_Hard}
所以 flag 就是 flag{PyC_1s_613u21i_N0t_Hard}
1 | import base64 |
signin(Python 反编译 + .pyz 恢复 + 线性变换)

提出可识别字符串发现是有 Python 写出来的
1 | main |
使用 pyinstxtractor.py 反编译出 .pyc 文件

main.pyc 反编译如下:
AccountChecker.__init__释放tmp.dll,加载 DLL 并获取enc函数self.accounts仅存一个用户:b'SCTFer',对应一个 Base64 解码后的 33 字节密文check比较buffer与预设密文,相等即返回 True,界面显示Succeeded! Flag is your password
从 tmp.dll 的导出表可以看到只有一个导出函数 enc
1 | int enc(char *username, char *password, char *out, int out_len) |
其中 transform() 是 64 轮类似 LFSR 的变换
1 | uint64_t transform(uint64_t x) |
由于这是 GF(2) 上的线性变换,可以对 64 位块求逆
1 | import base64 |
最后得到 flag:SCTF{We1c0m3_To_Sctf_2020_re_!!}