TL;DR
LangGraph------月下载超 5000 万的 Agent 编排框架------的持久化层(checkpointer)在 2025-2026 年连续爆出三个漏洞,Check Point Research 将其中两个串联成完整远程代码执行(RCE)链:
- CVE-2025-67644 :SQLite checkpointer 的
filter参数拼接进 SQL 时未转义 → SQL 注入,可往查询结果里塞入攻击者伪造的 checkpoint 行; - CVE-2026-28277 :checkpoint 加载时的 msgpack 反序列化允许按
module:callable(args)重建任意 Python 对象 → 攻击者控制的序列化数据可触发os.system()等任意调用; - CVE-2026-27022:Redis checkpointer 的 RediSearch 查询同样存在注入,可绕过 thread 隔离读到他人会话。
一句话总结:Agent 的「记忆」存哪,攻击面就在哪。 如果你的应用把用户输入直接传给 get_state_history(filter=...),攻击者不需要拿到数据库写权限,就能借 SQL 注入写入恶意 checkpoint,等下次状态恢复时执行任意命令。
修复版本:langgraph-checkpoint-sqlite >= 3.0.1、langgraph >= 1.0.10、langgraph-checkpoint-redis >= 1.0.2。
背景:checkpointer 是 Agent 的「记忆」
LangGraph 的核心抽象是状态图:每个节点执行后把状态写入持久层,多轮对话、断点恢复、人工审批都依赖这份状态。这份状态存在 checkpointer(检查点器)里,支持 SQLite / PostgreSQL / Redis 等后端。
checkpoint 存的不只是「变量」,还包括:会话上下文、工具调用中间结果、待恢复的执行位置、多 Agent 间的消息。攻击者拿到 checkpoint = 拿到 Agent 的记忆;篡改 checkpoint = 给 Agent 植入记忆;加载被篡改的 checkpoint = 让 Agent 执行攻击者指定的代码。 这就是这三个漏洞的杀伤逻辑。
官方对受影响面的定义:自托管 LangGraph + 暴露了 get_state_history()(接受用户可控 filter)+ 使用 SQLite 或 Redis checkpointer。LangChain 托管的 LangSmith Deployment 走 PostgreSQL 且不暴露该接口,官方声明不受影响。
漏洞 1:SQLite checkpointer 的 SQL 注入(CVE-2025-67644)
问题代码
SQLite checkpointer 用一张 checkpoints 表存状态,metadata 列存 JSON(如 {"user_id": "alice", "step": 1})。查询历史时,list() 方法的 filter 参数用来按 metadata 过滤:
python
def list(self, config, *, filter=None, before=None, limit=None):
# filter 形如 {"user_id": "alice"}
...
filter 字典被传给内部函数 _metadata_predicate() 拼 SQL:
python
# 漏洞代码(CVE-2025-67644)
for query_key, query_value in filter.items():
operator, param_value = _where_value(query_value)
predicates.append(
f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}"
# ↑ query_key 直接拼进字符串,没转义!
)
param_values.append(param_value)
query_key 来自调用方的 filter 字典 key。如果 key 里带一个单引号 ',就能逃出 JSON path 字符串,注入任意 SQL。
从注入到伪造 checkpoint
最终执行的查询长这样:
sql
SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id,
type, checkpoint, metadata
FROM checkpoints
WHERE ... -- ← 注入点
ORDER BY checkpoint_id DESC
攻击者注入 UNION SELECT 追加自己的行:
sql
SELECT ... FROM checkpoints
WHERE json_extract(CAST(metadata AS TEXT), '$.x') = 'y'
UNION SELECT 'thread1','ns','checkpoint1',NULL,
'msgpack', X'恶意序列化字节', '{}' --
ORDER BY checkpoint_id DESC
UNION SELECT 返回的伪造行里,checkpoint 列装着攻击者构造的 msgpack 二进制。查询结果被循环处理时,每一行都会走到反序列化:
python
async for (thread_id, checkpoint_ns, checkpoint_id, ...,
type, checkpoint, metadata) in cur:
yield CheckpointTuple(
...
self.serde.loads_typed((type, checkpoint)), # ← 反序列化恶意数据
)
到这里,SQL 注入已经升级成了「任意反序列化」------攻击者控制了要反序列化的字节流。接下来需要第二个漏洞完成最后一跳。
漏洞 2:msgpack 不安全反序列化(CVE-2026-28277)
反序列化路径
checkpoint 的序列化类型有几种:null / bytes / json / msgpack / pickle。默认走 msgpack(ormsgpack,Rust 实现的高性能 Python 绑定)。反序列化入口:
python
def loads_typed(self, data):
type_, data_ = data
if type_ == "json":
return json.loads(data_, object_hook=self._reviver)
elif type_ == "msgpack":
return ormsgpack.unpackb(
data_,
ext_hook=self._unpack_ext_hook, # ← 自定义扩展钩子
option=ormsgpack.OPT_NON_STR_KEYS,
)
...
msgpack 允许自定义扩展类型 (ext type),LangGraph 用它来序列化自定义 Python 对象。反序列化时 ext 数据交给 _msgpack_ext_hook:
python
# 漏洞代码(CVE-2026-28277 之前)
def _msgpack_ext_hook(code: int, data: bytes):
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
tup = ormsgpack.unpackb(data, ext_hook=_msgpack_ext_hook, ...)
# tup = (module, name, arg)
return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])
这一行 getattr(importlib.import_module(...), ...)(...) 的含义是:从 msgpack 数据里取出模块名、函数名、参数,import 并调用 。数据是攻击者控制的,于是传 ("os", "system", "echo PWN > /tmp/pwned.txt") 就会执行 os.system("echo PWN > /tmp/pwned.txt")。
完整攻击链(Check Point 披露)
- 应用暴露
get_state_history(filter=用户输入),filter 未清洗直接传给 checkpointer; - 攻击者构造恶意 filter,利用漏洞 1 的 SQL 注入
UNION SELECT往查询结果里塞一行checkpoint = 恶意 msgpack; - 应用遍历查询结果时调用
loads_typed()反序列化这行数据; _msgpack_ext_hook按module:callable(args)重建对象 → 执行os.system()→ 远程代码执行。
披露时间线:2025-11-19 同步给 LangChain 团队;2025-12-10 SQLi 修复(langgraph-checkpoint-sqlite 3.0.1);2026-03-05 msgpack 反序列化修复(langgraph-checkpoint 4.0.1 / langgraph 1.0.10)。Check Point Research 于 2026-06 发布完整研究《From SQLi to RCE -- Exploiting LangGraph's Checkpointer》。
漏洞 3:Redis checkpointer 注入(CVE-2026-27022)
同类问题也在 Redis checkpointer(langgraph-checkpoint-redis)出现:RedisSaver / ShallowRedisSaver 的 list() 构造 RediSearch 查询时,直接把 filter 的 key/value 拼进去,没有转义 RediSearch 特殊字符。RediSearch 里 | 是 OR 操作符,且有特定优先级:
less
# 攻击者注入的 filter value:
x) | (@thread_id:{*
# 生成的查询:
(@thread_id:{合法线程}) (@source:{x}) | (@thread_id:{*})
# 因优先级等价于:
(@thread_id:{合法线程} AND @source:{x}) OR (@thread_id:{*})
第二个子句匹配所有线程 的 checkpoint------thread 隔离被完全绕过,多租户应用里 A 用户能读到 B 用户的会话数据(横向越权 + 数据泄露)。修复版 1.0.2 引入了 escapeRediSearchTagValue() 对所有特殊字符做转义。
为什么说「Agent 的记忆层就是攻击面」
把三个漏洞放回 Agent 生产架构里看,真正值得警惕的是普遍存在三个认知盲区:
| 盲区 | 实际情况 |
|---|---|
| 「数据库在内网,没人能写」 | CVE-2025-67644 让只读接口变成写入口------注入发生在 SELECT 阶段,不需要任何写权限 |
| 「checkpoint 只是 JSON,很安全」 | 默认 msgpack + 自定义 ext 钩子 = 反序列化即代码执行,比 pickle 更隐蔽 |
| 「多租户靠 thread_id 隔离」 | CVE-2026-27022 证明隔离查询可以被注入改写,隔离边界形同虚设 |
Agent 框架把「持久化状态」从实现细节升级成了安全边界。上一代 Web 应用的教训(ORM 注入、反序列化 RCE)在 Agent 层原样重演,只是受害者从「数据库行」变成了「对话记忆」。这不是 LangGraph 一家的问题------Check Point 同期在 CrewAI / AutoGen / Microsoft Agent Framework 等主流框架也发现了同类 checkpoint 序列化问题,属于框架级别的系统性问题。
修复与自查清单
升级版本
| 漏洞 | 修复版本 | 升级对象 |
|---|---|---|
| CVE-2025-67644(SQLite SQLi) | >= 3.0.1 | langgraph-checkpoint-sqlite |
| CVE-2026-28277(msgpack 反序列化) | >= 1.0.10 / >= 4.0.1 | langgraph / langgraph-checkpoint |
| CVE-2026-27022(Redis 注入) | >= 1.0.2 | langgraph-checkpoint-redis |
代码层加固(不能只靠升级)
修复版引入了白名单机制,但要真正生效需要确认你的部署方式:
python
# 1. 环境变量开启严格模式(推荐生产必开)
# LANGGRAPH_STRICT_MSGPACK=true → JsonPlusSerializer 默认只反序列化内置安全类型
# 未开启时默认是 warn-and-allow(放行但告警)------不是真防护
# 2. 显式传 allowlist(精确到 module.class)
from langgraph.checkpoint.sqlite import SqliteSaver
saver = SqliteSaver.from_conn_string("foo.db")
serializer = saver.serde.with_msgpack_allowlist(
[("myapp.models", "MyState"), ("myapp.models", "ChatMessage")]
)
bash
# 3. 部署前自查:是否暴露了用户可控的 filter?
grep -rn "get_state_history" --include="*.py" app/ | head
grep -rn "filter=" --include="*.py" app/ | grep -i "request\|user\|input" | head
设计层建议
- 永远不要 把 HTTP 请求参数直接透传给
filter;服务端只允许白名单 key(如user_id、session_id),值做类型校验; - checkpoint 存储视为完整性敏感资产:限制写权限、定期轮换凭据、怀疑被入侵时立即轮换;
- 生产开
LANGGRAPH_STRICT_MSGPACK=true,先小流量验证 schema 兼容性再全量; - 多租户隔离不要只依赖 thread_id 查询过滤,应在持久层加真正的租户维度约束。
checkpoint 是怎么工作的:先看懂数据结构
要理解攻击为什么成立,得先看清 checkpoint 的存储模型。LangGraph 的 SQLite 表结构:
sql
CREATE TABLE checkpoints (
thread_id TEXT NOT NULL, -- 会话 ID,多轮对话同一 thread
checkpoint_ns TEXT NOT NULL DEFAULT '', -- 命名空间(子图/多 Agent 隔离)
checkpoint_id TEXT NOT NULL, -- 版本号(UUID)
parent_checkpoint_id TEXT, -- 父版本 → 构成版本链
type TEXT, -- 序列化类型: json/msgpack/pickle
checkpoint BLOB, -- 序列化后的状态本体
metadata BLOB, -- JSON 元数据
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
);
每个节点执行一步就写一条新 checkpoint,parent_checkpoint_id 指向前一条------所以一个 thread 的全部历史是一条版本链 。get_state_history() / list() 就是沿着这条链往回翻页,支持按 metadata 过滤(比如「只看 user_id=alice 的会话」)。
两个关键点:
-
thread_id是租户隔离的第一道闸 。多租户 SaaS 把不同用户映射到不同 thread_id,靠查询时强制thread_id=当前用户来防止 A 看到 B。CVE-2026-27022 打破的正是这道闸------注入| (@thread_id:{*})后,查询结果同时包含「本线程 + 所有线程」,而框架对结果不再校验 thread_id 是否匹配。 -
checkpointBLOB 是框架自动反序列化的。加载历史时框架不知道数据是「自己写的」还是「攻击者塞的」------它信任存储层返回的每一行。CVE-2025-67644 就是利用这个信任:SELECT 阶段注入 UNION 行,框架把这行当合法 checkpoint 反序列化。
所以攻击的本质是:借「查询历史」这个只读接口,把恶意字节送进「自动反序列化」这个危险管道。
实战推演:一个多租户 Agent SaaS 怎么被打穿
假设你在运营一个「AI 客服 Agent」SaaS:每个企业一个租户,每个终端用户一个 thread,Agent 状态用 LangGraph SQLite checkpointer 持久化。你提供 REST API 让前端查询某个会话的完整历史(用于「重新打开对话」功能):
python
# app.py ------ 你的 API 代码
@app.get("/api/threads/{thread_id}/history")
def thread_history(thread_id: str, user: User = Depends(get_current_user)):
# 你做了 thread 归属校验:只能看自己的 thread
ensure_owns_thread(user, thread_id)
# 但 filter 直接透传了查询参数!
filter_json = request.query_params.get("filter", "{}")
filter_dict = json.loads(filter_json)
return list_graph_history(
thread_id,
filter=filter_dict, # ← 漏洞入口
)
攻击者构造请求:
http
GET /api/threads/t_1001/history?filter={"user_id":"x') UNION SELECT 't_evil','','c_1',NULL,'msgpack',X'<恶意bytes>','{}' --"}
发生了什么:
| 步骤 | 位置 | 结果 |
|---|---|---|
| 1 | API 层 | ensure_owns_thread 校验通过------攻击者确实在查自己的 thread t_1001 |
| 2 | SQL 层 | filter 里的单引号逃逸 json path,UNION SELECT 注入成功 |
| 3 | 存储层 | 查询返回 t_1001 的真实历史 + 攻击者伪造的 t_evil 行 |
| 4 | 反序列化 | 框架遍历结果,对伪造行的 msgpack BLOB 调 loads_typed() |
| 5 | ext 钩子 | _msgpack_ext_hook 解析出 ("os","system","touch /tmp/pwned") 并调用 |
| 6 | 结果 | 服务器执行任意命令------而你全程只调用了一个「查询历史」接口 |
防御视角看这条链:每一层都以为自己在做正常事------API 层校验了归属,框架层只是「查个历史」,反序列化是「加载自己存的状态」。漏洞不在任何单一逻辑错误,而在层与层之间的信任假设:API 层信任 filter 是干净的 dict,框架层信任 SQL 结果是可信行,反序列化信任 BLOB 是内部数据。三处信任叠加,链就通了。
修复版的白名单:正确配置才生效
修复版 jsonplus.py(langgraph-checkpoint 3.0+ / 4.x)引入了多层白名单机制,但默认配置不等于开启防护,这是升级后最容易被忽略的点:
python
class JsonPlusSerializer:
def __init__(self, *, pickle_fallback: bool = False,
allowed_json_modules=None, allowed_msgpack_modules=_SENTINEL):
if allowed_msgpack_modules is _SENTINEL:
if STRICT_MSGPACK_ENABLED: # 读环境变量 LANGGRAPH_STRICT_MSGPACK
allowed_msgpack_modules = None # 严格:只允许内置安全类型
else:
allowed_msgpack_modules = True # 默认:全部放行 + 告警
注意默认分支:不开环境变量时是 True = warn-and-allow(放行但打日志)。很多团队升级到修复版就以为安全了,实际默认仍在反序列化任意类型,只是多了 warning------攻击者照样能打穿,只是服务器日志里多几行告警。真正的防护是二选一:
- 生产设
LANGGRAPH_STRICT_MSGPACK=true------内置安全类型白名单(datetime/uuid/decimal/set/ipaddress/pathlib/zoneinfo/regex 等)+ LangGraph 内部类型,其余全部拒绝; - 或者代码里显式传
allowed_msgpack_modules=[("myapp.models","MyState"), ...]------精确到符号,最严格。
验证是否真的开启(本地可跑):
bash
# 用官方回归测试的思路验证:恶意对象应被拒绝而非放行
python3 - <<'PY'
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
# 不带环境变量:默认 warn-and-allow(⚠️ 不安全)
s = JsonPlusSerializer()
print("default allowlist:", s._allowed_msgpack_modules) # True
# 带环境变量后:None = 只允许内置安全类型
import os
os.environ["LANGGRAPH_STRICT_MSGPACK"] = "true"
s2 = JsonPlusSerializer()
print("strict allowlist:", s2._allowed_msgpack_modules) # None → 严格模式
PY
另外两点加固细节:① 自定义 ext_hook(__unpack_ext_hook__ 参数)会绕过 默认白名单------官方把它设计成 escape hatch,但生产别用;② 修复版还移除了 json 模式的 constructor 反序列化(CVE-2025-64439 的修复),并弃用了 method= 字段分发------升级后旧格式 json checkpoint 仍能读(走内置安全类型),但不再执行任意构造。
十分钟自查:你的部署受影响吗
不确定自己是否踩中,按下面顺序查(每条都有明确输出,不用猜):
bash
# 1. 查依赖版本:是否低于修复线
pip show langgraph langgraph-checkpoint langgraph-checkpoint-sqlite langgraph-checkpoint-redis 2>/dev/null \
| grep -E "^(Name|Version):" | paste - -
# 对照:langgraph>=1.0.10、langgraph-checkpoint>=4.0.1(或3.0.1)、
# langgraph-checkpoint-sqlite>=3.0.1、langgraph-checkpoint-redis>=1.0.2
# 2. 查代码里是否把用户输入透传给 filter
grep -rn "get_state_history\|\.list(" --include="*.py" . | head -20
# 重点看:filter= 后面跟的是不是 request/query/body 直接来的值
# 3. 查是否真的开启了严格模式
grep -rn "LANGGRAPH_STRICT_MSGPACK" .env* docker-compose*.yml config/ 2>/dev/null || echo "未设置 → 默认 warn-and-allow(不安全)"
自查结果的三种处置:
| 情况 | 处置 |
|---|---|
| 版本低于修复线 + 暴露了 filter | 立即升级,这是可远程 RCE 的组合 |
| 版本已修复 + 未开严格模式 | 加 LANGGRAPH_STRICT_MSGPACK=true,灰度验证后全量 |
| 版本已修复 + 严格模式已开 + filter 不透传 | 低风险;仍建议把 checkpoint 库当敏感资产管理 |
如果拿不准自己项目里 checkpointer 的用法,最稳妥的临时缓解是:升级到修复版 + 开严格模式 + 在 API 层拒绝任何非白名单 key 的 filter 参数------三步做完,SQLi 链断、反序列化链断、外部输入面也关了。
总结:三层理解
- 初级 :升级到修复版本,
langgraph-checkpoint-sqlite>=3.0.1、langgraph>=1.0.10、langgraph-checkpoint-redis>=1.0.2,把环境变量LANGGRAPH_STRICT_MSGPACK=true开起来。 - 中级 :理解链条------
filter未转义 → SQL 注入伪造行 → msgpack ext 钩子重建任意对象 → RCE。任何一环被堵住,链就断。SQLi 修复能断链,但 msgpack 反序列化本身仍是纵深防御必须补的洞。 - 记忆锚点 :Agent 框架把状态持久化变成了安全边界。checkpoint 就是记忆,记忆可被投毒,投毒即执行。 凡是「用户可控输入 → 框架内部查询/反序列化」的路径,都要按攻击面审计。
同类家族:LangGraph 及 Agent 框架序列化漏洞速查
| 漏洞 | 组件 | 类型 | 状态 |
|---|---|---|---|
| CVE-2025-64439 | langgraph-checkpoint <3.0 | json 模式反序列化 RCE(GHSA-wwqv-p2pp-99h5) | 已修复(3.0+) |
| CVE-2025-67644 | langgraph-checkpoint-sqlite | SQL 注入 → 任意反序列化 | 已修复(3.0.1+) |
| CVE-2026-28277 | langgraph-checkpoint | msgpack ext 不安全反序列化 | 已修复(1.0.10+ / 4.0.1+) |
| CVE-2026-27022 | langgraph-checkpoint-redis | RediSearch 查询注入(thread 隔离绕过) | 已修复(1.0.2+) |
纵向观察:这四个洞时间跨度 2025-11 到 2026-03,横跨 SQLite / Redis / 序列化三个组件,暴露的是 LangGraph 在「持久化层安全」上的整体欠账------不是单点失误,而是框架把「状态存储」当内部实现细节、没按攻击面设计的结果。对使用者来说,唯一正确的姿势是:把 checkpointer 当数据库一样锁起来,把 filter 当用户输入一样过滤,把反序列化当代码执行一样敬畏。
原始出处 :Check Point Research《From SQLi to RCE -- Exploiting LangGraph's Checkpointer》(2026-06);GitHub Advisory GHSA-wwqv-p2pp-99h5 / GHSA-g48c-2wqr-h844 / GHSA-5mx2-w598-339m;langgraph
libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py(修复版含_normalize_allowlist/InvalidModuleError/LANGGRAPH_STRICT_MSGPACK白名单机制)