Postgres MCP Pro CVE-2026-85620 深度分析
TL;DR
- 漏洞:Postgres MCP Pro(PyPI 包
postgres-mcp,Crystal DBA 出品)的"受限模式(restricted mode)"可被绕过,执行pg_read_file()等文件读取函数,读取数据库宿主机任意文件(/etc/passwd、配置、凭据、TLS 私钥)。 - 编号与评级:CVE-2026-85620,CWE-863(Incorrect Authorization)。CVSS v4.0 9.2 Critical(
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N),CVSS v3.1 8.6。 - 影响版本:
0到0.3.0(含 0.3.0),截至目前没有已发布的修复版本,修复仍在 PR 审查中。 - 根因:SQL 安全层
safe_sql.py只对FuncCall类型的 AST 节点校验函数名白名单;函数写在FROM子句里会解析成RangeFunction节点——该节点类型在白名单里,但函数名从未被检查。 - 一行绕过:
SELECT pg_read_file('/etc/passwd')被拦截;SELECT * FROM pg_read_file('/etc/passwd')直接返回文件内容。 - 架构教训:应用层 allowlist / AST 解析器 / 只读事务,都不能替代数据库级 RBAC。当强制点在中间件、而中间件又能被 SQL 或提示注入影响时,信任模型会在第一个解析器缺口处崩塌。
一、事件背景:被当作生产安全边界的"受限模式"
Postgres MCP Pro 是一个开源的 MCP(Model Context Protocol)服务器,给 AI Agent 提供 PostgreSQL 的数据库健康检查、索引调优和查询执行能力。它向运维人员推销的核心卖点之一是 restricted mode(受限模式):把操作限制在只读事务内,并用一份 allowlist 校验传入的 SQL。
官方的隐含承诺是:用了受限模式,就能让 Agent 安全地碰数据库,不需要再单独建一个只读数据库角色。
这个承诺现在被打破了。
漏洞由 George Chen 于 2026 年 6 月 6 日报告,2026 年 9 月初公开披露。攻击向量是网络:任何能把 SQL 送进受限模式接口的一方——AI Agent、聊天界面、直连的 MCP 客户端,或受提示注入影响的 Agent——都能触发,且不需要对限制逻辑本身做任何认证。
这不是孤例。同一周还爆出:
- CVE-2026-82526:检索框架 R2R 的严重 SQL 注入;
- CVE-2026-85695:模型服务层 FastChat 的认证绕过。
三个 CVE 分别落在 AI Agent 技术栈的检索、服务、数据库访问三层,同一周披露。这不是巧合,是模式。
二、根因拆解:FuncCall 与 RangeFunction 的节点类型错配
2.1 一个函数,两种 AST 节点
SQL 解析器(Postgres MCP Pro 用的是 pglast,基于 libpg_query)把 SQL 文本转成抽象语法树(AST)。关键在于:同一个函数名,出现在不同语法位置时,会解析成不同的节点类型。
-- 写法 A:标准函数调用 → FuncCall 节点
SELECT pg_read_file('/etc/passwd');
-- 写法 B:放在 FROM 子句里 → RangeFunction 节点
SELECT * FROM pg_read_file('/etc/passwd');RangeFunction 是 PostgreSQL 里一种合法的语法结构,表示"把函数当作表来查询"——这本身完全正常,SELECT * FROM generate_series(1, 10) 就是这么用的。
2.2 校验逻辑漏掉了 RangeFunction 的函数名
safe_sql.py 的校验逻辑大致是这样的(结构还原,非原始源码):
# 概念还原:Postgres MCP Pro 0.3.0 safe_sql.py 的校验思路
ALLOWED_NODE_TYPES = {
"SelectStmt", "RangeVar", "RangeFunction", # ← RangeFunction 被放行
"ColumnRef", "A_Const", "ResTarget", "FuncCall", ...
}
ALLOWED_FUNCTIONS = {
"count", "sum", "avg", "now", "current_date", ...
}
def validate(node):
for n in walk(node):
node_type = type(n).__name__
if node_type not in ALLOWED_NODE_TYPES:
raise UnsafeQuery(f"node type not allowed: {node_type}")
# ⚠️ 只有 FuncCall 会走到这里做函数名白名单检查
if node_type == "FuncCall":
fname = func_name(n)
if fname not in ALLOWED_FUNCTIONS:
raise UnsafeQuery(f"function not allowed: {fname}")问题一目了然:
RangeFunction在ALLOWED_NODE_TYPES里(因为generate_series这类合法用法需要它);- 但函数名白名单检查只挂在
FuncCall分支上; - 于是
FROM pg_read_file(...)这种写法:节点类型合法 → 通过;函数名从未被检查 → 完全绕过。
一个未处理的节点类型,让整个安全层失效。
2.3 为什么这个 bug 特别危险
- 不需要复杂技巧。不是堆叠编码、不是注释截断、不是时间盲注——就是把函数从
SELECT挪到FROM,一个纯粹的语法位置变换。 - 白名单看着很"全"。
ALLOWED_NODE_TYPES和ALLOWED_FUNCTIONS两个集合都在,代码审查时很容易被判定为"已做白名单校验"。真正的问题是两个集合没有被正确地组合使用。 - AI Agent 会"自然"触发它。这是 MCP 场景的放大效应:Agent 不需要攻击者精心构造 payload。当模型面对"读一下这个文件"或"导出数据"的模糊指令时,很容易生成
FROM形式的 SQL。漏洞的可达性被 LLM 的自由生成能力显著提高。
三、影响面:从数据库一路摸到宿主机
成功利用后,攻击者获得的是任意文件读取原语,读取范围 = PostgreSQL 服务进程的权限范围:
| 目标 | 价值 |
|---|---|
/etc/passwd | 用户名枚举、UID 映射 |
| PostgreSQL 配置文件 | 连接串、认证方式、数据目录路径 |
应用配置 / .env | 数据库口令、第三方 API Key |
| TLS 私钥 | 中间人、会话解密 |
环境变量(通过 pg_read_file 配合目录列举) | 云凭据、K8s Service Account Token |
配套函数还有:
-- 列目录
SELECT * FROM pg_ls_dir('/var/lib/postgresql');
-- 取文件元信息
SELECT * FROM pg_stat_file('/etc/shadow');注意 CVSS v4.0 向量里 SC:H(后续系统影响高)——影响范围越过了脆弱组件本身,延伸到底层宿主机文件系统。这也解释了为什么它是 9.2 而不是 7 分档:数据库读文件 = 容器/主机逃逸的第一步。
四、为什么"应用层白名单"替代不了"数据库级 RBAC"
这是本次漏洞最值得记住的部分,比 CVE 编号本身重要得多。
Postgres MCP Pro 犯的错,本质上是架构错位:把一个应用层的抽象(allowlist、AST 校验器、只读事务)当成了数据库级的安全边界。
理想信任链: 实际信任链:
┌──────────────────────┐
用户 ──► 数据库 RBAC │ LLM / Agent(不可信) │
(内核级强制) └──────────┬───────────┘
│ 生成 SQL(可能被注入影响)
┌──────────▼───────────┐
│ MCP 中间件 │
│ └ AST 白名单(应用层)│ ← 强制点在这里
└──────────┬───────────┘
│ 只要解析器有缺口,全线崩塌
┌──────────▼───────────┐
│ PostgreSQL(数据层) │
│ └ 以高权限角色运行 │ ← 最后一道防线被架空
└──────────────────────┘三个结构性缺陷:
- 中间件必须"预见所有 SQL 语法变体"。这是不可能完成的任务。SQL 是完备的查询语言,AST 节点类型几十种,函数可以出现在
SELECT、FROM、WHERE、HAVING、WINDOW、CTE、LATERAL等各个位置。每一个位置都是一个新的解析分支,每一个分支都可能漏检。 - 强制点可被输入影响。当安全判定依赖"对攻击者可控字符串的解析结果"时,解析器的任何偏差都直接变成绕过。
- 数据层权限过大。如果 MCP 服务器连的数据库角色本身没有
pg_read_server_files权限,就算白名单被绕过,pg_read_file也会因权限不足失败。这才是真正可靠的那道墙。
一句话:应用层白名单是"尽力而为",数据库 RBAC 是"强制"。别拿前者当后者用。
五、防御清单
5.1 立即动作(在官方补丁发布前)
- 降权数据库角色(最高优先级):
-- 为 MCP 服务器创建专用低权角色
CREATE ROLE mcp_reader LOGIN PASSWORD 'xxx';
-- 明确撤销文件读取类权限
REVOKE pg_read_server_files FROM mcp_reader;
REVOKE pg_read_all_settings FROM mcp_reader;
REVOKE pg_read_all_stats FROM mcp_reader;
-- 只给业务所需的表级 SELECT
GRANT USAGE ON SCHEMA public TO mcp_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_reader;
-- 确认它绝不是超级用户
ALTER ROLE mcp_reader NOSUPERUSER NOCREATEROLE NOCREATEDB;注意:
pg_read_server_files只是最直接的一个。pg_ls_dir、pg_stat_file等函数的可用性还取决于角色是否超级用户、是否属于pg_monitor等预定义角色。用\du+逐项确认,别只看一条权限。
- 不要依赖受限模式作为安全边界。在补丁落地前,把 restricted mode 视为"防手滑"而非"防攻击"。
- 网络隔离:限制哪些客户端/Agent 能访问 MCP 服务器端口,别把实例直接暴露到不可信网络。
- 监控告警:盯
FROM子句里的文件系统函数(见 5.2)。
5.2 检测:怎么发现这类"FROM 子句走私"
在 PostgreSQL 侧开启 log_statement = 'all' 或使用 pgaudit,然后对日志做匹配:
# 抓取 FROM 子句中调用文件系统函数的可疑查询
grep -Ei 'from[[:space:]]+(pg_read_file|pg_read_binary_file|pg_ls_dir|pg_stat_file|pg_read_server_files)' \
/var/log/postgresql/postgresql-*.log在 MCP 层,如果你要自己实现 SQL 校验,正确做法是"节点类型 + 函数名"双白名单,且函数名检查必须覆盖所有可能承载函数的节点:
import pglast
from pglast import ast, parse_sql
ALLOWED_FUNCTIONS = {"count", "sum", "avg", "min", "max", "now", "generate_series"}
# 承载函数名的节点类型,缺一个就是一个绕过口子
FUNC_BEARING_NODES = (ast.FuncCall, ast.RangeFunction, ast.SQLValueFunction)
def collect_func_names(node):
"""遍历 AST,收集所有函数名,覆盖全部承载函数的节点类型。"""
names = set()
for n in pglast.walk(node):
if isinstance(n, FUNC_BEARING_NODES):
fn = n.funcname if hasattr(n, "funcname") else None
if fn is None and hasattr(n, "functions"): # RangeFunction.functions
for sub in n.functions:
names.update(collect_func_names(sub))
elif fn is not None:
names.add(fn[-1].val if hasattr(fn[-1], "val") else str(fn[-1]))
return names
def assert_safe(sql: str) -> None:
for stmt in parse_sql(sql):
used = collect_func_names(stmt)
illegal = used - ALLOWED_FUNCTIONS
if illegal:
raise UnsafeQuery(f"disallowed functions: {sorted(illegal)}")这段代码要表达的核心不是"这样写就安全了",恰恰相反:它证明了自己实现 SQL 校验有多脆弱——上面每个 hasattr 分支都是潜在漏检点,而 PostgreSQL 的 AST 节点类型远不止三种。所以结论回到上一节:能交给数据库 RBAC 的,不要交给应用层白名单。
六、小结
CVE-2026-85620 的技术含量不高——一行语法位置变换而已。但它的结构意义很高:
- 它再次验证了 MCP 生态的安全成熟度落后于采用速度。据 2026 年 Synvestable 报告,MCP 生态月 SDK 下载量已达 9700 万,公开服务器超 1 万个,28% 的《财富》500 强在生产环境运行 MCP 服务器;而 Zuplo 的 State of MCP 报告显示,50% 的 MCP 服务器开发者把"安全与访问控制复杂度"列为最大挑战。美国国防部 2026 年 6 月的一份文件也直接点名:"MCP 的扩散速度已经超过了其安全模型的成熟速度。"
- 它给出了一条通用判据:任何"让 AI 安全地访问 X"的中间件,都要问一句——它的强制点在哪里?如果强制点是应用层解析器,那它一定会在某个未处理的语法分支上崩塌。
对运维和平台团队,行动项很短:降权角色、撤销文件权限、盯住 FROM 子句、等补丁。对工具作者,教训更长:别把 allowlist 当安全边界卖。
参考资料
- CVE-2026-85620 官方记录(CVE Program,2026-09-04 更新)
- IONIX Threat Center: CVE-2026-85620 分析
- VulnCheck Advisory: Postgres MCP Pro 0.3.0 Restricted-Mode Bypass via FROM-Clause Function(受影响源码
safe_sql.py) - crystaldba/postgres-mcp Issue #178(漏洞报告与修复 PR)
- Adversa AI: Top MCP Security Resources — September 2026(同期 MCP 漏洞综述)
相关阅读

