资讯动态

CTF逆向必备:手工分析pyc文件与字节码还原实战

发布时间:2026/10/20 12:12:35 来源:尧图企业网站定制
1. 从一道逆向题说起为什么pyc文件值得单独研究做逆向题的时候遇到Python编译产物是绕不开的一类场景。很多人第一次碰到.pyc文件会有点懵——它既不是纯文本源码也不是常见的PE或ELF可执行文件用IDA打开一堆字节码指令看得头皮发麻。但恰恰是这种半成品状态让它成为CTF逆向赛里非常经典的一类考点。handcrafted-pyc这道题的名字本身就透露了关键信息handcrafted手工打造的。这意味着出题人不是简单拿py_compile编译一个脚本丢给你而是可能手工修改了字节码、篡改了magic number、或者构造了一个非标准结构的pyc文件。这类题目的核心考察点在于你是否真正理解pyc文件的二进制结构以及能否在工具失效的情况下手动还原出可读的字节码。这篇文章适合三类人一是正在刷CTF逆向题、卡在pyc类题目上的选手二是想搞清楚Python字节码底层机制的开发者三是需要分析Python打包产物比如某些混淆过的商业脚本的安全研究人员。我会从pyc的文件结构讲起一步步拆解手工分析的方法最后给出针对手工打造这类题目的通用解题思路。整个过程不需要你背指令表但需要你理解几个关键字段的含义。先说一个我踩过的坑早期做这类题我习惯性直接上uncompyle6或decompyle3结果遇到handcrafted的pyc直接报错退出连个有用的错误信息都不给。后来才明白工具越自动化对文件结构的假设就越多一旦出题人故意破坏某个字段工具就废了。所以这类题真正考的是你对结构的理解而不是工具用得多溜。2. pyc文件的二进制骨架magic、时间戳与code object2.1 文件头的16个字节到底存了什么一个标准的CPython pyc文件开头固定是16个字节的头部信息。不同Python版本这个头部长度略有差异但主流版本3.7之前是12字节3.7及以后是16字节结构基本一致。以Python 3.8为例头部布局是这样的偏移长度含义0x004字节magic number标识Python版本0x044字节bit fieldPEP 552引入的标志位0x084字节源文件修改时间戳或哈希0x0C4字节源文件大小magic number是识别版本的关键。比如Python 3.8的magic是0x550D0D0A小端存储后读出来是0A0D0D55Python 3.7是0x420D0D0A。这个值如果被出题人改掉工具就会拒绝解析报bad magic number。提示遇到工具报magic错误时不要急着放弃。先用十六进制编辑器看头4个字节对照Python各版本的magic表手动改回正确值往往就能让工具重新工作。2.2 code objectpyc的真正主体头部之后就是序列化的code object。这是Python字节码的核心载体它本身是一个嵌套结构——模块级是一个code object里面每个函数、类、lambda又各自是独立的code object。序列化格式用的是Python的marshal模块所以理论上你可以用marshal.loads()直接反序列化出来。一个code object包含这些关键字段co_argcount位置参数个数co_code字节码指令序列这是逆向的主战场co_consts常量元组字符串、数字、嵌套code object都在这里co_names全局名称元组比如函数名、属性名co_varnames局部变量名co_filename源文件名co_flags标志位标识是否是生成器、是否有可变参数等理解这个嵌套关系很重要。很多新手拿到pyc反编译失败是因为只看了模块级的code object没意识到真正的逻辑藏在co_consts里的子code object中。我一般会写个小脚本递归打印所有code object的层级先把结构摸清楚再动手。2.3 为什么handcrafted的pyc会让工具崩溃自动化反编译工具的工作流程大致是读头部→校验magic→marshal反序列化→遍历code object→按指令模式还原源码。任何一个环节的假设被打破工具就会失败。出题人常用的破坏手法有几种第一种是改magic让工具直接拒绝加载。第二种是手工修改co_code里的字节码插入非法指令或者改变跳转目标导致反编译器的控制流分析崩溃。第三种更狠——直接手工构造marshal数据流让marshal.loads()抛异常。面对这些情况最可靠的办法是绕过工具直接操作字节码。Python标准库里的dis模块可以反汇编code object即使反编译失败dis通常还能给出指令列表。这就是手工分析pyc的起点。3. 手工还原字节码从marshal到dis的完整链路3.1 第一步安全地加载code object不要直接用import去加载可疑的pyc因为模块级的代码会在导入时执行。正确做法是手动读取文件、跳过头部、用marshal反序列化import marshal import dis with open(target.pyc, rb) as f: f.read(16) # 跳过16字节头部Python 3.7 code marshal.load(f) print(type(code)) dis.dis(code)如果这一步就报错说明marshal数据流被破坏了。这时候需要看具体异常如果是ValueError: bad marshal data可能是某个字段的类型标记被改了如果是EOFError说明数据被截断。定位到出错位置后用十六进制编辑器对照marshal格式规范逐个字段检查。marshal的类型标记是有规律的c表示code objects表示字符串i表示整数(表示元组开始等。手工修复时把这些标记改回正确值往往能救回文件。3.2 第二步用dis读懂字节码逻辑dis.dis()输出的格式是行号 偏移 指令名 参数。对于逆向来说重点关注这几类指令LOAD_CONST加载常量参数是co_consts的索引LOAD_NAME/LOAD_FAST/LOAD_GLOBAL加载变量参数是名称元组索引STORE_NAME/STORE_FAST存储变量CALL_FUNCTION函数调用COMPARE_OP比较操作参数编码了比较类型JUMP_IF_FALSE_OR_POP等跳转指令控制流核心我习惯先把所有LOAD_CONST的常量列出来往往能直接看到flag的蛛丝马迹或者关键的判断字符串。然后再顺着跳转指令画出控制流图理解程序的判断逻辑。3.3 第三步处理被篡改的字节码handcrafted题目的精髓在于字节码可能被改过。常见的手法是替换操作码opcode比如把COMPARE_OP换成另一个数值让反编译器无法识别。这时候你需要查Python对应版本的opcode模块import opcode print(opcode.opmap) # 操作码名称到数值的映射 print(opcode.opname) # 数值到名称的映射对照opname表把非法操作码改回合理的指令。改的时候要注意指令长度——有些指令带参数占2字节有些不带占1字节改错了会导致后续指令全部错位。这是最考验耐心的一步我一般会先备份原文件改一处验证一次。注意不同Python版本的opcode表差异很大Python 3.6和3.8的指令集完全不同。分析前务必确认目标pyc的Python版本否则指令表对不上越改越乱。4. 实战拆解handcrafted-pyc的典型出题套路4.1 套路一magic被改导致工具罢工这是最基础也最常见的手法。出题人把magic number改成某个不存在的值uncompyle6直接报错。破解方法很简单用十六进制编辑器打开文件看头4字节对照Python版本magic表改回去。各版本magic速查小端显示Python版本magic十六进制3.60x0C0D0D333.70x0D0D0D423.80x0D0D0D553.90x0D0D0D61改完magic后如果时间戳和文件大小字段也被改了工具可能还会校验失败。这时候可以把这两个字段清零或者用--no-timestamp之类的选项跳过校验。实测下来大部分工具对时间戳的校验并不严格改对magic基本就能跑通。4.2 套路二co_code里插入垃圾指令出题人在正常字节码中插入一些永远不会被执行到的指令或者把某个NOP替换成非法操作码。这种题用dis反汇编时会出现255之类的未知指令。处理思路是先定位非法指令的位置判断它是否在有效执行路径上。如果是在跳转目标之外的死代码区直接忽略即可如果恰好在关键路径上就需要根据上下文推断原本应该是什么指令。比如前后都是LOAD_CONST和COMPARE_OP中间夹了个非法指令那它很可能原本是个LOAD_FAST。4.3 套路三手工构造marshal流最高级的手法是不用py_compile而是手工拼出marshal字节流。这种情况下文件结构可能完全不符合标准比如code object的字段顺序被打乱、类型标记被替换。应对方法是从marshal格式规范入手逐字节解析。marshal的格式其实不复杂核心就是类型标记数据的重复。你可以写一个简单的解析器遇到不认识的标记就打印出来人工判断。我遇到过一道题出题人把code object的类型标记从c改成了Cmarshal直接报错改成小写就正常了。4.4 套路四字节码逻辑混淆有些题目不破坏文件结构而是在字节码层面做逻辑混淆。比如用大量的跳转指令构造控制流平坦化或者把简单的比较拆成几十条指令。这种题工具能正常反编译但出来的代码可读性极差。对付这种题关键是动态分析配合静态分析。静态上先用dis理清基本块动态上可以用exec在沙箱里执行code object观察实际行为。不过执行前一定要确认代码没有危险操作最好在隔离环境里跑。5. 工具链选择与版本匹配的坑5.1 uncompyle6、decompyle3与pycdc的取舍这三个是pyc反编译的主力工具但各有脾气uncompyle6支持Python 2.7到3.8对3.8之后支持不好遇到新版本直接报错decompyle3uncompyle6的分支对3.7/3.8支持更好但更新慢pycdcC写的支持版本更广但反编译质量参差不齐我的经验是先确认pyc的Python版本再选对应工具。如果版本太新3.9这些工具基本都歇菜只能靠dis手工分析。另外工具报错时不要只看最后一行往上翻往往能看到具体的失败原因比如unsupported opcode或bad magic。5.2 版本不匹配的典型症状最常见的坑是拿Python 3.8的工具去分析3.6的pyc。症状是反编译出来的代码语法奇怪或者变量名错乱。这是因为不同版本的字节码指令集和marshal格式都有差异。判断版本的方法看magic number。如果magic被改了可以看co_code里的指令模式——比如Python 3.6有CALL_FUNCTION3.8改成了CALL_FUNCTION_KW和CALL_METHOD。多分析几道题你就能凭指令特征大致判断版本。5.3 自己写脚本比依赖工具更可靠做多了这类题我越来越倾向于自己写分析脚本。核心逻辑就三步读文件跳头部、marshal加载、递归遍历code object。下面这个脚本我用了很多次能打印出所有层级的code object和常量import marshal import dis import types def walk_code(code, depth0): indent * depth print(f{indent} code: {code.co_name} ) print(f{indent}args: {code.co_argcount}, vars: {code.co_varnames}) print(f{indent}consts: {code.co_consts}) dis.dis(code) for const in code.co_consts: if isinstance(const, types.CodeType): walk_code(const, depth 1) with open(target.pyc, rb) as f: f.read(16) code marshal.load(f) walk_code(code)这个脚本的好处是可控——工具崩了它不一定崩而且输出结构清晰方便定位问题。6. 从字节码反推源码逻辑的思维方法6.1 先找常量再找比较最后理控制流逆向字节码不要一上来就逐条读指令效率太低。我的习惯是分三步第一步把所有LOAD_CONST的常量提取出来。flag往往以字符串形式藏在常量里或者关键判断的阈值是数字常量。这一步经常能直接看到答案的轮廓。第二步找COMPARE_OP指令。这些是比较操作参数编码了比较类型、、等。围绕比较指令前后看就能找到判断逻辑的核心。第三步根据跳转指令画控制流。POP_JUMP_IF_FALSE、JUMP_ABSOLUTE这些指令决定了程序的执行路径。把基本块连起来程序的逻辑就清晰了。6.2 识别常见的输入校验模式CTF逆向题的核心通常是输入经过一系列变换后与目标值比较。在字节码层面这个模式表现为读取输入input()调用对应LOAD_NAME inputCALL_FUNCTION对输入做变换循环、字符操作、异或等与目标常量比较COMPARE_OPLOAD_CONST根据比较结果跳转POP_JUMP_IF_FALSE识别出这个模式后你只需要关注变换逻辑和目标常量中间的过程可以跳过。这能大幅提升分析速度。6.3 循环结构的字节码特征Python的循环在字节码里表现为跳转回前面。for循环通常有GET_ITER、FOR_ITER、JUMP_ABSOLUTE的组合while循环则是条件判断后POP_JUMP_IF_FALSE跳出末尾JUMP_ABSOLUTE跳回开头。看到FOR_ITER就知道是遍历看到JUMP_ABSOLUTE往回跳就知道是循环体。结合循环体内的操作就能还原出变换算法。我一般会把循环体单独拎出来分析忽略循环控制指令专注看数据是怎么被处理的。7. 几个容易翻车的细节与个人经验7.1 别忽略co_consts里的嵌套code object新手最容易犯的错是只看模块级code object发现里面只有几行代码就以为分析完了。实际上函数和类的逻辑都在co_consts里的子code object中。我见过一道题模块级只有一行main()调用真正的校验逻辑藏在main的code object里而main又调用了另一个嵌套函数。如果不递归遍历根本找不到关键逻辑。7.2 字符串可能是拼接出来的出题人为了防止你直接搜字符串会把flag拆成多个片段在运行时拼接。字节码里表现为多个LOAD_CONST后跟BINARY_ADD或BUILD_STRING。遇到这种情况你需要手动把常量按顺序拼起来。我一般会把所有字符串常量按出现顺序列出来然后根据拼接指令还原完整字符串。7.3 注意Python版本对字节码的影响同一个算法Python 3.6和3.8编译出来的字节码可能完全不同。3.8引入了CALL_FUNCTION_KW、ROT_FOUR等新指令3.9又改了LOAD_METHOD的行为。分析前一定要确认版本否则指令理解会出错。确认方法前面说过看magic或者看指令特征。7.4 动态执行是最后的验证手段静态分析得出答案后最好动态验证一下。把code object用exec跑一遍输入你推导出的flag看是否通过校验。但要注意安全——先确认代码没有文件操作、网络请求等危险行为。在隔离环境里跑或者用RestrictedPython之类的沙箱。提示动态执行时如果代码里有input()可以用unittest.mock替换掉直接喂入你的测试输入避免交互。7.5 备份原文件改一处验证一处手工修复pyc时最忌讳一次性改多个地方。改完一处就重新加载验证确认没问题再改下一处。我吃过亏——同时改了magic和几个操作码结果加载失败根本不知道是哪个改动出的问题只能从头再来。养成备份和增量修改的习惯能省很多时间。8. 这类题目的通用解题框架把上面的经验串起来面对一道handcrafted-pyc题目我的标准流程是这样的首先用十六进制编辑器看文件头确认magic是否正常。不正常就对照版本表改回去。然后尝试用marshal.load加载如果失败根据异常信息定位损坏的字段手工修复。加载成功后用递归脚本打印所有code object的结构重点关注co_consts里的字符串和嵌套code object。接着用dis反汇编先找常量和比较指令再理控制流。如果字节码被篡改导致dis输出异常对照opcode.opname表修复非法指令。修复时注意指令长度避免错位。最后根据分析结果推导输入动态执行验证。整个过程的核心是理解结构而非依赖工具工具只是辅助真正解决问题的是你对pyc格式和字节码的掌握。这套方法不仅适用于CTF分析任何Python编译产物都用得上。我后来分析过一些混淆过的商业脚本思路完全一样——先摸清结构再逐层剥离最后还原逻辑。区别只是商业脚本的混淆更复杂但底层原理是相通的。最后分享一个小技巧平时可以自己用py_compile编译一些简单脚本然后用十六进制编辑器观察生成的pyc对照dis输出理解每个字段的含义。这种自己造样本的练习方式比单纯刷题更能加深理解。等你对标准pyc的结构烂熟于心再遇到handcrafted的变体就能一眼看出哪里被动了手脚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑