{"repro_id":"REPRO-2026-00362","version":6,"title":"PostgreSQL Anonymizer ≤3.1.3 arbitrary code execution as extension superuser via crafted masking constructs","repro_type":"security","status":"published","severity":"high","description":"PostgreSQL Anonymizer (gitlab.com/dalibo/postgresql_anonymizer) versions ≤3.1.3 allow an unprivileged masked database user to execute arbitrary code with the privileges of the extension superuser. Attack primitives: crafted operators, domain casts, and view subqueries evaluated in the superuser security context during masking. CVSS 8.8 (high). Reproduction plan: build a PostgreSQL instance with postgresql_anonymizer ≤3.1.3 installed (docker-friendly), create an unprivileged role, define masking rules (dynamic masking), and demonstrate code execution as the extension superuser via crafted operator/cast/view constructs; then validate against the fixed version (3.1.4+). Companion issue: CVE-2026-83534 (privilege escalation during parallel execution, same project). Published 2026-09-06. No public PoC located; GHSA advisory + repo are primary sources.","root_cause":"# RCA Report — CVE-2026-19633\n\n## Summary\n\nPostgreSQL Anonymizer (`postgresql_anonymizer`, extension name `anon`) up to\nversion 3.1.3 fails to fully validate the expressions contained in masking\nrules at the moment the rules are declared. The rule validator\n(`src/input.rs::check_function` driving `src/walker.rs::is_untrusted_walker`)\nonly inspects `T_FuncCall` parse-tree nodes and resolves them against the\n\"trusted schema / trusted function\" policy. Three classes of attacker-crafted\nconstructs reach masking-time evaluation without ever being inspected: a\n**custom operator** (an `A_Expr` node whose operator resolves to an\nattacker-defined function), a **domain cast** (a `TypeCast` through an\nattacker-defined `DOMAIN` whose `CHECK` constraint calls attacker code), and a\n**view subquery / rangevar** (a sub-`SELECT` over an attacker-owned view whose\nbody calls attacker code). Because masking (static masking via\n`anon.anonymize_table()`, parallel static masking, replica masking, etc.) is\nnormally executed by the extension superuser, these untrusted expressions are\nevaluated **in the superuser security context**, giving the unprivileged\nmasked user arbitrary code execution and full superuser escalation.\n\n## Impact\n\n- **Package/component**: `postgresql_anonymizer` (GitLab project\n  `dalibo/postgresql_anonymizer`), the `anon` PostgreSQL extension. Root cause\n  lives in the Rust/pgrx core: `src/input.rs`, `src/walker.rs`\n  (`is_untrusted_walker`), and the static-masking engine (`src/static_masking.rs`).\n- **Affected versions**: ≤ 3.1.3 (all 3.x releases; the release-notes classify\n  the risk as very high on PostgreSQL 14 and instances upgraded from\n  PostgreSQL 14 or earlier, where `CREATE` on schema `public` is granted by\n  default).\n- **Risk level**: High, CVSS 8.8. An unprivileged masked database user (any\n  role owning a schema/table, i.e. anyone allowed to declare masking rules)\n  obtains arbitrary code execution with the privileges of the extension\n  superuser — both SQL superuser (e.g. `ALTER ROLE ... WITH SUPERUSER`) and\n  OS-level command execution as the `postgres` server user\n  (e.g. `COPY ... TO PROGRAM`).\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact**: arbitrary code execution as the\n  extension superuser (CVSS 8.8, \"unprivileged masked users can execute\n  arbitrary code ... malicious code can run with elevated privileges\").\n- **Reproduced impact from this run** (full end-to-end, production path,\n  non-sanitized): all three attack primitives (custom operator, domain cast,\n  view subquery) hidden inside accepted masking rules were evaluated in the\n  superuser security context when the superuser applied the masking policy.\n  Each payload executed `COPY ... TO PROGRAM` (a superuser-only OS command\n  execution capability) and wrote marker files as the `postgres` OS user, and\n  the operator payload additionally executed\n  `ALTER ROLE attacker WITH SUPERUSER`, turning the unprivileged attacker\n  into a superuser. The fixed build fails closed with the\n  `anon.nosuperuser` barrier error and produces no markers and no escalation.\n- **Parity**: `full` (claimed code-execution impact reproduced exactly, via\n  the real PostgreSQL SQL endpoint of a real `postgres:17` server running the\n  real extension).\n\n## Root Cause\n\n`SECURITY LABEL FOR anon ON COLUMN ... IS 'MASKED WITH FUNCTION <expr>'` is\nvalidated when the label is created (`src/input.rs::check_function`):\n\n1. the top-level node must be a `T_FuncCall`;\n2. when `anon.restrict_to_trusted_schemas` is on (default), the expression\n   tree is walked with `src/walker.rs::is_untrusted_walker`, which — for each\n   `T_FuncCall` node only — resolves the (schema-qualified) function name and\n   rejects it unless the function is labelled `TRUSTED` or lives in a trusted\n   schema.\n\n`is_untrusted_walker` never resolves **operators** (`A_Expr` nodes), never\nfollows **casts to user-defined types/domains** (whose `CHECK` constraints\nexecute user-defined functions), and never expands **rangevars** (views)\nreferenced by subqueries. Consequently a rule such as\n\n```sql\nMASKED WITH FUNCTION pg_catalog.int4(i OPERATOR(attack.*) 2)\n```\n\npasses validation although `attack.*` is an operator implemented by the\nattacker's untrusted `plpgsql` function. At masking time the rule text is\nspliced verbatim into the masking statement (static masking:\n`UPDATE <table> SET i = pg_catalog.int4(i OPERATOR(attack.*) 2)` executed via\nSPI by whoever runs `anon.anonymize_table()`, in practice the extension\nsuperuser), so the untrusted function runs with full superuser privileges.\n\nThe maintainers concluded that this static, parse-time validation approach is\nstructurally unsustainable (\"some information is not available until runtime\")\nand replaced the attempt with a privilege barrier:\n[fix commit `6f102520a86bd9a9075e751d7674afb93ba7db10`]\n\"**CVE-2026-19633: Escalation via custom types, operators and rangevars**\",\nreleased in 3.2.0 (advisory wording \"3.1.4 and later\"). The fix adds the\n`anon.nosuperuser` GUC (default `on`) which makes static, parallel static,\ndynamic and replica masking **error out when run by a superuser**\n(`src/error.rs::should_not_be_superuser`), delegating the protection to\nPostgreSQL's own ACLs / least-privilege model instead of trying to validate\nuntrusted expressions.\n\n## Reproduction Steps\n\n1. Script: `bundle/repro/reproduction_steps.sh` (self-contained; run with\n   `bash bundle/repro/reproduction_steps.sh`, optionally with\n   `PRUVA_ROOT=<bundle path>`).\n2. What the script does:\n   - resolves the source (prepared project cache mirror or GitLab) and\n     anchors both commits: vulnerable `d6989f5358131159f00f60860d744df2c685918c`\n     (= parent of the fix; verify: vulnerable tree lacks the fix hunk, fixed\n     tree contains it);\n   - builds one Docker image from `postgres:17` that compiles the extension\n     twice with the upstream build (`make extension install PGVER=pg17`,\n     Rust + pgrx 0.19.1): `/opt/anon-vuln` and `/opt/anon-fixed`;\n   - for each variant, twice (fresh containers `a1`/`a2`), starts a **real\n     PostgreSQL 17 server** (`shared_preload_libraries=anon`), waits for the\n     TCP healthcheck (`pg_isready -h 127.0.0.1`), then drives the whole attack\n     through the **real SQL endpoint** (`psql -h 127.0.0.1`):\n     1. superuser: `CREATE EXTENSION anon CASCADE` + standard\n        `SECURITY LABEL ... pg_catalog.int4/upper ... IS 'TRUSTED'`;\n     2. unprivileged attacker role: creates schema/table, the three payload\n        functions, a custom operator, a domain with a malicious `CHECK`, and\n        a malicious view; declares the three crafted masking rules (they are\n        **accepted** — this is the bypass);\n     3. superuser: `SELECT anon.anonymize_table('attack_<tag>.t')` — in the\n        vulnerable build the payloads run with superuser privileges; in the\n        fixed build this fails closed with the `anon.nosuperuser` error;\n     4. evidence: `pg_roles.rolsuper` of the attacker, marker files inside\n        the container (`/tmp/pruva_marker_{operator,domain,view}_<tag>`),\n        loader evidence (installed `anon.so` md5 matches the variant build, `SHOW shared_preload_libraries` = `anon`);\n   - writes `bundle/repro/runtime_manifest.json` and\n     `bundle/repro/validation_verdict.json` and exits 0 only if both\n     vulnerable attempts escalate (markers + superuser) and both fixed\n     attempts fail closed (error message, no markers, no escalation).\n3. Expected evidence of reproduction:\n   - vulnerable trigger returns `t` from `anon.anonymize_table()`; the three\n     raw single-line marker files\n     `CVE-2026-19633-RCE-{operator,domain,view}-<tag>` exist (extracted with\n     `docker cp`, exact bytes) plus `uid=999(postgres)` files proving the OS\n     user of the server executed them;\n     `pg_roles.rolsuper = 1` for the attacker role;\n   - fixed trigger raises\n     `ERROR:  Anon: Static and dynamic masking cannot be used with a superuser if anon.nosuperuser is set to true.`\n     with all three markers absent and `rolsuper = 0`.\n\n## Evidence\n\n- Per-attempt transcripts (immutable, listed in `runtime_manifest.json`):\n  `bundle/repro/proof/{vuln,fixed}_{a1,a2}_{session,markers,loader}.txt`\n- Diagnostic logs: `bundle/logs/reproduction_steps.log`,\n  `bundle/logs/docker_build.log`\n- Key excerpts (vulnerable attempt):\n  - `proof/vuln_a1_marker_operator.txt` (exact marker bytes):\n    `CVE-2026-19633-RCE-operator-a1`, and the same for `_domain_` / `_view_`;\n    `proof/vuln_a1_uid_operator.txt` contains `uid=999(postgres)`, proving the\n    payload ran as the PostgreSQL server OS user;\n  - `proof/fixed_a1_negative_control_observation.json`: strict\n    negative-control observation (target path reached, marker absent)\n  - `proof/vuln_a1_evidence.txt`: ` attacker_a1 | 1` (unprivileged attacker\n    became SUPERUSER)\n  - `proof/fixed_a1_trigger.txt`:\n    `ERROR:  Anon: Static and dynamic masking cannot be used with a superuser\n    if anon.nosuperuser is set to true.`\n- Environment: Ubuntu 26.04 host, Docker, `postgres:17` (PostgreSQL 17.11\n  Debian), `postgresql_anonymizer` built from commit\n  `d6989f5358131159f00f60860d744df2c685918c` (vulnerable) and\n  `6f102520a86bd9a9075e751d7674afb93ba7db10` (fixed), Rust stable +\n  cargo-pgrx 0.19.1, no sanitizers.\n\n## Recommendations / Next Steps\n\n- **Upgrade** `postgresql_anonymizer` to 3.2.0+ (advisory: \"3.1.4 and later\";\n  the actual fix commit shipped in 3.2.0). Keep `anon.nosuperuser = on`\n  (default) so no superuser can execute any masking strategy.\n- Run masking jobs (static/parallel/replica) with a dedicated least-privilege\n  role, never with a superuser; the extension now enforces this.\n- Where `pg_catalog` functions must be used in rules, prefer explicit\n  `TRUSTED` labels over loosening `anon.restrict_to_trusted_schemas`.\n- Testing recommendation: adopt the upstream regression tests\n  `elevation_via_custom_operator`, `elevation_via_type`,\n  `elevation_via_rangevar` (added with the fix) into CI for all supported\n  PostgreSQL majors.\n\n## Additional Notes\n\n- **Idempotency**: the script is re-runnable: Docker layer caching makes the\n  second run fast, containers/attempt work dirs are removed before each\n  attempt, marker names are per-attempt, and both `runtime_manifest.json` and\n  `validation_verdict.json` are rewritten deterministically. Verified by\n  running the script twice consecutively (both runs exit 0 with identical\n  verdicts).\n- **Attack preconditions** (all satisfied in the PoC, matching the advisory):\n  the attacker is an unprivileged `LOGIN` role with `CREATE` on some schema\n  (table owner ⇒ may declare masking rules); a superuser later executes the\n  masking policy. The `TRUSTED` labels on `pg_catalog.int4`/`pg_catalog.upper`\n  are a benign superuser-side configuration step (documented feature), not\n  part of the attack.\n- The advisory's reproduction plan mentions dynamic masking; the\n  superuser-context evaluation path demonstrated here (and in the vendor's own\n  regression tests for this CVE) is the static-masking application\n  (`anon.anonymize_table()`), which is exactly the code path the fix guards\n  with `anon.nosuperuser`. The rule-validation bypass itself is identical for\n  every masking strategy that consumes the same rule store.\n- Companion CVEs fixed in the same release cycle but **not** part of this\n  ticket: CVE-2026-83534 (parallel masking elevation) and CVE-2026-19634\n  (import function SQL injection) — both are already fixed in the vulnerable\n  anchor commit used here (`6f10252^`), which isolates CVE-2026-19633.\n","cve_id":"CVE-2026-19633","cwe_id":"CWE-94","source_url":"https://gitlab.com/dalibo/postgresql_anonymizer","reproduced_at":"2026-09-24T17:05:59.767528+00:00","duration_secs":4372.0,"tool_calls":355,"handoffs":2,"total_cost_usd":6.52823,"agent_costs":{"claim_matcher":0.030744,"judge":0.662873,"learning_policy":0.010497,"repro":2.324346,"support":0.146552,"vuln_variant":3.353218},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.030744},"judge":{"gpt-5.6-sol":0.662873},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.010497},"repro":{"accounts/fireworks/models/glm-5p3":2.324346},"support":{"accounts/fireworks/models/glm-5p3":0.146552},"vuln_variant":{"accounts/fireworks/models/glm-5p3":3.353218}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-24T17:06:01.628990+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":11564,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":18333,"category":"reproduction_script"},{"path":"bundle/repro/Dockerfile.anon","filename":"Dockerfile.anon","size":3051,"category":"other"},{"path":"bundle/repro/anon-select.sh","filename":"anon-select.sh","size":751,"category":"other"},{"path":"bundle/repro/proof/fixed_a1_loader.txt","filename":"fixed_a1_loader.txt","size":333,"category":"other"},{"path":"bundle/repro/proof/fixed_a1_marker_domain.txt","filename":"fixed_a1_marker_domain.txt","size":43,"category":"other"},{"path":"bundle/repro/proof/fixed_a1_marker_operator.txt","filename":"fixed_a1_marker_operator.txt","size":45,"category":"other"},{"path":"bundle/repro/proof/fixed_a1_marker_view.txt","filename":"fixed_a1_marker_view.txt","size":41,"category":"other"},{"path":"bundle/repro/proof/fixed_a1_markers.txt","filename":"fixed_a1_markers.txt","size":129,"category":"other"},{"path":"bundle/repro/proof/fixed_a1_session.txt","filename":"fixed_a1_session.txt","size":3861,"category":"other"},{"path":"bundle/repro/proof/fixed_a2_loader.txt","filename":"fixed_a2_loader.txt","size":333,"category":"other"},{"path":"bundle/repro/proof/fixed_a2_marker_domain.txt","filename":"fixed_a2_marker_domain.txt","size":43,"category":"other"},{"path":"bundle/repro/proof/fixed_a2_marker_operator.txt","filename":"fixed_a2_marker_operator.txt","size":45,"category":"other"},{"path":"bundle/repro/proof/fixed_a2_marker_view.txt","filename":"fixed_a2_marker_view.txt","size":41,"category":"other"},{"path":"bundle/repro/proof/fixed_a2_markers.txt","filename":"fixed_a2_markers.txt","size":129,"category":"other"},{"path":"bundle/repro/proof/fixed_a2_session.txt","filename":"fixed_a2_session.txt","size":3861,"category":"other"},{"path":"bundle/repro/proof/fixed_c1_loader.txt","filename":"fixed_c1_loader.txt","size":333,"category":"other"},{"path":"bundle/repro/proof/fixed_c1_marker_domain.txt","filename":"fixed_c1_marker_domain.txt","size":43,"category":"other"},{"path":"bundle/repro/proof/fixed_c1_marker_operator.txt","filename":"fixed_c1_marker_operator.txt","size":45,"category":"other"},{"path":"bundle/repro/proof/fixed_c1_marker_view.txt","filename":"fixed_c1_marker_view.txt","size":41,"category":"other"},{"path":"bundle/repro/proof/fixed_c1_markers.txt","filename":"fixed_c1_markers.txt","size":129,"category":"other"},{"path":"bundle/repro/proof/fixed_c1_session.txt","filename":"fixed_c1_session.txt","size":3861,"category":"other"},{"path":"bundle/repro/proof/vuln_a1_loader.txt","filename":"vuln_a1_loader.txt","size":330,"category":"other"},{"path":"bundle/repro/proof/vuln_a1_marker_domain.txt","filename":"vuln_a1_marker_domain.txt","size":29,"category":"other"},{"path":"bundle/repro/proof/vuln_a1_marker_view.txt","filename":"vuln_a1_marker_view.txt","size":27,"category":"other"},{"path":"bundle/repro/proof/vuln_a1_markers.txt","filename":"vuln_a1_markers.txt","size":213,"category":"other"},{"path":"bundle/repro/proof/vuln_a1_uid_domain.txt","filename":"vuln_a1_uid_domain.txt","size":71,"category":"other"},{"path":"bundle/repro/proof/vuln_a1_uid_view.txt","filename":"vuln_a1_uid_view.txt","size":71,"category":"other"},{"path":"bundle/repro/proof/vuln_a2_loader.txt","filename":"vuln_a2_loader.txt","size":330,"category":"other"},{"path":"bundle/repro/proof/vuln_a2_marker_domain.txt","filename":"vuln_a2_marker_domain.txt","size":29,"category":"other"},{"path":"bundle/repro/proof/vuln_a2_marker_view.txt","filename":"vuln_a2_marker_view.txt","size":27,"category":"other"},{"path":"bundle/repro/proof/vuln_a2_markers.txt","filename":"vuln_a2_markers.txt","size":213,"category":"other"},{"path":"bundle/repro/proof/vuln_a2_uid_domain.txt","filename":"vuln_a2_uid_domain.txt","size":71,"category":"other"},{"path":"bundle/repro/proof/vuln_a2_uid_operator.txt","filename":"vuln_a2_uid_operator.txt","size":71,"category":"other"},{"path":"bundle/repro/proof/vuln_a2_uid_view.txt","filename":"vuln_a2_uid_view.txt","size":71,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":7199,"category":"other"},{"path":"bundle/repro/sql/poc_evidence.sql","filename":"poc_evidence.sql","size":431,"category":"other"},{"path":"bundle/repro/sql/poc_setup.sql","filename":"poc_setup.sql","size":4414,"category":"other"},{"path":"bundle/repro/sql/poc_trigger.sql","filename":"poc_trigger.sql","size":427,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1616,"category":"other"}]}