Skip to content

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 都拥有拥有者的全部权限——包括函数作者从没打算开放的那部分。所以「让函数体只做该做的事」不是风格建议,而是安全边界。

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;

语法与用法 ​

text
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;

最小可运行示例——同样是「查订单」,两种身份的差别在于用户能不能直接读表:

sql
-- 建一个受限角色,只能调用函数、不能直接读表
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 版本:

sql
-- 撤销「所有角色都可执行」的默认开放
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;

验证结果:

sql
-- 以 app_limited 身份执行
SET ROLE app_limited;

SELECT demo.total_definer(1001) AS via_definer;      -- 成功:拥有者代它读表
SELECT * FROM demo.secret_orders;                    -- 失败:调用者自己无权读表
text
 via_definer
-------------
      170.00
(1 row)
text
ERROR:  permission denied for table secret_orders

这就是 SECURITY DEFINER 的价值:把「能读什么的决策」从调用者手里收回到函数里。

详细说明 ​

默认是 SECURITY INVOKER ​

不写安全关键字时是 INVOKER。它的语义最直白:函数是调用者自己的手,能做的事不多不少。

sql
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;
text
 prosecdef | owner
-----------+-------
 f         | ch
(1 row)

prosecdef = f 就是 INVOKER。这类函数的权限模型与视图、直接查询完全一致,不需要额外安全加固——它不会造成提权。

INVOKER 的局限也很明显:任何需要「跨权限访问」的场景都做不了。比如:

  • 普通用户需要只读一张含敏感列的表;
  • 普通用户需要插入审计日志表,但不该直接 INSERT;
  • 多租户场景里,用户只能看到自己租户的行。

这些都需要 DEFINER。

SECURITY DEFINER:以拥有者身份执行 ​

创建时若不写 SECURITY DEFINER,事后再改也很简单:

sql
ALTER FUNCTION demo.total_invoker(integer) SECURITY DEFINER;

改完必须做的第一件事是固定 search_path:

sql
ALTER FUNCTION demo.total_invoker(integer)
  SET search_path = pg_catalog, public;

原因见下方「search_path 劫持」一节——这是 DEFINER 函数唯一必须记住的加固动作。

一个常见的误区:DEFINER 不等于「超级用户」。执行身份是函数的拥有者。如果函数由普通角色 app_owner 创建,那么函数只有 app_owner 的权限,不是超级用户权限。只有 postgres 创建的函数才带超级用户权限:

sql
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;
text
    proname    | owner | rolsuper | prosecdef
---------------+-------+----------+-----------
 total_definer | ch    | t        | t
(1 row)

实践建议:DEFINER 函数一律由专用的、非超级用户的所有者创建,把提权范围钳制到「该所有者能做的那些事」上。

典型场景 ​

场景为什么需要 DEFINER设计要点
只读脱敏视图用户能查表,但不该看到敏感列函数只 SELECT 指定列,返回脱敏值
行级隔离用户只能看自己的行结合 current_user 或会话变量做过滤
审计写入用户只能「追加日志」,不能改日志函数只做 INSERT,表上不给 INSERT 权限
状态机跃迁用户只能经函数改状态,不能任意 UPDATE函数内校验前置状态与允许的跃迁
序列发放用户需要 nextval 但不该拥有序列封装成 DEFINER 函数只返回一个号
跨 schema 数据聚合用户只该看汇总,不该看明细只返回聚合结果

演示「只读脱敏」这一最常用的形态:

sql
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();
text
 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 未固定):

sql
-- 危险!未固定 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 里建表——这已是足够的前提。

sql
-- 攻击者(低权限用户 attacker)建的 schema
CREATE SCHEMA attacker;
GRANT USAGE ON SCHEMA attacker TO attacker;

第二步:攻击者在自己的 schema 里建同名表。

sql
-- 以 attacker 身份
SET ROLE attacker;
CREATE TABLE attacker.pay_records (amount numeric);
INSERT INTO attacker.pay_records (amount) VALUES (99999999.00);

第三步:攻击者把自己的 schema 放到 search_path 最前,然后调用函数。

sql
-- 仍然以 attacker 身份
SET search_path = attacker, public;
SELECT demo.payroll_bad();

第四步:函数内的未限定名 pay_records 解析到了 attacker.pay_records。

text
 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 钉死在一个攻击者无写权限的取值上:

sql
-- 安全:固定 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 与使用全限定名——两道防线,任一失效时另一道仍成立:

sql
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 也只允许它排在最后:

sql
-- 显式写 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:

sql
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
-- 错误!直接拼接参数 = 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() 的占位符与转义函数对应关系:

占位符等价函数用途
%Iquote_ident()标识符(表名、列名、schema 名)
%Lquote_literal()字符串字面量
%s无(原样插入)只用于可信内容,如拼接固定的 SQL 片段
%I + USING—参数值优先用 USING,避免手动转义

%s 不是转义

%s 只是原样插入,没有任何转义。用它插入用户输入等于把注入漏洞写进函数签名里。除「拼接固定 SQL 片段」外,一律用 %I / %L 或 USING。

权限:REVOKE 与 GRANT EXECUTE ​

PostgreSQL 新建函数时,默认把 EXECUTE 授予 PUBLIC。对 SECURITY INVOKER 函数这通常无碍(调用者本来就受自身权限约束),但对 SECURITY DEFINER 函数,这等于把提权入口开放给所有角色。

sql
-- 查看函数的 ACL(NULL 表示使用默认值:PUBLIC 可执行)
SELECT proname, proacl
FROM pg_proc
WHERE proname = 'total_definer' AND pronamespace = 'demo'::regnamespace;
text
    proname    | proacl
---------------+---------
 total_definer |
(1 row)

proacl 为空(NULL)代表默认权限,即 PUBLIC=X/owner。查看实际生效权限用 has_function_privilege:

sql
SELECT has_function_privilege('app_limited', 'demo.total_definer(integer)', 'EXECUTE') AS can_execute;
text
 can_execute
-------------
 t
(1 row)

标准做法是先 REVOKE 再按需 GRANT:

sql
-- 第一步:收回默认开放
REVOKE ALL ON FUNCTION demo.total_definer(integer) FROM PUBLIC;

-- 第二步:只授予需要的角色
GRANT EXECUTE ON FUNCTION demo.total_definer(integer) TO app_limited;
text
    proname    |        proacl
---------------+-----------------------
 total_definer | {ch=X/ch,app_limited=X/ch}
(1 row)

对「整批函数」还可用 ALTER DEFAULT PRIVILEGES 改掉新建默认值,避免逐个 REVOKE:

sql
-- 之后由 ch 在 demo schema 里新建的函数,默认不再对 PUBLIC 开放
ALTER DEFAULT PRIVILEGES IN SCHEMA demo REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;

这一条要在创建函数之前执行;已存在的函数仍需单独 REVOKE。

prosecdef 与安全相关的目录字段 ​

pg_proc 中和安全有关的字段:

字段含义
prosecdeft = SECURITY DEFINER,f = SECURITY INVOKER
proowner函数拥有者(DEFINER 下的实际执行身份)
proacl函数的 ACL;NULL 表示默认(PUBLIC 可执行)
proleakproof是否声明为 LEAKPROOF
proconfig函数级 SET 子句,如 search_path=...
prolang语言;指向 pg_language,可判断是否「不可信语言」

一条可直接用于审计的查询:列出所有没有固定 search_path 的 DEFINER 函数。

sql
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;
text
 schema |  function     | owner | args | settings | language
--------+---------------+-------+------+----------+----------
 demo   | payroll_bad   | ch    |      |          | plpgsql
(1 row)

这个查询的结果非空,就意味着库里存在 search_path 劫持风险。把它放进上线检查或日常巡检非常划算。

LEAKPROOF 简介 ​

LEAKPROOF 是另一条安全属性,用于行级安全(RLS)场景。默认 LEAKPROOF 为假:优化器必须假设函数可能把参数值泄漏给非特权用户,因此不能把这类函数下推到 RLS 策略的安全性检查之前。

text
CREATE FUNCTION 名称 ( 参数列表 ) RETURNS 返回类型
LANGUAGE 语言名
LEAKPROOF            -- 或 NOT LEAKPROOF(默认)
AS 函数体;

它只应在函数确实不会因参数不同而产生可观测差异时声明,例如 upper(text)、length(text)。滥用 LEAKPROOF 会把参数值通过错误信息、执行时间等侧信道泄漏出去,唯一能安全授予它的通常是超级用户。

一句话总结:LEAKPROOF 是给 RLS 优化用的,与 SECURITY DEFINER 是两件独立的事;普通业务函数不需要它。

与 ROLE / GRANT 的配合 ​

完整的权限链条是「角色 → schema → 对象 → 函数」,任一层缺失都会失败:

层需要的权限说明
角色LOGIN / 所属角色能连上数据库
schemaUSAGE能「看见」schema 里的对象
表(INVOKER 下)SELECT / INSERT 等调用者自己的表权限
表(DEFINER 下)由拥有者的权限决定调用者无需表权限
函数EXECUTE能调用
类型 / 语言USAGE(建函数时)创建函数才需要

一个常见的「配好了却不生效」原因:忘了给 schema 的 USAGE。

sql
-- 只给函数 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不可信需要编译动态库
sql
SELECT lanname, lanpltrusted, lanowner::regrole
FROM pg_language
ORDER BY lanname;
text
  lanname  | lanpltrusted | lanowner
-----------+--------------+----------
 internal  | t            | -
 c         | f            | -
 sql       | t            | -
 plpgsql   | t            | -
(4 rows)

lanpltrusted = f 即不可信语言。给普通用户 CREATE 权限时务必确认这一点:在不可信语言上放权,等于给对方超级用户权限。

实战示例 ​

完整演练:建角色、建表、两种身份对比、修复劫持漏洞、审计、清理。

第一步:准备角色与 schema

sql
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;
text
CREATE SCHEMA
SET
DROP ROLE
CREATE ROLE
DROP ROLE
CREATE ROLE
GRANT

第二步:建表与造数据

sql
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;
text
CREATE TABLE
INSERT 0 3
REVOKE

第三步:INVOKER 与 DEFINER 对比

sql
-- 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 身份执行两者:

sql
SET ROLE app_limited;

SELECT demo.salary_definer(1) AS definer_ok;      -- 成功
text
 definer_ok
------------
   20000.00
(1 row)
sql
SELECT demo.salary_invoker(1) AS invoker_result;
text
ERROR:  permission denied for table employees

同一个查询文本,只因为执行身份不同,一个成功一个失败。这就是两种安全上下文全部的区别。

第四步:复现 search_path 劫持

先建一个有漏洞的 DEFINER 函数(没有固定 search_path):

sql
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;

攻击者把它劫持:

sql
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;
text
  hijacked
-------------
 99999999.00
(1 row)

函数以拥有者身份读到了攻击者的表。若这个值参与后续的业务判断,攻击者已达成目的。

第五步:修复并验证

sql
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;
text
 fixed_result
--------------
      3000.00
(1 row)

修复后即使攻击者把 attacker 放在搜索路径最前,函数也只读 demo.payroll。

第六步:quote_ident 防注入对照

sql
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;
text
 normal_call
-------------
           3
(1 row)
sql
-- 恶意的表名被当成标识符整体转义,解析失败而不是执行注入
SELECT demo.count_safe('employees; DROP TABLE demo.employees; --') AS injection_attempt;
text
ERROR:  relation "public.employees; DROP TABLE demo.employees; --" does not exist

恶意字符串被当成了一个不存在的表名,注入没有发生。

第七步:审计库里的 DEFINER 函数

sql
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;
text
        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=... 就是已加固的标志。

第八步:权限核对

sql
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;
text
    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 从源头改掉。

第九步:清理

sql
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 里写明理由:

    sql
    COMMENT ON FUNCTION demo.salary_definer(integer) IS
      'SECURITY DEFINER:应用角色无 employees 表权限,经此函数只读单列 salary;已固定 search_path';
  • DEFINER 函数一律带上 SET search_path,并把 public 也纳入考虑——若 public 允许普通用户建对象,就不要把它放进路径:

    sql
    CREATE 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 的同时使用全限定名,双保险:

    sql
    SET 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,把这两句写在同一个迁移脚本里:

    sql
    REVOKE ALL ON FUNCTION app.read_rate(text) FROM PUBLIC;
    GRANT EXECUTE ON FUNCTION app.read_rate(text) TO app_api;
  • 用 ALTER DEFAULT PRIVILEGES 从源头关掉默认开放,减少遗漏:

    sql
    ALTER DEFAULT PRIVILEGES IN SCHEMA app REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
  • 动态 SQL 一律用 format() + %I / %L / USING,并在代码评审里把这作为硬性检查项:

    sql
    EXECUTE format('SELECT count(*) FROM app.%I WHERE id = $1', p_table)
      INTO v_count
      USING p_id;
  • 把「未固定 search_path 的 DEFINER 函数」审计查询接入 CI,每次迁移后自动跑一遍:

    sql
    SELECT 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 主键

参考链接 ​