💥 Buffer Overflow
Explicación acerca de los Buffers Overflows
© 2016-25. All rights reserved by Zasya Solutions.
¿Qué es un Buffer Overflow?
Un buffer overflow (desbordamiento de buffer) es una vulnerabilidad de seguridad que ocurre cuando un programa escribe más datos de los que un buffer puede almacenar, sobrescribiendo memoria adyacente.
Qué es un Buffer
Un buffer es un área contigua de memoria reservada para almacenar datos (como un array o un char buf[16]).
Cuando se excede su capacidad, los datos “extra” sobrescriben otras partes de la memoria, que pueden ser:
- Variables locales vecinas
- Saved frame pointer (RBP/EBP)
- Return address
- Otras estructuras de control
Para entender cómo funciona un buffer overflow a la perfección y saber cómo explotarlo, conviene primero comprender a bajo nivel cómo funciona la memoria de un proceso.
Esquema de memoria de un proceso
La memoria de un proceso se compone de 5 o 6 segmentos (dependiendo de la fuente). Cada uno tiene un propósito específico y se encuentra organizado de manera concreta. Estos segmentos son:
- Code (.text): Aquí se encuentran las instrucciones en lenguaje máquina.
- Data (.data): Aquí se almacenan las variables globales.
- Read-only data (.rodata): Aquí se encuentran las constantes globales y cadenas de texto.
- BSS (.bss): Contiene variables globales sin valor explícito o inicializadas a 0.
- Heap: Memoria dinámica solicitada en tiempo de ejecución.
- Stack (pila): Aquí se gestionan las llamadas a funciones.
Los dos últimos segmentos (Heap y Stack) se encuentran contiguos. Debido a esto, y a que su tamaño varía durante la ejecución del programa, es posible que ocurran desbordamientos de buffer si algún buffer excede los límites establecidos.
Un enfoque más visual puede ser el siguiente:
En función del gráfico que se consulte, los segmentos .data y .rodata pueden aparecer unificados en .data.
Además, es importante tener en cuenta que el Stack crece hacia direcciones bajas de memoria, mientras que el Heap crece hacia posiciones altas de memoria.
Segmento Code (.text)
El segmento .text contiene las instrucciones en lenguaje máquina que la CPU ejecuta directamente. En este segmento se almacenan todas las funciones compiladas del programa.
Normalmente tiene permisos de lectura y ejecución (r-x), pero no permite escritura, lo que protege el código frente a modificaciones accidentales o ataques de tipo code injection.
Un ejemplo de código simple en C que se almacena en el segmento .text es el siguiente:
1
2
3
int suma(int a, int b) {
return a + b;
}
En este caso, la función realiza una suma de dos parámetros, pero en la sección .text solo se almacenan las instrucciones máquina necesarias para realizar esa suma en registros. No se reserva espacio para int a o int b, ya que esto se gestionará en el momento en que se llame a la función, utilizando el stack (pila).
Segmento Data y Read-Only Data (.data / .rodata)
El segmento .data contiene las variables globales y estáticas que han sido inicializadas en el momento de la compilación. Estas variables existen durante toda la ejecución del programa y, a diferencia del código, este segmento tiene permisos de lectura y escritura (rw-), lo que permite modificar sus valores en tiempo de ejecución.
Por otro lado, el segmento .rodata (Read-Only Data) almacena constantes y cadenas literales globales del programa, y tiene permisos de solo lectura (r–), protegiendo los datos frente a modificaciones accidentales o ataques que intenten sobrescribir información constante.
Un ejemplo de código C correspondiente a este segmento sería:
1
2
3
4
5
6
int contador = 5; // va en .data (inicializado)
const char *msg = "Hola"; // "Hola" va en .rodata; el puntero en .data
int main() { // Almacenado en .text
...
}
Segmento BSS (.bss)
El segmento .bss (Block Starting Symbol) almacena las variables globales y estáticas que no tienen un valor explícito en el código o que están inicializadas a cero. Aunque no ocupan espacio en el archivo ejecutable, el sistema operativo reserva memoria para ellas en tiempo de ejecución, y sus valores se inicializan automáticamente a cero.
Este segmento permite que las variables existan durante toda la ejecución del programa y tiene permisos de lectura y escritura (rw-), al igual que .data.
Un ejemplo de código C correspondiente a este segmento sería:
1
static int buffer[1024]; // si no se inicializa, está en .bss y comienza en 0
Segmento Heap
El heap es el segmento de memoria reservado para la asignación dinámica en tiempo de ejecución, mediante funciones como malloc, calloc, realloc en C o new en C++.
A diferencia de las variables locales, la memoria en el heap persiste hasta que se libera explícitamente con free o delete, o hasta que finaliza el proceso. El heap crece hacia direcciones más altas en memoria y permite almacenar datos cuyo tamaño o duración no puede determinarse en tiempo de compilación.
Un ejemplo de código C correspondiente a este segmento sería:
1
char *p = malloc(100); // p apunta a un bloque de 100 bytes en el heap
Consideraciones al trabajar con el Heap
- No importa si el
mallocse realiza dentro o fuera de una función; la memoria reservada siempre se aloja en el heap. - Lo que cambia es dónde se almacena el puntero. Si el puntero
pes una variable local dentro de una función, vivirá en el stack y desaparecerá cuando la función termine. - Esto genera un problema: si antes de finalizar la función el puntero
pno se libera, el bloque de memoria seguirá presente en el heap pero no será accesible, provocando una fuga de memoria.
Un ejemplo gráfico sería:
1
2
3
4
void f() {
char *p = malloc(100); // bloque en heap
} // aquí p desaparece porque estaba en el stack,
// pero los 100 bytes siguen en el heap → memoria perdida
Si el puntero p es global o estático, seguirá existiendo durante toda la ejecución del programa y se podrá usar para acceder al bloque en el heap desde cualquier parte.
Segmento Stack (Pila)
La pila es una estructura de memoria que funciona según el principio LIFO (Last In, First Out), es decir, lo último que se añade es lo primero que se retira. Cada vez que se llama a una función, el sistema crea un frame de pila específico para esa función.
Este frame almacena:
- Los parámetros que se le pasaron a la función.
- Las variables locales de la función.
- La dirección de retorno, que indica a qué instrucción debe volver la CPU cuando la función termine.
Por ejemplo, en la función:
1
2
3
void f(int x) {
char buf[16]; // variable local
}
El buffer buf se encuentra dentro del frame de pila y existe únicamente mientras la función se ejecuta. Cuando la función termina, el frame se destruye y la memoria de buf queda libre para el siguiente frame. La pila crece hacia direcciones de memoria más bajas.
Para manejar la pila, la CPU utiliza registros específicos:
RSP/ESPapunta siempre al tope de la pila.RBP/EBPapunta al inicio del frame de la función actual, sirviendo como ancla para acceder a parámetros y variables locales.
La dirección de retorno almacenada en cada frame es utilizada por la instrucción ret para que la CPU pueda continuar correctamente la ejecución después de que la función termine.
Frame de pila y registros
Para entender bien el funcionamiento del stack y dónde pueden ocurrir problemas, es necesario explicar claramente qué son y cómo funcionan los frames de pila, así como cómo funcionan los registros ESP/RSP y EBP/RBP, ya que ambos conceptos están estrechamente enlazados.
Un frame de pila se crea cuando se llama a una función. Cuando esto ocurre, se genera en el stack una estructura parecida a la siguiente:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Direcciones altas
┌───────────────────────────┐
| Parámetro 3 (si hay) | ← [rbp+24]
├───────────────────────────┤
| Parámetro 2 (si hay) | ← [rbp+16]
├───────────────────────────┤
| Parámetro 1 (si hay) | ← [rbp+8]
├───────────────────────────┤
| Return address | ← guardado por 'call'
├───────────────────────────┤
| Saved RBP (frame anterior)| ← [rbp]
├───────────────────────────┤
| Variable local 1 | ← [rbp-8]
├───────────────────────────┤
| Variable local 2 / buffer | ← [rbp-16]
├───────────────────────────┤
| Variable local 3 | ← [rbp-24]
└───────────────────────────┘
Tope actual de la pila → RSP
Direcciones bajas
Como se aprecia, primero se deja espacio para los parámetros de la función, luego se almacena la dirección de retorno de la función, es decir, la dirección a donde debe volver la CPU una vez que la función termine:
1
2
3
4
int main() {
int x = f(); // -> Se llama a f(), cuando termine tiene que volver aquí la función
printf("%d\n", x);
}
Después, se guarda el registro RBP (arquitectura x86_64) o EBP (arquitectura x86) del frame anterior. Este registro se encarga de almacenar la dirección de memoria del inicio del frame de pila, es decir, la posición a partir de los parámetros y la dirección de retorno. Esto se debe a que se considera que la función comienza a ejecutar instrucciones una vez que se han guardado los parámetros y la dirección a donde volver al finalizar.
Al guardarse en el frame de pila el valor del RBP/EBP anterior, también se actualiza el registro RBP/EBP con esa dirección de memoria para poder operar con ella. Después de esto, simplemente se guardan las variables locales de la función y se restaura el registro RSP/ESP para apuntar al final del frame de pila anterior.
Registros RBP/EBP
Es conveniente explicar en detalle el concepto de RBP/EBP y el de Saved RBP.
Cuando hay un frame de pila y se llama a otra función, se genera un nuevo frame de pila encima del anterior (en realidad, “debajo”, porque la pila crece hacia abajo).
Al generarse este nuevo frame:
- Se guardan los parámetros, la dirección de retorno y la posición de memoria del registro
RBP/EBPanterior para no perderlo. - Al mismo tiempo, se actualiza el registro
RBP/EBPcon la posición de memoria donde se acaba de guardar el valor delRBP/EBPanterior.
Esto permite tener siempre localizada, como valor, la posición de memoria donde se encuentra el inicio del frame de pila de la función anterior. Por ello, cuando este frame de pila se borre, el registro RBP/EBP se sobreescribirá con el valor que había en la dirección de memoria a la que apuntaba, ya que allí estaba almacenada otra dirección de memoria.
Registros RSP/ESP (Frame de pila)
Una vez explicado el funcionamiento de los registros RBP/EBP, es necesario entender cómo funcionan los registros RSP (arquitectura x86_64) y ESP (arquitectura x86).
Para que se cree un frame de pila encima de otro, una función debe llamar a otra función. Por ejemplo, si la función B llama a la función C:
- En el stack ya existe un frame de pila para B donde se guarda todo lo que la función necesita.
- Al llamar a la función C con unos parámetros, vamos a suponer por sencillez que se guardan en el frame de pila de C (esto no es siempre cierto ya que en función de como se pasen los parámetros cambia, o si estamos ante una arquitectura x86_64).
Esto es importante para visualizar cómo se mueve el registro RSP/ESP cuando el frame de C desaparece:
- Cuando la función C termina, el registro
RSP/ESPse mueve a donde estabaRBP/EBP. - Se carga en el registro
RBP/EBPel valor que había en memoria. - El registro
EIPapunta aESPy se carga en él la dirección de memoria deret(ESP + 4). - Al ejecutar la instrucción
pop EIP, esta dirección de memoria se libera, moviendo el registroRSP/ESPa ese espacio libre. - El resultado es que queda justo por debajo de los argumentos de C, y ya es el propio programa el que se encarga de sumarle a
EIPel espacio que ocupaban los argumentos.
NOTA:
Cabe destacar que esta explicación y el diagrama presentado corresponden a arquitectura x86 y a un paso de parámetros por valor, para simplificar la comprensión.
En el caso de arquitectura x86_64:
- Los primeros 6 parámetros se pasan por registro.
- Existe un alineamiento de pila a 16 bytes.
- Hay una
red zonede 128 bytes por debajo delRSP.
A pesar de estas diferencias, los conceptos explicados hasta ahora son más que suficientes para comprender el funcionamiento general de la pila y los frames de función.

