[SECURITY] Two privilege-escalation paths present in 3.1.3 - trusted-schema walker validates only T_FuncCall, and to_seclabel() interpolates object names unescaped
IITM Pravartak security team has identied two issues, both taking an ordinary non-superuser role to SUPERUSER. Both are present in 3.1.3, the current release, and both were confirmed by exploitation on a release build of that tag rather than by reading source.
| # | Issue | Severity | Status in 3.1.3 |
|---|---|---|---|
| 1 | anon.restrict_to_trusted_schemas validates only the T_FuncCall node type, so operators, domain casts and view subqueries carry untrusted code past it |
High | present |
| 2 | to_seclabel() interpolates the object name with no escaping, so a crafted JSON key comments out the rest of the statement - including the random tag added in 3.1.1 |
Medium | present |
Filed confidentially. Nothing is published anywhere else.
1. Please read this first - Issue 2 is not a re-report of CVE-2026-11945
Issue 2 will look at first glance like something you already fixed. It is not.
Your 3.1.1 fix (commit 952affe) closed the label sink of to_seclabel(), and it works. I verified it: the dollar-quote tag is now a fresh 128-bit fastrand value per call, the literal label_test does not appear in the installed library, and the old payload is inert - it arrives at the label validator as a single value.
The name argument of the same function was never escaped. Because the statement is emitted on one line, a trailing -- inside the name comments out everything after it, including the random tag, so the 3.1.1 fix provides no protection on that path. I reached CREATE ROLE pwn_backdoor SUPERUSER LOGIN through it on 3.1.3. Same function, different parameter.
2. On the severity I have assigned
I have rated Issue 2 Medium rather than High, to stay consistent with your own CNA scoring of the closest published analogue. The PostgreSQL project scored CVE-2026-11945 - same two functions, same caller, same precondition - at CVSS 3.1 6.4, AV:N/AC:H/PR:H/UI:R/S:U/C:H/I:H/A:H. Issue 2 has the identical shape: escalation needs a privileged operator to import a rules file whose content an attacker influenced.
NVD scored that CVE 7.5 by taking PR:L/UI:N. That does not match the behaviour: the import functions are SECURITY INVOKER, so an unprivileged caller's injected statements run as that unprivileged role and gain nothing.
Issue 1 I have rated High because it needs no privileged second party - the table owner escalates themselves. Score both as you see fit; I set this out only so the difference between the two is visible.
3. Issue 1 - restrict_to_trusted_schemas validates one node type
Root cause
check_function() and check_when() in src/input.rs pass the raw parse tree to walker::is_untrusted_walker. The walker is not shallow - it recurses correctly and rejects an untrusted function call at any depth. The defect is that it validates only one node type, T_FuncCall, at src/walker.rs:165 in 3.1.3 (:155 in 3.0.13; the function is byte-identical between the tags, and unchanged at head):
if unsafe { pgrx::is_a(node, pg_sys::NodeTag::T_FuncCall) } { ... }
unsafe { pg_sys::raw_expression_tree_walker(node, Some(is_untrusted_walker), context_ptr) } // :200T_A_Expr, T_TypeCast, T_RangeVar, T_SubLink and T_CoerceViaIO appear nowhere in src/walker.rs. Any construct that reaches code without being a function call is unvalidated:
| Construct | Node | Validated? |
|---|---|---|
schema.func(col), at any nesting depth |
T_FuncCall |
yes - correctly rejected |
col OPERATOR(schema.op) 'x' |
T_A_Expr + operator name list |
no |
col::schema.dom - payload in the domain's CHECK |
T_TypeCast |
no |
(SELECT c FROM schema.v) |
RangeVar via sublink |
no |
The one T_A_Expr elsewhere is not a schema check: src/input.rs:144 lists it among check_when()'s permitted nodes, and check_node() at :122-129 inspects only the top-level node.
Why this is a security boundary, not a hardening preference
Stating this because it is the difference between a bug report and a "working as intended" reply. Your own docs/SECURITY.md documents this as a control, under "Limit masking filters only to trusted schemas":
By default, the database owner can only write masking rules with functions that are located in the trusted schemas which are controlled by the superusers.
The permissions table in the same document places "Declare a masking rule" in the Owner column, while reserving Configure the extension, Put a mask upon a role and Start dynamic masking to the Superuser column. The trusted-schema restriction is the documented mechanism keeping a rule-writing owner inside the owner tier. Issue 1 is an owner crossing that boundary to superuser.
Reproduction on 3.1.3, as an ordinary NOSUPERUSER NOCREATEROLE NOCREATEDB role
The control matters, because it shows the guard was enforcing during the test:
-- CONTROL - plain qualified call, correctly REJECTED
... IS 'MASKED WITH FUNCTION pg_catalog.upper(evil.pwn_ctl(secret))';
-- ERROR: evil.pwn_ctl(secret) does not belong in a TRUSTED schema
-- (a) operator ACCEPTED
... IS 'MASKED WITH FUNCTION pg_catalog.upper(secret OPERATOR(evil.###) ''x'')';
-- (b) domain cast ACCEPTED (payload lives in the domain's CHECK)
... IS 'MASKED WITH FUNCTION pg_catalog.upper(secret::evil.dom)';
-- (c) view sublink ACCEPTED
... IS 'MASKED WITH FUNCTION pg_catalog.upper((SELECT t FROM evil.v))';The payload logged current_user, session_user and pg_roles.rolsuper at execution time, so privilege attribution is measured rather than inferred:
- Static masking -
anon.anonymize_table()andanon.anonymize_database(): payload ran aspostgreswithrolsuper = t, andALTER ROLE alice SUPERUSERsucceeded.alicewas reset toNOSUPERUSERbefore each run, so each success stands alone. - Dynamic masking (
anon.transparent_dynamic_masking = on): payload ran as the reading role (reader, non-superuser) and itsALTER ROLEattempt was refused - arbitrary code execution as the reader, but no escalation.
Privileges required (measured)
An ordinary login role that owns the table it labels - SECURITY LABEL FOR anon on a column is gated on relation ownership, and a non-owner gets must be owner of relation - and that can create objects in some schema it owns. A self-owned schema is enough: no grants on public, no other non-default privilege.
Suggested fix
Validate untrusted-schema references anywhere in the expression: at minimum T_A_Expr (operator name lists), T_TypeCast (cast target type, transitively a domain's constraint), and range-table entries reached through sublinks.
A denylist keyed on node tag will keep reproducing this as node types are added - consider inverting to an allowlist of permitted node tags, so an unrecognised construct fails closed. Extending tests/sql/elevation_via_operator.sql to cover the masking-rule label path would catch the class at the general control rather than sink by sink.
4. Issue 2 - to_seclabel() interpolates the object name unescaped
What is fixed, and what is not
Fixed and working (3.1.1, commit 952affe): the dollar-quote tag is now fastrand::u128(..) unconditionally. Verified at runtime on a confirmed release build - four fresh backends produced four distinct 128-bit tags.
Not fixed: the name argument of the same function. to_seclabel() interpolates it with no escaping at all for schema, role and function names, and with a naive "{}" wrapper that does not escape an embedded " for table, view, foreign-table and column names. Since format! emits a single line, a trailing -- in the name comments out the remainder of the template including the random tag. This interpolation is identical at 3.0.13, 3.1.3 and head, so it is not a regression - it was simply never the sink that got attention.
Reproduction on 3.1.3
The tag is observable through the same defect, which doubles as proof that the name is unescaped:
SELECT anon.import_roles_rules('{"skynet IS ''x''; SELECT 1": "MASKED"}'::jsonb);
-- ERROR: 42601: syntax error at or near
-- "$label_117167721746813497432814227127682747272$ MASKED $label_...$"Escalation, through both import_roles_rules and import_database_rules, ended at:
CREATE ROLE pwn_backdoor SUPERUSER LOGIN; -- executed as postgresReachability, stated precisely
Both import functions have proacl NULL (EXECUTE to PUBLIC), and schema anon still grants USAGE to PUBLIC; a brand-new unprivileged LOGIN role has both. But prosecdef is f - these are SECURITY INVOKER - so an unprivileged caller's injected statements run as that unprivileged role, and an attempted CREATE ROLE failed on PostgreSQL's own CREATEROLE check.
The public EXECUTE grant is therefore a hardening defect and a reachability fact, not by itself an escalation primitive. The escalation needs the realistic operational shape: a privileged operator importing a rules file whose content an attacker influenced. Rules files are exactly the artefact shared between environments and imported by a DBA, and export_* / import_* exist to support that workflow.
Suggested fix
Quote the object name with quote_ident (or the pgrx equivalent) rather than interpolating it, and escape embedded " in the quoted forms. Preferably build the statement so no attacker-controlled text reaches statement position at all. Independently, consider revoking EXECUTE on the four import_* / export_* functions from PUBLIC by default - the current grant means any role that can connect can drive this code path.
5. Prior-art check
| CVE | Vector | Sink | Fixed in |
|---|---|---|---|
| CVE-2026-2360 | custom operator in public |
CREATE EXTENSION |
3.0.1 |
| CVE-2026-2361 | temporary view + function | anon.get_tablesample_ratio |
3.0.1 |
| CVE-2026-9617 | code in a column identifier | k-anonymity function | 3.1.0 |
| CVE-2026-11945 | JSON key/value → label | rule-import functions | 3.1.1 |
All four are the same class - plant code in an object or a string, a later privileged operation evaluates it - and each was fixed at its own sink. Both issues above are in that class, in sinks none of those fixes covers. Issue 2 is in the same function as CVE-2026-11945 but a different parameter.
Relevant to Issue 1: tests/sql/elevation_via_operator.sql, added for CVE-2026-2360 and present at 3.0.13 and 3.1.3, exercises the extension-creation script and anon.random_id_int(). It does not exercise the masking-rule label validator, so the operator vector remains live there.
I also searched your open issues for trusted_schemas, to_seclabel, import_database_rules and walker. The only hit was #648, a functional report about the walker and nested anon.* calls in TRUSTED PL/pgSQL, which is unrelated.
Please feel free to reach out for any queries or clarifications.
Regards,
Sarath Kumar, IITM Pravartak Security team.