Para entender lo que está pasando, el punto importante es que en C, las funciones no "saben" cuántos argumentos les dio la persona que llama. Para las funciones normales, el compilador detecta usos no válidos. Por ejemplo, si escribes esto:
#include <string.h>
/* ... */
memcpy(a, b);
luego el compilador se quejará en voz alta y se negará a finalizar la compilación: el encabezado incluido <string.h>
declara la función memcpy()
tomando tres parámetros (dos punteros y un entero del tipo size_t
). Si el compilador ve una llamada a memcpy()
con solo dos parámetros, la falta de coincidencia lo hará gritar.
Sin embargo, algunas funciones toman una cantidad variable de parámetros, por ejemplo, %código%. Para estas funciones, el compilador no puede hacer mucho chequeo. printf()
usa como primer argumento una "cadena de formato" cuyo contenido indica a printf()
cuántos otros argumentos debería encontrar y su tipo esperado. Cuando la cadena de formato es una constante literal, los compiladores inteligentes pueden hacer algunas verificaciones adicionales (con printf()
, esto se hace con " format "attribute , una extensión específica de gcc). Pero no puede hacer nada cuando la cadena de formato se obtiene en tiempo de ejecución. Imagina, por ejemplo, el siguiente código:
printf(s);
donde gcc
es una cadena que el atacante puede elegir (por ejemplo, algunos datos enviados a través de la red o un parámetro de invocación). El programador simplemente asumió que la cadena era "simple" (solo caracteres alfanuméricos) pero el atacante puede poner signos " s
" en ella, que %
interpreta al observar parámetros adicionales. No hay parámetros adicionales, por lo que printf()
realmente mirará las ranuras de memoria donde los parámetros adicionales habrían sido , si hubiera habido alguno. Estas ranuras son "más bajas en la pila" (técnicamente, en direcciones más altas, ya que las pilas "crecen hacia abajo" en la mayoría de las arquitecturas); allí se encuentran las variables locales, la dirección de retorno de la función y otros elementos de la pila para la función actual (y su interlocutor, y su interlocutor, etc.)
Al utilizar los especificadores printf()
, el atacante puede leer todas estas ranuras ( %u
imprimirá debidamente su contenido), y eso ya es malo. Con el parámetro printf()
, los poderes del atacante incluso se mejoran: %n
toma el siguiente parámetro, lo interpreta como un valor de puntero, lo sigue y escribe en la dirección apuntada a la cantidad actual de caracteres hasta el momento (la cantidad de caracteres impresos por %n
hasta el printf()
). Si la llamada %n
defectuosa ocurre en una función donde hay una variable local que también está bajo el control del atacante (por ejemplo, un valor entero simple), entonces el atacante puede hacer un printf()
para usar esa variable local como puntero, y escribir datos donde apunta. En pocas palabras, esto le da al atacante la capacidad de escribir los bytes que quiera, donde quiera que quiera en la memoria RAM. En ese momento, estás bastante condenado.
El código correcto habría sido:
printf("%s", s);
que luego forzó a %n
a imprimir la cadena controlada por el atacante "como está", sin hacer nada especial con los caracteres " printf()
".
Esta vulnerabilidad %
resalta el hecho de que C es un lenguaje bastante peligroso: no hay controles de límites, no hay controles de tipo fuerte ... puede hacer que el programa vea las ranuras de memoria como si fueran parámetros, mientras que no lo son, y también interpretar los patrones de bytes como si fueran punteros, que no lo son. Los programadores competentes de C escribirán su código para dar al compilador la mayor información posible, a fin de detectar errores (los agujeros de seguridad son solo errores de programación con un efecto que puede torcerse para ventaja del atacante). Pero la competencia es un recurso escaso, e incluso los mejores programadores cometen errores ocasionalmente.