Python pickle反序列化进阶:绕过R操作码黑名单与Gadget链构造

📅 2026/7/1 9:06:45
Python pickle反序列化进阶:绕过R操作码黑名单与Gadget链构造
1. 从一道题看Python pickle反序列化的“进阶”玩法最近在复盘一些CTF的Web题和Pwn题发现Python的pickle反序列化考点已经从简单的__reduce__执行命令进化出了不少“花活”。很多题目开始结合魔术方法、属性访问、操作码opcode绕过黑名单甚至利用Python自身的特性来构造利用链。这不再是“知道__reduce__就能拿分”的时代了。今天我们就从一个典型的“进阶”例题入手拆解pickle反序列化漏洞的深度利用技巧特别是如何绕过对危险操作码比如R的过滤。我会假设你已经了解pickle反序列化的基本风险即通过pickle.loads()加载恶意序列化数据可以导致任意代码执行。我们这次直接进入实战攻坚环节。这个场景在CTF中非常常见题目给你一个在线的Python服务它接收你输入的序列化数据然后进行反序列化。但是服务端可能会检查你提交的pickle字节流禁止使用R即REDUCE这个最直接的操作码来调用函数。这就像把最顺手的大门给锁上了。我们的目标就是在不使用R操作码的情况下依然实现命令执行或文件读取最终拿到flag。这需要我们深入理解pickle协议栈和Python对象模型。2. 理解Pickle协议栈与操作码黑名单绕过2.1 Pickle反序列化到底在做什么在讨论绕过之前我们必须清楚pickle在反序列化时本质上是在根据一串操作码opcode指令逐步“重建”一个对象。这个过程由一个叫做“栈机”的虚拟机执行。它有一个栈stack和一个内存memo操作码就是指挥这个虚拟机如何操作的命令。比如最简单的利用__reduce__生成的payload其操作码序列通常包含c(GLOBAL) 导入模块和函数如os.system。((MARK) 标记参数开始。V(UNICODE) 或S(STRING) 压入字符串参数如cat /flag。t(TUPLE) 将栈顶的多个元素组合成元组。R(REDUCE)这是关键一步。它调用栈顶第二项一个可调用对象如os.system参数为栈顶项一个元组如(cat /flag,)并将结果压回栈顶。如果黑名单过滤了R就等于禁止了直接调用函数。但pickle协议提供了几十个操作码我们的思路就从“如何用其他操作码组合达到与R相同的效果”展开。2.2 寻找R的替代品o(OBJ) 与i(INST) 操作码这是绕过R过滤的核心思路。R并非执行可调用对象的唯一途径。Pickle协议中还有两个用于构建对象的重要操作码o(OBJ) 这个操作码用于使用__new__和__init__方法构建对象。它的执行流程是从栈中弹出两个元素第一个是参数元组args第二个是类class。然后它会执行cls.__new__(cls, *args)和obj.__init__(*args)。关键在于__new__是一个静态方法它的调用不依赖于R。i(INST) 这是一个旧协议的操作码功能与o类似也是用于实例化对象。我们的突破口就在__new__方法上。在Python中许多内置类或常用类的__new__方法其行为可以被我们利用。例如tuple、list、dict、object.__new__本身甚至一些标准库类。但最经典、最直接的是os._wrap_close类 或subprocess.Popen.__new__。为什么是os._wrap_close在os模块中有一个内部类叫_wrap_close。当你使用os.popen()时返回的就是这个类的实例。它的__new__方法会接受一个参数命令字符串并执行它这简直是为我们量身定做的。通过o操作码调用os._wrap_close.__new__并传入命令字符串作为参数就能在不触发R的情况下执行命令。2.3 手工构造绕过R的Payload让我们抛开自动化工具手工理解一下如何构造这样的payload。我们需要构造的指令序列如下将os._wrap_close类压入栈。将我们想要执行的命令字符串如cat /flag压入栈。将命令字符串包装成一个元组压入栈因为__new__接受的是*args。使用o(OBJ) 操作码它会弹出栈顶的元组和类然后调用cls.__new__(cls, *args)。对应的pickle操作码序列protocol 0人类可读格式大致是c__builtin__ getattr (c__builtin__ __import__ Sos tR(S_wrap_close tR(c__builtin__ __import__ Sos tRSpopen tR.等等这里出现了R这是因为我们用了getattr和__import__的经典链而getattr的调用依赖R。如果黑名单也过滤了通过c导入__builtin__再getattr的方式呢或者更彻底一点我们如何完全不使用R来获取os._wrap_close思路升级利用GLOBAL(c) 直接导入c操作码可以直接导入模块下的属性。对于os._wrap_close我们可以尝试直接使用cos\n_wrap_close\n。但_wrap_close可能不是一个模块顶级的属性。更可靠的方式是利用__import__的返回值。但调用__import__(os)又需要R...这里就引出了另一个技巧利用builtins.exec或eval的变种。但如果我们能找到一个在导入时就被执行代码的路径呢这就是sys.modules的妙用。不过在高度受限的环境下最稳健的绕过方式往往是组合利用多个低风险操作码来模拟函数调用。一个经过实战检验的、不使用R的payload构造方法如下使用protocol 0以便于阅读# 目标执行 os.system(id) # 方法利用 tuple.__new__ 和 list.__setitem__ 的副作用不这太复杂。 # 更直接的方法利用 b (BUILD) 操作码和 __setstate__ 或 __dict__ 更新。实际上完全不用R而达到代码执行在CTF中更常见的出题点是利用__setstate__方法。当一个对象被b(BUILD) 操作码反序列化时如果该类定义了__setstate__方法并且序列化数据中包含了state那么__setstate__(state)就会被调用。如果我们能控制传入的state并且__setstate__方法内部有危险操作比如exec或eval就能实现利用。例如我们可以构造一个恶意类import pickle import os class Evil: def __setstate__(self, state): os.system(state) # 正常序列化 payload pickle.dumps(Evil()) print(payload.hex())观察生成的payload你会发现它使用了b操作码和__setstate__。但问题来了题目环境通常不会反序列化我们自定义的类因为服务端没有这个类的定义。除非...我们利用服务端已有的、并且__setstate__方法可被利用的类。这就进入了“Gadget链”的领域。我们需要在Python的标准库或题目代码中寻找一条从某个可被实例化的类开始通过一系列属性访问、方法调用最终能执行代码的链。这类似于PHP反序列化的POP链但在Python pickle中由于操作码的直接操控能力链的构造更加灵活。注意手工构造复杂的opcode链极其繁琐且容易出错。在实战中CTF选手通常会使用工具辅助生成如pickletools分析、或编写脚本拼接。但理解其原理是应对变形题目的根本。3. 例题实战Opcode绕过与链式构造假设我们遇到一道题其核心代码如下模拟import pickle import io import sys class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): # 只允许导入部分安全模块 if module in [__main__, utils] and not name.startswith(_): return super().find_class(module, name) # 完全禁止包含os, subprocess, eval, exec等危险模块 for bad in [os, subprocess, builtins, eval, exec]: if bad in module: raise pickle.UnpicklingError(fForbidden module: {module}) raise pickle.UnpicklingError(fGlobal {module}.{name} is forbidden) def safe_loads(data): 安全地反序列化并过滤R操作码 # 过滤REDUCE操作码 (opcode 0x52对应字符R) if bR in data: raise pickle.UnpicklingError(Dangerous opcode R detected!) file io.BytesIO(data) unpickler RestrictedUnpickler(file) return unpickler.load() # 服务端接收用户输入的序列化数据 user_input input(Input your pickle data (hex): ) try: data bytes.fromhex(user_input) obj safe_loads(data) print(Object loaded:, obj) except Exception as e: print(Error:, e)3.1 题目限制分析黑名单过滤R操作码 直接使用__reduce__生成的payload会被拦截。受限的find_class 只能导入__main__和utils模块下不以_开头的属性。这意味着我们无法直接导入os、subprocess等危险模块。目标 绕过以上限制实现任意命令执行或文件读取。3.2 利用思路拆解既然不能直接导入危险模块我们就需要在允许的模块__main__或utils中寻找可利用的“跳板”。题目通常会在__main__或提供的utils模块中定义一些类。我们的任务是审计这些类的代码寻找可以被pickle操作码触发的危险方法。常见的危险方法触发器__setstate__ 如前所述在b操作码时触发。__setattr__ 当对象属性被设置时触发。BUILD操作码会设置对象的__dict__。__getattr__/__getattribute__ 当访问不存在的属性或任何属性时触发。某些操作码如g/GET会触发属性获取。__repr__,__str__ 当对象被打印时触发。如果反序列化后对象被print或str()处理可能触发。__call__ 当对象被作为函数调用时触发。这通常需要R操作码但也许可以通过其他路径触发。__init__或__new__ 对象初始化时触发。o操作码会调用它们。构造链的步骤起点 我们需要一个在允许范围内的类作为反序列化的入口。通常我们会让pickle从__main__开始加载一个无害的类实例。传递 通过操作码如bBUILD修改这个实例的属性__dict__使其某个属性指向另一个对象或特定的值。这个修改过程可能会触发__setattr__。跳转 在__setattr__或__setstate__方法内部可能存在对某个属性的调用或执行。如果我们能控制这个属性比如让它成为一个字符串其内容是可执行的Python代码并且方法中使用了eval()或exec()尽管危险模块被禁但代码中可能已经存在就可能执行代码。执行 最终执行我们注入的代码。如果环境中没有eval/exec我们可能需要寻找其他路径比如利用os.popen的替代品如open(/flag).read()但这又需要文件操作函数。一个假设的utils模块例子# utils.py class Config: def __init__(self): self.data {} def __setstate__(self, state): # 危险直接使用exec执行state中的‘setup’字段 if setup in state: exec(state[setup]) self.data state.get(data, {})对于这个Config类我们的攻击payload就可以这样构造序列化一个utils.Config对象。在序列化数据中通过操作码控制使得其state为一个字典{setup: __import__(os).system(cat /flag), data: {}}。当b操作码应用到这个对象并设置state时就会触发__setstate__进而执行exec(__import__(os).system(...))。注意这里exec是utils.Config.__setstate__方法中已经存在的我们只是控制了它的参数因此绕过了find_class对builtins.exec的导入限制。3.3 Payload构造与调试使用pickletools可以让我们清晰地看到和编辑opcode。import pickle import pickletools import utils # 假设utils模块如上 # 1. 创建一个正常的Config对象并序列化 obj utils.Config() original_payload pickle.dumps(obj, protocol0) # 使用protocol 0便于阅读 print(Original opcodes:) pickletools.dis(original_payload) # 2. 分析并手动修改payload # 我们需要在payload中插入设置state的opcode。 # 通常一个带state的对象的序列化结尾部分会包含一个字典和b操作码。 # 我们可以先创建一个带恶意state的Config对象看看payload长什么样。 malicious_state {setup: __import__(os).system(id), data: {}} obj_malicious utils.Config() # 我们不能直接设置obj.__setstate__被调用但可以通过pickle的特定方式。 # 更简单的方法直接序列化一个 (obj, state) 的结构但pickle不支持。 # 正确方法我们需要理解默认的序列化格式然后手动拼接。 # 一个取巧但有效的方法利用pickle的“篡改”能力。 # 先序列化一个空Config得到基础payload。 base pickle.dumps(utils.Config(), protocol0) # 使用pickletools分析找到写入__dict__或应用b操作码的地方然后进行二进制替换。 # 这需要深厚的pickle协议知识在CTF中通常直接编写opcode脚本。 # 3. 编写opcode脚本手动构造概念性 # 以下是一个高度简化的概念性opcode序列并非直接可运行用于说明思路。 opcodes bc__main__ Config o # 现在栈顶是一个Config实例 ( # MARK开始构造state字典 Ssetup S__import__(os).system(cat /flag) Sdata d # 开始构造字典 . # ... 更多字典项 d # 结束字典构造此时栈顶为 (inst, dict) b # BUILD操作码调用inst.__setstate__(dict) . # STOP # 使用pickletools.assemble可以将文本opcode编译为二进制但格式非常严格。在实际CTF中选手往往会编写一个Python脚本使用pickletools.genops分析、pickle.PickleBuffer如果需要或直接字节操作来精细构造payload。这个过程充满了尝试和调试。实操心得遇到opcode过滤的题目不要急于写exp。首先用pickletools.dis分析题目正常功能生成的payload理解其对象结构。然后重点审计允许导入的类的方法。找到潜在的危险方法后思考哪些opcodeb,s,u,i,o等可以触发它。最后像搭积木一样从栈空状态开始一步步推演需要用哪些opcode将所需的数据和对象引用放到栈上正确的位置。4. 高级技巧利用__new__与__init__的差异进行绕过我们回到最初提到的o(OBJ)操作码。它先调用__new__再调用__init__。这里存在一个细微但可能被利用的差异__new__是一个静态方法而__init__是一个实例方法。在某些类的实现中__new__可能包含一些逻辑而__init__只是简单的赋值。更重要的是o操作码调用__new__时传入的参数是我们在pickle流中提供的。如果我们能找到一个类其__new__方法会处理参数并产生副作用比如执行命令而__init__方法无害甚至不存在比如是object.__init__那么即使用o操作码也能在__init__被调用前就完成利用。os._wrap_close就是一个完美例子。它的__new__方法会执行命令。所以即使用o操作码只要成功调用了os._wrap_close.__new__命令就会执行。__init__是否被调用、是否报错都无关紧要了因为代码已经执行。那么如何在不使用R的情况下将os._wrap_close类压入栈如果find_class完全禁止了os模块此路不通。但如果过滤不严比如只检查了os in module但没检查posix我们可以尝试导入posixos模块的底层模块。或者如果环境中有importlib可以尝试链式导入。另一个思路是利用已导入到内存中的模块引用。Python在sys.modules中缓存了所有已导入的模块。如果我们能通过允许的模块如__main__访问到sys然后通过sys.modules[os]拿到os模块再获取_wrap_close就可以绕过find_class的检查。因为find_class只在我们通过c或GLOBAL等opcode导入时触发而通过属性访问getattr从已导入模块中获取对象可能不经过find_class取决于Unpickler的具体实现。在标准的pickle.Unpickler.find_class中任何通过c/GLOBAL获取全局对象的操作都会触发它。但如果我们能用其他opcode组合模拟出属性访问的过程呢这非常困难。更实际的CTF场景是find_class的黑名单有遗漏。例如只禁了os但没禁os.path。而os.path下有一些函数如os.path.abspath用处不大但os.path本身是os模块的命名空间通过它也许能访问到os模块的属性不一定因为os.path可能是一个独立的模块。需要具体测试。5. 常见问题排查与实用技巧实录在构造和调试pickle反序列化payload时你会遇到各种错误。这里记录一些典型问题和解决方法。问题1pickle.UnpicklingError: stack underflow原因 操作码执行过程中试图从空栈中弹出元素。比如你用了t(TUPLE)操作码但之前没有用((MARK)标记足够多的元素在栈上。排查 使用pickletools.dis(your_payload)逐条检查opcode。确保每个需要操作栈的操作码执行前栈的状态符合预期。画一个简单的栈状态变化图非常有帮助。问题2AttributeError: Cant get attribute XXX on module __main__原因 在反序列化时Unpickler无法在你的当前命名空间或通过find_class找到类XXX。这通常发生在你序列化了一个自定义类的实例然后在另一个没有定义该类的环境中反序列化。解决 在CTF中这意味着你试图让服务端反序列化一个它不存在的类。你必须使用服务端已有的类白名单内的类作为利用链的起点。问题3Payload在本地成功在远程服务器上失败原因Python版本/协议差异 你用的pickle协议版本如protocol 4服务器不支持或者某些opcode的行为在不同Python版本间有细微差别。尽量使用兼容性最好的protocol 0或2。环境差异 服务器可能缺少某个模块或者模块版本不同导致类属性不一致。过滤规则 你对服务器的过滤规则理解有误它可能过滤了更多操作码或模块。调试在本地用Docker或虚拟机搭建一个与题目描述尽可能一致的环境。将服务器的错误信息与本地对比。如果服务器返回了详细的错误栈仔细阅读。尝试发送最简单的、不带任何攻击性的合法payload看是否能正常反序列化以验证基础功能。问题4如何高效地构造复杂的opcode链不要纯手工写 使用pickletools.genops分析现有payload理解结构。编写辅助函数 编写函数来生成常见的opcode序列块比如“将字符串压栈”、“构建字典”、“获取全局变量”等。使用汇编思路 将pickle虚拟机想象成一个简单的汇编器。先确定最终栈上需要什么例如一个执行了命令的_wrap_close实例然后倒推每一步需要什么操作码。利用pickletools.optimize 它可以优化掉一些冗余的opcode让payload更短。有时题目有长度限制这个功能就很有用。实用技巧使用pickletools进行深度分析import pickle import pickletools def analyze_and_optimize(payload): 分析并优化payload print( Original Disassembly ) pickletools.dis(payload) # 获取操作码列表 ops list(pickletools.genops(payload)) print(f\n Total {len(ops)} opcodes ) # 尝试优化去除BINPUT等备忘录操作可能使payload不可用需谨慎 # optimized pickletools.optimize(payload) # print(\n Optimized Disassembly (可能破坏依赖memo的payload) ) # pickletools.dis(optimized) # return optimized return payload # 示例分析一个简单对象的序列化结果 data pickle.dumps([1, 2, 3], protocol0) analyze_and_optimize(data)一个经典的CTF绕过R的Payload示例框架假设题目允许导入os但过滤了R。我们可以构造如下payloadprotocol 0来调用os.systemc__builtin__ __import__ Sos tR(Ssystem tR(Scat /flag tR.看这里还是有R。我们需要把tR构建元组并REDUCE替换掉。我们可以尝试用o来调用一个会执行命令的类的__new__。但如果我们坚持要调用os.system有没有办法不用R几乎不可能因为调用一个函数对象必然需要类似R的机制。所以我们必须转向其他不用R也能执行命令的入口点这就是为什么os._wrap_close和subprocess.Popen如此重要。一个真正不使用R调用os._wrap_close的payload雏形需要根据实际环境调整cos _wrap_close (Sid o.解释c 导入os._wrap_close类并压栈。( 标记参数开始。Sid 将命令字符串id压栈。t 将之前标记的所有元素这里只有id弹出并组合成元组(id,)然后压回栈顶。此时栈 [..., _wrap_close_class, (id,)]o 弹出栈顶两个元素元组(id,)和类_wrap_close。然后调用_wrap_close.__new__(_wrap_close, id)从而执行命令。. 停止。这个payload成功的关键在于find_class允许导入os._wrap_close且没有过滤o操作码。6. 防御视角与出题思路延伸从防御者或出题人的角度看完全杜绝pickle反序列化漏洞的唯一安全方法是永远不要反序列化不受信任的数据。如果业务必须使用可以考虑以下加固措施使用RestrictedUnpickler并严格白名单 不是黑名单是白名单。只允许反序列化业务逻辑明确需要的、安全的几个类。find_class中只放行白名单内的模块和类名。签名验证 如果序列化数据来自可信源可以先对数据进行数字签名验证完整性后再反序列化。替代方案 考虑使用更安全的序列化格式如JSON、YAML注意YAML也有反序列化风险、marshal仅用于Python内部或msgpack。从CTF出题角度pickle反序列化的进阶考点可以非常多操作码黑名单/白名单 如本题过滤R甚至过滤c、o、i、b等。沙箱逃逸 结合Python沙箱如禁用__builtins__、限制模块导入要求选手在有限的上下文中构造利用链。混合题型 与SSTI模板注入、XXEXML外部实体注入等结合pickle的payload可能作为触发其他漏洞的载体。隐写与编码 将pickle payload进行多重编码hex、base64、rot等或隐藏在图片、流量中。利用Python特性 如利用__eq__、__contains__等魔术方法在比较操作时触发代码执行结合pickle的反序列化过程。理解pickle反序列化的进阶利用不仅仅是掌握几个payload更是对Python对象生命周期、操作码虚拟机和安全编程模型的深度认知。每一次绕过尝试都是一次对Python解释器行为的探索。在实战中保持耐心细致分析善用工具从错误信息中寻找线索是攻克这类题目的不二法门。最后记住在合法授权的环境中进行测试切勿将这些技术用于非法攻击。