托管 PostgreSQL 面临一个天然矛盾:客户需要创建 FDW、发布、扩展等高级对象,平台却不能把真正的超级用户交出去。许多服务因此在 PostgreSQL 内核前增加安全加固扩展,先检查语句,再临时借用平台管理角色执行,最后恢复调用者身份。
问题在于,这不是简单的权限判断。只要“检查对象”和“实际执行对象”之间存在差异,短暂的提权窗口就可能变成跨越租户与基础设施边界的入口。相关研究在多家厂商的安全扩展中累计报告了 76 个问题,也暴露出托管数据库行业容易忽视的一类系统性风险。
真正危险的不是超级用户标志,而是临时提权窗口
典型的安全扩展大致执行以下流程:
- 解析客户提交的 DDL。
- 判断当前租户是否有权执行该操作。
- 将
current_user临时切换为平台管理角色。 - 把原始语句交回 PostgreSQL 内核执行。
- 恢复原来的身份。
从业务角度看,这能让“非超级用户”使用受控的高级功能;从安全角度看,它创造了一个经典的检查与使用分离问题,也就是 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 强大扩展能力的同时,重新定义并实现一套比上游更细的权限模型。只要平台借用了超级用户身份,所有名称解析、回调和可组合能力都必须按照真正的安全边界来审查。