BetterSWE DigestWhat is BetterSWE?

Deep Dive · Issue 14

The Postgres Extension That Quietly Held the Database Hostage

CREATE EXTENSION runs a stranger's SQL as superuser. Here's how a scheduling extension turns that into a covert shell.

An extension package being installed into a Postgres database with a superuser key

The trust model

Installing an extension hands over the keys

An extension package being installed into a Postgres database with a superuser key

Postgres extensions are not sandboxed. When you run CREATE EXTENSION, its install script executes with the privileges of whoever ran the command, which in practice is almost always a superuser, and every function and object it creates then joins the trusted core of the database. There is no smaller box the code runs inside.

  admin runs
  CREATE EXTENSION foo
        │  (as superuser)
        ▼
  foo--1.0.sql  ──runs with──▶  superuser rights
        │
        ▼
  new functions/views  ──▶  part of trusted core
The install script inherits the caller's privileges, then its objects become trusted.

"Held hostage" here is a metaphor for covert seizure of superuser control, not ransomware or encrypted data. Several unrelated extensions, from pg_cron to logical replication, have hit the same failure class: code meant to run with reduced privilege ends up running as whoever triggers it later. Once that happens, the extension owns the database.

Flagship case: pg_cron

A duplicate job ID becomes a superuser shell

Two scheduled jobs sharing one ID, a worker executing the superuser one

pg_cron stores scheduled jobs in a table cron.job, keyed by a jobid primary key, and loads them into an in-memory hash table before it checks who is allowed to run them as superuser. A low-privileged user who owns cron.job can exploit that ordering.

ALTER TABLE cron.job DROP CONSTRAINT job_pkey;
INSERT INTO cron.job (jobid, username, command)
VALUES (42, 'postgres', 'ALTER ROLE evil SUPERUSER');
Drop the uniqueness constraint, then plant a second row with the same jobid as user postgres.

Because the in-memory job struct is built and hashed by jobid before the superuser check rejects the planted row, the legitimate task ends up pointing at the overwritten postgres entry in the hash table. The background worker then connects as postgres and runs the attacker's SQL, even with cron.enable_superuser_jobs explicitly turned off. The fix landed in pg_cron 1.6.5.

The sleeper agent

A trap that springs when an admin installs later

A dormant planted function waiting in the public schema for a future extension install

Extension install scripts often use CREATE OR REPLACE on their functions and views. If an object with that exact name and signature already exists, planted in advance by a low-privileged user with CREATE rights in the public schema, the install blindly adopts it and grants it the extension's elevated privileges. This is CVE-2022-2625, affecting PostgreSQL 10 through 14 at a CVSS of 7.1.

  1. Plant object (low-priv user creates a function in public)
  2. Wait (dormant, no symptom)
  3. Admin installs extension (CREATE EXTENSION runs)
  4. Adopted (planted object inherits extension privileges)

This is the closest thing to a literal hostage: the trap sits dormant with no immediate effect until an admin installs a specific extension, possibly weeks later. PostgreSQL 15 shrank the attack surface by revoking CREATE on the public schema from PUBLIC by default, which stops most planting, though re-granting CREATE to untrusted roles reopens it.

Why the trust model leaks

The sandbox built to shrink privilege

Nested privilege contexts with a leak escaping the security-restricted operation sandbox

Postgres separates session_user, your real login, from current_user, the effective user that a SECURITY DEFINER function or SET ROLE can switch. That mechanism was originally built in 2008 to raise privilege, then adapted to safely drop it for maintenance work like VACUUM FULL and ANALYZE, which is where the sharp edges live.

The security-restricted operation sandbox blocks state that could outlive the privileged step.

The security-restricted operation sandbox blocks anything that could persist past the privileged step, like temp tables or deferred triggers, so no "time bomb" fires after privileges revert. But five classic vectors keep letting one user's object run inside another's context: triggers, index functions, CHECK constraints, DEFAULT expressions, and rules or views.

CVE-2024-0985 shows the pattern: a late privilege drop in REFRESH MATERIALIZED VIEW CONCURRENTLY let a view's creator run arbitrary functions as whoever issued the refresh, often a superuser lured into it. The trick used CREATE RULE to turn the internal temp table into a view, escaping the sandbox at the moment privileges dropped too late; the fix shipped in Postgres 16.2.

When the cloud delegates

One injected string, then the whole build pipeline

Injection in a managed Postgres function escalating out to a Kubernetes pod and build pipeline

IBM Cloud exposed logical replication to customers through a custom create_subscription function, owned by the ibm superuser role and marked SECURITY DEFINER. It built a dblink_exec call by string-formatting user-supplied parameters without sanitizing them, so a crafted db_name or host_ip broke out of the intended statement and ran attacker SQL as superuser.

SQL injection as ibm superuser
        │
        ▼
 COPY ... TO PROGRAM  ──▶  reverse shell on the VM
        │
        ▼
 /var/run/secrets/.../token  ──▶  kubectl can-i
        │
        ▼
 imagePullSecrets  ──▶  internal CI/CD build servers
Superuser was only step one; the chain reached IBM's build pipeline (Wiz's "Hell's Keychain").

The reason this hit a managed service and not a laptop is delegation. Default Postgres only lets superusers create subscriptions, but cloud providers hand that power to customer roles, which widens the blast radius exactly where the trust model is weakest. From one injected string the researchers reached command execution, a Kubernetes service-account token, and finally the internal build pipeline.

The pattern, not one event

The database trusts what you install

Why it matters

PostgreSQL runs extension code inside its trusted core with no sandbox, and the same privilege-context failure keeps resurfacing across unrelated extensions: a scheduler, a planted function, a replication worker, a cloud helper. The common thread is code that runs as whoever triggers it later, usually a superuser. As managed services delegate what used to be superuser-only operations, that gap moves from a theoretical footgun to a real multi-tenant exposure, which is why extension provenance and schema privileges are worth auditing rather than trusting by default.

The short version

  • `CREATE EXTENSION` runs an install script with the caller's privileges, so extensions join the database's trusted core with no sandbox.
  • pg_cron's job-ID collision let a low-priv user run SQL as `postgres` even with superuser jobs disabled, fixed in **1.6.5**.
  • CVE-2022-2625 lets a dormant planted object get adopted and elevated when an admin later installs an extension; Postgres 15 revoking `CREATE` on `public` shrinks this.
  • The `SECURITY DEFINER` and security-restricted-operation model keeps leaking through triggers, DEFAULT expressions, rules, and late privilege drops.
  • Managed clouds delegating subscription creation turned this trust gap into real superuser-to-pipeline compromise (Wiz's "Hell's Keychain").

Sources

  1. 8.Constructing a Postgres Privilege Escalation Exploit - saites.dev
  2. 9.Mitigating PostgreSQL Privilege Escalation in Versions 10 to 14 | EDB
  3. 10.PostgreSQL: Privilege Escalation Vulnerability via pg_cron · Advisory · google/security-research · GitHub
  4. 11.Hell’s Keychain: Supply-chain vulnerability in IBM Cloud Databases for PostgreSQL allows potential unauthorized database access | Wiz Blog