逆向分析Python PYD加密模块:从二进制到算法还原实战

📅 2026/7/28 15:34:59
逆向分析Python PYD加密模块:从二进制到算法还原实战
1. 项目概述一次从混淆到清晰的逆向之旅最近在复盘一些CTF比赛的题目特别是逆向工程方向的发现一个挺有意思的样本rand0m.pyd。这个文件来自Ciscn长城杯的一道题目表面上看是一个Python的动态链接库pyd文件但它的名字“rand0m”就透着一股子“此地无银三百两”的味道。逆向工程的核心魅力就在于剥开开发者精心设计的层层伪装还原代码最原始的逻辑和意图。这次实战我们就来一起拆解这个rand0m.pyd看看它背后到底藏着什么样的Python加密算法以及我们如何一步步将其“打回原形”。对于刚接触逆向的朋友来说pyd文件可能有点陌生。它本质上是Windows平台下用C/C编写的、可供Python直接调用的动态链接库DLL只不过后缀名是.pyd。逆向这类文件我们既需要传统的二进制逆向分析技能去理解其C/C层面的逻辑又需要时刻关联Python的调用约定和数据结构因为它的最终服务对象是Python解释器。这个过程就像侦探破案既有对“物证”二进制代码的细致勘查也需要对“动机”实现特定Python功能的合理推断。通过这个案例你不仅能学到针对Python扩展模块的逆向分析方法还能深入理解一种常见的代码保护或算法隐藏手段。无论你是CTF爱好者、安全研究人员还是对Python底层交互感兴趣的开发者相信这次从文件载入、静态分析到动态调试、算法还原的完整流程都能给你带来实实在在的收获。我们这就开始。2. 逆向环境搭建与初步侦察工欲善其事必先利其器。逆向分析需要一个稳定、高效的环境特别是面对可能带有混淆或反调试机制的二进制文件。2.1 工具链选择与配置我的分析环境主要基于Windows 10/11因为pyd是Windows特有的格式。工具选择上遵循“静态动态结合高低级分析互补”的原则静态分析利器IDA Pro/GhidraIDA Pro无疑是行业标准其强大的反汇编引擎、F5伪代码生成功能需Hex-Rays插件和丰富的插件生态能极大提升逆向效率。面对pyd文件IDA可以像分析普通DLL一样将其打开自动识别出导出函数。Ghidra美国国家安全局NSA开源的工具免费且功能强大其反编译质量有时甚至优于IDA。对于预算有限或喜欢开源工具的学习者Ghidra是非常好的选择。它同样能很好地处理PE文件DLL/pyd。我通常会两者配合使用用IDA快速浏览和定位用Ghidra进行交叉验证或深度分析某些复杂函数。动态调试搭档x64dbg/OllyDbg 与 Python调试x64dbg现代、开源且活跃的调试器对64位和32位程序支持都很好。我们将用它来附加到Python解释器进程动态跟踪pyd模块被加载和调用的过程观察内存数据、寄存器变化和函数调用栈。Python自身调试在Python层面我们可以使用pdb或ipdb库进行脚本级调试单步执行调用该pyd模块的Python脚本观察输入输出。这有助于我们将二进制层面的行为与Python逻辑对应起来。辅助与查看工具Dependency Walker 或 CFF Explorer用于快速查看pyd文件的导出函数表。一个正常的、供Python使用的pyd文件必然会导出PyInit_模块名函数对于Python 3这是模块的初始化入口。查看导出函数是验证文件类型和获取第一个切入点的最快方式。文本编辑器/十六进制编辑器用于查看文件中可能嵌入的字符串、资源或配置数据。有时关键的提示或密钥就以明文形式藏在二进制文件的某个角落。注意分析环境最好在虚拟机中搭建。虽然CTF题目通常无害但养成良好的安全习惯至关重要。虚拟机提供了隔离的沙箱环境避免意外情况影响宿主机。2.2. 目标文件初探它到底是什么拿到rand0m.pyd第一步不是直接扔进IDA而是做一些基础检查。文件类型确认# 在Linux/Mac下可以使用file命令但在Windows环境下我们可以用Python的magic库或直接看后缀和PE结构。 # 使用CFF Explorer打开可以清晰看到它是一个标准的Windows PE32 executable (DLL for x64)。确认它是64位的DLL文件符合现代Python 3扩展模块的常见格式。查看导出函数 使用Dependency Walker打开rand0m.pyd。在导出函数列表中我们期望找到一个名为PyInit_rand0m的函数。果然我们看到了它。这是Python 3扩展模块的初始化函数入口。模块被import时解释器就是通过这个函数来初始化模块并构建模块对象的。这证实了它确实是一个合法的Python扩展模块。字符串扫描 用IDA或十六进制编辑器扫描文件中的可打印字符串。在逆向中字符串往往是重要的路标。我们可能会发现一些有趣的字符串比如调试信息如__FILE__,__LINE__如果编译时未去除。错误信息如“Invalid key”,“Decryption failed”。算法相关的常量如“AES”,“RSA”, 或者一些固定的魔数0xDEADBEEF等。Python对象相关的类型名如“str”,“int”,“list”。 在这个案例中扫描可能发现一些看似随机但反复出现的字节序列或者提示性的单词这为我们后续分析算法类型提供了初步线索。初步侦察告诉我们这是一个为Python 3设计的、名为rand0m的扩展模块其核心逻辑封装在二进制代码中。接下来就要深入其内部了。3. 静态深度剖析揭开二进制层的神秘面纱静态分析的目标是在不运行代码的情况下尽可能理解程序的逻辑结构。我们将以PyInit_rand0m为起点顺藤摸瓜。3.1. 入口函数与模块结构解析用IDA Pro加载rand0m.pyd定位到PyInit_rand0m函数。反编译后按F5我们可能会看到类似下面的结构PyObject *PyInit_rand0m(void) { PyObject *m; // 1. 定义模块方法表 static PyMethodDef module_methods[] { {encrypt, (PyCFunction)encrypt_func, METH_VARARGS, Encrypt data.}, {decrypt, (PyCFunction)decrypt_func, METH_VARARGS, Decrypt data.}, {get_key, (PyCFunction)get_key_func, METH_NOARGS, Get internal key.}, {NULL, NULL, 0, NULL} // 哨兵表示结束 }; // 2. 定义模块结构 static struct PyModuleDef module_def { PyModuleDef_HEAD_INIT, rand0m, // 模块名 NULL, // 模块文档 -1, // 模块状态大小-1表示全局状态 module_methods // 模块方法表 }; // 3. 创建模块对象 m PyModule_Create(module_def); if (m NULL) return NULL; // 4. 可能在这里初始化一些模块级常量或状态 // PyModule_AddObject(m, CONSTANT, PyLong_FromLong(1234)); return m; }这是非常标准的Python C扩展模块初始化模板。关键信息在于module_methods这个数组。它定义了该模块暴露给Python的所有函数。从这里我们看到这个rand0m模块提供了三个函数encrypt,decrypt,get_key。这立刻将我们的分析范围聚焦到了加密解密功能上。3.2. 核心函数逆向encrypt与decrypt现在我们需要深入分析encrypt_func和decrypt_func这两个核心函数。双击函数名跳转到其实现。逆向C函数时的关注点函数签名与参数解析METH_VARARGS表示函数接收PyObject *args元组参数。在函数内部一定会使用PyArg_ParseTuple或PyArg_UnpackTuple来解析Python传递过来的参数。static PyObject *encrypt_func(PyObject *self, PyObject *args) { const char *input_data; int input_len; if (!PyArg_ParseTuple(args, s#, input_data, input_len)) { return NULL; // 解析失败Python层会抛出TypeError } // ... 加密逻辑 }这里的格式字符串s#表示解析一个字节串bytes或str及其长度。确认参数类型和数量是理解函数功能的第一步。识别算法特征核心步骤 这是逆向加密算法的关键。我们需要在反编译的代码中寻找以下模式常量表查找大的、固定的数组通常是256、512字节。这可能是S盒Substitution-box用于AES、DES等、置换表或者预计算的轮常量。循环结构加密算法通常涉及多轮round迭代。寻找带有固定次数如10、12、14对应AES-128/192/25616对应DES的for循环。位操作大量使用与、|或、^异或、左移、右移操作。异或加密、流密码或分组密码的轮函数中非常常见。标准库函数调用如果代码调用了memcpy、memset可能是在处理数据块如果调用了malloc/free可能在动态分配中间状态缓冲区。魔数在代码中直接出现的特定十六进制数可能是算法的初始向量IV、初始常量或算法标识。数据流跟踪 尝试理解输入数据input_data是如何被处理的。它被复制到了哪里经历了哪些变换查表、移位、异或最终输出是如何构建成Python字节串对象PyBytes_FromStringAndSize返回的以rand0m.pyd可能的发现为例假设在分析encrypt_func时我们看到了一个256字节的常量数组S_BOX一个明显的16字节数据块处理循环循环内包含字节替换通过S_BOX、行移位通过固定的索引交换和列混合涉及有限域GF(2^8)上的乘法和异或并且循环轮数为10。那么几乎可以断定这是AES-128算法。如果看到的操作更简单比如只是一个基于某个初始种子的伪随机数生成器PRNG生成一串密钥流然后与明文逐字节异或那么它可能是一个简单的流密码也许是类RC4或自定义的线性同余生成器LCG。3.3. 密钥管理分析get_key_func的作用get_key_func函数通常用于获取内部使用的密钥。逆向这个函数可能发现密钥是硬编码在二进制文件中的固定值。密钥是通过某种确定性算法例如基于文件创建时间、硬盘序列号等“种子”在运行时生成的。密钥需要外部输入get_key只是返回当前状态的密钥。如果密钥是硬编码的我们可以直接在IDA的字符串窗口或反编译代码中看到它。如果是计算生成的就需要逆向其生成算法这可能和加密算法本身一样复杂甚至是同一个PRNG。实操心得在静态分析时善用IDA的“交叉引用”Xrefs功能。选中一个函数名、变量名或常量按X键可以看到所有调用它或被它调用的地方。这对于理清函数间关系、追踪密钥流向至关重要。例如通过交叉引用找到S_BOX在哪里被使用就能快速定位到加密的核心轮函数。4. 动态调试验证让代码“跑”起来说话静态分析给出了一个假设例如这是AES-128加密。动态调试的目的就是验证这个假设并获取运行时才能确定的数据比如动态生成的密钥、中间状态值。4.1. 编写测试Python脚本首先我们需要一个脚本来调用这个pyd模块触发我们感兴趣的代码路径。# test_rand0m.py import rand0m # 导入我们正在分析的pyd模块 # 测试1: 获取密钥 print(“[] Trying to get key...”) try: key rand0m.get_key() print(f” Key obtained: {key} (type: {type(key)})”) if isinstance(key, bytes): print(f” Key hex: {key.hex()}”) except Exception as e: print(f” Failed to get key: {e}”) # 测试2: 加密一段已知明文 print(“[] Testing encryption...”) plaintext b”This is a test message for rand0m pyd!” try: ciphertext rand0m.encrypt(plaintext) print(f” Plaintext: {plaintext}”) print(f” Ciphertext hex: {ciphertext.hex()}”) print(f” Ciphertext length: {len(ciphertext)}”) except Exception as e: print(f” Encryption failed: {e}”) # 测试3: 尝试解密如果解密函数可用 print(“[] Testing decryption...”) try: decrypted rand0m.decrypt(ciphertext) print(f” Decrypted: {decrypted}”) if decrypted plaintext: print(“ [SUCCESS] Decryption matches plaintext!”) else: print(“ [FAILURE] Decryption does NOT match!”) except Exception as e: print(f” Decryption failed (might need key or IV): {e}”)这个脚本会依次尝试调用三个函数并打印结果。运行它我们可以从Python层面观察模块的行为确认函数是否正常工作以及输入输出的大致特征。4.2. 使用x64dbg附加调试启动调试器打开x64dbg。运行Python脚本在命令行或终端中运行python test_rand0m.py。此时Python解释器进程python.exe会启动。附加进程在x64dbg中点击菜单File - Attach在进程列表中找到python.exe注意区分32位和64位我们的pyd是64位的所以要附加64位的python.exe点击附加。定位目标模块/DLL附加成功后程序会暂停。按F9让程序继续运行。然后我们需要让调试器在rand0m.pyd的代码被加载或执行时停下来。方法A下断点在模块加载在x64dbg的符号面板Symbols tab或模块面板Modules tab中查找rand0m.pyd。如果还没加载可以按F9让脚本跑一会儿直到import rand0m执行模块被加载。加载后在模块上右键可以查看其导出函数。直接在PyInit_rand0m、encrypt_func或decrypt_func的地址上下断点。方法B下断点在Python导入函数我们知道Python通过LoadLibrary或类似的API加载pyd。我们可以在kernel32.dll的LoadLibraryW或LoadLibraryA函数上下断点。当断点命中时查看栈上或RCX/RDX寄存器中指向的字符串如果是rand0m.pyd的路径那么再单步执行就会进入模块的初始化流程。调试核心函数成功在encrypt_func入口断下后就可以开始单步F7/F8调试了。关注参数查看PyArg_ParseTuple调用前args参数的内容。内存数据在数据转储Dump窗口跟随指向输入明文和输出缓冲区的指针。关键常量验证静态分析时发现的S_BOX等常量在内存中的值。中间值在加密循环的每一轮后检查数据块的状态看是否符合AES等算法的预期变换。最终输出观察加密后的字节是如何被组装成PyBytes对象并返回的。4.3. 动态与静态结合还原算法通过动态调试我们可以验证静态分析猜想例如单步执行时观察数据是否确实按照AES的字节替换、行移位、列混合的步骤在变化。如果是假设就被证实了。提取运行时数据如果密钥是运行时计算出来的我们可以在计算完成后直接从内存中提取密钥的字节值。理解异常处理如果输入了错误的数据比如长度不对观察程序是如何抛出Python异常的通常是通过PyErr_SetString设置错误信息并返回NULL。踩坑记录调试Python C扩展时一个常见的难点是Python对象的内存管理。在调试器中直接查看PyObject*指针指向的内容可能不直观因为它是复杂的结构体。一个技巧是关注最终返回给Python的PyBytes对象其内部的数据缓冲区指针通常是直接可读的字节序列。另外注意Python的引用计数但在逆向分析中我们通常更关注数据逻辑而非生命周期管理。5. 算法还原与Python实现经过静态和动态分析假设我们已经确定rand0m.pyd实现了一个使用硬编码密钥的AES-128-ECB加密。5.1. 提取关键参数密钥从get_key_func的实现或内存中提取出16字节的密钥。假设通过调试发现密钥是{0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C}这正好是AES标准测试中的一个常用密钥。模式与填充观察加密函数。如果它对每个16字节块独立处理且没有看到明显的初始化向量IV处理那么很可能是ECB模式。查看输入数据长度不是16倍数时的处理是直接报错还是进行了填充常见的可能是PKCS#7填充。在代码中寻找在加密前扩展缓冲区并在末尾添加填充字节的逻辑。S盒与轮常数确认使用的S盒是标准的AES S盒。可以对比内存中提取的256字节数组与公开的AES S盒是否一致。5.2. 编写等价的Python解密脚本现在我们用纯Python借助pycryptodome或cryptography库重新实现这个加密逻辑以验证我们的分析并能够自由地加密解密。# rand0m_solver.py from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad # 用于处理填充 import binascii # 从逆向分析中提取的硬编码密钥 HARDCODED_KEY bytes([0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C]) def encrypt_like_rand0m(plaintext: bytes) - bytes: 模拟rand0m.pyd的加密行为 # 假设使用AES-128-ECB模式PKCS7填充 cipher AES.new(HARDCODED_KEY, AES.MODE_ECB) # 原始pyd模块可能已经处理了填充这里我们显式进行PKCS7填充以确保兼容性 padded_data pad(plaintext, AES.block_size) ciphertext cipher.encrypt(padded_data) return ciphertext def decrypt_like_rand0m(ciphertext: bytes) - bytes: 模拟rand0m.pyd的解密行为 cipher AES.new(HARDCODED_KEY, AES.MODE_ECB) padded_plaintext cipher.decrypt(ciphertext) # 移除PKCS7填充 plaintext unpad(padded_plaintext, AES.block_size) return plaintext def main(): # 测试数据 test_msg b”This is a test message for rand0m pyd!” print(f”Original: {test_msg}”) # 使用我们的实现加密 my_cipher encrypt_like_rand0m(test_msg) print(f”Our Ciphertext (hex): {my_cipher.hex()}”) # 使用我们的实现解密 my_decrypted decrypt_like_rand0m(my_cipher) print(f”Our Decrypted: {my_decrypted}”) assert my_decrypted test_msg, “Decryption failed!” print(“[✓] Our implementation works.”) # 可选与原始rand0m.pyd的输出对比 # 需要先运行之前的test_rand0m.py获取其输出的ciphertext_hex # original_ciphertext_hex “...” # if my_cipher.hex() original_ciphertext_hex: # print(“[✓] Perfect match with original pyd!”) # else: # print(“[!] Output mismatch. Need to check mode/padding/algorithm details.”) if __name__ “__main__”: main()运行这个脚本如果它能成功加密解密并且加密结果与直接调用rand0m.pyd的encrypt函数得到的结果完全一致那么恭喜你已经成功逆向并复现了该模块的加密算法。5.3. 处理变种与混淆实际情况可能更复杂自定义S盒如果S盒不是标准的你需要将提取的256个字节值定义为一个列表并实现一个基于此S盒的替换函数。AES的其他步骤行移位、列混合、轮密钥加保持不变但替换操作需使用你的自定义S盒。修改的轮函数可能算法只是“类AES”但轮常数、列混合矩阵或被修改。你需要仔细对照静态分析出的每一步操作。白盒加密在一些强保护场景下可能会遇到白盒加密实现将密钥和算法深度混淆查表操作巨大且复杂。逆向这种实现极其耗时需要耐心地跟踪数据流识别出加密轮次和基本的混淆门如线性变换、异或。这超出了入门范畴但核心思想仍是识别模式和数据变换。6. 总结与拓展思考这次对rand0m.pyd的逆向工程走完了一个从黑盒二进制文件到清晰算法逻辑的完整流程。我们经历了环境准备、静态分析、动态调试、算法还原和代码复现。关键在于将高级的Python调用与底层的C/C实现联系起来并通过特征识别来定位算法。对于CTF解题一旦还原出算法和密钥flag往往就是解密一段给定的密文。对于软件安全研究这种分析有助于理解第三方闭源模块的行为评估其安全性例如使用硬编码密钥是非常不安全的行为。一些延伸的思考和建议自动化尝试对于简单的异或或已知算法的加密可以编写脚本暴力破解密钥或参数。但在CTF中通常需要分析。关注初始化函数PyInit_*函数里除了定义方法有时还会执行一些全局初始化比如生成密钥、读取资源文件等这里也可能藏有重要逻辑。字符串与资源不要忽略文件中的其他资源。有时密钥或关键逻辑会以加密形式存储在文件的资源段或某个附加数据段中在初始化时解密加载。对抗反调试一些商业保护工具加固的pyd可能会检测调试器。你需要熟悉常见的反调试技巧如IsDebuggerPresent,CheckRemoteDebuggerPresent, 时间差检测等并绕过它们。x64dbg的插件如ScyllaHide可以帮助隐藏调试器。逆向工程就像解谜需要耐心、细致的观察和合理的猜测验证。从rand0m.pyd这样一个具体目标入手将理论知识与实战操作结合是提升逆向技能的最佳途径。希望这个详细的流程能为你打开一扇窗让你在探索二进制世界时更有方向和信心。