PyArmor-Unpacker逆向实战:三种方法解包Python代码混淆与加密
1. 项目概述为什么我们需要PyArmor-Unpacker如果你在Python逆向工程或者安全分析的圈子里待过一阵子大概率听说过PyArmor。它是一款非常流行的Python代码混淆和加密工具很多开发者用它来保护自己的商业软件防止源码被轻易反编译。但反过来对于安全研究员、代码审计人员或者只是想学习某个闭源工具内部逻辑的开发者来说这层保护就成了一个需要被“解开”的盒子。PyArmor-Unpacker这个项目就是专门用来做这件事的——它是一个针对PyArmor混淆的“解包器”或者说“反混淆器”。简单来说这个工具能帮你把经过PyArmor处理过的、无法直接阅读的.pyc文件或打包后的可执行文件还原成接近原始的Python字节码.pyc甚至源代码。这不仅仅是“破解”那么简单它更像是一场在Python虚拟机PVM层面上的攻防演练。你需要理解PyArmor是如何在内存中动态加解密代码对象的理解Python的帧Frame、代码对象Code Object和字节码Bytecode的运作机制然后找到那个关键的“钩子”点在代码执行的生命周期中把解密后的内容“捞”出来。我接触这个项目是因为之前分析一个用PyArmor保护的第三方库当时手动跟踪非常痛苦。后来发现了Svenskithesource在GitHub上开源的PyArmor-Unpacker它提供了三种不同的解包方法覆盖了从动态注入到静态分析的多种场景。这个工具本身是开源的但更重要的是它附带的详细技术文档Write-Up几乎是一步一步教你如何逆向PyArmor的保护机制。这篇文章我就结合自己的使用经验和源码分析带你从零开始彻底搞懂PyArmor-Unpacker的原理和用法让你不仅能“用”工具更能“懂”工具背后的门道。2. PyArmor保护机制深度解析在动手拆解之前我们必须先弄清楚我们的“对手”是如何工作的。如果你连保护壳的原理都不清楚谈何脱壳PyArmor的核心保护思路并非对源代码进行不可逆的变形而是运行时动态加解密。2.1 核心原理代码对象的“动态外衣”Python代码在执行前会被编译成字节码这些字节码及其相关的元信息如常量、变量名、行号表共同构成一个“代码对象”Code Object。PyArmor的魔法就施加在这个代码对象上。它遍历目标脚本中的所有代码对象包括模块、函数、类方法等对它们的字节码进行加密。但是代码对象不能完全加密否则Python解释器根本无法加载它。因此PyArmor采用了一种“包裹”策略包裹头部Wrap Header在每个原始代码对象的字节码前面插入一小段固定的引导代码。这段代码的核心是调用一个名为__armor_enter__的内置函数。这个函数是PyArmor运行时的核心它的作用就是在内存中将紧随其后的、已加密的字节码解密。修改原始字节码原始字节码被加密。同时PyArmor会扫描字节码中所有的绝对跳转指令如JUMP_ABSOLUTE因为插入了头部代码的偏移量发生了变化所以需要增加这些跳转指令的参数oparg确保跳转目标正确。包裹尾部Wrap Footer在加密的原始字节码之后插入另一段固定代码调用__armor_exit__函数。这个函数的作用与__armor_enter__相反它会重新加密刚才解密的字节码确保没有明文的代码对象残留在内存中从而增加动态分析的难度。整个过程可以想象成原始的代码对象像一份文件PyArmor给它套上了一个保险箱加密。但这个保险箱有个特殊的锁__armor_enter__只在执行时短暂打开解密执行完立刻关上__armor_exit__重新加密。我们的目标就是在锁打开的那一瞬间把文件内容复制出来。2.2 运行时组件pytransform模块当你用PyArmor打包一个脚本后生成的目录里总会有一个pytransform文件夹。这里面有两个关键文件_pytransform.dll(Windows) 或_pytransform.so(Linux/macOS)这是用C编写的核心保护模块包含了__armor_enter__和__armor_exit__等关键函数的实现以及各种反调试、校验逻辑。__init__.py一个纯Python的桥接文件。它的主要工作是加载上述的动态链接库DLL/SO并将其中的函数暴露给Python的全局命名空间使得脚本开头的pyarmor_runtime()调用能够成功。被保护的脚本入口通常长这样from pytransform import pyarmor_runtime pyarmor_runtime() __pyarmor__(__name__, __file__, b\x50\x59\x41\x5...) # 一大串加密数据pyarmor_runtime()负责初始化环境并注册关键函数而__pyarmor__这个调用才是真正触发对主模块代码对象进行解密和执行的核心入口。2.3 限制模式Restrict Mode的挑战PyArmor提供了不同级别的“限制模式”来对抗逆向分析。默认的模式通常为1就包含了两道关键的防线这也是PyArmor-Unpacker需要绕过的引导限制Bootstrap Restrict防止在非正常环境下如交互式Python Shell直接调用pyarmor_runtime()来获取__armor_enter__等函数。如果你尝试在REPL里导入并调用会得到Check bootstrap restrict mode failed的错误。执行限制在__pyarmor__函数内部会进行更深层次的检查例如验证主模块文件是否被篡改、是否在预期的环境中执行等。注意理解这两道防线是理解后续三种解包方法差异的关键。方法一和方法二需要绕过第一道防线而方法三静态解包则需要同时考虑如何绕过第二道防线。3. 三种解包方法全解构与实操PyArmor-Unpacker仓库的methods文件夹下提供了三种不同的解包脚本。它们适用于不同的Python版本和场景复杂度和成功率也各有不同。最重要的一条原则确保你用于解包的Python解释器版本与目标被保护程序编译时使用的Python版本完全一致版本不匹配是大多数奇怪错误的根源。3.1 方法一动态注入与部分解包这是最“传统”的动态方法思路直接运行目标程序然后在内存中注入代码 dump 出解密后的内容。核心思路运行被PyArmor保护的脚本让PyArmor运行时正常加载。通过进程注入工具将一段Python代码注入到目标进程的内存中。注入的代码会绕过引导限制获取到__armor_enter__等函数然后遍历调用栈找到主模块的代码对象并导出。详细操作步骤准备环境从仓库的methods/method_1目录下复制所有文件到你的目标程序比如target.py或target.exe所在的目录。运行目标程序直接双击或命令行启动你的目标程序。此时程序会卡住因为它会等待注入或者你需要快速进行下一步。进程注入使用进程查看/注入工具如Process Hacker 2或Cheat Engine。找到目标Python进程的PID。使用项目推荐的注入器PyInjector。你需要根据目标程序是32位x86还是64位x64选择对应的DLL文件进行注入。注入成功后PyInjector会在目标进程中执行一个预定义的Python脚本通常是code.py。执行解包脚本注入完成后在外部运行method_1.py。这个脚本会与注入的代码协作尝试 dump 出解密后的代码对象。获取结果如果一切顺利你可以在当前目录下找到一个run.py文件。这个文件包含了被部分解包后的代码你可以尝试运行它。方法一的局限性部分解包这个方法主要dump出的是主模块的代码对象。如果程序中有大量函数只在特定条件下才被解密和执行这些函数的代码可能无法被捕获。依赖注入需要第三方注入工具步骤繁琐且在对抗某些反调试或进程保护时可能失败。环境依赖必须让目标程序运行起来对于会立刻退出的程序或恶意软件风险较高。实操心得使用Process Hacker 2注入时务必以管理员身份运行。注入后如果目标进程崩溃很可能是PyInjector的版本x86/x64与目标进程不匹配。对于打包成单文件的exe程序注入点可能不太一样需要多尝试几次。3.2 方法二动态注入与完整修复方法二在方法一的基础上更进一步它不仅dump代码还尝试在内存中“修复”代码对象彻底移除PyArmor的包裹层得到一个干净的、原始的.pyc文件。核心思路前几步与方法一相同运行目标程序并注入PyInjector。注入的代码在获取到当前运行帧Frame的代码对象后进行一项关键操作修改字节码。它找到__armor_enter__函数调用后的POP_TOP指令将其替换为RETURN_VALUE指令。这个修改的意图是当执行流到达这里时不再继续执行后续被加密的原始字节码而是直接返回__armor_enter__函数的返回值即解密后的代码对象数据。这样我们就“骗过”了执行流程直接拿到了解密后的内容。拿到解密后的代码对象后脚本会递归地遍历其中所有的子代码对象如嵌套函数并执行一个“清理”过程移除包裹头部LOAD_GLOBAL __armor_enter__; CALL_FUNCTION; ...和包裹尾部LOAD_GLOBAL __armor_exit__; ...。修复因移除头部而失效的绝对跳转指令的偏移量。从代码对象的co_names存储名称的元组中删除__armor_enter__和__armor_exit__的引用。最终输出一个完全解包、修复后的.pyc字节码文件。详细操作步骤从methods/method_2目录复制文件到目标程序目录。运行目标程序。使用进程注入工具注入PyInjector。注入完成后在dumps目录下即可找到解包后的.pyc文件。可选使用反编译工具如pycdc或decompyle3将.pyc文件反编译为Python源代码。方法二的优势与挑战优势输出的是完整的、可独立运行的字节码文件理论上还原度最高。挑战对Python字节码结构的操作需要非常精确尤其是处理EXTENDED_ARG扩展参数和跳转偏移量计算时极易出错。项目作者也提到对于异步代码对象async函数的支持可能有问题。注意事项修复跳转偏移量是这里的难点。PyArmor在插入头部时增加了原始跳转指令的参数值。当我们移除头部后必须等量减少这些参数值。计算时必须考虑EXTENDED_ARG的存在它是一个特殊的操作码用于支持大于2551字节的参数值。错误的计算会导致反编译后的代码逻辑完全混乱。3.3 方法三静态解包基于审计钩子这是最新、也是最巧妙的方法它完全不需要运行目标程序实现了真正的“静态分析”。它利用了Python 3.8引入的审计钩子Audit Hooks功能。核心思路PyArmor为了执行被加密的代码最终必须调用Python内置的marshal.loads()函数来反序列化解密后的代码对象数据。Python的审计系统可以监控特定事件marshal.loads正是其中之一。我们可以安装一个审计钩子在marshal.loads被调用时拦截它。当PyArmor运行时调用marshal.loads来加载一个代码对象时我们的钩子函数会收到事件通知和对应的参数即被加载的加密数据。此时我们可以将这个数据截获并保存下来。由于截获的数据仍然是PyArmor加密格式的我们还需要模拟一个“运行时”环境来解密它。这里用了一个巧妙的技巧通过exec(open(‘target.pyc’).read())的方式在同一个Python解释器内“间接”执行目标文件从而绕过PyArmor对直接执行__pyarmor__函数的某些限制检查。一旦pyarmor_runtime()被成功调用我们就有了可用的__armor_enter__函数。接下来对于每一个通过审计钩子捕获的加密代码对象数据我们都可以手动调用__armor_enter__来解密它然后复用方法二中的“修复”逻辑清理包裹层最终得到干净的代码对象。详细操作步骤从methods/method_3目录复制文件到目标.pyc文件所在目录。在命令行执行python bypass.py your_target.pyc。解包过程会自动进行结果输出到dumps目录。同样可以使用pycdc等工具对输出的.pyc进行反编译。方法三的优势与限制巨大优势无需注入、无需运行目标程序安全性高尤其适合分析可疑的或行为激烈的文件。版本限制该方法严重依赖marshal.loads的审计事件而此功能仅在Python 3.9.7 及以上版本中才为marshal.loads启用。因此它无法用于解包由更低版本Python编译的PyArmor程序。逻辑复杂需要处理审计钩子的递归触发问题钩子函数内部调用marshal.loads也会触发钩子代码实现上需要精细的状态管理。实操心得这是目前最推荐的方法只要你的环境符合Python版本要求。运行bypass.py时如果遇到Check bootstrap restrict mode failed错误说明目标程序使用了较强的限制模式bypass.py脚本内部已经集成了通过内存补丁绕过此限制的代码类似于方法一中的补丁确保其能正常工作。如果失败检查Python版本匹配性。4. 关键技术细节与避坑指南4.1 绕过引导限制内存补丁的艺术无论是方法一、二还是三只要需要主动调用pyarmor_runtime()都可能遇到引导限制。PyArmor-Unpacker采用了一种经典的内存补丁Memory Patching方式来绕过。原理_pytransform.dll中有一个函数负责检查“引导限制模式”。通过调试器如x64dbg定位到这个检查点会发现它本质上是一个条件跳转如果检查失败就跳转到错误处理流程如果成功就继续执行。我们的目标就是让这个检查永远“成功”。操作用调试器找到检查失败后的跳转指令通常是jne或jnz将其之后直到函数返回前的所有指令全部替换为无操作指令NOP, 0x90。这样无论检查结果如何程序流都会滑过错误处理代码继续向下执行从而绕过限制。Python实现项目中的restrict_bypass.py脚本自动化了这个过程。它使用ctypes库操作Windows APIVirtualProtect来修改目标内存页的权限改为可读可写可执行然后用memset将特定地址范围的指令填充为0x90NOP。这个偏移地址需要针对不同版本的_pytransform.dll进行定位脚本中硬编码的地址是针对某个特定版本的。重要警告内存补丁具有版本特异性如果PyArmor更新了_pytransform.dll补丁的偏移地址很可能失效。你需要用调试器重新定位检查点并计算新的偏移量。这是动态方法的主要维护成本。4.2 修复字节码处理跳转与EXTENDED_ARG这是方法二和方法三的核心算法之一也是最容易出错的地方。问题假设PyArmor包裹头部增加了10个字节的代码。原始字节码中有一条指令JUMP_ABSOLUTE 100意思是跳转到偏移量100的指令。插入头部后为了跳转到同一个逻辑位置PyArmor会将这条指令修改为JUMP_ABSOLUTE 110。当我们移除头部后这条指令必须被改回JUMP_ABSOLUTE 100。解决方案遍历字节码识别所有JUMP_ABSOLUTE操作码将其参数减去包裹头部的长度。复杂情况EXTENDED_ARGPython字节码指令的参数通常只有1个字节0-255。当跳转目标超过255时就需要使用EXTENDED_ARG操作码。EXTENDED_ARG可以连续出现它们与后续的操作码共同组成一个多字节的参数。计算规则是参数 ((EXTENDED_ARG1 8) | ((EXTENDED_ARG2 8) | ... | oparg))。例如参数300的表示可能是EXTENDED_ARG 1 JUMP_ABSOLUTE 44因为1 * 256 44 300。在修复时我们必须先正确解析出原始参数考虑可能存在的多个EXTENDED_ARG减去偏移量然后根据新参数的值重新计算并生成所需的EXTENDED_ARG序列并正确地插入回字节码数组中。这个过程需要非常小心地处理字节码数组的索引因为插入EXTENDED_ARG会改变数组长度。4.3 静态解包的“陷阱”与解决方案方法三的静态解包看似完美但也有陷阱递归触发审计钩子我们在审计钩子中截获marshal.loads调用然后需要自己调用__armor_enter__来解密数据这可能会再次触发marshal.loads审计事件。如果不加控制会导致无限递归。项目最初的解决方法是检查dumps目录是否存在但这并不可靠如果目录已存在脚本就会错误地执行原程序。更好的方案在钩子函数内部设置一个线程局部threading.local状态标志或使用一个全局计数器。当进入钩子处理逻辑时立即设置标志在标志已设置的情况下直接放行marshal.loads调用不做处理。绕过执行限制直接exec(open(‘target.pyc’).read())在某些高限制模式下可能仍然会被检测到。项目作者提到可以像绕过引导限制一样通过逆向分析_pytransform.dll找到执行限制的检查点并进行内存补丁。这是一个更深入的对抗层面。版本兼容性如前所述marshal.loads的审计事件是版本相关的。对于Python 3.10字节码的存储格式又有变化绝对跳转索引除以2以节省空间解包器也需要对应调整。这些都在项目的“已知问题”列表中需要社区贡献代码来完善。5. 常见问题排查与实战技巧在实际使用PyArmor-Unpacker的过程中你肯定会遇到各种问题。这里我整理了一份常见问题速查表和一些独家技巧。问题现象可能原因解决方案注入后目标进程崩溃或无反应1.PyInjector架构x86/x64与目标进程不匹配。2. 目标进程有反注入或保护。3. Python环境路径问题。1. 确认目标进程位数使用对应版本的注入器。2. 尝试在虚拟机或干净环境中运行关闭杀毒软件。3. 确保注入器能找到正确的Python DLL。运行bypass.py提示Check bootstrap restrict mode failed1. Python版本不匹配。2._pytransform.dll版本较新内存补丁偏移失效。3. 目标使用了非默认的限制模式。1. 使用python -V和工具如pyinstxtractor看打包信息确认版本。2. 使用调试器重新定位_pytransform.dll中的检查点更新restrict_bypass.py中的偏移量。3. 尝试使用方法一或方法二。解包成功但反编译出的代码逻辑混乱或报错1. 跳转偏移量修复错误尤其是EXTENDED_ARG处理。2. 解包不完整某些嵌套函数未处理。3. 反编译器如pycdc对高版本Python支持不佳。1. 检查解包器日志确认修复逻辑。可尝试用dis模块手动分析输出的.pyc字节码。2. 确保解包器递归遍历了所有co_consts中的代码对象。3. 尝试使用其他反编译器如uncompyle6或decompyle3或使用对应版本的Python生成字节码参考。方法三解包时dumps目录下没有文件或文件为空1. 审计钩子未正确安装或触发。2. 目标文件不是有效的.pyc或由不支持的Python版本生成。3.exec(open(...))方式未能成功触发PyArmor解密流程。1. 在bypass.py的钩子函数开头加打印确认是否被调用。2. 用file命令或十六进制编辑器检查文件头确认Python魔数Magic Number。3. 尝试在钩子函数中直接打印event和arg看是否有marshal.loads事件。解包出的代码缺少部分功能或变量PyArmor可能启用了“代码替换”或“代码混淆”等高级选项这些选项会修改代码结构本身而不仅仅是加密。这类深度混淆的还原难度极大PyArmor-Unpacker可能无法完全处理。需要结合动态调试在关键函数执行时下断点直接查看内存中的对象和值。独家避坑技巧环境隔离始终在虚拟机或独立的容器环境中进行解包操作特别是处理来源不明的文件。动态注入和运行未知程序本身就有风险。版本侦探在解包前不惜一切代价确定目标程序使用的Python版本和PyArmor版本。对于可执行文件可以用strings命令搜索 “Python” 字符串或用pyinstxtractor解包PyInstaller打包的exe来获取信息。分步验证不要指望一键成功。对于方法二和三可以修改解包脚本让它每处理一个代码对象就打印日志或保存中间结果。这能帮你定位是在处理哪个函数时出了错。字节码可视化当反编译结果不合理时直接使用Python内置的dis模块反汇编解包得到的.pyc文件。对比正常代码的字节码和解包后代码的字节码能帮你快速发现跳转错误或指令异常。社区与源码PyArmor-Unpacker是一个开源项目遇到问题时仔细阅读项目的Issue和Pull Request很多坑已经有人踩过。更重要的是阅读源码write-up.md和核心的unpacker.py等文件包含了大量的实现思路和注释是最好的学习资料。解包PyArmor保护的程序本质上是一场与保护机制作者之间的智力博弈。PyArmor-Unpacker提供了强大的武器但能否成功运用取决于你对Python内部机制的理解深度和解决问题的耐心。这个工具本身也在不断进化以应对PyArmor新版本的变化。保持学习深入原理才是应对这类挑战的不二法门。