Seguridad en C: los dieciséis fallos que más se repiten en programas escritos en C, agrupados en cuatro bloques: la memoria, los enteros, la entrada y el sistema, y los secretos. Para cada uno, el patrón de código que hay que evitar, cómo lo detectan el compilador, los sanitizadores o Valgrind, la forma segura comprobada con GCC 15, y los fallos reales que causó.
C da acceso directo a la memoria y no comprueba casi nada mientras el programa corre. Eso lo hace rápido y, a la vez, explica por qué tantos fallos de seguridad famosos están en programas escritos en C: Microsoft y el equipo de Chromium han calculado por separado que alrededor del 70 % de sus fallos graves de seguridad son errores de memoria. La buena noticia es que esos errores se repiten, se pueden reconocer y casi todos se detectan con herramientas gratuitas que vienen con GCC y Clang. Esta página reúne los más habituales. Cada uno tiene la misma estructura: qué es, el patrón que hay que evitar, cómo se detecta (con la salida real del compilador o del sanitizador) y la forma segura.
Si estás aprendiendo C, cada lección del curso tiene su recuadro «Ojo con la seguridad» con estos mismos fallos en contexto, y el proyecto final los junta todos. Las opciones del compilador que endurecen el ejecutable, y los fallos reales que muestran por qué importan, están en Estándares y parches.
Memoria
1. Desbordamiento de búfer
Escribir más bytes de los que caben en un array. Lo que sobra machaca la memoria de al lado: otras variables, datos de control de la pila o del montón. Es el fallo más clásico de C, el del gusano de Morris en 1988, y sigue apareciendo. Casi siempre viene de funciones que copian «hasta el final» sin saber cuánto cabe en el destino: strcpy, strcat, sprintf, gets (eliminada del lenguaje en C11) o scanf("%s").
El patrón que hay que evitar
char nombre[16];
strcpy(nombre, entrada); /* entrada viene de fuera */
sprintf(ruta, "/datos/%s.txt", nombre);
scanf("%s", nombre);Cómo se detecta
Durante el desarrollo, AddressSanitizer lo detiene en el acto (lo vimos en la lección 4). En producción, la opción _FORTIFY_SOURCE sustituye strcpy y compañía por versiones que comprueban el tamaño del destino cuando el compilador lo conoce, y abortan el programa en lugar de dejar que siga con la memoria corrompida:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
char usuario[8];
if (argc > 1) {
strcpy(usuario, argv[1]);
printf("Hola, %s\n", usuario);
}
return 0;
}gcc -O2 -D_FORTIFY_SOURCE=3 fortify.c -o fortify ./fortify maria_fernanda_lopez
salida con error*** buffer overflow detected ***: terminated
El programa termina con la señal SIGABRT (código de salida 134). Un cierre brusco no es agradable, pero es infinitamente mejor que un programa que sigue funcionando con la memoria en manos de quien escribió la entrada.
La forma segura
#include <stdio.h>
bool copiar_nombre(char *destino, size_t tam, const char *origen)
{
int n = snprintf(destino, tam, "%s", origen);
return n >= 0 && (size_t) n < tam;
}
int main(void)
{
char nombre[8];
const char *pruebas[] = {"Ana", "Maximiliano"};
for (int i = 0; i < 2; i++) {
if (copiar_nombre(nombre, sizeof nombre, pruebas[i])) {
printf("Copiado: %s\n", nombre);
} else {
printf("Demasiado largo: %s\n", pruebas[i]);
}
}
return 0;
}salidaCopiado: Ana Demasiado largo: Maximiliano
Cómo encontrarlo en tu código: grep -rnwE "gets|strcpy|strcat|sprintf|vsprintf" src/ y cualquier scanf con %s o %[ sin ancho. Cada resultado se revisa: si el origen puede ser más largo que el destino, se cambia.
2. El byte del nulo y los errores de uno (off-by-one)
Una variante muy frecuente: reservar o recorrer un elemento de menos o de más. El caso típico en cadenas es reservar strlen(s) bytes y olvidar el del '\0'; en bucles, usar <= en vez de <. Un solo byte basta: CVE-2021-3156, el fallo de sudo conocido como Baron Samedit, era un error de uno que permitía a cualquier usuario local hacerse root, y estuvo casi diez años en el código.
El patrón que hay que evitar
char *copia = malloc(strlen(s)); /* falta el + 1 */ strcpy(copia, s);
Cómo se detecta
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
const char *s = "decodigo";
char *copia = malloc(strlen(s));
if (copia == nullptr) return EXIT_FAILURE;
strcpy(copia, s);
printf("%s\n", copia);
free(copia);
return 0;
}salida con error==134==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7335b23e0018 at pc 0x7715b37b2fc4 bp 0x7ffdd869d4a0 sp 0x7ffdd869cc60
WRITE of size 9 at 0x7335b23e0018 thread T0
…
#1 0x0000004011ee in main /curso/off_by_one.c:10
…
0x7335b23e0018 is located 0 bytes after 8-byte region [0x7335b23e0010,0x7335b23e0018)
allocated by thread T0 here:
#0 0x7715b381c08b in malloc (/usr/local/lib64/libasan.so.8+0x11a08b)
#1 0x0000004011c9 in main /curso/off_by_one.c:8
…
SUMMARY: AddressSanitizer: heap-buffer-overflow /curso/off_by_one.c:10 in mainASan dice que se escribieron 9 bytes (WRITE of size 9) en un bloque de 8: las letras y el nulo.
La forma segura
Desde C23, strdup es estándar: reserva el tamaño justo, nulo incluido, y copia. Y para copiar sabiendo la longitud, memcpy con largo + 1 calculado una sola vez:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
char *copia = strdup("decodigo");
if (copia == nullptr) return EXIT_FAILURE;
printf("%s (%zu)\n", copia, strlen(copia));
free(copia);
return 0;
}salidadecodigo (8)
3. Leer fuera de los límites
No hace falta escribir para tener un problema grave: leer de más filtra lo que haya en la memoria contigua. Es lo que pasó con Heartbleed (CVE-2014-0160) en OpenSSL: el programa respondía a un mensaje copiando tantos bytes como el propio mensaje decía tener, sin comprobar cuántos tenía de verdad. Quien enviaba un mensaje corto diciendo que era largo recibía de vuelta fragmentos de la memoria del servidor, claves privadas incluidas.
El patrón que hay que evitar
/* el mensaje trae su propia longitud: confiar en ella es el error */ memcpy(respuesta, mensaje->datos, mensaje->longitud_declarada);
Cómo se detecta
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
size_t declarada;
size_t real;
char *datos;
} Mensaje;
int main(void)
{
Mensaje m = {.declarada = 64, .real = 4};
m.datos = malloc(m.real);
if (m.datos == nullptr) return EXIT_FAILURE;
memcpy(m.datos, "hola", 4);
char eco[64];
memcpy(eco, m.datos, m.declarada);
printf("%.4s\n", eco);
free(m.datos);
return 0;
}salida con error==217==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6d3d4a3e0014 at pc 0x711d4b791a97 bp 0x7ffd05309e60 sp 0x7ffd05309620
READ of size 64 at 0x6d3d4a3e0014 thread T0
#0 0x711d4b791a96 in memcpy (/usr/local/lib64/libasan.so.8+0x117a96)
#1 0x0000004012bc in main /curso/sobrelectura.c:19
…
0x6d3d4a3e0014 is located 0 bytes after 4-byte region [0x6d3d4a3e0010,0x6d3d4a3e0014)
allocated by thread T0 here:
#0 0x711d4b79408b in malloc (/usr/local/lib64/libasan.so.8+0x11a08b)
#1 0x00000040126c in main /curso/sobrelectura.c:14
…
SUMMARY: AddressSanitizer: heap-buffer-overflow /curso/sobrelectura.c:19 in mainLa forma segura
Toda longitud que venga de fuera se compara con lo que realmente se recibió, y con el tamaño del destino, antes de usarla:
#include <stdio.h>
#include <string.h>
bool eco_seguro(char *destino, size_t tam_destino, const char *datos, size_t recibidos, size_t declarada)
{
if (declarada > recibidos || declarada > tam_destino) {
return false;
}
memcpy(destino, datos, declarada);
return true;
}
int main(void)
{
char eco[64];
printf("%s\n", eco_seguro(eco, sizeof eco, "hola", 4, 4) ? "aceptado" : "rechazado");
printf("%s\n", eco_seguro(eco, sizeof eco, "hola", 4, 64) ? "aceptado" : "rechazado");
return 0;
}salidaaceptado rechazado
Cómo encontrarlo en tu código: en todo código que procese formatos binarios o protocolos (tamaños en cabeceras, campos de longitud), busca cada memcpy, memmove y bucle que use una longitud leída de la entrada, y comprueba que se valida antes. Es justo el código que conviene someter a fuzzing.
4. Uso tras liberar y doble liberación
Usar un puntero después de free, o liberarlo dos veces. Los dos corrompen el gestor de memoria, y el uso tras liberar es especialmente peligroso porque, si un atacante consigue que se reserve otro bloque en el mismo sitio, el puntero viejo pasa a leer o escribir datos que controla él. Es una de las categorías de fallos más explotadas en navegadores. Los vimos en detalle, con la salida de ASan, en la lección 6.
El patrón que hay que evitar
free(sesion);
...
if (sesion->admin) { ... } /* sesion ya no existe */La forma segura
- Cada bloque tiene un dueño claro, el único que lo libera. Si una función devuelve memoria, su documentación dice quién la libera.
- Pon el puntero a
nullptrjusto después defree: un uso posterior falla de forma clara, y un segundofree(nullptr)no hace nada. - Prueba siempre con
-fsanitize=address, que detecta ambos al ejecutar. GCC avisa además al compilar en los casos evidentes (-Wuse-after-free, dentro de-Wall).
5. Variables sin inicializar
Una variable local sin valor inicial contiene lo que hubiera en esa zona de la pila, que puede ser un dato de una función anterior, incluso uno sensible. Si de ella depende una decisión (si el usuario es administrador, cuántos bytes copiar), el comportamiento cambia de una ejecución a otra.
El patrón que hay que evitar
int permitido; if (es_administrador(usuario)) permitido = 1; if (permitido) borrar_todo();
Cómo se detecta
El compilador avisa en muchos casos, sobre todo con -O2, como vimos en la lección 2. Cuando el valor depende de la entrada, Valgrind lo detecta al ejecutar:
#include <stdio.h>
int main(void)
{
int permitido;
if (getchar() == 's') {
permitido = 1;
}
if (permitido) {
printf("Acceso concedido\n");
} else {
printf("Acceso denegado\n");
}
return 0;
}salida con error (respondiendo «n»)==200== Conditional jump or move depends on uninitialised value(s) ==200== at 0x401153: main (sin_inicializar.c:9) … Acceso concedido … ==200== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
Fíjate en la línea del medio: el usuario respondió «n» y el programa concedió el acceso, porque permitido tenía basura distinta de cero. Valgrind señala la línea 9, el if que depende del valor sin inicializar.
La forma segura
Inicializar siempre al declarar (int permitido = 0;) y, como red de seguridad, compilar con -ftrivial-auto-var-init=zero, que pone a cero toda variable local que no tenga valor (la activa -fhardened). No arregla la lógica, pero convierte un valor imprevisible en uno conocido.
Enteros
6. Desbordamiento al calcular un tamaño
Cuando un tamaño se calcula multiplicando, como cantidad * sizeof(elemento), y cantidad viene de fuera, el producto puede dar la vuelta (en size_t el desbordamiento está definido: vuelve a empezar desde cero). El programa reserva un bloque pequeño y luego escribe cantidad elementos en él.
El patrón que hay que evitar
size_t cantidad = leer_cantidad(archivo); /* viene de fuera */ Registro *r = malloc(cantidad * sizeof *r); for (size_t i = 0; i < cantidad; i++) leer_registro(archivo, &r[i]);
Cómo se detecta
#include <stdio.h>
#include <stdint.h>
#include <stdckdint.h>
int main(void)
{
size_t cantidad = SIZE_MAX / 8 + 2;
size_t bytes = cantidad * 8;
printf("%zu elementos de 8 bytes = %zu bytes\n", cantidad, bytes);
size_t comprobado;
if (ckd_mul(&comprobado, cantidad, (size_t) 8)) {
printf("ckd_mul: el producto no cabe, se rechaza\n");
}
return 0;
}salida2305843009213693953 elementos de 8 bytes = 8 bytes ckd_mul: el producto no cabe, se rechaza
Más de dos trillones de elementos «caben» en 8 bytes: con eso, un malloc tendría éxito y el bucle escribiría fuera. UBSan no lo detecta, porque para los enteros sin signo no es un error del lenguaje.
La forma segura
calloc(cantidad, tamaño) hace la multiplicación comprobada por dentro y devuelve nullptr si no cabe. Si necesitas el producto, ckd_mul de C23, como arriba. Y antes de nada, un límite razonable para lo que venga de fuera: ningún archivo de clientes tiene mil millones de registros.
7. Mezclar enteros con y sin signo
Una longitud negativa que llega a una función que espera size_t se convierte en un número enorme. Y al revés: comprobar if (longitud < tam_maximo) con longitud de tipo int y negativa deja pasar el valor. Las reglas de conversión de C hacen que estas comparaciones no signifiquen lo que parece.
Cómo se detecta
Lo detectan dos avisos que no están en -Wall -Wextra y conviene activar en código que maneja tamaños: -Wconversion y -Wsign-conversion:
#include <stdio.h>
#include <string.h>
void copiar(char *destino, const char *origen, int longitud)
{
if (longitud < 64) {
memcpy(destino, origen, longitud);
}
}
int main(void)
{
char buffer[64];
copiar(buffer, "hola", 4);
printf("%.4s\n", buffer);
return 0;
}gcc: avisoconversion.c: In function 'copiar':
conversion.c:7:33: warning: conversion to 'size_t' {aka 'long unsigned int'} from 'int' may change the sign of the result [-Wsign-conversion]
7 | memcpy(destino, origen, longitud);
| ^~~~~~~~La forma segura
Usa size_t para todo lo que sea un tamaño, una longitud o un índice, desde que el dato entra hasta que se usa. Si tiene que venir como int, comprueba que no sea negativo antes de convertirlo.
8. Comprobaciones que el optimizador borra
Este es el fallo más desconcertante de C. Como el desbordamiento de un entero con signo es comportamiento indefinido, el compilador puede suponer que nunca ocurre, y usar esa suposición para simplificar. Una comprobación escrita para detectar el desbordamiento después de que ocurra es, para él, código que nunca puede ser verdadero, y la elimina:
El patrón que hay que evitar
int resultado = x + suma;
if (resultado < x) { /* «si se ha desbordado...» */
return ERROR;
}Cómo se detecta
El mismo código, compilado sin optimizar y con -O2:
#include <stdio.h>
#include <limits.h>
bool cabe(int x, int suma)
{
int resultado = x + suma;
return resultado > x;
}
int main(void)
{
printf("¿Cabe INT_MAX + 100? %d\n", cabe(INT_MAX, 100));
return 0;
}salida con gcc -O0¿Cabe INT_MAX + 100? 0
salida con gcc -O2¿Cabe INT_MAX + 100? 1
Sin optimizar, la suma da la vuelta y la función dice la verdad. Con -O2, GCC razona que x + suma > x equivale a suma > 0 para enteros que no se desbordan, y responde que sí cabe: la comprobación ha dejado de comprobar. Si lo escribes en una sola expresión, x + 100 > x, GCC la simplifica incluso sin optimizar. Se han encontrado fallos así en el núcleo de Linux. UBSan (-fsanitize=undefined) detecta el desbordamiento al ejecutar.
La forma segura
Comprobar antes de operar, con la operación que no puede desbordarse: ckd_add de C23, o la comparación clásica if (x > INT_MAX - 100). Las dos funcionan igual con cualquier nivel de optimización.
Entrada, salida y el sistema
9. Cadenas de formato
Pasar un texto que viene de fuera como primer argumento de printf, fprintf o syslog. Si ese texto contiene %, la función lo interpreta como huecos y lee (o, con %n, escribe) argumentos que nunca se pasaron.
El patrón que hay que evitar
printf(mensaje_del_usuario); syslog(LOG_INFO, linea_recibida);
Cómo se detecta
El aviso que lo detecta, -Wformat-security, no está en -Wall; se activa por separado o con -Wformat=2, y muchas distribuciones lo ponen por omisión al compilar sus paquetes:
#include <stdio.h>
int main(int argc, char *argv[])
{
if (argc > 1) {
printf(argv[1]);
printf("\n");
}
return 0;
}gcc: avisoformato.c: In function 'main':
formato.c:6:9: warning: format not a string literal and no format arguments [-Wformat-security]
6 | printf(argv[1]);
| ^~~~~~La forma segura
El formato siempre es una constante escrita por ti: printf("%s", mensaje), o fputs(mensaje, stdout) si no hay nada que formatear.
10. Inyección de órdenes
system() y popen() pasan su texto al intérprete de órdenes (/bin/sh). Si ese texto incluye un dato externo, caracteres como ;, | o $( ) se interpretan como órdenes adicionales.
El patrón que hay que evitar
char orden[256]; snprintf(orden, sizeof orden, "convert %s miniatura.png", archivo_subido); system(orden);
La forma segura
No pasar por el intérprete: ejecutar el programa directamente con posix_spawnp (o fork y execvp), dándole los argumentos como un array. Cada elemento llega al programa tal cual, por raro que sea:
#include <stdio.h>
#include <spawn.h>
#include <sys/wait.h>
extern char **environ;
int main(void)
{
const char *archivo = "foto; echo esto no debe ejecutarse";
char *argumentos[] = {"printf", "Argumento recibido: [%s]\\n", (char *) archivo, nullptr};
pid_t pid;
int error = posix_spawnp(&pid, "printf", nullptr, nullptr, argumentos, environ);
if (error != 0) {
fprintf(stderr, "No se pudo ejecutar printf\n");
return 1;
}
int estado;
waitpid(pid, &estado, 0);
printf("Terminó con código %d\n", WEXITSTATUS(estado));
return 0;
}salidaArgumento recibido: [foto; echo esto no debe ejecutarse] Terminó con código 0
El punto y coma llega como parte del argumento y no ejecuta nada. Aun así, el programa que llamas puede interpretar sus argumentos: un nombre de archivo que empieza por - parece una opción. Valida los datos contra lo que esperas (por ejemplo, solo letras, números y un punto) y, cuando el programa lo admita, pon -- antes de los archivos.
Cómo encontrarlo en tu código: grep -rnwE "system|popen|execl|execlp" src/, y revisa también las llamadas a exec que construyan argumentos con snprintf.
11. Archivos: comprobar y luego abrir
Comprobar algo de un archivo y después usarlo deja un hueco entre ambos pasos: en ese tiempo, otro proceso puede cambiar el archivo, o sustituirlo por un enlace a otro. Se llama TOCTOU (time of check to time of use) y es peligroso sobre todo en programas que se ejecutan con más privilegios que quien los llama, o que escriben en carpetas compartidas como /tmp.
El patrón que hay que evitar
if (access(ruta, F_OK) != 0) { /* «si no existe...» */
FILE *f = fopen(ruta, "w"); /* ...y entre medias, alguien lo crea */
}La forma segura
Hacer la comprobación y la acción en un solo paso, para que el sistema las garantice juntas. Desde C23, fopen tiene el modo "wx": crea el archivo y falla si ya existía (en POSIX, open con O_CREAT | O_EXCL, y O_NOFOLLOW para no seguir enlaces). Para temporales, mkstemp, como vimos en la lección 7:
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(void)
{
for (int i = 1; i <= 2; i++) {
FILE *f = fopen("informe.txt", "wx");
if (f == nullptr) {
printf("Intento %d: %s\n", i, strerror(errno));
continue;
}
fputs("datos\n", f);
fclose(f);
printf("Intento %d: creado\n", i);
}
remove("informe.txt");
return 0;
}salidaIntento 1: creado Intento 2: File exists
12. Variables de entorno en programas con privilegios
Las variables de entorno las controla quien ejecuta el programa. En un programa normal no importa, pero en uno que corre con más privilegios (con el bit SUID, como sudo o passwd), cualquier variable que influya en su comportamiento es una entrada de un usuario sin privilegios. Así fue Looney Tunables (CVE-2023-4911): un desbordamiento de búfer en el cargador de programas de glibc al procesar la variable GLIBC_TUNABLES, que permitía a un usuario local hacerse root a través de cualquier programa SUID.
La forma segura
- En programas con privilegios, lee el entorno con
secure_getenv(glibc), que devuelvenullptrcuando el programa se ejecuta con privilegios elevados. - Trata cualquier variable como dato externo: longitud máxima y contenido validado, como cualquier otra entrada.
- Mejor aún, evita los programas SUID: que un servicio con privilegios reciba peticiones por un canal controlado.
13. Funciones prohibidas en un manejador de señales
Una señal (como SIGINT al pulsar Ctrl+C, o SIGALRM de un temporizador) interrumpe el programa en cualquier punto y ejecuta su manejador. Si el programa estaba en medio de un malloc y el manejador llama a printf, malloc, free o syslog, las estructuras internas quedan corruptas. Solo un puñado de funciones son seguras ahí (las async-signal-safe). Es exactamente el fallo de regreSSHion (CVE-2024-6387): el manejador de SIGALRM del servidor de OpenSSH llamaba a funciones que no lo eran, una regresión de un fallo ya corregido en 2006, y un atacante remoto sin cuenta podía aprovechar la carrera.
El patrón que hay que evitar
void al_terminar(int senal)
{
syslog(LOG_INFO, "cerrando"); /* no es segura en un manejador */
free(conexiones);
exit(0);
}La forma segura
El manejador solo levanta una bandera de tipo volatile sig_atomic_t, y el programa la mira en su bucle principal, donde sí puede hacer de todo:
#include <stdio.h>
#include <signal.h>
static volatile sig_atomic_t terminar = 0;
static void al_terminar(int senal)
{
(void) senal;
terminar = 1;
}
int main(void)
{
struct sigaction sa = {.sa_handler = al_terminar};
sigemptyset(&sa.sa_mask);
sigaction(SIGINT, &sa, nullptr);
int vueltas = 0;
while (!terminar) {
vueltas++;
if (vueltas == 3) raise(SIGINT);
}
printf("Señal recibida tras %d vueltas: ahora cierro con calma\n", vueltas);
return 0;
}salidaSeñal recibida tras 3 vueltas: ahora cierro con calma
Secretos
14. Números aleatorios predecibles
rand() es un generador pensado para simulaciones y juegos, no para secretos: con unas pocas salidas se puede reconstruir su estado, y si la semilla es time(NULL), basta con probar los segundos cercanos. Un token de sesión, una contraseña temporal o una clave generados así se pueden adivinar.
El patrón que hay que evitar
srand(time(nullptr)); int token = rand();
La forma segura
Usar el generador criptográfico del sistema: getrandom() en Linux, o arc4random_buf, disponible en glibc desde la versión 2.36 y en macOS y los BSD:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
unsigned char token[16];
arc4random_buf(token, sizeof token);
for (size_t i = 0; i < sizeof token; i++) {
printf("%02x", token[i]);
}
printf("\n");
printf("Un dado sin sesgo: %u\n", arc4random_uniform(6) + 1);
return 0;
}salida (cambia en cada ejecución)348e805b96b3fec7bf9c6993773cbe80 Un dado sin sesgo: 6
arc4random_uniform(6) da un número de 0 a 5 sin el sesgo que tiene el clásico rand() % 6.
15. Secretos que se quedan en la memoria
Borrar una contraseña con memset antes de liberarla parece correcto, pero el compilador ve que esa memoria no se vuelve a leer y puede eliminar el memset por inútil. La contraseña se queda en la memoria del proceso, donde puede acabar en un volcado o en la memoria de intercambio.
El patrón que hay que evitar
char clave[64]; leer_clave(clave, sizeof clave); usar(clave); memset(clave, 0, sizeof clave); /* el optimizador puede borrarlo */
La forma segura
C23 añadió memset_explicit, que el compilador no puede eliminar, pero glibc 2.41, la de nuestras pruebas, todavía no la trae. Mientras tanto, explicit_bzero (glibc y los BSD) hace lo mismo:
#include <stdio.h>
#include <string.h>
int main(void)
{
char clave[32];
snprintf(clave, sizeof clave, "%s", "mi clave secreta");
printf("Usando una clave de %zu caracteres\n", strlen(clave));
explicit_bzero(clave, sizeof clave);
printf("Primer byte tras borrar: %d\n", clave[0]);
return 0;
}salidaUsando una clave de 16 caracteres Primer byte tras borrar: 0
16. Comparar secretos en tiempo constante
memcmp y strcmp paran en el primer byte distinto, así que tardan un poco más cuantos más bytes aciertes. Midiendo ese tiempo muchas veces, un atacante puede adivinar un token o una firma byte a byte. Para comparar secretos hay que recorrerlos siempre enteros:
#include <stdio.h>
#include <string.h>
bool iguales_seguro(const unsigned char *a, const unsigned char *b, size_t n)
{
unsigned char diferencia = 0;
for (size_t i = 0; i < n; i++) {
diferencia |= a[i] ^ b[i];
}
return diferencia == 0;
}
int main(void)
{
const unsigned char esperado[] = "a3f9c27e";
const unsigned char recibido[] = "a3f9c27x";
printf("%d\n", iguales_seguro(esperado, recibido, 8));
printf("%d\n", iguales_seguro(esperado, esperado, 8));
return 0;
}salida0 1
En código de producción, mejor usar la función de una biblioteca criptográfica, como CRYPTO_memcmp de OpenSSL o sodium_memcmp de libsodium, que además se protegen de las optimizaciones del compilador.
Cómo revisar un proyecto que ya existe
- Compila con todos los avisos:
-Wall -Wextra -Wformat=2 -Wconversion -Wsign-conversion, sin optimizar y con-O2. En un proyecto antiguo saldrán cientos; empieza por los de formato, conversiones en tamaños y variables sin inicializar. - Ejecuta las pruebas con sanitizadores:
-fsanitize=address,undefined. Si no hay pruebas, ejecuta el programa con entradas reales y raras. - Pasa analizadores estáticos:
gcc -fanalyzer,clang-tidyoscan-build, como en el proyecto. Para proyectos abiertos, servicios como Coverity Scan son gratuitos. - Haz fuzzing del código que lee datos: libFuzzer (
clang -fsanitize=fuzzer,address) o AFL++ generan millones de entradas buscando la que rompe el programa. Muchos proyectos abiertos lo hacen de forma continua en OSS-Fuzz, de Google. - Busca los patrones de esta página con
grepy revisa cada resultado a mano. - Entrega con las protecciones del compilador y mantén al día el compilador y las bibliotecas: Estándares y parches.
Lista de comprobación
- Comprobación
- ☐Ningún
gets,strcpy,strcatnisprintfcon datos de fuera;snprintfcon el recorte comprobado - ☐Cada reserva de cadena incluye el byte del
'\0'; los bucles usan<con el tamaño - ☐Toda longitud que viene de fuera se valida contra lo recibido y contra el destino
- ☐Cada bloque de memoria tiene un dueño; tras
free, el puntero anullptr - ☐Todas las variables se inicializan al declararlas
- ☐Los tamaños se multiplican con
callocockd_mul; los tamaños sonsize_t - ☐Las comprobaciones de desbordamiento se hacen antes de operar
- ☐El formato de
printfysysloges siempre una constante - ☐Nada de
systemnipopencon datos externos;posix_spawnpcon un array - ☐Archivos creados en un solo paso:
"wx",O_EXCL,mkstemp - ☐Los manejadores de señales solo levantan una bandera
volatile sig_atomic_t - ☐Secretos con
getrandomoarc4random, comparados en tiempo constante y borrados conexplicit_bzero - ☐Pruebas con ASan y UBSan, análisis estático y, para el código que lee datos, fuzzing
- ☐Ejecutables con
-fhardenedo sus opciones equivalentes
Todo el código de esta página que tiene salida se compiló con GCC 15.3 y se ejecutó en un contenedor limpio sin acceso a la red; las salidas y los informes de los sanitizadores están copiados de esa ejecución. Los fragmentos marcados como «el patrón que hay que evitar» se muestran para reconocerlos. Esta página explica los fallos para detectarlos y corregirlos, no cómo aprovecharlos.
