SECURITY DEFINER / INVOKER
默认情况下,函数以调用者的身份执行——调用者读不到的表,函数里也读不到。SECURITY DEFINER 打破这条规则:函数改以定义者(通常是超级用户)的身份执行,于是普通用户可以借它访问本无权访问的数据。这是实现「受控提权」的标准手段,也是 PostgreSQL 最经典的提权漏洞来源。本节讲清两种执行身份、search_path 劫持的完整推演、以及让 SECURITY DEFINER 函数安全的编写清单。
与「IMMUTABLE / STABLE / VOLATILE」的分工
IMMUTABLE / STABLE / VOLATILE 决定的是优化器能对函数做什么假设;本节讲的 SECURITY INVOKER / SECURITY DEFINER 决定的是函数以谁的身份、带着哪些权限执行。两者都在函数头的选项区,互不干扰,但都必须在建函数时就想清楚。
前置知识
阅读本节前,建议先了解:CREATE FUNCTION 基础、IMMUTABLE / STABLE / VOLATILE
基础概念
每个函数都有一个执行身份(Security Context)。默认是 SECURITY INVOKER:函数内的所有权限检查,都以调用它的那个用户的名义进行。SECURITY DEFINER 则把身份切换为函数的拥有者(Owner)。
| 概念 | 术语 | 说明 |
|---|---|---|
| 调用者权限 | SECURITY INVOKER | 默认值;函数内权限检查用调用者的身份 |
| 定义者权限 | SECURITY DEFINER | 函数内权限检查用函数拥有者的身份 |
| 受控提权 | Controlled Privilege Elevation | 有意让低权限用户经函数完成高权限操作 |
| 函数拥有者 | Function Owner | 创建函数的角色,SECURITY DEFINER 下即执行身份 |
| 搜索路径劫持 | search_path Hijacking | 利用 search_path 让函数解析到攻击者的同名对象 |
| 显式权限检查 | proacl | 函数上的 ACL,决定谁能调用 |
| 泄漏证明 | LEAKPROOF | 声明函数不会泄漏参数信息给非特权用户 |
| 不可信语言 | Untrusted Language | 允许任意内存访问的语言(如未受信的 plpythonu) |
两种身份的差异集中在这几点:
| 维度 | SECURITY INVOKER(默认) | SECURITY DEFINER |
|---|---|---|
| 执行身份 | 调用者 | 函数拥有者 |
| 权限检查 | 用调用者的权限 | 用拥有者的权限 |
| 能否读调用者无权读的表 | 不能 | 能 |
| 能否写调用者无权写的表 | 不能 | 能 |
| 权限提升风险 | 无 | 有(需谨慎设计) |
| 典型用途 | 普通业务函数、视图式封装 | 受控访问、行级过滤、审计写入 |
默认 search_path | 跟随调用者 | 跟随调用者(必须手工固定) |
先记住这条:SECURITY DEFINER 改变的是身份,不是权限范围。函数一旦以拥有者身份执行,函数体里的任意一句 SQL 都拥有拥有者的全部权限——包括函数作者从没打算开放的那部分。所以「让函数体只做该做的事」不是风格建议,而是安全边界。
SELECT p.proname,
CASE WHEN p.prosecdef THEN 'DEFINER' ELSE 'INVOKER' END AS sec_context,
pg_get_userbyid(p.proowner) AS owner,
pg_get_function_identity_arguments(p.oid) AS args
FROM pg_proc AS p
JOIN pg_namespace AS n ON n.oid = p.pronamespace
WHERE n.nspname = 'pg_catalog'
AND p.proname IN ('current_user', 'session_user', 'has_table_privilege')
ORDER BY p.proname;语法与用法
CREATE [ OR REPLACE ] FUNCTION 名称 ( 参数列表 )
RETURNS 返回类型
LANGUAGE 语言名
[ IMMUTABLE | STABLE | VOLATILE ]
[ SECURITY INVOKER | SECURITY DEFINER ] -- 不写 = INVOKER
[ SET 配置项 { TO | = } 值 [, ...] ] -- DEFINER 下必须固定 search_path
AS 函数体;
-- 事后修改
ALTER FUNCTION 名称 ( 参数类型列表 ) SECURITY DEFINER;
ALTER FUNCTION 名称 ( 参数类型列表 ) SET search_path = pg_catalog, public;最小可运行示例——同样是「查订单」,两种身份的差别在于用户能不能直接读表:
-- 建一个受限角色,只能调用函数、不能直接读表
DROP ROLE IF EXISTS app_limited;
CREATE ROLE app_limited LOGIN PASSWORD 'change_me';
-- 表的所有者是超级用户 / 建表角色
CREATE TABLE demo.secret_orders (
id bigint PRIMARY KEY,
owner_id integer NOT NULL,
amount numeric(12, 2) NOT NULL
);
INSERT INTO demo.secret_orders (id, owner_id, amount) VALUES
(1, 1001, 120.00),
(2, 1002, 80.00),
(3, 1001, 50.00);
-- INVOKER:调用者自己必须能读表,否则函数报权限错误
CREATE FUNCTION demo.total_invoker(p_owner integer) RETURNS numeric
LANGUAGE sql STABLE
AS $$ SELECT COALESCE(sum(amount), 0) FROM demo.secret_orders WHERE owner_id = p_owner $$;
-- DEFINER:以拥有者身份执行,调用者不需要读表权限
CREATE FUNCTION demo.total_definer(p_owner integer) RETURNS numeric
LANGUAGE sql STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$ SELECT COALESCE(sum(amount), 0) FROM demo.secret_orders WHERE owner_id = p_owner $$;收回默认权限并只开放 DEFINER 版本:
-- 撤销「所有角色都可执行」的默认开放
REVOKE ALL ON FUNCTION demo.total_definer(integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION demo.total_invoker(integer) FROM PUBLIC;
-- 只授权给受限角色
GRANT EXECUTE ON FUNCTION demo.total_definer(integer) TO app_limited;
-- 表权限完全不给 app_limited
REVOKE ALL ON demo.secret_orders FROM PUBLIC, app_limited;验证结果:
-- 以 app_limited 身份执行
SET ROLE app_limited;
SELECT demo.total_definer(1001) AS via_definer; -- 成功:拥有者代它读表
SELECT * FROM demo.secret_orders; -- 失败:调用者自己无权读表 via_definer
-------------
170.00
(1 row)ERROR: permission denied for table secret_orders这就是 SECURITY DEFINER 的价值:把「能读什么的决策」从调用者手里收回到函数里。
详细说明
默认是 SECURITY INVOKER
不写安全关键字时是 INVOKER。它的语义最直白:函数是调用者自己的手,能做的事不多不少。
CREATE FUNCTION demo.mine(p_id integer) RETURNS bigint
LANGUAGE sql
AS $$ SELECT id FROM demo.secret_orders WHERE owner_id = p_id LIMIT 1 $$;
SELECT prosecdef, pg_get_userbyid(proowner) AS owner
FROM pg_proc
WHERE proname = 'mine' AND pronamespace = 'demo'::regnamespace; prosecdef | owner
-----------+-------
f | ch
(1 row)prosecdef = f 就是 INVOKER。这类函数的权限模型与视图、直接查询完全一致,不需要额外安全加固——它不会造成提权。
INVOKER 的局限也很明显:任何需要「跨权限访问」的场景都做不了。比如:
- 普通用户需要只读一张含敏感列的表;
- 普通用户需要插入审计日志表,但不该直接
INSERT; - 多租户场景里,用户只能看到自己租户的行。
这些都需要 DEFINER。
SECURITY DEFINER:以拥有者身份执行
创建时若不写 SECURITY DEFINER,事后再改也很简单:
ALTER FUNCTION demo.total_invoker(integer) SECURITY DEFINER;改完必须做的第一件事是固定 search_path:
ALTER FUNCTION demo.total_invoker(integer)
SET search_path = pg_catalog, public;原因见下方「search_path 劫持」一节——这是 DEFINER 函数唯一必须记住的加固动作。
一个常见的误区:DEFINER 不等于「超级用户」。执行身份是函数的拥有者。如果函数由普通角色 app_owner 创建,那么函数只有 app_owner 的权限,不是超级用户权限。只有 postgres 创建的函数才带超级用户权限:
SELECT p.proname,
pg_get_userbyid(p.proowner) AS owner,
r.rolsuper,
p.prosecdef
FROM pg_proc AS p
JOIN pg_roles AS r ON r.rolname = pg_get_userbyid(p.proowner)
WHERE p.proname IN ('total_definer')
AND p.pronamespace = 'demo'::regnamespace; proname | owner | rolsuper | prosecdef
---------------+-------+----------+-----------
total_definer | ch | t | t
(1 row)实践建议:DEFINER 函数一律由专用的、非超级用户的所有者创建,把提权范围钳制到「该所有者能做的那些事」上。
典型场景
| 场景 | 为什么需要 DEFINER | 设计要点 |
|---|---|---|
| 只读脱敏视图 | 用户能查表,但不该看到敏感列 | 函数只 SELECT 指定列,返回脱敏值 |
| 行级隔离 | 用户只能看自己的行 | 结合 current_user 或会话变量做过滤 |
| 审计写入 | 用户只能「追加日志」,不能改日志 | 函数只做 INSERT,表上不给 INSERT 权限 |
| 状态机跃迁 | 用户只能经函数改状态,不能任意 UPDATE | 函数内校验前置状态与允许的跃迁 |
| 序列发放 | 用户需要 nextval 但不该拥有序列 | 封装成 DEFINER 函数只返回一个号 |
| 跨 schema 数据聚合 | 用户只该看汇总,不该看明细 | 只返回聚合结果 |
演示「只读脱敏」这一最常用的形态:
CREATE TABLE demo.customers (
id integer PRIMARY KEY,
name text NOT NULL,
phone text NOT NULL, -- 敏感
email text NOT NULL -- 敏感
);
INSERT INTO demo.customers (id, name, phone, email) VALUES
(1, 'alice', '13800000001', 'alice@example.com'),
(2, 'bob', '13800000002', 'bob@example.com');
-- 只暴露「姓名 + 掩码手机号」,不暴露 email
CREATE FUNCTION demo.masked_customers()
RETURNS TABLE(id integer, name text, phone_masked text)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
SELECT c.id,
c.name,
left(c.phone, 3) || '****' || right(c.phone, 4)
FROM demo.customers AS c;
$$;
REVOKE ALL ON FUNCTION demo.masked_customers() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION demo.masked_customers() TO app_limited;
SET ROLE app_limited;
SELECT * FROM demo.masked_customers(); id | name | phone_masked
----+-------+--------------
1 | alice | 138****0001
2 | bob | 138****0002
(2 rows)email 列在函数里根本没有出现,因此无论调用者怎么调用都拿不到它。
search_path 劫持:完整推演
SECURITY DEFINER 函数运行时,search_path 默认沿用调用者的设置。这就是漏洞的全部根源:函数体里的未限定名会按调用者的 search_path 解析,而调用者可能在自己的 schema 里放一个同名对象。
假设这是有漏洞的函数(SET search_path 未固定):
-- 危险!未固定 search_path 的 DEFINER 函数
CREATE FUNCTION demo.payroll_bad()
RETURNS numeric
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_total numeric;
BEGIN
SELECT sum(amount) INTO v_total FROM pay_records; -- 未限定!
RETURN v_total;
END;
$$;
GRANT EXECUTE ON FUNCTION demo.payroll_bad() TO app_limited;攻击过程分四步:
第一步:攻击者获得「在某个 schema 建对象」的权限。 很多系统会给每个用户一个专属 schema,或允许在 public 里建表——这已是足够的前提。
-- 攻击者(低权限用户 attacker)建的 schema
CREATE SCHEMA attacker;
GRANT USAGE ON SCHEMA attacker TO attacker;第二步:攻击者在自己的 schema 里建同名表。
-- 以 attacker 身份
SET ROLE attacker;
CREATE TABLE attacker.pay_records (amount numeric);
INSERT INTO attacker.pay_records (amount) VALUES (99999999.00);第三步:攻击者把自己的 schema 放到 search_path 最前,然后调用函数。
-- 仍然以 attacker 身份
SET search_path = attacker, public;
SELECT demo.payroll_bad();第四步:函数内的未限定名 pay_records 解析到了 attacker.pay_records。
payroll_bad
-------------
99999999.00
(1 row)函数以拥有者身份读取了攻击者的表,返回了攻击者可控的值。如果这个值被用来做金额计算、权限判定或写回系统表,攻击者就完成了提权。
search_path 劫持不是理论风险
这条攻击链早在 2007 年就被公开讨论(CVE-2007-3278 一类的 SECURITY DEFINER + search_path 问题),此后 PostgreSQL 每个大版本都在收紧默认行为,但根本对策只有一条:在 SECURITY DEFINER 函数上显式固定 search_path。任何未固定 search_path 的 DEFINER 函数都应视为已存在提权漏洞。
修复方式——把 search_path 钉死在一个攻击者无写权限的取值上:
-- 安全:固定 search_path,且不包含任何「用户可写」的 schema
CREATE FUNCTION demo.payroll_safe()
RETURNS numeric
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public -- attacker schema 不在其中
AS $$
DECLARE
v_total numeric;
BEGIN
SELECT sum(amount) INTO v_total FROM pay_records;
RETURN v_total;
END;
$$;更好的做法是同时固定 search_path 与使用全限定名——两道防线,任一失效时另一道仍成立:
CREATE FUNCTION demo.payroll_safest()
RETURNS numeric
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $$
DECLARE
v_total numeric;
BEGIN
SELECT sum(amount) INTO v_total FROM demo.pay_records; -- 全限定
RETURN v_total;
END;
$$;注意 SET search_path = pg_catalog 的写法:把路径缩到只有系统目录,任何用户对象都必须全限定才能访问。对「只用系统函数 + 少数几张表」的函数,这是最紧的选择。
一条容易忽略的细节:pg_temp schema 默认排在搜索路径最前(隐式),攻击者可以在其中建临时表。因此显式 SET search_path 时不要写入 pg_temp,PostgreSQL 也只允许它排在最后:
-- 显式写 pg_temp 也只能放最后,写错位置会报错
SET search_path = pg_catalog, pg_temp; -- 允许
-- SET search_path = pg_temp, pg_catalog; -- 报错:pg_temp 必须位于最后动态 SQL 与标识符拼装
DEFINER 函数里若需要动态 SQL,禁止直接拼接不可信输入。函数名、表名、列名用 quote_ident(),字面量用 quote_literal(),参数值走 USING:
CREATE FUNCTION demo.count_rows(p_table text)
RETURNS bigint
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
v_count bigint;
BEGIN
-- 正确:标识符经 quote_ident 转义,并以全限定名拼装
EXECUTE format('SELECT count(*) FROM public.%I', p_table)
INTO v_count;
RETURN v_count;
END;
$$;错误写法(直接拼接)会让攻击者通过参数注入任意 SQL:
-- 错误!直接拼接参数 = SQL 注入,在 DEFINER 函数里等于直接提权
CREATE FUNCTION demo.count_rows_bad(p_table text)
RETURNS bigint
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
v_count bigint;
BEGIN
-- p_table 传入 'users; DROP TABLE audit_log; --' 就会执行
EXECUTE 'SELECT count(*) FROM ' || p_table INTO v_count;
RETURN v_count;
END;
$$;format() 的占位符与转义函数对应关系:
| 占位符 | 等价函数 | 用途 |
|---|---|---|
%I | quote_ident() | 标识符(表名、列名、schema 名) |
%L | quote_literal() | 字符串字面量 |
%s | 无(原样插入) | 只用于可信内容,如拼接固定的 SQL 片段 |
%I + USING | — | 参数值优先用 USING,避免手动转义 |
%s 不是转义
%s 只是原样插入,没有任何转义。用它插入用户输入等于把注入漏洞写进函数签名里。除「拼接固定 SQL 片段」外,一律用 %I / %L 或 USING。
权限:REVOKE 与 GRANT EXECUTE
PostgreSQL 新建函数时,默认把 EXECUTE 授予 PUBLIC。对 SECURITY INVOKER 函数这通常无碍(调用者本来就受自身权限约束),但对 SECURITY DEFINER 函数,这等于把提权入口开放给所有角色。
-- 查看函数的 ACL(NULL 表示使用默认值:PUBLIC 可执行)
SELECT proname, proacl
FROM pg_proc
WHERE proname = 'total_definer' AND pronamespace = 'demo'::regnamespace; proname | proacl
---------------+---------
total_definer |
(1 row)proacl 为空(NULL)代表默认权限,即 PUBLIC=X/owner。查看实际生效权限用 has_function_privilege:
SELECT has_function_privilege('app_limited', 'demo.total_definer(integer)', 'EXECUTE') AS can_execute; can_execute
-------------
t
(1 row)标准做法是先 REVOKE 再按需 GRANT:
-- 第一步:收回默认开放
REVOKE ALL ON FUNCTION demo.total_definer(integer) FROM PUBLIC;
-- 第二步:只授予需要的角色
GRANT EXECUTE ON FUNCTION demo.total_definer(integer) TO app_limited; proname | proacl
---------------+-----------------------
total_definer | {ch=X/ch,app_limited=X/ch}
(1 row)对「整批函数」还可用 ALTER DEFAULT PRIVILEGES 改掉新建默认值,避免逐个 REVOKE:
-- 之后由 ch 在 demo schema 里新建的函数,默认不再对 PUBLIC 开放
ALTER DEFAULT PRIVILEGES IN SCHEMA demo REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;这一条要在创建函数之前执行;已存在的函数仍需单独 REVOKE。
prosecdef 与安全相关的目录字段
pg_proc 中和安全有关的字段:
| 字段 | 含义 |
|---|---|
prosecdef | t = SECURITY DEFINER,f = SECURITY INVOKER |
proowner | 函数拥有者(DEFINER 下的实际执行身份) |
proacl | 函数的 ACL;NULL 表示默认(PUBLIC 可执行) |
proleakproof | 是否声明为 LEAKPROOF |
proconfig | 函数级 SET 子句,如 search_path=... |
prolang | 语言;指向 pg_language,可判断是否「不可信语言」 |
一条可直接用于审计的查询:列出所有没有固定 search_path 的 DEFINER 函数。
SELECT n.nspname AS schema,
p.proname AS function,
pg_get_userbyid(p.proowner) AS owner,
pg_get_function_identity_arguments(p.oid) AS args,
p.proconfig AS settings,
l.lanname AS language
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 p.prosecdef -- SECURITY DEFINER
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
AND NOT EXISTS (
SELECT 1
FROM unnest(COALESCE(p.proconfig, '{}'::text[])) AS cfg
WHERE cfg LIKE 'search\_path=%'
)
ORDER BY n.nspname, p.proname; schema | function | owner | args | settings | language
--------+---------------+-------+------+----------+----------
demo | payroll_bad | ch | | | plpgsql
(1 row)这个查询的结果非空,就意味着库里存在 search_path 劫持风险。把它放进上线检查或日常巡检非常划算。
LEAKPROOF 简介
LEAKPROOF 是另一条安全属性,用于行级安全(RLS)场景。默认 LEAKPROOF 为假:优化器必须假设函数可能把参数值泄漏给非特权用户,因此不能把这类函数下推到 RLS 策略的安全性检查之前。
CREATE FUNCTION 名称 ( 参数列表 ) RETURNS 返回类型
LANGUAGE 语言名
LEAKPROOF -- 或 NOT LEAKPROOF(默认)
AS 函数体;它只应在函数确实不会因参数不同而产生可观测差异时声明,例如 upper(text)、length(text)。滥用 LEAKPROOF 会把参数值通过错误信息、执行时间等侧信道泄漏出去,唯一能安全授予它的通常是超级用户。
一句话总结:LEAKPROOF 是给 RLS 优化用的,与 SECURITY DEFINER 是两件独立的事;普通业务函数不需要它。
与 ROLE / GRANT 的配合
完整的权限链条是「角色 → schema → 对象 → 函数」,任一层缺失都会失败:
| 层 | 需要的权限 | 说明 |
|---|---|---|
| 角色 | LOGIN / 所属角色 | 能连上数据库 |
| schema | USAGE | 能「看见」schema 里的对象 |
表(INVOKER 下) | SELECT / INSERT 等 | 调用者自己的表权限 |
表(DEFINER 下) | 由拥有者的权限决定 | 调用者无需表权限 |
| 函数 | EXECUTE | 能调用 |
| 类型 / 语言 | USAGE(建函数时) | 创建函数才需要 |
一个常见的「配好了却不生效」原因:忘了给 schema 的 USAGE。
-- 只给函数 EXECUTE 是不够的,还要能进入 schema
GRANT USAGE ON SCHEMA demo TO app_limited;
GRANT EXECUTE ON FUNCTION demo.total_definer(integer) TO app_limited;另一个方向的问题是过度授权。DEFINER 函数的所有者若是超级用户且函数体包含动态 SQL,那么函数就是一条通往超级用户的路。所以:
DEFINER函数的所有者用专用角色,而不是postgres;- 该角色只被授予函数真正需要的那几张表的权限;
- 定期用上面的审计查询复核。
不可信语言
PostgreSQL 把语言分成可信(trusted)与不可信(untrusted)。不可信语言允许不受限的内存访问,只有超级用户能用来创建函数:
| 语言 | 是否可信 | 说明 |
|---|---|---|
sql | 可信 | 普通用户可创建(需相应对象权限) |
plpgsql | 可信 | 默认安装,普通用户可创建 |
plperlu / plpythonu | 不可信 | 需超级用户创建 |
plperl / plpython3u(受信变体) | 可信 | 由 plperl / 受信包装提供 |
c | 不可信 | 需要编译动态库 |
SELECT lanname, lanpltrusted, lanowner::regrole
FROM pg_language
ORDER BY lanname; lanname | lanpltrusted | lanowner
-----------+--------------+----------
internal | t | -
c | f | -
sql | t | -
plpgsql | t | -
(4 rows)lanpltrusted = f 即不可信语言。给普通用户 CREATE 权限时务必确认这一点:在不可信语言上放权,等于给对方超级用户权限。
实战示例
完整演练:建角色、建表、两种身份对比、修复劫持漏洞、审计、清理。
第一步:准备角色与 schema
CREATE SCHEMA IF NOT EXISTS demo;
SET search_path = demo, public;
-- 受限应用角色(只能调用函数)
DROP ROLE IF EXISTS app_limited;
CREATE ROLE app_limited LOGIN PASSWORD 'change_me';
-- 攻击者角色(能在自己 schema 建对象)
DROP ROLE IF EXISTS attacker;
CREATE ROLE attacker LOGIN PASSWORD 'change_me';
GRANT USAGE ON SCHEMA demo TO app_limited, attacker;CREATE SCHEMA
SET
DROP ROLE
CREATE ROLE
DROP ROLE
CREATE ROLE
GRANT第二步:建表与造数据
CREATE TABLE demo.employees (
id integer PRIMARY KEY,
name text NOT NULL,
salary numeric(12, 2) NOT NULL,
phone text NOT NULL
);
INSERT INTO demo.employees (id, name, salary, phone) VALUES
(1, 'alice', 20000.00, '13800000001'),
(2, 'bob', 15000.00, '13800000002'),
(3, 'carol', 30000.00, '13800000003');
-- 表只允许拥有者访问,app_limited 无任何表权限
REVOKE ALL ON demo.employees FROM PUBLIC;CREATE TABLE
INSERT 0 3
REVOKE第三步:INVOKER 与 DEFINER 对比
-- INVOKER 版:调用者需自己有权读表
CREATE FUNCTION demo.salary_invoker(p_id integer) RETURNS numeric
LANGUAGE sql STABLE
AS $$ SELECT salary FROM demo.employees WHERE id = p_id $$;
-- DEFINER 版:以拥有者身份读表
CREATE FUNCTION demo.salary_definer(p_id integer) RETURNS numeric
LANGUAGE sql STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$ SELECT salary FROM demo.employees WHERE id = p_id $$;
REVOKE ALL ON FUNCTION demo.salary_invoker(integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION demo.salary_definer(integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION demo.salary_invoker(integer) TO app_limited;
GRANT EXECUTE ON FUNCTION demo.salary_definer(integer) TO app_limited;以 app_limited 身份执行两者:
SET ROLE app_limited;
SELECT demo.salary_definer(1) AS definer_ok; -- 成功 definer_ok
------------
20000.00
(1 row)SELECT demo.salary_invoker(1) AS invoker_result;ERROR: permission denied for table employees同一个查询文本,只因为执行身份不同,一个成功一个失败。这就是两种安全上下文全部的区别。
第四步:复现 search_path 劫持
先建一个有漏洞的 DEFINER 函数(没有固定 search_path):
RESET ROLE;
CREATE TABLE demo.payroll (
id integer PRIMARY KEY,
amount numeric(12, 2) NOT NULL
);
INSERT INTO demo.payroll (id, amount) VALUES (1, 1000.00), (2, 2000.00);
CREATE FUNCTION demo.payroll_total_bad() RETURNS numeric
LANGUAGE sql STABLE
SECURITY DEFINER
AS $$ SELECT sum(amount) FROM payroll $$; -- 危险:未限定表名
REVOKE ALL ON FUNCTION demo.payroll_total_bad() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION demo.payroll_total_bad() TO attacker;攻击者把它劫持:
SET ROLE attacker;
CREATE SCHEMA IF NOT EXISTS attacker;
CREATE TABLE attacker.payroll (amount numeric(12, 2));
INSERT INTO attacker.payroll (amount) VALUES (99999999.00);
SET search_path = attacker, public;
SELECT demo.payroll_total_bad() AS hijacked; hijacked
-------------
99999999.00
(1 row)函数以拥有者身份读到了攻击者的表。若这个值参与后续的业务判断,攻击者已达成目的。
第五步:修复并验证
RESET ROLE;
-- 修复:固定 search_path,并改用全限定名
CREATE OR REPLACE FUNCTION demo.payroll_total_bad() RETURNS numeric
LANGUAGE sql STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$ SELECT sum(amount) FROM demo.payroll $$;
RESET ROLE;
SET ROLE attacker;
SET search_path = attacker, public;
SELECT demo.payroll_total_bad() AS fixed_result; fixed_result
--------------
3000.00
(1 row)修复后即使攻击者把 attacker 放在搜索路径最前,函数也只读 demo.payroll。
第六步:quote_ident 防注入对照
RESET ROLE;
CREATE FUNCTION demo.count_safe(p_table text) RETURNS bigint
LANGUAGE plpgsql STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
v_count bigint;
BEGIN
EXECUTE format('SELECT count(*) FROM public.%I', p_table) INTO v_count;
RETURN v_count;
END;
$$;
SELECT demo.count_safe('employees') AS normal_call; normal_call
-------------
3
(1 row)-- 恶意的表名被当成标识符整体转义,解析失败而不是执行注入
SELECT demo.count_safe('employees; DROP TABLE demo.employees; --') AS injection_attempt;ERROR: relation "public.employees; DROP TABLE demo.employees; --" does not exist恶意字符串被当成了一个不存在的表名,注入没有发生。
第七步:审计库里的 DEFINER 函数
SELECT n.nspname AS schema,
p.proname AS function,
pg_get_userbyid(p.proowner) AS owner,
p.proconfig AS settings
FROM pg_proc AS p
JOIN pg_namespace AS n ON n.oid = p.pronamespace
WHERE p.prosecdef
AND n.nspname = 'demo'
ORDER BY p.proname; function | owner | settings
------------------------+-------+--------------------------------
count_safe | ch | {search_path=pg_catalog, public}
payroll_total_bad | ch | {search_path=pg_catalog, public}
salary_definer | ch | {search_path=pg_catalog, public}
(3 rows)settings 里出现 search_path=... 就是已加固的标志。
第八步:权限核对
SELECT p.proname,
p.proacl,
has_function_privilege('app_limited', p.oid, 'EXECUTE') AS app_ok,
has_function_privilege('attacker', p.oid, 'EXECUTE') AS attacker_ok
FROM pg_proc AS p
WHERE p.pronamespace = 'demo'::regnamespace
AND p.proname IN ('salary_definer', 'salary_invoker', 'count_safe')
ORDER BY p.proname; proname | proacl | app_ok | attacker_ok
----------------+-------------------------------+--------+-------------
count_safe | | t | t
salary_definer | {ch=X/ch,app_limited=X/ch} | t | f
salary_invoker | {ch=X/ch,app_limited=X/ch} | t | f
(3 rows)count_safe 的 proacl 为空——它仍使用默认权限,PUBLIC 可执行。这是典型遗漏:新建的 DEFINER 函数必须逐个 REVOKE,或用 ALTER DEFAULT PRIVILEGES 从源头改掉。
第九步:清理
RESET ROLE;
DROP FUNCTION demo.count_safe(text);
DROP FUNCTION demo.payroll_total_bad();
DROP FUNCTION demo.salary_definer(integer);
DROP FUNCTION demo.salary_invoker(integer);
DROP TABLE demo.payroll;
DROP TABLE demo.employees;
DROP SCHEMA attacker CASCADE;
DROP SCHEMA demo CASCADE;
DROP ROLE attacker;
DROP ROLE app_limited;注意事项
不写安全关键字就是
SECURITY INVOKER。绝大多数业务函数应当保持默认,不要为了「省事」而标DEFINER。SECURITY DEFINER函数必须固定search_path。这是唯一一条无论如何都不能省的加固措施,未固定即视为存在提权漏洞。pg_temp在搜索路径中隐式优先。即使用户没有建 schema 的权限,也能建临时表。SET search_path里不要包含pg_temp(它最多只能排最后)。DEFINER的身份是函数拥有者,不是超级用户。由普通角色创建的DEFINER函数只拥有该角色的权限;反之,由超级用户创建的函数即拥有超级用户权限。新建函数默认对
PUBLIC开放EXECUTE。DEFINER函数必须REVOKE ALL ... FROM PUBLIC后按需GRANT。REVOKE之后不要忘了 schema 的USAGE,否则调用方会得到permission denied for schema而不是函数权限错误,容易误判。DEFINER函数里禁止拼接不可信输入。动态 SQL 的标识符用%I/quote_ident(),字面量用%L/quote_literal(),参数值用USING。format()的%s没有任何转义,只可用于可信的固定片段。SET search_path会阻断LANGUAGE sql函数的内联。安全与极致性能在此处不可兼得:DEFINER函数要接受这一代价。不可信语言(
plperlu、plpythonu、c)只允许超级用户创建。给普通用户CREATE权限前务必确认语言可信。LEAKPROOF与SECURITY DEFINER无关,它服务于行级安全。普通业务函数不要声明LEAKPROOF。不要在
DEFINER函数里执行用户可控的EXECUTE,包括EXECUTE 'SET ...'之类看似无害的设置语句。DEFINER函数不能用来绕过行级安全(RLS)之外的全部隔离——它绕过的是普通表权限,RLS 策略仍然按函数拥有者的身份生效,设计时要一并考虑。审计要常态化。「所有未固定
search_path的DEFINER函数」这条查询应纳入 CI 或巡检脚本,而不是靠人工记忆。改
DEFINER函数的拥有者要谨慎。ALTER FUNCTION ... OWNER TO会同时改变执行身份,可能瞬间放大或缩小权限。
最佳实践
默认用
SECURITY INVOKER,只在明确需要受控提权时才用DEFINER,并在函数COMMENT里写明理由:sqlCOMMENT ON FUNCTION demo.salary_definer(integer) IS 'SECURITY DEFINER:应用角色无 employees 表权限,经此函数只读单列 salary;已固定 search_path';DEFINER函数一律带上SET search_path,并把public也纳入考虑——若public允许普通用户建对象,就不要把它放进路径:sqlCREATE FUNCTION app.read_rate(p_code text) RETURNS numeric LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, app -- 不放 public AS $$ SELECT rate FROM app.fx_rate WHERE code = p_code $$;在用
DEFINER的同时使用全限定名,双保险:sqlSET search_path = pg_catalog AS $$ SELECT rate FROM app.fx_rate WHERE code = p_code $$;DEFINER函数的所有者用专用角色,不要用postgres:sql-- 专用所有者只被授予函数真正需要的表权限 CREATE ROLE app_definer_owner NOLOGIN; GRANT SELECT ON app.fx_rate TO app_definer_owner; ALTER FUNCTION app.read_rate(text) OWNER TO app_definer_owner;新建
DEFINER函数后立即REVOKE+GRANT,把这两句写在同一个迁移脚本里:sqlREVOKE ALL ON FUNCTION app.read_rate(text) FROM PUBLIC; GRANT EXECUTE ON FUNCTION app.read_rate(text) TO app_api;用
ALTER DEFAULT PRIVILEGES从源头关掉默认开放,减少遗漏:sqlALTER DEFAULT PRIVILEGES IN SCHEMA app REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;动态 SQL 一律用
format()+%I/%L/USING,并在代码评审里把这作为硬性检查项:sqlEXECUTE format('SELECT count(*) FROM app.%I WHERE id = $1', p_table) INTO v_count USING p_id;把「未固定
search_path的DEFINER函数」审计查询接入 CI,每次迁移后自动跑一遍:sqlSELECT n.nspname, p.proname FROM pg_proc AS p JOIN pg_namespace AS n ON n.oid = p.pronamespace WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND NOT EXISTS ( SELECT 1 FROM unnest(COALESCE(p.proconfig, '{}'::text[])) AS c WHERE c LIKE 'search\_path=%' );DEFINER函数体保持最小:只做一件事(读一列、写一条审计、放一个号),避免「顺带」也能做别的操作。函数越大,提权面越大。对外的
DEFINER函数返回结构固定,不要返回SELECT *或record,否则将来加列会意外扩大暴露面。给应用角色与函数作者分离权限:应用角色只有
EXECUTE,函数所有者只有必要的表权限,避免「一个角色既是超级用户又是函数所有者」。
下一节
函数部分到此结束。接下来的阶段七进入约束与索引体系,第一站是:PRIMARY KEY 主键