SQL 注入 + msgpack 反序列化 = RCE:LangGraph Checkpointer 三洞连爆——Agent 的记忆层就是攻击面

TL;DR

LangGraph------月下载超 5000 万的 Agent 编排框架------的持久化层(checkpointer)在 2025-2026 年连续爆出三个漏洞,Check Point Research 将其中两个串联成完整远程代码执行(RCE)链

  1. CVE-2025-67644 :SQLite checkpointer 的 filter 参数拼接进 SQL 时未转义 → SQL 注入,可往查询结果里塞入攻击者伪造的 checkpoint 行;
  2. CVE-2026-28277 :checkpoint 加载时的 msgpack 反序列化允许按 module:callable(args) 重建任意 Python 对象 → 攻击者控制的序列化数据可触发 os.system() 等任意调用;
  3. CVE-2026-27022:Redis checkpointer 的 RediSearch 查询同样存在注入,可绕过 thread 隔离读到他人会话。

一句话总结:Agent 的「记忆」存哪,攻击面就在哪。 如果你的应用把用户输入直接传给 get_state_history(filter=...),攻击者不需要拿到数据库写权限,就能借 SQL 注入写入恶意 checkpoint,等下次状态恢复时执行任意命令。

修复版本:langgraph-checkpoint-sqlite >= 3.0.1langgraph >= 1.0.10langgraph-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 披露)

  1. 应用暴露 get_state_history(filter=用户输入),filter 未清洗直接传给 checkpointer;
  2. 攻击者构造恶意 filter,利用漏洞 1 的 SQL 注入 UNION SELECT 往查询结果里塞一行 checkpoint = 恶意 msgpack
  3. 应用遍历查询结果时调用 loads_typed() 反序列化这行数据;
  4. _msgpack_ext_hookmodule: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 / ShallowRedisSaverlist() 构造 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_idsession_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 的会话」)。

两个关键点:

  1. thread_id 是租户隔离的第一道闸 。多租户 SaaS 把不同用户映射到不同 thread_id,靠查询时强制 thread_id=当前用户 来防止 A 看到 B。CVE-2026-27022 打破的正是这道闸------注入 | (@thread_id:{*}) 后,查询结果同时包含「本线程 + 所有线程」,而框架对结果不再校验 thread_id 是否匹配

  2. checkpoint BLOB 是框架自动反序列化的。加载历史时框架不知道数据是「自己写的」还是「攻击者塞的」------它信任存储层返回的每一行。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.pylanggraph-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------攻击者照样能打穿,只是服务器日志里多几行告警。真正的防护是二选一:

  1. 生产设 LANGGRAPH_STRICT_MSGPACK=true------内置安全类型白名单(datetime/uuid/decimal/set/ipaddress/pathlib/zoneinfo/regex 等)+ LangGraph 内部类型,其余全部拒绝;
  2. 或者代码里显式传 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.1langgraph>=1.0.10langgraph-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 白名单机制)

相关推荐
starrysky8101 小时前
一个 NameError 掩盖了真根因:IPython 补全在 Django shell 里二次崩溃——从 jedi 版本错位查到 CPython LOAD_GLOBAL
angular.js
starrysky8101 小时前
NVD 9.8、官方 7.5:Redis TLS use-after-free 的评分之争——tlsProcessPendingData 悬指针踩空全解析
angular.js
巴勒个啦6 天前
2026 年 CSS 选型真相:我用 Tailwind v4 + 原生新特性重构了一个组件库
前端·angular.js
starrysky8108 天前
服务全绿、队列却 15 天没人消费?redis-py 8.0 默认切 RESP3 的阻塞命令坑
angular.js
starrysky8108 天前
写个管道就被 BrokenPipeError 打爆?先看 CPython 把 SIGPIPE 藏哪了
angular.js
starrysky81016 天前
画像空不是故障:Honcho peer card 观察者视角源码拆解——1956 条结论为何换不来一张卡
angular.js
starrysky81023 天前
Hindsight 记忆数据增删改实操:70 个 API 端点的 CRUD 地图
angular.js
starrysky81023 天前
从 472ms 到 150ms:Hindsight 的 SQL 模板为何能超越 Text2SQL
angular.js
starrysky8101 个月前
sqlite3.OperationalError: database is locked 为什么 timeout=10 秒没生效?SQLite 锁升级死锁路径完整排障
angular.js