Binary Exploitation Challenge Writeups
Here are my writeups from any Binary Exploitation Challenges I have done. Mainly from CTF Workshop which you can download yourself and follow along if you like.
Recently, I’ve been learning Binary Exploitation and Exploit Development, I have been putting my notes into my Binary Exploitation Notes page, and after a few weeks of learning, I’m starting to get the hang of some Buffer Overflow concepts, so I thought I would do some writeups on some challenges I have been doing.
Buffer Overflows.
Challenge 1: BOF1
The first challenge we have is BOF1, which is a 32-bit executable, with no RELRO, no canary, no PIE/ASLR, however NX is enabled, which means we cannot jump to custom shellcode for example.
Running the file with some input doesn’t do anything, so lets have a look at the file in Ghidra
Ghidra shows us the main
function, which asks the user to scan in input, the input
variable has a 64 byte buffer, after that, the function checks to see if the local_14
variable is “not-equal-to” (!=
) 0, if it isn’t, then it will print “you win!” and call system("/bin/sh")
which will pop a shell.
To exploit this, we just need to scan in more than 64 bytes into the input! Which should be easy, just spam A
’s!
Now that was easy, but kinda boring, lets move onto the next challenge.
Challenge 2: BOF2
The second challenge has the same protections and compilation as the BOF1 binary, 32-bit executable, no RELRO, no canary, no PIE/ASLR, but the NX bit is enabled
Running the file and giving it some random input, we can see the binary thinks we suck because we didn’t exploit it!
Lets take a look at the binary in Ghidra and see what it’s doing…
Now I renamed some of the variables here to make it a bit easier to understand (you can do this by clicking on a variable and pressing “L” on your keyboard to change the name).
The binary has a few variables, the ones that matter are input
and isDEADBEEF
. input
has a buffer size of 64 bytes, and isDEADBEEF
is at first set to 0. Then, the user input is scanned in, after that, a check is made to see if the isDEADBEEF
variable is set to 0x21524111
. If we hover over this value, we can see that in hex it translates to DEADBEEF
We can also see this more clearly in the disassembly without having to hover over the 0x21524111
value:
As you can see the !=
operator is in the if statement again, remember that means “not-equal-to”, in this case, if the isDEADBEEF
variable is NOT equal to 0xdeadbeef
, then the binary prints out “you suck!” and we lose.
In order to get the binary to print out “you win!” and pop a shell, we need to write 0xdeadbeef
to the end of our input, lets write an exploit using pwntools
to do this.
You can copy the above exploit below:
from pwn import *
target = process("./bof2") # set the process we are targeting
payload = b""
payload += b"A"*64 # fill up the `input` variable with 64 bytes, since it has a 64 byte buffer
payload += p32(0xdeadbeef) # write 0xdeadbeef with 32-bit endianness, since the binary is 32-bit
target.send(payload) # fire the payload
target.interactive() # go into interactive mode
This is what the exploit is doing:
- importing the
pwntools
library - setting the target process to
bof2
- building a payload which sends 64 A’s into the input, and because the input buffer is 64 bytes this will fill it up completely
- it then writes
0xdeadbeef
in 32-bit endianness to the end of the payload, which should check out as good when theif
statement checks to see if it matches - it then sends the payload and starts interactive mode
Lets see if it worked:
As you can see, it worked perfectly and we popped a shell!
Challenge 3: BOF3
The next binary is the exact same as the last two binaries, we load it up, input something, and it tells us we suck. It also has the same protections
Lets dive into Ghidra to see whats going on in the background:
In the main
function, we can see that we have a local_14
variable which calls the lose
function, then we have our input scanned in, which again has a 64 byte buffer, then finally, the local_14
variable is called which in turn calls the lose
function.
In the list of functions, we can see the lose
function, and another hidden function which isnt called anywhere else named win
The lose
function just prints “you suck!” however the win
function prints “you win!” and gives us a shell:
So, how can we call the win
function before the lose
function is called?
Simple, by overwriting the return address with the memory address of the win
function…
In this case, the memory address of the win
function is 0x080484ab
which you can see highlighted in green below:
What I mean by overwriting the return address is, when a function is called and it completes (for example, the lose
function), the return()
function is called at the end, which contains a memory address which either calls back to the main
function memory address, another function memory address (if we were in a loop for example) or exits entirely.
REMEMBER: The eip
register (rip
on 64-bit binaries) is the Instruction Pointer, if the next process in the code flow is to return back to another function for example, the eip
register would have the memory address of the function to return to inside it
Lets take a look at the way this would be exploited in GEF debugger before writing anything. (Feel free to use your favorite debugger, aka plain old GDB, pwndbg, r2 or PEDA):
I’m going to debug it in GEF:
As you can see, there is a line at main+36
which reads 0x08048518 <+36>: call 0x80483a0 <__isoc99_scanf@plt>
, this is where our input is scanned into the binary, I’m going to set a breakpoint at the instruction just after this one at main+41
or 0x0804851d
using b *0x0804851d
Once this breakpoint is set, I can run the program, and it will ask me for my input, since we know the buffer of the input
variable is 64 from Ghidra and I am doing a demonstration, I’m going to use this buffer overflow pattern generator to generate a 64 character long pattern:
Once I run the program and paste this 64 character long pattern, anything AFTER those 64 characters should go into the eip
register, to prove this, I’m going to add 4 B’s to the end of this 64 character pattern, making it a total of 68 characters, overflowing the buffer by 4 characters.
If everything goes correctly, the eip
register will be filled with BBBB
As you can see, we hit our breakpoint, but the eip
register has not been filled up yet, that is because we need to continue through some more instructions before until we see the return address being called… You can use the ni
command in most GDB-based debuggers to do this:
After one ni
, you can see the stack being filled up, but the eip
register still hasnt changed yet:
After two ni
, we can see the eax
register has been filled with “BBBB”, this is a good sign:
And after a third ni
, boom! We can see the program crashed and is no longer running, and the eip register has been filled with “BBBB”:
Perfect! So, now all we have to do to exploit the program and call the win
function is to replace the “BBBB”’s we filled the eip
register up with, with the memory address of the win
function, lets write an exploit for this with pwntools!
Here is the exploit for you to copy:
from pwn import *
target = process("./bof3")
payload = b""
payload += b"A"*64
payload += p32(0x80484ab)
f = open("payload", "wb") # make "payload" file
f.write(payload) # Write payload to "payload" file
f.close() # finito
target.sendline(payload)
target.interactive()
And when we run the exploit:
We successfully overwrote the eip
register with the win
function memory address, called the win
function which printed “you win!”, and popped a shell.
Now, you’re probably wondering what the whole f = open
and f.write(payload)
business is about. That is just the script outputting the payload to a file called “payload”, you can use these files with GDB-like debuggers to input the payload straight into the execution of the binary, like below, you can see inside the “payload” file is all 64 bytes of A’s to fill up the buffer until we get to the return address, then the final ��^D^H
, is actually the win
functions memory address in 32-bit endianness.
This payload can be ran within GEF and other debuggers like this:
As you can see above, I used r < payload
to run the program with the “payload” string as the input, and we got the “you win!” message.
ROP
Challenge 1: ROP1
The first ROP challenge fried my head a bit, but I managed to figure it out. The file is a 32-bit executable, with no RELRO, no canary, no PIE/ASLR, but it does have the NX bit enabled:
Opening the file in Ghidra, the main
function just calls the func
function.
Inside the func
function, the porgram scans in our input into a 64 byte buffer and we have a check that is similar to the previous BOF2 challenge, however, no win
function is called after the check is complete, the program just exits:
On the other hand, we do have a win
function, at memory address 0x80484ab
So, what do we need to do in order to exploit this?
- We need to overflow the buffer with 64 bytes
- We need to complete the
deadbeef
check in thefunc
function - We need to fill up the rest of the buffer with padding that will reach the eip (return address) register
- We need to overwrite the eip (return address) register with the memory address of the
win
function
Since we have done the first deadbeef
check before in one of the previous challenges, we know how that is going to work in the exploit:
But what about overwriting the return address?
Well, first off we need to find the offset between our scanned in input, and the eip
register (return address), which can be found in Ghidra or GEF
In Ghidra:
The [-0x50]
is the offset between the input
(start of our scanned in input) and the eip
register (return address)
In GEF:
This is a bit more complex, but will give 100% accurate results, as sometimes, Ghidra doesnt figure out the offsets, or something, idk i heard it somewhere. Either way its good to know both methods
First off, set a breakpoint for just after the input is entered:
Second, run the binary and input something, I inputted 33012002
, and use the search-pattern
and i f (shortened version of "info frame")
commands to get the addresses of the start of our input, and the eip
register
As seen above, the start of my input is at 0xffffd0dc
, and as you can see near the bottom, the eip
at 0xffffd12c
If you go into any hex calculator or just use Python, you can figure out the offset by subtracting the value of the start of the input with the eip
register, like this:
As you can see, we get the same offset from Ghidra, which is 0x50
, or 80
in decimal (this will become important soon)
So, we have the first check done by filling up the input buffer with 64 bytes and adding 0xdeadbeef
to the end of it, but if we just run that, we dont actually win, we just don’t get the “you suck!” message
We still need to overwrite the return address with the address of the win
function. Now, we know that the offset is 0x50
which is 80
bytes (0x50 in decimal is 80), and we have already filled up 68 bytes of the stack. The reason i say 68 bytes instead of 64 is because we filled up the initial buffer with 64 A’s, then added the final 0xdeadbeef
onto the end which allowed us to complete the inital check, and since deadbeef
is 4 bytes (de
, ad
, be
, ef
) (each 2 letters is 1 byte), we get a total of 68 bytes filled up because 64 + 4 = 68
.
So, we have already filled up 68 bytes of the stack, and we need to pad the rest in order to get to the eip
register so we can overwrite it, so lets do 80 - 68 = 12
, so we need to pad 12
more bytes in order to reach the eip
register (return address)
(For less confusion, the initial A’s are for the first 64 byte overflow, and the B’s are for the padding to the ```eip`` register)
After we have done that, we are now at the eip
register (return address) and we should now be able to add the address of the win
function to the end of our payload, and that should overwrite the return address with the address of the ````win``` function
The address of the win
function is 0x080484ab
and can be found in Ghidra, seen below highlighted in green:
Below is the complete exploit, which you can copy if you want:
from pwn import *
target = process("./rop1")
payload = b""
payload += b"A"*64
payload += p32(0xdeadbeef)
payload += b"B"*12
payload += p32(0x080484ab)
f = open("payload", "wb") # make "payload" file
f.write(payload) # Write payload to "payload" file
f.close() # finito
target.send(payload)
target.interactive()
And as you can see, the exploit worked flawlessly:
Count ya bytes, people!