托管 PostgreSQL 的超级用户护栏为何会失效:从权限窗口到名称解析漂移

2026-09-23 40 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:13 分钟

托管 PostgreSQL 面临一个天然矛盾:客户需要创建 FDW、发布、扩展等高级对象,平台却不能把真正的超级用户交出去。许多服务因此在 PostgreSQL 内核前增加安全加固扩展,先检查语句,再临时借用平台管理角色执行,最后恢复调用者身份。

问题在于,这不是简单的权限判断。只要“检查对象”和“实际执行对象”之间存在差异,短暂的提权窗口就可能变成跨越租户与基础设施边界的入口。相关研究在多家厂商的安全扩展中累计报告了 76 个问题,也暴露出托管数据库行业容易忽视的一类系统性风险。

真正危险的不是超级用户标志,而是临时提权窗口

典型的安全扩展大致执行以下流程:

  1. 解析客户提交的 DDL。
  2. 判断当前租户是否有权执行该操作。
  3. 将 current_user 临时切换为平台管理角色。
  4. 把原始语句交回 PostgreSQL 内核执行。
  5. 恢复原来的身份。

从业务角度看,这能让“非超级用户”使用受控的高级功能;从安全角度看,它创造了一个经典的检查与使用分离问题,也就是 TOCTOU:扩展检查的是一个名字或一组 OID,内核稍后执行的却可能是重新解析后的另一个对象。

以 CREATE FOREIGN DATA WRAPPER 为例,语句可以引用 handler 和 validator 函数。如果扩展在普通用户身份下确认这些函数来自受信任扩展,随后切换身份并把仍然包含函数名称的原始语句交给内核,那么 PostgreSQL 可能在新的 current_user、search_path 和权限上下文中再次解析名称。

风险链条可以概括为:

用户提交带名称的 DDL
        ↓
安全扩展解析名称并完成放行检查
        ↓
current_user 切换为平台管理角色
        ↓
PostgreSQL 再次解析原始名称
        ↓
实际调用的 OID 可能不再是已检查的 OID

这说明只校验“函数名看起来正确”远远不够。即使第一次解析得到的 OID 属于允许列表,只要执行阶段重新解析了未经限定的名称,之前的结论就可能失效。

search_path 为什么能改变安全结论

PostgreSQL 的对象名称解析依赖 search_path。其中 $user 具有特殊含义:它会展开为当前有效用户对应的 schema 名称。

因此,同一条语句在身份切换前后可能具有不同含义:

提权前:current_user = customer
$user 解析为 customer schema

提权后:current_user = postgres
$user 解析为 postgres schema

角色和 schema 又是彼此独立的对象。拥有数据库级 CREATE 权限的用户,在某些配置下可能创建一个与平台管理角色同名的 schema,而不需要控制那个角色。若安全扩展允许未经 schema 限定的函数名,这种名称解析漂移就可能让检查阶段看到官方函数、执行阶段调用同名的租户函数。

防御代码不应把名称字符串当作稳定的安全主体。一个更可靠的实现思路如下。这里是面向扩展开发者的伪代码,并非 PostgreSQL 可直接调用的 API:

/* 防御性伪代码:重点是约束设计,而不是具体 API。 */

Oid checked_handler = resolve_fully_qualified_name(stmt->handler);
Oid checked_validator = resolve_fully_qualified_name(stmt->validator);

require_schema_qualified(stmt->handler);
require_schema_qualified(stmt->validator);
require_extension_member(checked_handler, ALLOWED_EXTENSION);
require_extension_member(checked_validator, ALLOWED_EXTENSION);
require_not_tenant_owned(checked_handler);
require_not_tenant_owned(checked_validator);

set_local_search_path("pg_catalog, provider_private");
switch_to_provider_role();

Oid execution_handler = resolve_fully_qualified_name(stmt->handler);
Oid execution_validator = resolve_fully_qualified_name(stmt->validator);

require(execution_handler == checked_handler);
require(execution_validator == checked_validator);

execute_statement();
restore_original_role();

关键点有四个:

  • 强制使用完全限定名称,而不是依赖调用者的 search_path。
  • 在提权前固定并校验 OID。
  • 在提权后再次确认解析结果没有变化。
  • 将提权期间的 search_path 收缩到 pg_catalog 和平台私有 schema,并确保租户没有这些 schema 的 CREATE 权限。

仅仅增加函数名黑名单并不能解决这个问题,因为别名、目录修改、不同语言入口或其他可回调对象都可能绕过字符串比较。

从数据库超级用户走到宿主机:为什么函数黑名单不够

托管服务通常会阻止 lo_export、服务器文件读取函数以及 COPY ... PROGRAM。这是必要措施,因为文件写入能力一旦与加载原生模块的能力组合,就可能演变为 PostgreSQL 进程内的本地代码执行。

但安全边界不能只建立在函数名上。PostgreSQL 的系统目录保存了函数与底层实现之间的映射;LANGUAGE internal 和 LANGUAGE C 又允许高权限用户接近数据库进程内部能力。研究显示,如果平台允许用户创建 internal-language 函数,或允许修改相关系统目录,那么即使显式屏蔽了危险函数名,攻击者仍可能通过另一个名称触达相同底层实现。

这也解释了 PostgreSQL 内核与云平台之间的责任边界:在 PostgreSQL 自身的传统威胁模型里,能够创建 LANGUAGE internal 函数的用户通常已经被视为完全可信。托管平台如果想让客户拥有部分超级用户能力、同时禁止宿主机访问,就等于建立了一个比上游 PostgreSQL 更细的新安全边界。这个边界需要由平台完整维护,不能假设内核会自动保护它。

对平台而言,至少应同时控制:

  • LANGUAGE internal 与 LANGUAGE C 函数的创建权限;
  • pg_catalog 和平台私有 schema 的写权限;
  • 对 pg_proc 等系统目录的直接修改;
  • 大对象导出、服务端文件读写和外部程序执行;
  • 可以在 DDL 执行期间触发回调的 handler、validator、事件触发器和扩展脚本;
  • 提权前后发生的对象名称二次解析。

可直接运行的只读审计

下面的脚本不会创建角色、函数或扩展,只读取当前数据库的角色、schema、过程语言和函数元数据。将连接串放入 DATABASE_URL 后即可运行。托管服务可能隐藏部分目录信息,因此“没有查到”不等于“没有风险”。

cat > managed-pg-guardrail-audit.sql <<'SQL'
\pset pager off
\echo '== Session identity and search path =='
SELECT current_user,
       session_user,
       current_setting('search_path') AS search_path;

\echo '== Roles with security-sensitive attributes =='
SELECT rolname,
       rolsuper,
       rolcreaterole,
       rolcreatedb,
       rolreplication,
       rolbypassrls
FROM pg_roles
WHERE rolsuper
   OR rolcreaterole
   OR rolreplication
   OR rolbypassrls
ORDER BY rolname;

\echo '== Schemas writable by PUBLIC or the current user =='
SELECT n.nspname AS schema_name,
       pg_get_userbyid(n.nspowner) AS owner,
       has_schema_privilege('public', n.oid, 'CREATE') AS public_can_create,
       has_schema_privilege(current_user, n.oid, 'CREATE') AS current_user_can_create
FROM pg_namespace AS n
WHERE has_schema_privilege('public', n.oid, 'CREATE')
   OR has_schema_privilege(current_user, n.oid, 'CREATE')
ORDER BY n.nspname;

\echo '== Untrusted procedural languages =='
SELECT lanname,
       lanpltrusted,
       lanispl
FROM pg_language
WHERE NOT lanpltrusted
ORDER BY lanname;

\echo '== User-defined functions using internal or C =='
SELECT n.nspname AS schema_name,
       p.proname AS function_name,
       pg_get_userbyid(p.proowner) AS owner,
       l.lanname AS language,
       pg_get_function_identity_arguments(p.oid) AS arguments
FROM pg_proc AS p
JOIN pg_namespace AS n ON n.oid = p.pronamespace
JOIN pg_language AS l ON l.oid = p.prolang
WHERE l.lanname IN ('internal', 'c')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY n.nspname, p.proname;

\echo '== Installed extensions and their owners =='
SELECT e.extname,
       e.extversion,
       pg_get_userbyid(e.extowner) AS owner,
       n.nspname AS schema_name
FROM pg_extension AS e
JOIN pg_namespace AS n ON n.oid = e.extnamespace
ORDER BY e.extname;
SQL

psql "$DATABASE_URL" -X -v ON_ERROR_STOP=1 \
  -f managed-pg-guardrail-audit.sql

审计结果需要结合平台设计解释:

  • public_can_create = true 的 schema 若出现在高权限代码的 search_path 中,应被视为高风险。
  • 普通租户能够创建 internal 或 c 函数,通常意味着平台定义的隔离边界存在严重缺口。
  • 业务 schema 中出现由平台管理角色拥有的回调函数,需要核查其 SECURITY DEFINER、固定 search_path 和参数处理。
  • 宽泛的 CREATEROLE、BYPASSRLS 或扩展管理能力,应确认是否符合产品承诺。

这个脚本无法发现共享缓冲区篡改、原生扩展中的内存漏洞或闭源 hook 的逻辑错误。平台方还需要在服务端记录身份切换、DDL 对象 OID、扩展 hook 判定结果和系统目录变更,而不是只记录最终 SQL 文本。

采用建议:把租户到基础设施当成独立边界

评估此类问题时,不能只问“是否拿到了 rolsuper”。更重要的问题是,攻击前后新增了什么能力:能否访问其他租户、读取备份或 WAL、写入宿主机文件、加载原生代码,或者影响控制面。

对托管 PostgreSQL 提供商,一份最低限度的检查清单是:

  • 列出每个会临时切换身份的 DDL 路径,并建立状态机测试。
  • 对提权前后解析得到的 OID做一致性断言。
  • 禁止租户写入高权限 search_path 中的任何 schema。
  • 默认拒绝 LANGUAGE internal、LANGUAGE C 及系统目录更新。
  • 不依赖危险函数名黑名单,而应封锁底层能力。
  • 将低权限用户到实例管理员、实例管理员到宿主机、宿主机到其他租户分别建模。
  • 为异常 DDL、系统目录变化和提权窗口内回调建立监控。
  • 对每个绕过同时验证隔离层是否仍能阻止跨租户影响。

对采购方,则应要求供应商明确说明:默认数据库管理员是不是安全边界内的可信主体、哪些操作由临时超级用户完成、是否允许 internal/C 函数,以及数据库进程与控制面、备份和其他租户之间采用了什么隔离机制。

托管 PostgreSQL 的安全难点并不是少写一条 SUPERUSER,而是在保留 PostgreSQL 强大扩展能力的同时,重新定义并实现一套比上游更细的权限模型。只要平台借用了超级用户身份,所有名称解析、回调和可组合能力都必须按照真正的安全边界来审查。


相关推荐