5 min read

PostGREShell: The PostgreSQL Bug That Turns Backup Accounts into Server Takeover

Monday night I did something I'd been putting off: I actually read the Cyera write-up on PostGREShell instead of skimming the headlines. Twenty minutes later I was SSHing into our production database box at 11pm, running SELECT rolname FROM pg_roles WHERE rolreplication; and holding my breath a little. If you run PostgreSQL, you should probably do the same tonight.

The short version: CVE-2026-6471 lets an account with the REPLICATION privilege run arbitrary code on the database server. Not superuser accounts. Regular, boring replication accounts, the kind you hand out to backup tools, CDC pipelines, and monitoring systems. Cyera found the flaw had been sitting in PostgreSQL's logical decoding since 9.4 shipped in 2014. Twelve years. It's fixed now in 18.6, 17.11, 16.15, 15.19, and 14.24, but there's an upgrade gotcha that will quietly break your CDC pipeline if you don't know about it.

Dark server room with racks of database hardware

How the bug works

Logical decoding is how external tools read changes out of the write-ahead log. A tool creates a replication slot and names an output plugin that formats the changes. Here's the part that made me wince: the plugin name from CREATE_REPLICATION_SLOT gets passed straight to the OS library loader. On Linux and macOS that's dlopen(), on Windows it's LoadLibrary(). No validation. No sanitization.

PostgreSQL does have a check for this. The SQL LOAD command passes a !superuser() flag that triggers path validation before anything gets loaded. The replication path passes nothing. The protocol parser even accepts almost any character inside a quoted plugin name, so slashes, ../ traversal, and Windows UNC paths all survive the trip. Cyera's summary was blunt: the whole bug is code execution via dlopen().

Once code runs inside the backend process, it runs as the postgres OS user. In their demo, the researchers wrote the role catalog directly and turned their replication account into a permanent superuser, plus three persistence tricks that survived a restart.

Why replication accounts are the soft spot

Every serious Postgres deployment has at least one. pgBackRest or WAL-G for backups, a Debezium pipeline for change data capture, maybe a standby in another region. All of them hold the REPLICATION attribute, and most people file those credentials under "operational plumbing" mentally. Low risk, limited blast radius.

That assumption is what makes this nasty. Exploitation needs three things: an account with REPLICATION, wal_level = logical, and a way to get a malicious library in front of the server. Windows is the most direct path since a UNC path over SMB (port 445) fetches the DLL from a machine the attacker controls without touching the disk. Linux and macOS need NFS automounting enabled. Otherwise the attacker needs to plant a file on the server first, which is still a plausible move once they have any foothold in your network.

Two details worth knowing. Cyera searched VirusTotal and found 114 malicious PostgreSQL plugins already in the wild, including miners and reverse shells. And there's no public PoC for this specific CVE yet, which means the window for patching calmly is open but shrinking.

The audit I ran

Three queries, in this order. I keep them in Snippet Ark now since I expect to run them quarterly from here on.

-- Who has REPLICATION? Trim anything that doesn't need it.
SELECT rolname, rolcanlogin FROM pg_roles WHERE rolreplication;

-- Is logical decoding even on? If it says 'replica', remote
-- exploitation is off the table for this bug.
SHOW wal_level;

-- What plugins are actually in use?
SELECT slot_name, plugin, active FROM pg_replication_slots
WHERE plugin IS NOT NULL;

The first query is where you'll likely find surprises. We had a replication account created in 2021 for a migration project that ended two weeks later. The account never left. If yours has that too, this is a good excuse.

The upgrade gotcha

Now the part that will bite people mid-upgrade. The fix introduces a whitelist parameter called output_plugin_libraries, and it defaults to just pgoutput and test_decoding. If you run anything else, wal2json and decoderbufs being the common ones, logical decoding gets refused after you upgrade until an administrator adds the library and reloads.

# postgresql.conf
output_plugin_libraries = 'pgoutput, test_decoding, wal2json'

A reload is enough, no restart needed. If you forget, the failure is visible in the server log:

ERROR:  library "wal2json" may not be used as an output plugin
HINT:  Add it to output_plugin_libraries

Check your slots before upgrading, not after your CDC pipeline pages you. One more version note: 18.5 was skipped entirely because a regression was caught before release, so don't be confused when 18.4 jumps straight to 18.6.

Locking the door anyway

Even patched, this changed how I look at database servers. Three cheap hardening moves: restrict replication connections in pg_hba.conf to the specific hosts that need them, and block outbound SMB (445) and NFS (2049) from database boxes. Neither should ever leave a database server. On Linux, turn off autofs if nothing uses it.

The uncomfortable lesson here isn't really about PostgreSQL. It's that we treat "backup account" as a synonym for "harmless account," and attackers know it. Your service credentials deserve the same audit energy as your admin accounts. I fixed our zombie migration account at 11:40pm and went to bed feeling weirdly good about it.

If you're into database performance as well as database security, I wrote earlier about Redis caching patterns, which pairs nicely with this one for anyone running a full data stack.