# 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 <expr>'` 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 <table> 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=<bundle path>`).
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_<tag>.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}_<tag>`),
        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}-<tag>` 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.
