Escribir C defensivo
La primera línea de defensa no es el compilador, eres tú: validar toda entrada en la frontera, hacer la aritmética de tamaños comprobada, escribir con límites explícitos en cada copia, y aplicar una checklist de revisión que convierte la seguridad de un acto de fe en un proceso repetible.
Las mitigaciones del compilador son la última línea de defensa; tú eres la primera. Un canario contiene el daño de un desbordamiento que ya escribiste; el C defensivo hace que no lo escribas. Este cierre del nivel destila las lecciones anteriores en hábitos concretos: dónde validar, cómo hacer aritmética que no mienta, cómo escribir copias que conozcan su techo, y una checklist con la que revisar cualquier código —el tuyo o el ajeno— con la mirada de quien busca la línea donde un dato cruza al lado del control.
- Validar toda entrada externa en la frontera, antes de que toque la lógica.
- Hacer aritmética de tamaños comprobada con las utilidades de C23.
- Escribir copias y formateos con límites explícitos en cada operación.
- Aplicar una checklist de revisión de código repetible y sistemática.
Validar en la frontera
El principio que organiza todo el C defensivo es la frontera de confianza: la línea imaginaria que separa los datos que vienen de fuera —red, archivos, argumentos, variables de entorno— de la lógica interna que asume que sus valores son sanos. La regla es que nada cruza esa frontera sin validarse, y que la validación ocurre una sola vez, en el borde, no dispersa por medio programa.
int leer_edad(const char *texto, int *salida) {
char *fin;
errno = 0;
long v = strtol(texto, &fin, 10);
if (fin == texto || *fin != '\0') // no era un numero, o sobraba texto
return -1;
if (errno == ERANGE || v < 0 || v > 150) // fuera de rango plausible
return -1;
*salida = (int)v; // solo aqui el dato es de fiar
return 0;
}
Fíjate en lo que hace esta función más allá de convertir: comprueba que toda la cadena era un número, que no hubo desbordamiento en strtol, y que el valor cae en un rango de dominio razonable. Nunca uses atoi, que no puede señalar el error. Después de esta frontera, el resto del programa opera con un int en [0, 150] y no necesita volver a dudar. Validar en el borde concentra la desconfianza en un lugar auditable en vez de esparcirla —y olvidarla— por cada uso.
Aritmética segura y límites explícitos
Las dos operaciones que más exploits engendran son la aritmética de tamaños y la copia de bytes. Ambas tienen una forma correcta que deberías escribir por reflejo.
Aritmética comprobada
ckd_add, ckd_sub, ckd_mul de stdckdint.h: calculan si el resultado desborda antes de que un tamaño corrupto llegue a malloc.
Copias con límite
snprintf en vez de sprintf, memcpy con un tamaño verificado, nunca strcpy ni gets. Toda escritura conoce su techo.
Anular al liberar
free(p); p = nullptr; como una sola unidad mental. Neutraliza la doble liberación y convierte el use-after-free en un cuelgue limpio.
Formato literal
La cadena de formato de printf es siempre un literal; los datos son argumentos. Nunca printf(dato), siempre printf("%s", dato).
En la práctica, estas reglas se combinan en el patrón más común de todos —reservar espacio para n elementos y llenarlo— que hay que escribir así:
#include <stdckdint.h>
#include <stdlib.h>
int *reservar(size_t n) {
size_t bytes;
if (ckd_mul(&bytes, n, sizeof(int))) // rechaza el desbordamiento de tamano
return nullptr;
return malloc(bytes);
}
void guardar(char *dst, size_t cap, const char *nombre, int id) {
snprintf(dst, cap, "%s-%d", nombre, id); // nunca escribe mas de cap bytes
}
ckd_mul blinda la reserva; snprintf recibe la capacidad del destino y jamás la excede, truncando en lugar de desbordar. La disciplina es siempre la misma: la función que escribe conoce el tamaño del destino, porque en C un búfer no lo lleva consigo y por tanto se lo tienes que decir tú, en cada llamada, sin excepción.
El contrato de cada función
El C defensivo maduro no dispersa las comprobaciones al azar: las organiza en torno a un contrato explícito por función. Cada función declara qué exige de quien la llama (sus precondiciones), qué garantiza a cambio (sus postcondiciones) y quién es dueño de la memoria que recibe o devuelve. Hacer ese contrato visible convierte suposiciones tácitas —“claro que el puntero no será nulo”— en aserciones verificables.
#include <assert.h>
// Contrato: dst no nulo, cap > 0, src una cadena valida.
// Garantiza: dst queda terminada en NUL; devuelve bytes escritos.
// Propiedad: dst pertenece al llamador; esta funcion solo escribe en el.
size_t copiar_seguro(char *dst, size_t cap, const char *src) {
assert(dst != nullptr && cap > 0 && src != nullptr); // precondiciones
size_t n = 0;
while (n + 1 < cap && src[n] != '\0') { // limite explicito
dst[n] = src[n];
n++;
}
dst[n] = '\0'; // postcondicion
return n;
}
Las assert documentan y verifican las precondiciones durante el desarrollo, y desaparecen en producción con NDEBUG sin dejar coste. La cabecera de comentario no es decoración: es el contrato que quien llama debe cumplir y que quien revisa debe verificar. La propiedad de la memoria es la parte más olvidada y la que más use-after-free previene: decir en cada función quién libera qué elimina la ambigüedad de la que nacen las liberaciones dobles y los punteros colgantes. Una función que documenta “el llamador es dueño de dst” nunca lo liberará por su cuenta, y una que documenta “devuelvo memoria que debes liberar” avisa a quien la use de su deber. Cuando el contrato es explícito, la corrección deja de depender de que todos recuerden lo mismo.
La checklist de revisión
La seguridad no es un estado que se alcanza, sino un proceso que se repite. Una checklist convierte la revisión de código de un acto de intuición en un procedimiento sistemático que no depende del día que tengas.
flowchart TD A[Revisar un fragmento de C] --> B[De donde viene cada dato] B --> C[Se valida en la frontera antes de usarse] C --> D[Cada copia conoce el tamano del destino] D --> E[Cada aritmetica de tamano esta comprobada] E --> F[Cada puntero se anula al liberarse] F --> G[La cadena de formato es siempre literal] G --> H[Pasa por ASan UBSan y analisis estatico] style H fill:#a6e3a1,color:#11111b
Recórrela ante cualquier función que procese datos no confiables, y para cada punto pregunta no “parece bien” sino “¿dónde está la línea que lo garantiza?”:
- Origen de los datos. ¿Cuáles vienen de fuera de la frontera de confianza? Márcalos mentalmente como hostiles hasta que se validen.
- Validación en el borde. ¿Cada entrada externa se comprueba —rango, tamaño, formato— antes de tocar la lógica? ¿Una sola vez y en un lugar auditable?
- Límites en las escrituras. ¿Toda copia usa una función con tamaño (
snprintf,memcpyverificado) y jamásstrcpy,strcat,sprintfogets? - Aritmética de tamaños. ¿Cada multiplicación o suma que alimenta un
mallocpasa porckd_mulockd_add? - Vida de los punteros. ¿Cada
freeva seguido de anular el puntero? ¿Ningún puntero se usa tras liberarse ni antes de inicializarse? - Datos como control. ¿Ningún dato externo es la cadena de formato, ni un índice sin comprobar, ni el destino de una escritura?
- Herramientas. ¿El código pasa por
-fsanitize=address,undefined, análisis estático y, si procesa formatos complejos, fuzzing?
El error mental más caro en seguridad es creer que existe el código seguro como un estado terminal —que un día terminas de asegurarlo y ya está. No existe. Existe el proceso seguro: la disciplina repetida, en cada función y en cada revisión, de preguntarse dónde cruza un dato la frontera y qué garantiza que lo hace sano. La diferencia es profunda. El código que “parece seguro” descansa en la memoria y el estado de ánimo de quien lo escribió; el proceso seguro descansa en pasos verificables que cualquiera puede reproducir y que las herramientas pueden automatizar. Por eso esta lección no te da una lista de funciones prohibidas para memorizar, sino una checklist para aplicar: la primera envejece con cada nueva clase de bug, la segunda es un hábito que se afila con el uso. Y por eso el C defensivo y las mitigaciones del compilador no compiten, se completan. Tú cazas los bugs antes de que existan, con validación en la frontera, aritmética comprobada y límites explícitos; el compilador contiene los que se te escaparon, con canarios, NX y ASLR; los sanitizers y el fuzzing encuentran en desarrollo los que sobrevivieron a ambos. Ninguna capa es suficiente; juntas hacen de C —un lenguaje que por diseño no te protege de nada— una herramienta con la que se puede escribir software que aguante el mundo real. La confianza total que C deposita en ti no es una trampa: es una responsabilidad, y el C defensivo es la forma de estar a su altura.
- Toma una función tuya que lea datos externos y reescribe su entrada para validarla en la frontera con
strtolo equivalente, rechazando todo lo que no encaje en su dominio. - Sustituye cada
sprintf,strcpyostrcatque encuentres por su variante con tamaño, y confirma que ninguna copia escribe sin conocer la capacidad del destino. - Recorre la checklist completa sobre un módulo de tu proyecto y anota cada incumplimiento; conviértelo en una lista de correcciones priorizada por gravedad.
- Compila ese módulo con
-fsanitize=address,undefined -fno-sanitize-recover=ally con el conjunto completo de flags de hardening, y demuestra que pasa limpio antes de darlo por revisado.