# REPRO-2026-00362: PostgreSQL Anonymizer ≤3.1.3 arbitrary code execution as extension superuser via crafted masking constructs ## Summary Status: published Severity: high CVSS: Unknown CWE: CWE-94 (Improper Control of Generation of Code ('Code Injection')) Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00362 CVE: CVE-2026-19633 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # RCA Report — CVE-2026-19633 ## Summary PostgreSQL Anonymizer (`postgresql_anonymizer`, extension name `anon`) up to version 3.1.3 fails to fully validate the expressions contained in masking rules at the moment the rules are declared. The rule validator (`src/input.rs::check_function` driving `src/walker.rs::is_untrusted_walker`) only inspects `T_FuncCall` parse-tree nodes and resolves them against the "trusted schema / trusted function" policy. Three classes of attacker-crafted constructs reach masking-time evaluation without ever being inspected: a **custom operator** (an `A_Expr` node whose operator resolves to an attacker-defined function), a **domain cast** (a `TypeCast` through an attacker-defined `DOMAIN` whose `CHECK` constraint calls attacker code), and a **view subquery / rangevar** (a sub-`SELECT` over an attacker-owned view whose body calls attacker code). Because masking (static masking via `anon.anonymize_table()`, parallel static masking, replica masking, etc.) is normally executed by the extension superuser, these untrusted expressions are evaluated **in the superuser security context**, giving the unprivileged masked user arbitrary code execution and full superuser escalation. ## Impact - **Package/component**: `postgresql_anonymizer` (GitLab project `dalibo/postgresql_anonymizer`), the `anon` PostgreSQL extension. Root cause lives in the Rust/pgrx core: `src/input.rs`, `src/walker.rs` (`is_untrusted_walker`), and the static-masking engine (`src/static_masking.rs`). - **Affected versions**: ≤ 3.1.3 (all 3.x releases; the release-notes classify the risk as very high on PostgreSQL 14 and instances upgraded from PostgreSQL 14 or earlier, where `CREATE` on schema `public` is granted by default). - **Risk level**: High, CVSS 8.8. An unprivileged masked database user (any role owning a schema/table, i.e. anyone allowed to declare masking rules) obtains arbitrary code execution with the privileges of the extension superuser — both SQL superuser (e.g. `ALTER ROLE ... WITH SUPERUSER`) and OS-level command execution as the `postgres` server user (e.g. `COPY ... TO PROGRAM`). ## Impact Parity - **Disclosed/claimed maximum impact**: arbitrary code execution as the extension superuser (CVSS 8.8, "unprivileged masked users can execute arbitrary code ... malicious code can run with elevated privileges"). - **Reproduced impact from this run** (full end-to-end, production path, non-sanitized): all three attack primitives (custom operator, domain cast, view subquery) hidden inside accepted masking rules were evaluated in the superuser security context when the superuser applied the masking policy. Each payload executed `COPY ... TO PROGRAM` (a superuser-only OS command execution capability) and wrote marker files as the `postgres` OS user, and the operator payload additionally executed `ALTER ROLE attacker WITH SUPERUSER`, turning the unprivileged attacker into a superuser. The fixed build fails closed with the `anon.nosuperuser` barrier error and produces no markers and no escalation. - **Parity**: `full` (claimed code-execution impact reproduced exactly, via the real PostgreSQL SQL endpoint of a real `postgres:17` server running the real extension). ## Root Cause `SECURITY LABEL FOR anon ON COLUMN ... IS 'MASKED WITH FUNCTION '` is validated when the label is created (`src/input.rs::check_function`): 1. the top-level node must be a `T_FuncCall`; 2. when `anon.restrict_to_trusted_schemas` is on (default), the expression tree is walked with `src/walker.rs::is_untrusted_walker`, which — for each `T_FuncCall` node only — resolves the (schema-qualified) function name and rejects it unless the function is labelled `TRUSTED` or lives in a trusted schema. `is_untrusted_walker` never resolves **operators** (`A_Expr` nodes), never follows **casts to user-defined types/domains** (whose `CHECK` constraints execute user-defined functions), and never expands **rangevars** (views) referenced by subqueries. Consequently a rule such as ```sql MASKED WITH FUNCTION pg_catalog.int4(i OPERATOR(attack.*) 2) ``` passes validation although `attack.*` is an operator implemented by the attacker's untrusted `plpgsql` function. At masking time the rule text is spliced verbatim into the masking statement (static masking: `UPDATE SET i = pg_catalog.int4(i OPERATOR(attack.*) 2)` executed via SPI by whoever runs `anon.anonymize_table()`, in practice the extension superuser), so the untrusted function runs with full superuser privileges. The maintainers concluded that this static, parse-time validation approach is structurally unsustainable ("some information is not available until runtime") and replaced the attempt with a privilege barrier: [fix commit `6f102520a86bd9a9075e751d7674afb93ba7db10`] "**CVE-2026-19633: Escalation via custom types, operators and rangevars**", released in 3.2.0 (advisory wording "3.1.4 and later"). The fix adds the `anon.nosuperuser` GUC (default `on`) which makes static, parallel static, dynamic and replica masking **error out when run by a superuser** (`src/error.rs::should_not_be_superuser`), delegating the protection to PostgreSQL's own ACLs / least-privilege model instead of trying to validate untrusted expressions. ## Reproduction Steps 1. Script: `bundle/repro/reproduction_steps.sh` (self-contained; run with `bash bundle/repro/reproduction_steps.sh`, optionally with `PRUVA_ROOT=`). 2. What the script does: - resolves the source (prepared project cache mirror or GitLab) and anchors both commits: vulnerable `d6989f5358131159f00f60860d744df2c685918c` (= parent of the fix; verify: vulnerable tree lacks the fix hunk, fixed tree contains it); - builds one Docker image from `postgres:17` that compiles the extension twice with the upstream build (`make extension install PGVER=pg17`, Rust + pgrx 0.19.1): `/opt/anon-vuln` and `/opt/anon-fixed`; - for each variant, twice (fresh containers `a1`/`a2`), starts a **real PostgreSQL 17 server** (`shared_preload_libraries=anon`), waits for the TCP healthcheck (`pg_isready -h 127.0.0.1`), then drives the whole attack through the **real SQL endpoint** (`psql -h 127.0.0.1`): 1. superuser: `CREATE EXTENSION anon CASCADE` + standard `SECURITY LABEL ... pg_catalog.int4/upper ... IS 'TRUSTED'`; 2. unprivileged attacker role: creates schema/table, the three payload functions, a custom operator, a domain with a malicious `CHECK`, and a malicious view; declares the three crafted masking rules (they are **accepted** — this is the bypass); 3. superuser: `SELECT anon.anonymize_table('attack_.t')` — in the vulnerable build the payloads run with superuser privileges; in the fixed build this fails closed with the `anon.nosuperuser` error; 4. evidence: `pg_roles.rolsuper` of the attacker, marker files inside the container (`/tmp/pruva_marker_{operator,domain,view}_`), loader evidence (installed `anon.so` md5 matches the variant build, `SHOW shared_preload_libraries` = `anon`); - writes `bundle/repro/runtime_manifest.json` and `bundle/repro/validation_verdict.json` and exits 0 only if both vulnerable attempts escalate (markers + superuser) and both fixed attempts fail closed (error message, no markers, no escalation). 3. Expected evidence of reproduction: - vulnerable trigger returns `t` from `anon.anonymize_table()`; the three raw single-line marker files `CVE-2026-19633-RCE-{operator,domain,view}-` exist (extracted with `docker cp`, exact bytes) plus `uid=999(postgres)` files proving the OS user of the server executed them; `pg_roles.rolsuper = 1` for the attacker role; - fixed trigger raises `ERROR: Anon: Static and dynamic masking cannot be used with a superuser if anon.nosuperuser is set to true.` with all three markers absent and `rolsuper = 0`. ## Evidence - Per-attempt transcripts (immutable, listed in `runtime_manifest.json`): `bundle/repro/proof/{vuln,fixed}_{a1,a2}_{session,markers,loader}.txt` - Diagnostic logs: `bundle/logs/reproduction_steps.log`, `bundle/logs/docker_build.log` - Key excerpts (vulnerable attempt): - `proof/vuln_a1_marker_operator.txt` (exact marker bytes): `CVE-2026-19633-RCE-operator-a1`, and the same for `_domain_` / `_view_`; `proof/vuln_a1_uid_operator.txt` contains `uid=999(postgres)`, proving the payload ran as the PostgreSQL server OS user; - `proof/fixed_a1_negative_control_observation.json`: strict negative-control observation (target path reached, marker absent) - `proof/vuln_a1_evidence.txt`: ` attacker_a1 | 1` (unprivileged attacker became SUPERUSER) - `proof/fixed_a1_trigger.txt`: `ERROR: Anon: Static and dynamic masking cannot be used with a superuser if anon.nosuperuser is set to true.` - Environment: Ubuntu 26.04 host, Docker, `postgres:17` (PostgreSQL 17.11 Debian), `postgresql_anonymizer` built from commit `d6989f5358131159f00f60860d744df2c685918c` (vulnerable) and `6f102520a86bd9a9075e751d7674afb93ba7db10` (fixed), Rust stable + cargo-pgrx 0.19.1, no sanitizers. ## Recommendations / Next Steps - **Upgrade** `postgresql_anonymizer` to 3.2.0+ (advisory: "3.1.4 and later"; the actual fix commit shipped in 3.2.0). Keep `anon.nosuperuser = on` (default) so no superuser can execute any masking strategy. - Run masking jobs (static/parallel/replica) with a dedicated least-privilege role, never with a superuser; the extension now enforces this. - Where `pg_catalog` functions must be used in rules, prefer explicit `TRUSTED` labels over loosening `anon.restrict_to_trusted_schemas`. - Testing recommendation: adopt the upstream regression tests `elevation_via_custom_operator`, `elevation_via_type`, `elevation_via_rangevar` (added with the fix) into CI for all supported PostgreSQL majors. ## Additional Notes - **Idempotency**: the script is re-runnable: Docker layer caching makes the second run fast, containers/attempt work dirs are removed before each attempt, marker names are per-attempt, and both `runtime_manifest.json` and `validation_verdict.json` are rewritten deterministically. Verified by running the script twice consecutively (both runs exit 0 with identical verdicts). - **Attack preconditions** (all satisfied in the PoC, matching the advisory): the attacker is an unprivileged `LOGIN` role with `CREATE` on some schema (table owner ⇒ may declare masking rules); a superuser later executes the masking policy. The `TRUSTED` labels on `pg_catalog.int4`/`pg_catalog.upper` are a benign superuser-side configuration step (documented feature), not part of the attack. - The advisory's reproduction plan mentions dynamic masking; the superuser-context evaluation path demonstrated here (and in the vendor's own regression tests for this CVE) is the static-masking application (`anon.anonymize_table()`), which is exactly the code path the fix guards with `anon.nosuperuser`. The rule-validation bypass itself is identical for every masking strategy that consumes the same rule store. - Companion CVEs fixed in the same release cycle but **not** part of this ticket: CVE-2026-83534 (parallel masking elevation) and CVE-2026-19634 (import function SQL injection) — both are already fixed in the vulnerable anchor commit used here (`6f10252^`), which isolates CVE-2026-19633. ## Reproduction Details Reproduced: 2026-09-24T17:05:59.767Z Duration: 4372 seconds Tool calls: 355 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00362 pruva-verify CVE-2026-19633 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00362&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00362/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-19633 - Source: https://gitlab.com/dalibo/postgresql_anonymizer ## Artifacts - bundle/repro/rca_report.md (analysis, 11564 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 18333 bytes) - bundle/repro/Dockerfile.anon (other, 3051 bytes) - bundle/repro/anon-select.sh (other, 751 bytes) - bundle/repro/proof/fixed_a1_loader.txt (other, 333 bytes) - bundle/repro/proof/fixed_a1_marker_domain.txt (other, 43 bytes) - bundle/repro/proof/fixed_a1_marker_operator.txt (other, 45 bytes) - bundle/repro/proof/fixed_a1_marker_view.txt (other, 41 bytes) - bundle/repro/proof/fixed_a1_markers.txt (other, 129 bytes) - bundle/repro/proof/fixed_a1_session.txt (other, 3861 bytes) - bundle/repro/proof/fixed_a2_loader.txt (other, 333 bytes) - bundle/repro/proof/fixed_a2_marker_domain.txt (other, 43 bytes) - bundle/repro/proof/fixed_a2_marker_operator.txt (other, 45 bytes) - bundle/repro/proof/fixed_a2_marker_view.txt (other, 41 bytes) - bundle/repro/proof/fixed_a2_markers.txt (other, 129 bytes) - bundle/repro/proof/fixed_a2_session.txt (other, 3861 bytes) - bundle/repro/proof/fixed_c1_loader.txt (other, 333 bytes) - bundle/repro/proof/fixed_c1_marker_domain.txt (other, 43 bytes) - bundle/repro/proof/fixed_c1_marker_operator.txt (other, 45 bytes) - bundle/repro/proof/fixed_c1_marker_view.txt (other, 41 bytes) - bundle/repro/proof/fixed_c1_markers.txt (other, 129 bytes) - bundle/repro/proof/fixed_c1_session.txt (other, 3861 bytes) - bundle/repro/proof/vuln_a1_loader.txt (other, 330 bytes) - bundle/repro/proof/vuln_a1_marker_domain.txt (other, 29 bytes) - bundle/repro/proof/vuln_a1_marker_view.txt (other, 27 bytes) - bundle/repro/proof/vuln_a1_markers.txt (other, 213 bytes) - bundle/repro/proof/vuln_a1_uid_domain.txt (other, 71 bytes) - bundle/repro/proof/vuln_a1_uid_view.txt (other, 71 bytes) - bundle/repro/proof/vuln_a2_loader.txt (other, 330 bytes) - bundle/repro/proof/vuln_a2_marker_domain.txt (other, 29 bytes) - bundle/repro/proof/vuln_a2_marker_view.txt (other, 27 bytes) - bundle/repro/proof/vuln_a2_markers.txt (other, 213 bytes) - bundle/repro/proof/vuln_a2_uid_domain.txt (other, 71 bytes) - bundle/repro/proof/vuln_a2_uid_operator.txt (other, 71 bytes) - bundle/repro/proof/vuln_a2_uid_view.txt (other, 71 bytes) - bundle/repro/runtime_manifest.json (other, 7199 bytes) - bundle/repro/sql/poc_evidence.sql (other, 431 bytes) - bundle/repro/sql/poc_setup.sql (other, 4414 bytes) - bundle/repro/sql/poc_trigger.sql (other, 427 bytes) - bundle/repro/validation_verdict.json (other, 1616 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00362 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00362/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00362 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev