ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

CTF逆向实战:PyInstaller打包与SMC自修改代码破解分析

CTF逆向实战:PyInstaller打包与SMC自修改代码破解分析 1. 项目概述一次典型的CTF逆向工程实战复盘最近在复盘一些经典的CTF竞赛题目特别是逆向工程Reverse Engineering, RE方向的发现“[CISCN2021]华北区_ctf_re_imnotavirus”这道题很有意思。它不像那些纯粹考验算法逆向的题目而是结合了Python打包、代码混淆和自修改代码SMC技术非常贴近现实中恶意软件分析或软件保护破解的场景。题目名字“imnotavirus”我不是病毒本身就带点调侃意味暗示了程序行为的隐蔽性。对于刚接触CTF逆向或者想深入理解Python程序逆向、PyInstaller打包文件分析的朋友来说这道题是一个绝佳的练手材料。它要求你不仅会静态分析还要动态调试更要理解程序在运行时的自我修改行为。接下来我就结合自己的解题过程把这道题的完整分析思路、用到的工具链、踩过的坑以及最终的破解方法系统地梳理一遍。2. 核心思路与技术点拆解拿到一个未知的可执行文件第一步永远是信息收集。这道题给的是一个名为imnotavirus的文件在Linux环境下可能是imnotavirus.elfWindows下可能是imnotavirus.exe本质一样。从标题和网络热词可以明确几个关键方向CISCN2021国赛、RE逆向、SMCSelf-Modifying Code自修改代码、PyInstaller。这几乎是在明示这是一个由PyInstaller打包的Python程序并且在运行时可能修改自身的代码段。2.1 为什么是PyInstaller在CTF的逆向题中Python打包的题目非常常见尤其是PyInstaller。因为它能将Python脚本及其依赖打包成一个独立的可执行文件方便分发但也为逆向增加了门槛——你无法直接看到源代码。逆向PyInstaller打包的程序核心目标是提取出原始的Python字节码.pyc文件或脚本。PyInstaller打包后程序的入口点是一个C语言写的引导程序bootloader它会解压一个归档文件通常叫PYZ-00.pyz或类似其中包含了压缩的Python模块和资源然后启动Python解释器执行你的脚本。2.2 SMC自修改代码意味着什么SMC是一种代码保护技术程序在运行时动态地修改自身的一部分指令或数据使得静态分析时看到的代码与实际运行的代码不同。在这道题里结合“imnotavirus”这个名字很可能程序在内存中解密或还原出真正的关键逻辑比如校验flag的逻辑而静态分析时这部分逻辑是加密或混淆的。这就要求我们必须进行动态分析在内存中捕获这些被修改后的代码。2.3 解题路线图预判基于以上两点我们可以规划出大致的解题路径确认打包方式使用file、strings命令或查壳工具确认是否为PyInstaller打包。解包提取内容使用专用工具如pyinstxtractor解包尝试获取.pyc文件。分析.pyc文件对.pyc文件进行反编译查看Python源代码。但因为有SMC这里得到的源码可能是不完整的或被混淆的。动态调试使用调试器如gdb搭配pwndbg或针对Python的uncompyle6、pycdc进行动态分析运行程序在关键点如输入提示、校验函数下断点观察内存和寄存器状态。定位并分析SMC例程找到负责解密或修改代码的函数分析其算法从而还原出真实的逻辑。编写求解脚本根据还原出的逻辑编写Python脚本计算出flag或模拟校验过程。3. 环境准备与初步静态分析工欲善其事必先利其器。我们先搭建好分析环境。3.1 工具链准备以下是我在分析时用到的主要工具在Kali Linux或Ubuntu环境下很容易安装基础检查工具file,strings,objdumpPyInstaller解包pyinstxtractor.py(GitHub上有开源项目)Python字节码反编译uncompyle6(推荐) 或pycdc动态调试gdbpwndbg插件强大的Linux调试环境strace/ltrace跟踪系统调用和库函数调用。脚本编写任意文本编辑器或IDE。首先给目标文件添加执行权限并运行一下看看它的行为chmod x imnotavirus ./imnotavirus程序很可能会提示输入比如“Please input your flag:”。输入一个测试字符串如flag{test}后程序可能输出“Wrong!”或者直接退出。这个交互过程证实了它是一个需要输入并进行校验的程序。用file命令查看文件类型file imnotavirus输出很可能类似于imnotavirus: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]..., stripped。这说明是一个64位的Linux可执行文件并且被strip过了移除了符号表增加了静态分析的难度。用strings命令粗略搜索一下可打印字符串strings imnotavirus | grep -i -E “py|flag|wrong|correct|input”你可能会看到一些PyInstaller相关的字符串如PyInstaller、pyi、Python库路径以及程序输出的提示信息。这进一步确认了它是Python打包的程序。3.2 使用pyinstxtractor解包这是最关键的一步。我们需要从打包的可执行文件中提取出Python的字节码文件。下载pyinstxtractor.py脚本。运行它来解包目标文件python3 pyinstxtractor.py imnotavirus如果成功会生成一个名为imnotavirus_extracted的目录。进入这个目录你会看到很多文件。其中最重要的通常是PYZ-00.pyz_extracted目录里面是所有被压缩的Python模块的.pyc文件。一个与原始脚本同名的文件可能叫imnotavirus没有后缀这个文件是打包时的主脚本编译后的字节码文件但它缺少了.pyc文件的魔数Magic Number和时间戳头。注意从PyInstaller提取的.pyc文件往往是“去头”的即缺少了文件开头的几个字节魔数和时间戳。直接使用uncompyle6反编译可能会失败。我们需要从同一个Python版本的标准库中找一个.pyc文件将其头部复制过来。3.3 修复并反编译主脚本首先确定打包使用的Python版本。可以在imnotavirus_extracted目录里找找有没有包含版本信息的文件或者用strings在原始文件中搜索“Python”。 假设我们确定是Python 3.8。那么我们需要一个Python 3.8的.pyc文件头。一个简单的方法是python3.8 -c “import py_compile; py_compile.compile(‘/dev/null’, ‘test_header.pyc’)” head -c 16 test_header.pyc pyc_header这行命令生成了一个空的.pyc文件并提取其前16个字节作为头文件。对于Python 3.7这个头通常是16字节。然后找到解包出来的主脚本文件假设叫imnotavirus没有后缀# 将正确的头文件与去头的字节码文件合并 cat pyc_header imnotavirus imnotavirus_fixed.pyc现在尝试用uncompyle6反编译uncompyle6 imnotavirus_fixed.pyc imnotavirus_decompiled.py如果成功你将得到一个Python源代码文件。打开它你可能会看到类似下面的结构# uncompyle6 version 3.7.4 # Python bytecode 3.8 (3413) # Decompiled from: Python 3.8.10 (default, Nov 14 2022, 12:59:47) # [GCC 9.4.0] # Embedded file name: imnotavirus.py # Compiled at: 2021-07-01 00:00:00 # Size of source mod 2**32: 1000 bytes import base64, codecs, marshal, types def decrypt_code(encrypted_data, key): # 某种解密函数 ... def main(): print(“Please input your flag:”) user_input input() # ... 这里可能调用了一个被SMC保护起来的校验函数 encrypted_func b’...很长一串base64或字节...’ # 解密并执行 code_obj marshal.loads(decrypt_code(base64.b64decode(encrypted_func), key)) check_func types.FunctionType(code_obj, globals(), “check”) if check_func(user_input): print(“Correct!”) else: print(“Wrong!”) if __name__ “__main__”: main()这是一个典型的SMC实现模式核心的校验逻辑check_func被加密后以数据形式如base64字符串存储在程序中。在main函数运行时调用一个decrypt_code函数将其解密再通过marshal.loads和types.FunctionType在内存中动态创建一个函数对象并执行。实操心得uncompyle6有时会对复杂的控制流反编译失败或者产生不太易读的代码。如果遇到这种情况可以尝试使用pycdc工具或者直接使用dis模块分析字节码。命令是python3 -m dis imnotavirus_fixed.pyc。虽然读起来费劲但对于理解程序流很有帮助。4. 深入动态分析与SMC破解静态分析到这里我们已经知道了程序的大致框架它有一个解密函数用来解密一段包含核心逻辑的代码。接下来就需要动态调试弄清楚两件事1. 解密用的key是什么2. 解密后的check_func到底做了什么4.1 动态调试策略我们不能直接调试Python字节码但可以调试这个ELF可执行文件。我们的目标是找到内存中解密后的代码段或者直接获取到解密所需的密钥。一种方法是使用gdb在解密函数调用之后、动态函数执行之前设置断点。但因为我们没有符号表定位函数地址比较困难。一个更实用的方法是结合Python的ptrace和字符串搜索。首先用strace看看程序运行时的系统调用strace -o trace.log ./imnotavirus输入测试flag后查看trace.log关注read读取输入、write输出结果、以及可能的mmap或mprotect内存权限修改SMC可能会用到等调用。这能帮我们了解程序的大致执行流。更有效的是使用ltrace来跟踪库函数调用ltrace -o libtrace.log ./imnotavirus在libtrace.log里你可以看到程序调用了哪些库函数比如base64_decode、一些密码学相关函数如openssl的或者内存操作函数。这能为我们指明解密可能发生的位置。4.2 内存转储与逆向最直接的方法是让程序运行起来然后在它提示输入之前暂停或者在它解密完代码但尚未执行时将进程的内存转储dump出来进行分析。我们可以写一个简单的gdb脚本gdb -q ./imnotavirus在gdb中(gdb) set disassembly-flavor intel (gdb) break *main # 如果知道main函数地址的话。更通用的方法是断在入口点_start之后或者通过plt表断在puts或printf用于打印提示信息 (gdb) run程序会在断点处停下。我们可以单步执行ni或si同时观察寄存器和栈的变化。我们的目标是找到那个加载了加密字符串base64并调用解密函数的代码区域。一个更取巧的办法是既然我们知道解密后的代码会变成一个函数对象被执行那么我们可以尝试在Python C API层面找突破口。PyInstaller打包的程序最终会调用PyEval_EvalCode或PyObject_Call来执行代码。我们可以在这些函数上设断点。在gdb中如果Python是动态链接的我们可以(gdb) break PyEval_EvalCode (gdb) continue当断点命中时查看此时的函数参数它应该是一个PyCodeObject指针。这个对象里就包含了字节码。我们可以尝试用gdb命令打印或导出这部分内存。实际操作中的技巧很多时候题目为了简化解密密钥可能就硬编码在解密函数附近或者是一个简单的固定值如异或一个固定字节。在静态反编译出的decrypt_code函数里仔细查看其实现。它很可能是一个简单的循环异或XOR操作。例如def decrypt(data, key): return bytes([data[i] ^ key[i % len(key)] for i in range(len(data))])如果key是可见字符串在静态的.pyc文件里就能找到。如果key是计算出来的就需要动态跟或者逆向计算过程。4.3 编写解密脚本假设我们通过动态调试或静态分析成功找到了加密的字节串encrypted_func和密钥key。那么我们就可以在外部复现解密过程。提取加密数据从反编译的源代码中复制出那个很长的b’…’字节串或者base64字符串。实现解密函数根据反编译看到的decrypt_code函数逻辑用Python重写一遍。解密并反编译import base64, marshal, types, dis encrypted_b64 “...” # 粘贴过来的base64 encrypted_bytes base64.b64decode(encrypted_b64) key b“your_found_key” # 找到的密钥 decrypted_bytes decrypt(encrypted_bytes, key) # 你的解密函数 # 尝试加载为代码对象 try: code_obj marshal.loads(decrypted_bytes) # 反编译查看源码 import uncompyle6 uncompyle6.code_deparse(code_obj) # 或者直接反汇编 dis.dis(code_obj) except Exception as e: print(“可能不是有效的marshal数据:”, e) # 可能是直接解密成了Python源码字符串 print(decrypted_bytes.decode(‘utf-8’, errors‘ignore’))分析核心校验逻辑解密后我们终于能看到真正的check函数了。它可能是一个复杂的校验比如对输入字符串进行一系列变换移位、异或、加减、比较最后与一个内置的数组或字符串进行比较。5. 逆向校验逻辑与Flag生成假设我们成功解密并看到了类似下面的check函数经过简化和美化def check(s): if len(s) ! 32: return False enc [0x12, 0x34, 0x56, ...] # 一个长度为32的字节数组 for i in range(32): if (ord(s[i]) ^ 0x55) i ! enc[i]: return False return True这就是一个典型的逐字符校验。我们的目标就是找到一个字符串s使得对于所有i(ord(s[i]) ^ 0x55) i enc[i]成立。那么求解就很简单了写一个逆向脚本enc [0x12, 0x34, 0x56, ...] # 从代码里复制过来的数组 flag_chars [] for i in range(32): # 逆向运算 (x ^ 0x55) i enc[i] x ^ 0x55 enc[i] - i x (enc[i] - i) ^ 0x55 c (enc[i] - i) ^ 0x55 # 注意边界检查c应该在可打印字符范围内 if 32 c 126: flag_chars.append(chr(c)) else: # 如果不在说明我们的逆向公式可能错了或者运算有溢出需要调整比如用 0xff取模 c (enc[i] - i) 0xff flag_chars.append(chr(c ^ 0x55)) flag ‘’.join(flag_chars) print(flag)运行这个脚本就能得到最终的flag格式很可能就是flag{...}。常见问题与排查解密失败最常见的原因是.pyc文件头修复不正确。确保使用的Python版本与打包版本完全一致。可以多试几个常见的头Python 3.6, 3.7, 3.8, 3.9。pyinstxtractor有时也会输出它检测到的Python版本务必参考。解密函数逻辑复杂如果解密函数不是简单的异或可能涉及AES、DES等标准加密或者自定义的混淆算法。这时就需要耐心逆向解密函数本身。动态调试时可以在解密函数入口和出口打印encrypted_data和decrypted_data直接获取输入输出对有时可以绕过算法分析黑盒。校验逻辑复杂校验可能不是简单的线性运算可能包含非线性变换、查表S-Box、多轮循环等。这时需要将校验算法完整地用Python实现出来然后通过约束求解如z3库来求解flag。这是CTF逆向中的高级技巧。程序反调试题目可能包含反调试技术如检测ptrace、检测调试器、设置定时器干扰等。遇到程序异常退出或行为异常要考虑到这一点。解决方法包括使用LD_PRELOAD注入hook函数绕过检测或者在虚拟机、qemu用户态环境中调试。6. 完整工具链与自动化思路对于这类PyInstallerSMC的题目可以总结出一套半自动化的分析流程提高效率自动化解包与修复编写脚本自动调用pyinstxtractor并尝试用常见Python版本的头部修复主脚本。字符串与常量提取对反编译出的源码或字节码进行正则匹配自动提取出所有看起来像base64、hex、字节数组的常量。解密函数识别通过函数名如decrypt、decode、xor或特定API调用base64.b64decode、codecs.decode、bytes.fromhex定位解密函数。动态Hook使用Frida框架编写脚本注入到目标进程中Hook解密函数直接打印出解密前后的数据。这是非常强大的动态分析手段可以绕过很多静态混淆。约束求解集成将逆向出的校验逻辑用z3的表达式重写让求解器自动算出满足条件的输入。这道“[CISCN2021]华北区_ctf_re_imnotavirus”题目完美地串联了PyInstaller逆向、字节码修复、SMC原理、动态调试和简单的密码学逆向这些知识点。它不像那些纯粹炫技的题目每一步都有明确的目的和现实意义。通过亲手完成这样一道题你对软件保护与破解的理解会上一个台阶。下次再遇到“我不是病毒”这样的程序你就能自信地说“让我看看你的真面目。”
返回列表