- CVSS 3.0: 8.8 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
- CWE: CWE-122 (Heap-based Buffer Overflow)
- Affected: all PostgreSQL versions before 18.2 / 17.8 / 16.12 / 15.16 / 14.21
-
Component:
contrib/pgcrypto -
Fix commit:
379695d3cc70d040b547d912ce4842090d917ece(Feb 8, 2026, Michael Paquier) - Discovered by: Team Xint Code at ZeroDay.Cloud 2025, an autonomous AI code analysis tool
This bug has existed since pgcrypto was first contributed in 2005 — roughly 20 years. pgcrypto is a trusted extension, meaning any role with CREATE privilege on a database can install it without superuser access. Application roles are routinely granted exactly that privilege.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
SELECT pgp_pub_decrypt_bytea($1::bytea, $2::bytea);
That's the entire barrier between an attacker with authenticated SQL access — stolen credentials, a SQL injection point, lateral movement from a compromised host — and the vulnerable code path.
Where the bug lives
File: contrib/pgcrypto/pgp-pubdec.c
Function: pgp_parse_pubenc_sesskey()
This function parses a PGP public-key encrypted session key packet. It decrypts the RSA/ElGamal payload, derives the session key length from the decrypted message as msglen - 3, and copies that many bytes into ctx->sess_key.
Pre-patch code:
/*
* got sesskey
*/
ctx->cipher_algo = *msg;
ctx->sess_key_len = msglen - 3;
memcpy(ctx->sess_key, msg + 1, ctx->sess_key_len);
ctx->sess_key is a fixed-size array inside PGP_Context, capped at PGP_MAX_KEY (256 bits / 8 = 32 bytes). But sess_key_len is derived straight from msglen, and msglen comes from the RSA/ElGamal modulus size minus PKCS#1 v1.5 padding. An attacker who controls the PGP message controls msglen, which flows unchecked into the memcpy length — pushing well past the 32-byte destination with no bounds check at all.
Why the heap layout makes this so exploitable
Inside decrypt_internal(), allocations happen in this order:
[ctx (PGP_Context, holds sess_key)] → [src (MBuf)] → [dst (MBuf)]
An MBuf holds pointers describing a buffer: start (data), cursor (read_pos), and end (data_end). Because the overflow originates in sess_key, it runs directly through the adjacent src struct and into dst. The gaps between palloc chunk headers are deterministic, so an attacker can preserve valid-looking header bytes inside the overflow payload — the corrupted allocations still free cleanly through pfree(), with no crash to give the attack away.
PostgreSQL's process model compounds this. Each connection gets its own backend via fork() from postmaster, and the child inherits the parent's address space. Every connection to the same postmaster shares an identical ASLR layout, so an address leaked on one connection stays valid on the next.
The three-stage exploit chain
Stage 1 — Information leak. The overflow payload partially overwrites dst->data with two null bytes, redirecting it to a lower heap address. When decrypt_internal() returns, mbuf_steal_data(dst) returns everything between the corrupted dst->data and the original dst->data_end — a window of heap memory containing stale PIE text pointers and heap addresses. One call yields both the PIE base and a heap reference.
Stage 2 — Arbitrary write. The attacker opens a fresh connection (same address space) and triggers the overflow again. This time all four dst fields — data, data_end, read_pos, buf_end — are forged to point at a target address. A PGP_PKT_SYMENCRYPTED_DATA packet later in the same PGP message writes attacker-controlled plaintext through the forged dst, producing an arbitrary-address, arbitrary-value write primitive.
Stage 3 — Privilege escalation to RCE. The target is CurrentUserId, a global in PostgreSQL's .data section that holds the OID of the currently authenticated role. Overwriting it with 10 (BOOTSTRAP_SUPERUSERID) grants superuser for the rest of the session. From there, COPY (SELECT ...) TO PROGRAM '{command}' executes arbitrary OS commands as the user running the PostgreSQL daemon.
Inside the public PoC (poc.py)
The published exploit (var77/CVE-2026-2005) breaks the above into 7 concrete steps:
-
Heap pointer leak — corrupt the
mdstbuffer's malloc chunk header. When PostgreSQL callspfree()on it, the allocator's error message leaks the pointer address, revealing the heap location ofmdst->data. -
Arbitrary read — a second overflow overwrites
mdst->datato point atleaked_ptr - 0x10000. After decryption,mbuf_steal_data()returns the contents at that address as the decrypted output, dumping heap memory that may contain stale code pointers. -
Pointer scan — the dump is scanned for 8-byte little-endian values that look like code addresses (
>= 0x500000000000) but sit outside the heap region — candidate PIE pointers. -
PIE base voting — each candidate is tested against ELF symbol offsets from the postgres binary. Every
(addr - sym_offset)pair whose page offset matches casts a vote; candidates with 10+ votes survive, and the smallest base (PIE is the lowest-mapped segment) wins. -
PIE base validation — using the arbitrary read, the exploit reads
CurrentUserIdatcandidate_base + current_user_offsetand checks it against the session's known OID to confirm the base. -
Arbitrary write setup — the overflow corrupts both
msrcandmdstMBuf headers in one shot. Forgedmsrc->data/read_pospoint at an embedded symmetric-encrypted packet holding the superuser OID (10); forgedmdst->datapoints atCurrentUserId - 4— the-4compensates forSET_VARSIZEinpgp-pgsql.c:533, which writes the output length into the first 4 bytes. -
Privilege escalation — with
CurrentUserIdnow10(bootstrap superuser),COPY FROM PROGRAMruns the specified OS command as the postgres system user.
The fix
The patch adds a bounds check against PGP_MAX_KEY before the memcpy ever runs:
// contrib/pgcrypto/pgp-pubdec.c, pgp_parse_pubenc_sesskey()
unsigned sess_key_len;
...
sess_key_len = msglen - 3;
if (sess_key_len > PGP_MAX_KEY)
{
px_debug("incorrect session key length=%u", sess_key_len);
res = PXE_PGP_KEY_TOO_BIG;
goto out;
}
/*
* got sesskey
*/
ctx->cipher_algo = *msg;
ctx->sess_key_len = sess_key_len;
memcpy(ctx->sess_key, msg + 1, ctx->sess_key_len);
A new error code was added to contrib/pgcrypto/px.h:
#define PXE_PGP_KEY_TOO_BIG -111
with a matching message registered in contrib/pgcrypto/px.c:
{PXE_PGP_KEY_TOO_BIG, "Public key too big"},
The full patch spans 8 files and ships a regression test (contrib/pgcrypto/sql/pgp-pubkey-session.sql) plus the script that generates its test vectors (contrib/pgcrypto/scripts/pgp_session_data.py). The test feeds pgp_pub_decrypt_bytea() a PGP message crafted with an oversized session key length and asserts it now fails cleanly with ERROR: Public key too big.


Top comments (0)