MBE lab6C walkthrough
About MBE
Some time ago I came across RPISEC's free Modern Binary Exploitation course (https://github.com/RPISEC/MBE) which I can't recommend enough. You get lectures, challenges and a ready out-of-the-box operational Ubuntu VM to play with. Yup, this course is Linux-focused, which made it a great extension to my recently passed OSCE (which is, or at least was at the time, Windows-only). After completing only about 16, maybe 17 challenges (there are ten chapters, 3 challenges each => 30 + 2 additional challenges with no source code provided) I can conclude I learned comparably as much as doing my OSCE, but quite different knowledge (again, different OS and also different techniques), which again is great. And finally got myself together to put some of my notes out here. If you don't feel like doing but would like to get the feel, this is a read for you.
How it works
Our environment is the VM provided RPISEC (can be found here https://github.com/RPISEC/MBE/releases/download/v1.1_release/MBE_VM.vmdk.gz).
The target program is usually a setuid binary, running with its owner's effective uid. If we can execute arbitrary code, we steal the flag which is always located in /home/<USER>/.pass (which is a clear text unix password for that user account), whereas <USER> corresponds to the current target level. E.g. lab6C is the start user for the level 6, lab6B is the target user, hence /levels/lab06/lab6C is a setuid binary owned by lab6B so we obtain the pass and therefore can advance to the next level. Please refer to RPISEC's github page to find all info, including credentials, slides, resources and so on.
lab6C
This challenge (https://github.com/RPISEC/MBE/blob/master/src/lab06/lab6C.c) is the first one from level 6, which should be done with ASLR turned on for all the time.
This is how the program behaves when we're not trying to abuse it (it does not really send our 'tweet' anywhere, just internal buffer operations):
Now, spoiler alert, first a quick glance at the source code to see where the vulnerability is.
First, there are some self-explanatory definitions:
Then it gets more interesting:
We have a secret_backdoor() function which simply reads up to 128-byte string from the standard input and then performs the libc system() wrapper on the exec() syscall (with a fork() and sh). The function is not explicitly called anywhere from the code, so it's clear we are not going to need a shellcode here; it's all about redirecting the execution to this function.
Now, to the vulnerability. We have several functions calling each other, so let's go through them in the order of the call sequence.
First, we have a standard main() function:
And here is the handle_tweet() function:
So, a local instance of the savestate structure (which was declared in the beginning of the file) is defined here, locally, on the stack.
username and msglen fields are initialized, then there are two two calls; set_username() and set_tweet(), respectively. Both calls take a pointer to the save instance of the savestate structure (so the pointer will point at the handle_tweet() function's stack). And this is the stack we are about to overflow (we'll get to how in a minute) to redirect the execution flow, overwriting handle_tweet's saved RET pointing back to main (the next instruction after the handle_tweet() call, which is just a return EXIT_SUCCESS;.
To illustrate, this is a simplified stack layout while inside of the handle_tweet() function, after the local struct was defined, but before the two set_username() and set_tweet() calls:
We will overwrite the save.tweet buffer outside its 140 bytes and write down over the username, msglen and then the saved RET.
Once the handle_tweet() function call returns, instead of going back to the last instruction of main(), the execution flow will go to our secret_backdoor() function.
So, the overflow must be possible in one of the two set_username(), set_tweet() functions. They both take a pointer to that buffer, so they can operate on it.
Let's see the set_username() function then:
Looks OK at the first glance. The devil's in the details (line 75):
for(i = 0; i <= 40 && readbuf[i]; i++) // this is where the problem starts
save->username[i] = readbuf[i]; //write
The <= conditional operator (instead of just <) is the culprit here. Instead of being able to write up to 40 bytes of the username, we can write 41. One byte more - which is enough to overwrite the previously initialized value of 140.
So once the set_username() call returns, the username is set, while the msglen is set to an arbitrary value that we will smuggle in the additional 41-th byte provided as the username.
This is how the second function, set_tweet(), looks like:
So the function has a quite big (1024 bytes, even too big for our needs) local buffer. To keep the big picture clear, this is how the stack will look like inside the set_tweet() function call, after calling fgets(), but before calling strncpy():
And this is where the buffer overflow that will allow us to overwrite the bottom saved RET occurs (lab6C.c:59):
strncpy(save->tweet, readbuf, save->msglen);
If we provide an arbitrary one-byte integer value higher than 140 in the 41-st byte of the username, we'll then be able to write more than 140 bytes from the 1024-byte local buffer, starting at the savestate.tweet address, up until the saved RET to overwrite with the address of the secret_backdoor() function.
Controlling the message length
Let's start simple and crash the program.
As at the time I started this I did not know a better way to provide arbitrary (non-printable) input using standard input/output without actual coding, here is how I was doing it (using two console windows simultaneously):
1) in one console window, I touched a file to use as an input buffer: /tmp/input6C
2) in the second window, I ran the following to have the program read all the input from that file as it appears:
gdb /levels/lab06/lab6C
[... once gdb loaded ....]
run < `tail -f /tmp/input6C`
In the first window I could then play with the printf command, putting arbitrary bytes into the /tmp/input6C, so they would go to the standard input of the target process.
We know we would need at least 140 + 40 + 8 bytes to overwrite the saved RET. Should actually be more, considering saved EBPs (stack frames) and function arguments. Something around 200. To find out how many bytes exactly do I need to overwrite to control the EIP, I used pattern_create output (some folks prefer to use the one provided with metasploit, I use one of the python implementations that can be found on github).
Already knowing that the 41-st byte of the first input line is the integer controlling the message length, I knew the username should look like this:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\0xff
We set the new message length to maximum value possible 0xff (255), to make sure we overwrite the saved RET without caring what else do we overwrite.
The next line should be the pattern_create output, so here goes (this is actually pattern_create 400
output):
lab6C@warzone:/tmp$ printf "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xff" >> input6C
lab6C@warzone:/tmp$ echo "" >> input6C
lab6C@warzone:/tmp$ echo "Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9Af0Af1Af2Af3Af4Af5Af6Af7Af8Af9Ag0Ag1Ag2Ag3Ag4Ag5Ag6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4Ai5Ai6Ai7Ai8Ai9Aj0Aj1Aj2Aj3Aj4Aj5Aj6Aj7Aj8Aj9Ak0Ak1Ak2Ak3Ak4Ak5Ak6Ak7Ak8Ak9Al0Al1Al2Al3Al4Al5Al6Al7Al8Al9Am0Am1Am2Am3Am4Am5Am6Am7Am8Am9An0An1An2A" >> input6C
Sending that input to the target process attached to gdb, reading from tail -f /tmp/input6C
, resulted in this:
Guessed arguments:
arg[0]: 0xbffff518 --> 0xb7fd8000 (">>: >: Welcome, ", 'A' <repeats 40 times>, "\377>: Tweet @Unix-Dude\n")
arg[1]: 0xbffff0f0 ("Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7[...]Ag2Ag3Ag4Ag5Ag"...)
arg[2]: 0xff
Invalid $PC address: 0x67413567
[------------------------------------stack-------------------------------------]
0000| 0xbffff5e0 ("6Ag7Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4\277\064\366\377\277$ ")
0004| 0xbffff5e4 ("Ag8Ag9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4\277\064\366\377\277$ ")
0008| 0xbffff5e8 ("g9Ah0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4\277\064\366\377\277$ ")
0012| 0xbffff5ec ("0Ah1Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4\277\064\366\377\277$ ")
0016| 0xbffff5f0 ("Ah2Ah3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4\277\064\366\377\277$ ")
0020| 0xbffff5f4 ("h3Ah4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4\277\064\366\377\277$ ")
0024| 0xbffff5f8 ("4Ah5Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4\277\064\366\377\277$ ")
0028| 0xbffff5fc ("Ah6Ah7Ah8Ah9Ai0Ai1Ai2Ai3Ai4\277\064\366\377\277$ ")
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Stopped reason: SIGSEGV
0x67413567 in ?? ()
So yeah, the saved RET was overwritten, as set_tweet() read whole 400 bytes of the pattern written to the readbuf, while msglen set to 255 made strncpy() copy 255 bytes from it to the save.tweet buffer, overwriting everything the entire save structure and the saved RET below it as illustrated on the diagram above.
0x67413567 in ?? ()
means this is what we wrote to the saved RET, and, in consequence, what went to the EIP register. The program crashed (segmentation fault), as this is not a valid address in its virtual address space). It's a unique 4-character sequence from the 400-byte pattern string we used.
To see what is the exact number of bytes between the beginning of our controlled buffer and the saved RET we run the pattern_offset tool (comes along with pattern_create) with it as argument, so it calculates this for us:
ewilded@localhost:~$ pattern_offset 67413567
hex pattern decoded as: g5Ag
196
So far so good.
For starters, to make this process simpler, we are going to develop this exploit with ASLR disabled. Once we think the exploit's ready, we turn ASLR back on (use the gameadmin:gameadmin credentials to get sudo su on the VM):
root@warzone:/home/gameadmin# echo 0 > /proc/sys/kernel/randomize_va_space
root@warzone:/home/gameadmin# cat /proc/sys/kernel/randomize_va_space
0
OK, let's peek where the secret_backdoor() function is (from attached gdb):
gdb-peda$ p secret_backdoor
$1 = {<text variable, no debug info>} 0x8000072b <secret_backdoor>
So, after our 196 bytes of garbage, we should put 0x8000072b into our buffer to move the execution to the secret_backdoor() function (and then the last thing would be to provide a command to execute).
We can already say using this address won't work because it contains a nullbyte (doesn't go well with string-operating functions like fgets()).
Also, we know this address will be randomized with ASLR on, so using a fixed address won't do. Without leaking the memory layout somehow and calculating the address based on known offsets, we could either bruteforce (just keep running the exploit until our hardcoded address happens to be the correct one... this is just a 4-byte address as we're dealing with 32-bits, which is bad enough, while with x64 the likelihood is practically never)... Or perhaps perform so called partial overwrite instead.
Partial overwrites
ASLR only partially randomizes virtual addresses - which means only some of the bytes (the more significant ones, 'on the left') are hard to predict, while the least significant bytes (the ones 'on the right') - which are just the offsets within the code segment and are known to us as long as we can read the binary - stay untouched.
For example, 0x8000072b under ASLR becomes 0xbf76072b.
So, the OS does partial ASLR on the more significant bytes, leaving the least significant bytes alone. Thus, to keep things fair, we do a partial overwrite too, but on the least significant bytes (so we only overwrite one or two bytes instead of all 4), while leaving the two more significant bytes alone, because they already have the proper valid values set by the OS and we don't need to know them at all to attain a valid ASLR-ed address (as long as we're redirecting the execution to an instruction in the same text segment).
Of course partial overwrites are not always possible. In this case, we can use 196 bytes of garbage + 2 bytes of arbitrary offset within the code segment to change the saved RET to the address of secret_backdoor().
Moving on with the exploit
So, our exploit is (we're still playing without ASLR yet):
echo -e "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xff" >> input6C
echo -e "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\x2b\x07" >> input6C
echo "cat /home/lab3C/.pass" >> input6C
And it fails like this:
Legend: code, data, rodata, value
Stopped reason: SIGSEGV
0x000a072b in ?? ()
Interestingly, the newline character got copied in. Also, for some reason, the next character was nullified, just like the entire string was copied instead of just 198 bytes we wanted.
Oh right. This is because we're still overwriting the msglen with the maximum possible value of 255 (0xff). Instead, we should use 0xc6 (198).
Ironically, before I realized this little mistake, a managed to search for existing solutions to peek from in case I got stuck and found this amazing repository:
https://github.com/Corb3nik/MBE-Solutions
So I looked at the lab6C solution only to discover that it is using pwnlib (true awesomeness, making exploit dev much easier and allowing me to ditch the retarded tail -f thing :D).
After carefully analysing the code I decided to just give it a go, but from the very beginning I knew something wasn't right (line 12):
payload = p8(0xff) * 196
payload += p32(0xb775d72b)
The payload sent to the program as the 'tweet' content consists of 196 bytes (49 dwords) + a dword -> 200 dwords. So, the last dword 0xb775d72b does not seem to be a partial overwrite, but a full overwrite with a fixed address instead.
The only explanation I thought of was that the author left the PoC with a fixed value of secret_backdoor() function from the non-ASLR version of the exploit - or extracted the information about the memory layout from somewhere else and calculated the address with ASLR on. Anyway, I knew it would not work on my VM and guess what - it in fact didn't :D
So I decided to take corb3nik's solution code as a template and modify it so I could attach to the running process with gdb once its PID is known and then see exactly what's going on:
https://github.com/ewilded/MBE-snippets/blob/master/lab6C/ex_attempt.py
Setting the context.log_level
variable to = 'debug'
showed the real awesomeness of pwnlib, displaying all the input/output exchanged with the app in hex, revealing all the non-printable characters along with how many bytes were received/sent.
Very, very helpful.
So, I made this version that worked on non-ASLR:
https://github.com/ewilded/MBE-snippets/blob/master/lab6C/ex_attempt2.py
And did not want to work once I switched ASLR back on.
So I ran the debugger again, only to see that the text segment addresses changed from non-ASLR 0x80000XXX to ASLR-ed 0xb77YYXXX (whereas XXX is the Relative Virtual Address - the fixed offset within the segment, while YY is the only really randomized part).
For example, secret_backdoor() had, depending on the instance, values like:
`0xb775e72b`
`0xb773a72b`
`0xb77dd72b`
So e != a (the '7' halfbyte remains unaffected) and we can't do partial half-byte writes... Which in this case can be simply and non-elegantly solved with a small bruteforce. Just stick to any fixed second least-significant byte value you see in gdb, in my case such as 'a7', 'e7', '17' and so on. Statistically one in 16 attempts should work, in my case the result was more like one in 8), which in this case (a local console app) is acceptable - it just has to be kept in mind this exploit is not 100% reliable (https://github.com/ewilded/MBE-snippets/blob/master/lab6C/ex_attempt2_aslr.py).