news 2026/10/17 5:22:07

PostgreSQL函数权限测试实战:从默认PUBLIC到SECURITY DEFINER防越权

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL函数权限测试实战:从默认PUBLIC到SECURITY DEFINER防越权

PostgreSQL函数权限测试,听起来像是只有DBA才在意的小话题。直到有一次项目临近发布,测试同事在数据库里随手敲了一段SQL,发现一个本该只给财务模块使用的月度汇总函数,任何角色都能直接调用,我们才真正意识到:PG里函数的默认权限比大多数人印象中要开放得多。这篇文章会结合我这几年的实测经历,聊透函数权限测试到底该测什么、怎么测,以及几个容易让测试结果失真、白忙一场的坑。

适合谁看?如果你正在维护PostgreSQL实例、负责应用权限设计,或者只是写了很多函数但从来没关心过"谁会执行它",这篇都值得读一读。我会尽量把原理讲清楚,也会给出可以直接抄的测试脚本和检查思路。

1. 一次函数越权事故引发的思考:PG函数权限不是"默认安全"

1.1 事故复盘:谁能执行这个函数?

当时的情况是这样的:A同学负责某个内部报表模块,他写了一个函数用来统计员工薪酬区间,放在业务schema下,名字很简单,就叫get_salary_detail(emp_id int)。开发阶段为了方便联调,他顺手对几个角色执行了GRANT EXECUTE,但从来没有想过要回收默认权限。

上线前的安全自查里,测试同事用了一个只拥有登录权限、没有任何业务授权的账号,居然也成功执行了这个函数,而且能看到返回的薪酬数据。排查下来原因让人哭笑不得:函数创建之后,PostgreSQL默认把EXECUTE权限授予了PUBLIC,也就是说所有能连上数据库的账号,只要知道函数名和参数,都可以直接调用。这不是哪条语句写错了,而是从一开始就没人意识到"函数需要收权"这件事。

这个案例太典型了。我们总以为数据库权限管理的重点是表、视图、schema,却忘了函数同样是可执行对象,而且PG在函数权限上有一个相当宽松的默认值。只要函数里塞了敏感的查询逻辑,哪怕表权限收得再严,函数也可能成为绕过表权限的后门。

1.2 函数权限在整个PG权限体系中的位置

PostgreSQL的权限体系可以简单分成几层:实例层、数据库层、schema层、对象层。函数是属于对象层的一个具体对象,和表、序列、视图平级。

但函数有个特殊性:它的可执行性受两层约束。第一层,用户必须对函数所在的schema拥有USAGE权限,否则连函数名都访问不到;第二层,用户要对函数本身拥有EXECUTE权限,否则即使是调用也会被拒绝。

更麻烦的是第三层:函数一旦执行起来,函数体里如果继续查询表、调用其他函数,又会触发底层对象的权限检查。所以测试函数权限时,你其实在验证一条完整的链路:schema的进入权、函数的执行权、函数内部对象的访问权。这三层只要有一层断掉,调用就会失败;反过来,这三层如果都洞开,权限测试就等于白做。

搞清楚这个三层链路之后,我们的测试目标就明确了:不是简单跑一句"能执行成功",而是要分角色、分函数、分场景去验证每一层到底通不通,以及不该通的场景到底会不会被挡下来。

2. EXECUTE与USAGE:函数权限的核心构成与默认陷阱

2.1 两层权限缺一不可

先说最基础的授权语句。假设我们有一个业务模式app,里面有一个函数:

CREATE SCHEMA app; CREATE FUNCTION app.order_summary(order_id bigint) RETURNS TABLE(order_no text, total numeric) LANGUAGE sql AS $$ SELECT order_no, total_amount FROM app.orders WHERE id = order_id; $$;

现在我创建一个只读角色,希望它只能执行这个汇总函数,不能碰原始表:

CREATE ROLE app_readonly LOGIN PASSWORD 'readonly_pwd'; REVOKE ALL ON FUNCTION app.order_summary(bigint) FROM PUBLIC; GRANT USAGE ON SCHEMA app TO app_readonly; GRANT EXECUTE ON FUNCTION app.order_summary(bigint) TO app_readonly;

然后模拟这个角色去调用:

SET ROLE app_readonly; SELECT * FROM app.order_summary(42);

如果你漏掉了GRANT USAGE ON SCHEMA app,执行结果会是这样:

ERROR: permission denied for schema app

而如果你保留USAGE权限,却漏掉了GRANT EXECUTE,则会得到:

ERROR: permission denied for function app.order_summary(bigint)

两类报错信息完全不同,但排查方向很明确:前者是schema门没开,后者是函数钥匙没给。用生活化的类比来说,schema有点像一栋办公楼的大门,函数是办公室门。只给办公室钥匙却进不了楼,或者能进楼却开不了办公室门,都办不成事。

实际测试中,最容易出现的情况是:GRANT EXECUTE给了,但schema的USAGE忘了给,开发在本地用超级账号测又测不出来,直到用真实角色一跑就报错。反过来也有,USAGE给了,EXECUTE没给,结果应用侧一脸懵"明明能访问schema,怎么函数就不能调"。所以测试的时候一定要把这两层分开验。

2.2 默认PUBLIC EXECUTE:PG给了所有人一把钥匙

在继续往下说之前,必须先把PG的一个默认行为讲透:只要创建一个函数,且没有显式回收权限,那么PUBLIC——也就是所有角色——天然拥有这个函数的EXECUTE权限。

这并不是Bug,而是PostgreSQL长期以来的兼容性设计。它默认假设"函数是可以被调用的",就像很多SQL语言里函数天生是公开接口一样。但放在生产环境里,这个默认值就成了安全漏洞的高发区。

尤其需要注意的是,PostgreSQL 15之后,publicschema的默认权限发生了变化,普通角色不再能在publicschema里随意创建表了,但函数的PUBLIC EXECUTE默认行为一直保留着。也就是说,即使你升级到了新版本,也别指望系统帮你自动收紧函数权限。该手动REVOKE的,一条都不能少。

所以我的建议是:任何函数创建完成后,如果不希望所有角色都能调用,至少先执行一句收权:

REVOKE ALL ON FUNCTION app.order_summary(bigint) FROM PUBLIC;

然后通过GRANT定向给需要调用的角色。如果不做这一步,后面的一切精细授权都是给"默认全开"的大门口只加了一道小栅栏。

2.3 用什么命令查看函数权限

测试之前先学会看权限。最直接的方式是用psql的\df+:

\df+ app.order_summary

输出里会有一列Access privileges,当你没有做过任何权限调整时,它可能是空的或者显示默认ACL;当你执行过REVOKE和GRANT之后,会看到类似这样的内容:

app_readonly=X/postgres

其中X表示EXECUTE,postgres是授权者。如果你看到=X/postgres这种“等号左边什么都没有”的条目,就要警觉了,这说明PUBLIC被授予了EXECUTE。

更准确的方式是直接查系统目录。PostgreSQL把函数权限存在pg_proc表的proacl字段里:

SELECT p.proname, pg_get_userbyid(p.proowner) AS owner, p.proacl FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'app' ORDER BY p.proname;

proacl为NULL时,表示使用的是默认ACL,也就是PUBLIC EXECUTE。一旦你做了REVOKE操作,这个字段就会变成一条具体的ACL数组,你就能清楚地看到哪个角色拥有什么权限了。做权限审计时写一个这样的查询脚本,比在pgAdmin的界面里一个个点要靠谱得多。

3. SECURITY DEFINER与search_path:权限测试里最容易翻车的地方

3.1 执行者权限与定义者权限的本质差别

函数在创建时可以声明两种安全上下文:SECURITY INVOKER(默认)和SECURITY DEFINER。

SECURITY INVOKER的意思是,函数以"调用者"的身份执行,函数内部查询表、调用其他函数时,用的是当前调用用户自己的权限。这符合直觉,也很好测试:谁调用,就是谁的权利范围。

SECURITY DEFINER则完全不同。它会让函数以"函数属主"的身份执行,哪怕调用者是个权限很低的登录账号,只要你有这个函数的EXECUTE权限,函数内部就能用属主的权限去访问那些调用者原本碰不到的对象。

用个比喻:这就像大楼里某个房间的门禁卡绑定在领队身上,你是队员,只要领队带你进去,你就能进入房间;但你还想单独进别的房间,门禁不认你。

权限测试里,对SECURITY DEFINER函数的测试逻辑要彻底转变。普通函数测的是"调用者有没有足够的权限跑完函数体内SQL";而SECURITY DEFINER函数要测的是"函数属主的权限是否被安全地封装住了,调用者能不能借助这个函数越权拿到不该拿的东西"。这也是这类函数最容易出漏洞的地方。

3.2 search_path攻击的实际测试

说SECURITY DEFINER安全风险,最经典的就是search_path劫持。原理一句话就能讲明白:函数内部如果写了不带schema前缀的表名或函数名,那么这些名字在函数执行时是依赖当前会话的search_path来解析的。如果调用者能控制search_path,又能在某个提前搜索的schema里放一个同名对象,函数就可能解析到攻击者准备的对象上。

我建议每个做权限测试的人都在测试环境里复现一次这个过程,才能真正理解为什么SECURITY DEFINER函数必须固定search_path。

先建一个"看起来正常"的函数:

CREATE FUNCTION public.sensitive_lookup(account_id int) RETURNS text LANGUAGE sql SECURITY DEFINER AS $$ SELECT note FROM account_notes WHERE id = account_id; $$;

注意,这个函数没有设置search_path。现在,假设一个低权限用户在pg_temp临时schema里建了一个同名的表:

SET ROLE app_readonly; CREATE TEMP TABLE account_notes (note text); INSERT INTO account_notes VALUES ('这是伪造数据'); SELECT public.sensitive_lookup(1); SELECT current_user;

如果这个函数真的解析到了临时表,查询就会返回"这是伪造数据"。这意味着函数的执行上下文被调用者劫持了。即使这次只是搬出一个无害的临时表,真实攻击里也可以替换成恶意函数、读取不该读的数据。

正确的写法是,在函数定义时显式锁定搜索路径:

CREATE FUNCTION public.sensitive_lookup(account_id int) RETURNS text LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ SELECT note FROM account_notes WHERE id = account_id; $$;

把pg_catalog放在最前面,再显式带上要访问的业务schema,调用者再想通过pg_temp拦截就无能为力了。

在测试环节,你至少应该做到:对每一个SECURITY DEFINER函数,检查它是否显式设置了search_path;然后模拟低权限用户设置一个恶意的search_path再调用它,看看结果是否发生异常。这两步做下来,能拦掉绝大多数经典漏洞。

3.3 重载函数是权限测试里最隐蔽的盲区

PostgreSQL允许函数重载:函数名相同,但参数类型或参数个数不同,它们是两个完全独立的对象。权限也是独立授予的。

举个例子:

CREATE FUNCTION app.fmt_price(int) RETURNS text ... CREATE FUNCTION app.fmt_price(numeric) RETURNS text ...

这时候如果你执行:

GRANT EXECUTE ON FUNCTION app.fmt_price(int) TO app_readonly;

那你只给int版本开了权限,numeric版本依然只有PUBLIC权限(如果没回收)或者谁都调不了(如果回收了)。应用端如果恰好调用的是numeric版本,测试就会莫名其妙失败;更危险的是,如果两个版本都没收PUBLIC,而测试只测了其中一个版本,就会漏掉另一个。

所以在测试脚本里,我建议按函数OID遍历,而不是按函数名遍历。用下面这种查询把同一个函数名下的所有重载都捞出来:

SELECT p.oid::regprocedure AS func, p.proacl, p.prosecdef FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'app' AND p.proname = 'fmt_price' ORDER BY p.oid::regprocedure::text;

把重载版本全部列出来再逐一授权和测试,才能避免"测了A版本、漏了B版本"的尴尬。

4. 一套可落地的函数权限测试流程:从角色设计到模拟执行

4.1 准备工作:角色、模式、函数的规划

权限测试最忌讳临时抱佛脚。如果连"谁应该执行哪个函数"都没定义清楚,测试就只是碰运气。我的习惯是先把角色模型梳理出来,再谈测试。

一个常见的业务角色划分大概是这样的:

角色用途期望的函数权限
app_anon匿名只读访问只允许执行公开查询函数
app_readonly内部报表只读允许执行汇总、查询类函数
app_writer业务写入允许执行插入、更新类函数
app_admin管理运维允许执行管理类函数
app_service服务账号允许执行全部业务函数

然后为每个函数注册"预期权限"。不需要写复杂的工具,一张表格就够了,字段包括:函数签名、所属schema、预期可执行角色、是否SECURITY DEFINER、函数内部访问哪些表、是否需要表权限。

这个表是后面所有测试断言的参照物。没有预期就谈不上测试。

4.2 分场景测试:越权执行、正常执行、异常路径

准备好了之后,我会把测试拆成三类场景:

第一类是正常路径。用目标角色执行应该成功的函数,确认返回结果正常。比如:

SET ROLE app_readonly; SELECT * FROM app.order_summary(42);

第二类是越权路径。用一个不该有权限的角色去执行不该调的函数,期望出现权限报错。比如:

SET ROLE app_anon; SELECT * FROM app.get_payroll_data(1); -- 期望报错:permission denied for function app.get_payroll_data

这类场景非常关键。只测"该通的通"是不够的,还要测"不该通的必须被挡住"。

第三类是异常路径。包括给不存在的函数签名授权、用错误的数据类型调用、在schema USAGE缺失的情况下调用函数、对已删除的角色执行GRANT等。这些场景主要是验证权限系统不会因为异常输入而出现意外放行。

实际操作时,我强烈建议每个场景都单独开启一个事务,或者至少每次SET ROLE之后在场景结束立刻RESET ROLE。很多人把SET ROLE当作一次性操作,结果测试脚本越跑越长,角色越切越乱,后面的测试就全部失真了。

一个标准测试块的写法大致是这样:

BEGIN; SET LOCAL ROLE app_readonly; SELECT * FROM app.order_summary(42); RESET ROLE; ROLLBACK;

用SET LOCAL ROLE可以避免角色切换泄漏到事务外,测试更干净。

4.3 用has_function_privilege自动化断言

手工场景测完,还要有自动化兜底。PostgreSQL提供了一个现成的权限判断函数has_function_privilege,可以直接在SQL里断言某个角色对某个函数是否拥有EXECUTE权限。

语法是:

has_function_privilege('app_readonly', 'app.order_summary(bigint)', 'EXECUTE')

返回true或false。用它写权限矩阵非常合适:

SELECT r.rolname AS role_name, p.oid::regprocedure AS func_name, has_function_privilege(r.rolname, p.oid, 'EXECUTE') AS can_execute FROM pg_roles r CROSS JOIN pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'app' AND r.rolname IN ('app_anon', 'app_readonly', 'app_writer') AND p.proname IN ('order_summary', 'get_payroll_data') ORDER BY func_name, role_name;

这个查询会生成一张“角色-函数-是否可执行”的矩阵表。你可以把预期结果写进断言脚本里,比如"app_anon对get_payroll_data的can_execute必须为false",一旦查询结果出现true就是回归失败。

同样的,schema层的USAGE权限可以用has_schema_privilege来断言:

SELECT has_schema_privilege('app_anon', 'app', 'USAGE');

把这两类断言组合起来,就完成了三层权限链路里前两层的自动化覆盖。CI里直接跑SQL,不用人工反复点界面。

4.4 如何把测试覆盖到函数内部的SQL权限

很多测试做到has_function_privilege就停了,但我想提醒一句:EXECUTE权限通过,不代表函数内部SQL一定能跑通,也不代表安全边界一定没问题。

对SECURITY INVOKER函数,函数体里的表权限严格遵循调用者权限。也就是说,即使app_readonly有EXECUTE权限,但如果它没有函数内部查询表的SELECT权限,调用依然会报错。这种错误在权限测试里特别值得留意,因为它暴露了"函数接口与底层数据权限不一致"的问题。测试时一定要真实执行一下函数,不能只看ACL。

对SECURITY DEFINER函数,则要看另一个方向:函数属主对内部对象是否有足够权限;以及函数体内是否存在动态SQL拼接、未限定schema的引用、可被调用者干预的对象名。这类函数的测试要把代码Review和运行时验证结合起来,不能只靠黑盒。

注意:一个常见的安全误区是,把通用查询接口做成SECURITY DEFINER。如果函数内有拼接的SQL,调用者就可能通过参数注入改变语义,等于用函数属主的身份执行任意SQL。权限测试一旦发现这种函数,应该第一时间打回重做设计,而不是继续测EXECUTE权限。

关于PL/pgSQL还有一个值得注意的点:函数创建时,PostgreSQL不一定立即检查函数体里所有对象的权限,很多检查发生在第一次实际执行时。尤其是PL/pgSQL函数,定义时能创建成功,运行时才因为权限不足报错。所以"创建成功"绝不等于"权限无误",必须把执行这一步纳入测试。

5. 我踩过的几个坑:测试结果"看着对"其实全错

5.1 用超级用户做测试等于没测

有一段时间,我见过不少同事的权限测试流程是:用postgres账号连数据库,然后执行函数,发现一切正常,于是得出结论"权限没问题"。这个结论毫无意义。超级用户天然绕过所有权限检查,它在测试环境里的"成功"什么都证明不了。

正确姿势是创建一个最低权限角色,然后用它真实登录或者SET ROLE切换过去再执行。psql里可以直接用环境变量指定用户:

PGUSER=app_readonly PGPASSWORD=readonly_pwd psql -h localhost -d appdb -c "SELECT * FROM app.order_summary(42);"

更麻烦的一点是,很多人测试时用postgres身份SET ROLE app_readonly,执行完忘了RESET ROLE,后续语句也全部在app_readonly身份下执行,整个测试会话就乱套了。每次切换都养成马上复位、或者用SET LOCAL ROLE的习惯,能省很多排查时间。

5.2 连接池复用导致的角色混乱

到了应用侧,坑就更隐蔽了。很多应用使用连接池,一个数据库连接会被多个请求复用。如果某个请求为了绕过权限临时执行了SET ROLE,却没有复位,连接放回池里之后,下一个请求就会顶着上一个请求的角色继续跑。

这在权限测试里造成的现象是:测试脚本跑出来的结果一会儿正常一会儿报错,完全取决于连接池把哪条连接分配给了哪段测试。

我的建议是:涉及角色切换的测试,一律使用独立连接、独立事务,并且用SET LOCAL ROLE限制在事务内。千万不要在长连接会话里到处SET ROLE然后指望它自己清理干净。

5.3 函数重载漏补授权,线上直接404

这个问题在第3章提过,但还是要再强调一次,因为我在实际项目里因为重载漏授权吃过亏。当时有两个同名函数,参数一个是int,一个是bigint。应用里调用的是bigint版本,测试脚本授权的是int版本。结果测试环境一切正常,到生产环境一调用,白屏报错。

权限测试的最后一公里一定是对照线上真实的函数签名。建议在测试脚本开头自动拉取所有函数签名,并和权限申请单逐一比对,而不是在文档里写"给order_summary授权"然后靠人脑猜是哪个重载。

5.4 函数内部动态SQL的权限绕过风险

最后说一个可能颠覆很多人直觉的点:即便函数本身是SECURITY INVOKER,如果函数体里执行的是EXECUTE动态SQL,权限检查依然基于调用者身份,但如果函数被设计成SECURITY DEFINER,动态SQL的权限就完全看属主了。

我曾经见过一个"为了查询方便"创建的SECURITY DEFINER函数,里面用format('SELECT ...')拼接了一个可变对象名。表面上"只有内部运维角色能调用",实际上任何获得该函数EXECUTE权限的用户,都可以通过特殊参数让函数执行属主权限下的任意查询,相当于被授权者瞬间变成了半个管理员。

权限测试的时候,对这类函数要格外敏感。我的一般处理办法是:把所有SECURITY DEFINER函数拉出来单独审查,只要函数体内有动态SQL、未限定schema的表名、或者接收外部输入的标识符,一律要求重写,或者改造为最小权限模式。

最后再分享一个小习惯。我现在每次上线前,都会跑一遍"函数权限快照"脚本,把库里所有函数的属主、ACL、SECURITY DEFINER状态、是否固定search_path导出成一个视图,跟前一次上线的快照做diff。只要出现了预期之外的权限变化,就能在发布前拦截掉,而不是等应用被用户调炸了再回查。这个习惯帮我挡掉了不少次权限事故,建议你也可以在自己的测试流程里加一道这样的检查。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 5:21:55

手把手拆解K8s:从零部署集群到YAML与排错全攻略

手把手拆解 K8s:从零部署一个集群,YAML 和排错指令都给你整理好了搞 Kubernetes 也有一阵子了,从最初只知道kubectl get pods,到后来被各种组件按在地上摩擦,再到能独立把一套集群从裸机到可选网络插件、从部署应用到排…

作者头像 李华
网站建设 2026/10/17 5:21:53

NVD API 2.0与CPE匹配:Python实现漏洞预警自动抓取与影响面分析

搞漏洞监控的人都有个共同感受:CVE编号满天飞,但真正能落到自家系统上的预警,靠的是结构化的数据。NVD(美国国家漏洞库)的API 2.0是我日常拉取漏洞数据的首选,它不只给一个CVE编号,还会附带CPE&…

作者头像 李华
网站建设 2026/10/17 5:20:52

TensorFlow迁移学习实战:VGG16图像分类微调全流程

先聊个真实的场景。我之前接到一个图像分类的任务,数据只有五千多张图、十个类别,设备也只是一块普通的消费级显卡。如果从零开始训一个残差网络,大概率是过拟合加训练时间漫长,调参调到怀疑人生。但同样是这个任务,换…

作者头像 李华
网站建设 2026/10/17 5:20:48

Claude Code模板实战:用CLAUDE.md与斜杠命令驯服AI编程助手

用了两个多月 claude-code,我最大的感触不是它能写多少代码,而是没有模板的 claude-code,约等于一匹脱缰的野马。今天让它看一段代码,它给你写成一篇分析报告;明天让它重构一个模块,它顺手把无关文件也改了…

作者头像 李华
网站建设 2026/10/17 5:19:57

Windows/Linux/macOS彻底停止所有Python进程的速查指南

说实话,每个做开发的人电脑里都积压过一堆没退干净的 Python 进程。跑着的爬虫脚本停不下来,Jupyter 内核残留、FastAPI 调试进程挂在那儿,甚至还会出现“明明关闭了 IDE,但是项目端口还被占用”的诡异情况。Windows、Linux、macO…

作者头像 李华
网站建设 2026/10/17 5:18:34

异构编程实战:从核函数到访存优化的关键思路

在高性能计算异构编程这个方向上摸爬滚打了好几年,我最大的感受是:很多人把它想成“把代码搬到显卡上跑一跑”,但真正动手之后才发现,搬过去只是一个开始。异构编程改变的不只是代码的运行位置,更是你思考并行问题的方…

作者头像 李华