wk5 - buffer overflow
Theme: Buffer overflow
bof (50)
Solve
Run readelf -h bof to gain basic info on the compiled binary. We notice it's a static binary and no-PIE, which means all the symbols addresses are absolute rather than offsets.
Use Binja for static analysis. The goal is to map out the program. Entering Binja, we see there's a main() function that allocates a buffer and prompts for user input. Looking around, we notice an interesting function called get_shell(). It pops a shell for us. Our goal is to call this function by overwriting the return address when the program prompts for user input, because then, we can take control of the remote server.
Open up stack view of main's stack frame on Binja, and we notice that the buffer is at an offset of 0x28 bytes before the return address. This means we should enter 0x28 bytes of any character, and immediately enter the address of get_shell() such that it overwrites precisely the return address.
We'll write a python script for this exploit. For sending the address of get_shell, we use p64(), which accepts a hex address, encodes it in little-endian by default, and pads the rest of it with zeroes so the whole address is 8 bytes long. Little-endian is desired as return addresses are interpreted as numbers, which are read in little-endian in our case.
Script
from pwn import *
print("before ELF line")
p = remote("offsec-chalbroker.osiris.cyber.nyu.edu", 1280)
p.recvuntil("123): ".encode()) # .encode() turns str into raw bytes
p.sendline("[ID]".encode()) # enter your Net ID
p.recvuntil("> ".encode())
p.sendline(b"B"*0x28 + p64(0x401218))
# ^ same as: p.sendline(b"B"*0x28 + b"\x18\x12\x40\x00\x00\x00\x00\x00")
# p64() sends the addr in little-endian and pads it with zeroes until it's
# 8 bytes full
p.interactive()Summary
Steps: unchecked buffer on stack > use user input to overwrite return addr to call an interesting function called get_shell(), which pops a shell for us on the remote system > run
lsto see files > notice flag.txt >cat flag.txtto see its contentConcepts: memory layout - function prologue -
callinstruction before function prologueTechniques: when you overwrite the return addr, make sure your sent-in return addr is complete. This means if the target architecture is 64-bits (8 bytes) where memory addrs are encoded with 8 bytes, your sent-in addr should also be 8 bytes. This implies padding the end with zeroes may be required.
Problems: ELF takes up too much memory on Amazon VM and quits the script. That's why I'm manually writing get_shell's address instead of using p64...
bypass (50)
Solve
Let's map out the program with Binja. main() calls an init() function which opens a file "/dev/urandom" and stores whatever's read into the address of the global variable number. Then, main() prints a question and calls the get_input() function. Inside get_input, user input is taken into the buffer. A local variable called number_1 gets assigned number. And this number gets printed out to us. The only way to avoid fail() and noreturn is if number equals number_1. Beyond these two functions is a function called win() which will pop us a shell. This means that inside get_input(), our goal is to overwrite the return address to win(), while maintaining that number_1 equals number.
Look at the stack view of get_input() on Binja to figure out how we should structure our input. ![[Captura de pantalla 2024-10-22 a las 13.50.56.png]] We see that the buffer is 0x28 bytes before the return address. But we can't just send in 0x28 bytes of whatever data plus 8 bytes for return address, because that would overwrite var_10, which is actually the local variable number_1. Recall that number_1 should equal number. This means we should fill in 24 bytes of any data, then 8 bytes equal to number, then 8 bytes of any data, then 8 bytes for the desired return address. How can we access the number so we can send it in? Number gets printed out to us. So we can take that, turn it into hex number and send it in little-endian as raw bytes.
Write a pwntools script to send the correct payload:
Notice that for return address, I didn't put in address to win() which is supposed to be 0x401383. Instead, I put 0x1390, which is inside of win(), specifically the line that prints "You made it!". The reason is if the return address was overwritten to be win(), we effectively jump to win(). No call instruction is run, which means no return address is pushed onto the stack before jumping. Looking at the disassembly for win(), entering win() which is what happens after we jump to it, the rbp gets pushed onto the stack. Since the rbp is 8 bytes, the stack grows by 8 bytes, i.e. the stack pointer rsp gets decremented by 8. According to rules, before every call instruction, the stack pointer must be 16-byte aligned, meaning it's a multiple of 16, otherwise the call instruction will not run. In our case, after pushing rbp, the stack pointer is no longer 16-byte aligned, but 8-byte aligned. This is a problem, because the desired line call system will not run. The problem was found because I originally entered win()'s address for return address, received the "You made it!" string, but the shell didn't pop. I then ran gdp.debug in the python script to see what caused the program crash. Checking info registers allowed me to see the value of rsp at the time of crash, and I could see it did not end in 0 (i.e. not a multiple of 16).
Our method of bypassing the stack-pointer alignment problem is to choose the return address to be somewhere that still gets us to the system call line, but skips the push-rbp part such that our stack pointer remains 16-byte aligned. I chose 0x401390 to be the return address.
lockbox (200)
Solve
Do initial recon of binary using Binja. Within main, it first calls init() which initializes a global variable "key" to be a particular value. Then, it outputs some messages and prompts user input using gets(). Two more variables are founds after gets(). The first variable gets dereferenced and assigned to the second variable. This implies the first variable should a valid memory address, and the second value will be stored at that address.
Looking beyond main, we see an interesting function win(). We want to call win() because it contains a system call. However, the sys call currently is system("exit"), which will not pop us a shell. Before the sys call is a conditional. If key == 0xbeeff0cacc1a, we will update the variable my_string to be something new. Click on my_string to see that it's a string with the value "exit". We make an inductive guess that the sys call is made on whatever my_string is; my_string is passed as an argument to the sys call. Therefore, our goal is to change my_string from "exit" to "/bin/sh". We also guess that the updating to "/bin/sh" is already handled by the update of my_string involving an XOR operation. So all we have to do, is to make sure key equals key == 0xbeeff0cacc1a.
Combining this with main's two variables, it is logical for us to overwrite the first variable to be the address of the global variable key, and overwrite the second variable to be 0xbeeff0cacc1a. Done right, this would indeed make key equal 0xbeeff0cacc1a.
Our chance to overwrite the two variables in addition to the return address in order to call win() is when main() calls gets(). Let's construct our input!
Open stack view on Binja while in main. Notice that the user input buffer is at -0x48 from return address. The buffer itself takes 16 bytes. Immediately after is the first variable, taking 8 bytes. Then comes the second variable of 8 bytes. Then, there's 0x28 bytes of space for us to fill before reaching return address. We can construct our payload as shown in this pwntools script:
Run this python script and obtain the flag!
Last updated