
1. 项目概述与核心挑战最近在复盘一些经典的CTF逆向题目特别是国赛CISCN的真题总能发现不少精妙的设计。今天要拆解的是2021年华北赛区的一道逆向题名为“imnotavirus”。光看这个名字就挺有意思“我不是病毒”结合赛题常见的套路这往往暗示着程序可能涉及一些反调试、混淆或者自修改代码SMC技术试图让自己看起来“无害”或难以分析。这道题在当年和后续的讨论中热度一直不低因为它巧妙地结合了PyInstaller打包、SMC以及一些基础的逆向分析技巧非常适合用来锻炼逆向工程中“剥洋葱”的能力——你需要一层一层地揭开它的伪装。这道题的目标很明确作为一个CTF逆向题最终目的是找到一个被隐藏起来的字符串也就是常说的“flag”。但过程绝不简单。题目给我们的通常就是一个可执行文件没有源代码没有说明文档一切都需要我们通过静态分析和动态调试来还原。对于新手来说看到PyInstaller打包的.exe可能会有点发怵觉得Python逆向是不是需要什么特殊工具链。其实不然它的分析路径非常经典掌握了这套方法以后遇到类似的“打包Python程序”逆向你都能从容应对。接下来我会带你完整走一遍从拿到文件到最终提取flag的全过程。我们会先快速定位程序类型然后利用工具解包得到Python字节码再分析其核心逻辑最后攻克其中的SMC保护。我会把每个步骤的原理、用到的工具、可能遇到的坑以及我实战中的技巧都交代清楚。无论你是刚接触CTF逆向的新手还是想深化对Python程序逆向和SMC技术理解的老手相信这篇详细的复盘都能给你带来收获。2. 初步分析与文件类型识别拿到一个未知的可执行文件第一步永远不是直接双击运行在CTF中随意运行未知程序是危险的也可能触发自毁机制而是进行初步的“体检”。在Linux下我们可以用file命令在Windows下可以用一些PE工具或者直接看图标和感觉。对于这道“imnotavirus”用file命令查看或者观察其大小通常几MB到十几MB一个明显的特征是它会有一个PyInstaller的启动器。更直接的方法是使用Python社区里一个神器般的工具——pyi-archive_viewer它是PyInstaller自带的但即使你没有安装完整的PyInstaller也可以找到独立的脚本来使用。不过在CTF环境中我们更常用的是一个叫做pyinstxtractor的Python脚本。它的作用就是专门逆向PyInstaller打包的过程将打包好的exe文件解包还原出其中的Python字节码文件.pyc以及其他资源文件。操作非常简单python pyinstxtractor.py imnotavirus.exe运行后它会生成一个以.exe_extracted结尾的文件夹。进去之后你会看到一堆文件。关键的文件通常有两个一个是PYZ-00.pyz_extracted目录里面包含了程序依赖的库文件另一个就是与我们的exe同名的文件比如imnotavirus没有后缀这个文件非常重要它包含了程序的主逻辑代码但它是编译后的Python字节码。这里有一个非常重要的坑直接提取出来的.pyc文件可能是“残缺”的它缺少了Python字节码文件头部的魔数Magic Number和时间戳信息。这是因为PyInstaller为了节省空间和加快加载去掉了这部分。如果我们直接用uncompyle6或decompyle3这样的反编译工具去处理这个文件会报错提示“Invalid pyc/pyo file”。解决办法就是“补头”。我们需要从一个标准的、相同Python版本编译的.pyc文件中拷贝它的前16个字节或者12个字节取决于Python版本通常3.7是16字节覆盖到我们提取出来的文件开头。如何知道Python版本呢一个方法是运行程序看报错信息另一个更可靠的方法是在解包后的目录里有时会有一个struct文件里面就记录了打包时使用的Python版本。对于这道2021年的题很大概率是Python 3.7-3.9。我们可以自己用对应版本的Python随便编译一个.pyc文件作为“头”的供体。实操心得我习惯准备一个“补头”脚本。先自己创建一个test.py里面写个print(“hello”)然后用目标版本的Python执行python -m py_compile test.py生成test.pyc。用十六进制编辑器如010 Editor或HxD打开这个test.pyc复制前16个字节。再打开我们提取出的无头imnotavirus文件将内容全部选中粘贴到test.pyc那16个字节之后然后另存为一个新的文件比如imnotavirus_fixed.pyc。这样我们就得到了一个可以被反编译工具识别的完整.pyc文件。3. 反编译与主逻辑分析有了完整的imnotavirus_fixed.pyc文件后我们就可以使用反编译工具了。我个人常用uncompyle6uncompyle6 imnotavirus_fixed.pyc imnotavirus_decompiled.py如果顺利你会得到一个可读的Python源代码文件。打开这个文件我们就进入了逆向分析的核心阶段——理解程序逻辑。通常这类CTF题的主程序结构不会特别复杂。你可能会看到一个main函数或者直接是全局的代码。核心逻辑往往是接收用户输入经过一系列变换或校验如果正确则输出flag否则输出错误信息。在“imnotavirus”这道题里我们很可能会发现一些不寻常的地方。首先你可能会看到大段的、看起来毫无意义的字节数组bytearray数据定义。比如encrypted_data bytearray([0x12, 0x34, 0x56, ...]) # 很长的一段数据其次你可能会看到一些对bytearray或bytes类型变量的__setitem__操作或者使用ctypes库进行内存操作。例如import ctypes # ... 一些代码 ... ctypes.memmove(id(data) offset, id(new_bytes), len(new_bytes))或者更直接的对函数对象的代码对象__code__进行修改def some_func(): pass some_func.__code__ new_code_object看到这类代码你的“SMC雷达”就应该响起来了。SMC即Self-Modifying Code自修改代码。程序在运行时会动态地修改自身的一部分代码通常是解密另一段被加密的代码。在Python层面由于一切都是对象修改函数的字节码__code__属性是实现SMC的一种常见手段。在反编译出的代码中你可能会发现一个关键函数它的函数体看起来很简单甚至就一个pass或return None但在它之前有一大段复杂的解密逻辑目标就是构造一个新的code对象然后赋值给这个关键函数。这意味着这个函数的真实逻辑在静态分析时是看不到的它只有在程序运行到解密环节之后才会被“塑造”出来。注意事项反编译出的代码可能因为版本问题或补头不精确导致部分结构异常比如if语句错乱、变量名丢失变成_0xdeadbeef这种。这时候不要慌结合上下文和Python字节码知识可以用dis模块反汇编来理解。我们的首要目标是找到数据流输入从哪里来被谁处理结果到哪里去和控制流哪个函数被动态修改了在哪里被调用。4. 深入SMC动态解密与代码重建当我们定位到那个被动态修改的关键函数假设它叫check_flag后静态分析就暂时走到头了。我们需要看到它被解密后的真实面貌。有以下几种策略动态调试Debugging这是最直接的方法。使用Python调试器如pdb或者IDE集成的调试器在解密代码执行之后、关键函数被调用之前设置断点。然后检查check_flag.__code__的内容。我们甚至可以手动执行dis.dis(check_flag.__code__)来反汇编它的字节码或者尝试用uncompyle6去反编译这个code对象。但直接在打包后的exe里调试Python代码比较麻烦。代码模拟执行Emulation既然我们有反编译出的主程序逻辑解密部分我们可以尝试将这部分逻辑单独提取出来写一个Python脚本模拟执行。把解密算法、那些字节数组数据都拷贝过来让我们的脚本运行解密过程输出解密后的新字节码。然后我们将这段字节码保存为文件再补头、反编译。这种方法要求解密逻辑不依赖于特定的运行时环境如特定的内存地址。打补丁与转储Patching and Dumping这是一个非常实用且高效的方法。我们修改反编译出的源代码在解密完成、即将执行关键函数的地方插入几行代码把解密后的code对象内容check_flag.__code__.co_code以及相关的常量co_consts等信息打印到文件里。然后我们用PyInstaller或者更简单的直接用Python重新打包这个修改后的脚本运行它就能得到解密后的字节码数据。这个方法避免了复杂的调试直接“骗取”程序的劳动成果。对于“imnotavirus”我倾向于使用第三种方法。具体步骤如下首先仔细分析反编译出的源代码找到解密逻辑的终点即check_flag.__code__ new_code这一行。在这行之后插入我们的转储代码# ... 原解密逻辑 ... check_flag.__code__ types.CodeType(...) # 假设解密后创建了新的CodeType对象 # --- 我们插入的代码开始 --- import marshal with open(decrypted_code.bin, wb) as f: # 通常我们需要保存整个code对象marshal.dump可以序列化它 marshal.dump(check_flag.__code__, f) print(“[*] Decrypted code dumped to decrypted_code.bin“) # --- 我们插入的代码结束 --- # 然后程序可能会调用 check_flag(input_str)但是直接marshal.dump一个code对象可能在后续加载时遇到环境问题。一个更稳妥的方法是我们只提取最核心的字节码序列co_code和它依赖的常量表co_consts然后手动构造一个最简单的函数来承载它再反编译这个新函数。# 插入的转储代码 import types, marshal, dis decrypted_code_obj check_flag.__code__ # 保存原始字节码和常量 with open(co_code.bin, wb) as f: f.write(decrypted_code_obj.co_code) with open(co_consts.bin, wb) as f: marshal.dump(decrypted_code_obj.co_consts, f) # 为了验证可以创建一个临时函数并反汇编 temp_func types.FunctionType(decrypted_code_obj, globals()) print(“[*] Disassembly of decrypted function:“) dis.dis(temp_func)运行修改后的程序我们就能得到co_code.bin和co_consts.bin以及一份反汇编的文本。有了字节码和常量我们就可以尝试重建。常见问题插入的代码可能导致原程序逻辑错误或崩溃比如变量作用域问题。一个技巧是将插入的代码放在整个解密逻辑之后但在程序退出或进入下一个阶段之前。有时也需要import sys; sys.exit(0)让程序在转储后立即退出避免后续执行出错。5. 字节码分析与flag验证逻辑还原拿到解密后的字节码co_code.bin和常量表后我们离flag就只有一步之遥了。Python字节码是一种栈式虚拟机指令虽然可读性比源代码差但结合常量表完全可以理解其逻辑。我们可以使用Python内置的dis模块来反汇编字节码。但dis.dis()需要的是一个code对象。所以我们需要先重建一个最简单的code对象。我们知道一个code对象有很多属性除了co_code和co_consts还有co_names全局变量名、co_varnames局部变量名、co_argcount参数个数等。对于这道题被解密的check_flag函数很可能只接收一个参数用户输入的字符串并且逻辑是自包含的不依赖太多外部变量。我们可以根据反汇编输出上一步打印的来推断这些信息。或者更简单粗暴的方法是直接使用原code对象的其他属性只替换co_code和co_consts不我们已经有整个code对象的序列化数据如果用marshal.dump保存了的话。最直接的方法是将decrypted_code.bin如果保存了完整code对象用marshal.load加载。用types.FunctionType将其转换为一个函数对象。用uncompyle6反编译这个函数对象或者用dis进行更深入的分析。如果只有co_code和co_consts我们可以尝试根据常见的函数模板来构造import types, marshal, dis # 加载保存的字节码和常量 with open(co_code.bin, rb) as f: co_code f.read() with open(co_consts.bin, rb) as f: co_consts marshal.load(f) # 假设函数有一个参数没有局部变量名字叫‘check_flag’ co_argcount 1 co_nlocals 0 co_stacksize 64 # 可以设大一点 co_flags 67 # 通常值表示有参数、有代码 co_name check_flag co_filename dumped co_firstlineno 1 co_lnotab b # 行号表通常可以空 # 创建code对象 code_obj types.CodeType( co_argcount, 0, co_nlocals, co_stacksize, co_flags, co_code, co_consts, (), (), co_name, co_filename, co_firstlineno, co_lnotab ) # 创建函数并反编译 func types.FunctionType(code_obj, {}) try: import uncompyle6 from io import StringIO out StringIO() uncompyle6.code_deparse(code_obj, outout) print(out.getvalue()) except: print(“[*] Decompilation failed, falling back to disassembly:“) dis.dis(code_obj)运行这段脚本我们有很大的机会得到解密后check_flag函数的Python源代码。这个函数的逻辑通常就是校验flag的核心算法。它可能将我们的输入与一个硬编码的字符串或数组进行比较也可能进行一系列运算如异或、加减、置换后比较结果。分析这个函数我们就能知道正确的输入flag应该满足什么条件。有时是直接比较字符串flag就藏在常量里有时需要逆向一个算法。对于这道题根据其名称和SMC的运用算法可能不会太复杂重点在于绕过保护看到算法本身。排查技巧如果反编译失败dis的输出就是我们的救命稻草。你需要学习一些基本的Python字节码指令比如LOAD_FAST加载局部变量、LOAD_CONST加载常量、COMPARE_OP比较操作、POP_JUMP_IF_FALSE条件跳转等。关注LOAD_CONST加载了哪些常量通过索引在co_consts表中查找这些常量很可能就是用于比较的正确结果或者密钥。跟踪数据在栈上的流动可以还原出算法。6. 算法逆向与flag生成假设我们成功还原出了check_flag函数的源代码它看起来可能是这样的def check_flag(s): if len(s) ! 32: return False v [] for i in range(0, 32, 2): v.append((ord(s[i]) 8) | ord(s[i1])) # ... 一些对v的操作 ... enc [ ... ] # 一个硬编码的数组 for a, b in zip(v, enc): if a ! b: return False return True或者是一个简单的异或def check_flag(s): key [0x12, 0x34, ...] for i, c in enumerate(s): if ord(c) ^ key[i % len(key)] ! target[i]: return False return True我们的任务就是根据这个校验逻辑反向求出满足所有条件的字符串s。情况一直接比较或简单映射。如果s经过简单变换后直接与enc数组比较那么我们可以逆向这个变换。例如上面的第一个例子它将两个字符组合成一个16位整数。那么我们可以将enc中的每个整数拆分成高8位和低8位分别转换为字符。enc [0x4865, 0x6c6c, ...] # 假设的enc数组 flag_chars [] for num in enc: high (num 8) 0xff low num 0xff flag_chars.append(chr(high)) flag_chars.append(chr(low)) flag .join(flag_chars)情况二涉及运算如异或。这是CTF中最常见的。如果s[i] ^ key[i] target[i]那么由于异或的自反性a ^ b c则a c ^ b我们可以直接计算s[i] chr(target[i] ^ key[i])。情况三更复杂的运算。可能需要写一个小脚本模拟校验过程但改为求解。例如如果算法是可逆的就写逆算法如果不可逆但搜索空间小比如只涉及可打印字符可以暴力枚举。在“imnotavirus”这道题中结合SMC这种保护方式其核心加密算法往往不会设计得极其复杂否则就失去了重点。关键点在于找到那个被隐藏的比较数据。这个数据很可能以字节数组的形式存在于我们最初反编译出的主程序代码中也就是那一长串bytearray数据。在解密函数执行后这部分数据可能被解密成另一段代码也可能直接被用作校验的target数组。所以我们的最终步骤通常是从还原的check_flag函数中找到用于比较的target数据enc数组。理解输入字符串s到比较数据v的转换过程。编写逆过程脚本从target反推出s。将s用flag格式如flag{...}或CISCN{...}包裹提交。实操心得在编写求解脚本时务必注意Python 2和Python 3在字符串处理上的区别bytes vs str。原题是Python 3打包所以所有操作都是基于bytes或bytearray的可能性很大。在逆算法时使用bytes类型和int之间的转换b[i],ord(),chr()要格外小心。另外记得检查反编译代码中是否有将flag进行encode(‘utf-8’)或decode(‘hex’)等操作确保逆过程匹配。7. 完整解题流程回顾与工具链总结让我们从头到尾串联一下整个解题流程并整理一下用到的关键工具和命令形成一个可以复用的“checklist”文件识别使用file命令或观察初步判断为PyInstaller打包的Python可执行文件。解包提取使用pyinstxtractor.py解包exe得到无头的.pyc主文件。python pyinstxtractor.py imnotavirus.exe字节码补头使用相同Python版本编译一个简单脚本生成标准.pyc将其头部前16字节与提取出的无头主文件内容合并生成fixed.pyc。反编译主程序使用uncompyle6反编译补头后的文件得到主程序源代码main.py。uncompyle6 fixed.pyc main_decompiled.py分析SMC逻辑在main_decompiled.py中定位解密代码和被修改的函数如check_flag。理解解密过程。动态转储解密代码修改main_decompiled.py在解密完成后、函数被调用前插入代码将解密后的check_flag.__code__对象用marshal.dump保存到文件。然后运行这个修改后的脚本可以直接用Python解释器运行无需重新打包。还原解密函数加载转储的code对象用uncompyle6反编译或使用dis分析其字节码得到真实的check_flag函数逻辑。逆向算法分析check_flag函数找到比较数据和变换算法编写逆向脚本求解出正确的输入字符串。格式化flag将求解出的字符串按照题目要求的格式如flag{...}进行提交。工具链清单解包工具pyinstxtractor.py(必备)十六进制编辑器010 Editor,HxD或Bless(用于补头可选可用Python脚本代替)反编译工具uncompyle6或decompyle3(核心)Python字节码分析内置dis模块 (备用)序列化工具内置marshal模块 (用于转储/加载code对象)脚本编写任意文本编辑器或IDE避坑指南版本一致性补头、反编译、运行修改脚本时尽量使用相同版本的Python解释器特别是主版本号如3.7/3.8/3.9。版本不一致可能导致magic number不匹配或字节码指令集不同。反编译失败如果uncompyle6报错可以尝试decompyle3。如果都不行就依赖dis模块进行手动分析。手动分析时重点关注LOAD_CONST、COMPARE_OP、BUILD_LIST、CALL_FUNCTION等指令。SMC多层嵌套有些题目会设计多层SMC即解密出一段代码这段代码运行后又会解密另一段。思路是一样的找到每一层的解密逻辑和修改点逐层剥开。可以在每一层解密后都插入转储代码。防调试陷阱题目可能集成了一些简单的反调试比如检测是否被ptraceLinux下或者检查运行时间。在动态分析时要注意。对于PyInstaller的题目通常反调试较少重点还是在静态分析和代码模拟。通过这样一套组合拳绝大多数基于PyInstaller打包并采用SMC保护的CTF逆向题都能被攻克。“imnotavirus”这道题就是一个非常标准的教学案例它涵盖了从文件识别、解包、反编译、SMC识别与绕过到算法逆向的全流程。掌握它你就掌握了解决一类问题的通用方法论。