Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

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 CriticalCVSS: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。
  • 影响版本00.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)。关键在于:同一个函数名,出现在不同语法位置时,会解析成不同的节点类型

sql
-- 写法 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 的校验逻辑大致是这样的(结构还原,非原始源码):

python
# 概念还原: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}")

问题一目了然:

  1. RangeFunctionALLOWED_NODE_TYPES 里(因为 generate_series 这类合法用法需要它);
  2. 函数名白名单检查只挂在 FuncCall 分支上
  3. 于是 FROM pg_read_file(...) 这种写法:节点类型合法 → 通过;函数名从未被检查 → 完全绕过

一个未处理的节点类型,让整个安全层失效。

2.3 为什么这个 bug 特别危险

  • 不需要复杂技巧。不是堆叠编码、不是注释截断、不是时间盲注——就是把函数从 SELECT 挪到 FROM,一个纯粹的语法位置变换。
  • 白名单看着很"全"ALLOWED_NODE_TYPESALLOWED_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

配套函数还有:

sql
-- 列目录
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(数据层)  │
                          │  └ 以高权限角色运行    │ ← 最后一道防线被架空
                          └──────────────────────┘

三个结构性缺陷:

  1. 中间件必须"预见所有 SQL 语法变体"。这是不可能完成的任务。SQL 是完备的查询语言,AST 节点类型几十种,函数可以出现在 SELECTFROMWHEREHAVINGWINDOW、CTE、LATERAL 等各个位置。每一个位置都是一个新的解析分支,每一个分支都可能漏检。
  2. 强制点可被输入影响。当安全判定依赖"对攻击者可控字符串的解析结果"时,解析器的任何偏差都直接变成绕过。
  3. 数据层权限过大。如果 MCP 服务器连的数据库角色本身没有 pg_read_server_files 权限,就算白名单被绕过,pg_read_file 也会因权限不足失败。这才是真正可靠的那道墙。

一句话:应用层白名单是"尽力而为",数据库 RBAC 是"强制"。别拿前者当后者用。

五、防御清单

5.1 立即动作(在官方补丁发布前)

  1. 降权数据库角色(最高优先级):
sql
-- 为 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_dirpg_stat_file 等函数的可用性还取决于角色是否超级用户、是否属于 pg_monitor 等预定义角色。\du+ 逐项确认,别只看一条权限。

  1. 不要依赖受限模式作为安全边界。在补丁落地前,把 restricted mode 视为"防手滑"而非"防攻击"。
  2. 网络隔离:限制哪些客户端/Agent 能访问 MCP 服务器端口,别把实例直接暴露到不可信网络。
  3. 监控告警:盯 FROM 子句里的文件系统函数(见 5.2)。

5.2 检测:怎么发现这类"FROM 子句走私"

在 PostgreSQL 侧开启 log_statement = 'all' 或使用 pgaudit,然后对日志做匹配:

bash
# 抓取 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 校验,正确做法是"节点类型 + 函数名"双白名单,且函数名检查必须覆盖所有可能承载函数的节点

python
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 当安全边界卖。

参考资料


相关阅读

上次更新于: