1. Defensa en capas
Ninguna herramienta lo encuentra todo. El análisis estático ve todos los caminos pero no los datos reales; los sanitizadores ven los datos reales pero solo en los caminos que la ejecución recorre; el compilador avisa de lo que es seguro, no de lo que es probable; strace ve las llamadas al sistema pero no las variables. Por eso se combinan:
| Capa | Cuándo actúa | Qué atrapa bien | Qué se le escapa |
|---|---|---|---|
| Advertencias del compilador | Al compilar | Conversiones, comparaciones mixtas, formatos, variables sin usar o sin inicializar, fall-through. | Casi todo lo que depende de valores en tiempo de ejecución. |
| Análisis estático | Al compilar o aparte | Fugas, doble liberación, uso tras liberar, nulos sin comprobar, descriptores perdidos. | Flujos complejos (falsos negativos) y a veces avisa de caminos imposibles (falsos positivos). |
| Endurecimiento (SSP, FORTIFY) | Al ejecutar, siempre | Algunos desbordes de pila y de funciones de cadenas: aborta en vez de seguir. | Muchos desbordes: solo se detecta lo que toca ciertas zonas. |
| Sanitizadores (ASan, UBSan, TSan) | Al ejecutar, en pruebas | Memoria, comportamiento indefinido, carreras: con el informe exacto de la línea. | Lo que las pruebas no ejercitan; enlentecen el programa (no son para producción). |
assert y comprobaciones propias | Al ejecutar | Violaciones de las suposiciones que el programador escribió. | Lo que nadie pensó en escribir. |
strace | Al ejecutar | Qué archivos abre, qué falla, qué espera, qué procesos crea. | El estado interno del programa. |
2. Más avisos del compilador, y -Werror
En el tema 4 se vieron -Wall y -Wextra. Para un proyecto serio conviene un conjunto más estricto. Un programa con una colección de descuidos clásicos lo muestra:
#include <stdio.h>
#include <string.h>
int sin_prototipo() { return 1; }
static int clasificar(int x)
{
switch (x) {
case 1:
puts("uno");
case 2:
puts("dos");
break;
}
return 0;
}
int main(int argc, char **argv)
{
int n = strlen(argv[0]);
unsigned int u = 5;
int i = -1;
char *s = "constante";
double d = 0.1;
float f = d;
if (i < u)
puts("menor");
for (int n = 0; n < argc; n++)
;
printf(argv[0]);
if (d == 0.1)
puts("igual");
(void)s; (void)f; (void)n;
return clasificar(argc) + sin_prototipo();
}
$ gcc -std=c17 -Wall -Wextra -c avisos.c -o /dev/null 2>&1 | grep warning
avisos.c:27:11: warning: comparison of integer expressions of different signedness: ‘int’ and ‘unsigned int’ [-Wsign-compare]
avisos.c:31:5: warning: format not a string literal and no format arguments [-Wformat-security]
avisos.c:10:9: warning: this statement may fall through [-Wimplicit-fallthrough=]
$ EXTRA="-Wpedantic -Wshadow -Wconversion -Wstrict-prototypes -Wmissing-prototypes -Wwrite-strings -Wformat=2 -Wfloat-equal -Wcast-qual -Wnull-dereference -Wundef"
$ gcc -std=c17 -Wall -Wextra $EXTRA -c avisos.c -o /dev/null 2>&1 | grep warning
avisos.c:4:5: warning: function declaration isn’t a prototype [-Wstrict-prototypes]
avisos.c:20:13: warning: conversion from ‘size_t’ {aka ‘long unsigned int’} to ‘int’ may change value [-Wconversion]
avisos.c:23:15: warning: initialization discards ‘const’ qualifier from pointer target type [-Wdiscarded-qualifiers]
avisos.c:25:15: warning: conversion from ‘double’ to ‘float’ may change value [-Wfloat-conversion]
avisos.c:27:11: warning: comparison of integer expressions of different signedness: ‘int’ and ‘unsigned int’ [-Wsign-compare]
avisos.c:29:14: warning: declaration of ‘n’ shadows a previous local [-Wshadow]
avisos.c:31:5: warning: format not a string literal and no format arguments [-Wformat-security]
avisos.c:32:11: warning: comparing floating-point with ‘==’ or ‘!=’ is unsafe [-Wfloat-equal]
avisos.c:10:9: warning: this statement may fall through [-Wimplicit-fallthrough=]
De 3 avisos pasan a 9 (el orden de la lista es el del compilador, no el del archivo). Cada nuevo aviso apunta a un descuido real:
-Wconversiony-Wfloat-conversion:int n = strlen(…)mete unsize_tde 64 bits en unintde 32, yfloat f = dpierde precisión. Son el origen de muchos errores con longitudes y tamaños.-Wsign-compare(incluido en-Wextra):i < uconi = -1compara tras convertir-1a sin signo, ¡y es falso! Una trampa del tema 7 que el compilador detecta.-Wshadow: elndelforoculta alnexterior, y las dos variables con el mismo nombre confunden.-Wformat-security:printf(argv[0])usa un texto externo como formato; si contiene%so%nel programa lee o escribe memoria ajena. Debe serprintf("%s", argv[0]). (Este aviso ya figura en el conjunto básico porque Ubuntu lo activa por omisión.)-Wstrict-prototypes:int sin_prototipo()en C no significa «sin parámetros» sino «parámetros sin comprobar»; debe ser(void).-Wwrite-strings(el aviso aparece como-Wdiscarded-qualifiers):char *s = "constante"permite modificar un literal, que está en memoria de solo lectura; debe serconst char *.-Wfloat-equal: comparardoublecon==casi nunca hace lo esperado.-Wimplicit-fallthrough: elcase 1«cae» alcase 2sin unbreak; casi siempre es un olvido. Si es intencional, se declara con[[fallthrough]];(C23) o__attribute__((fallthrough));.
Ver los avisos no basta si se acumulan y nadie los lee. La práctica habitual es -Werror, que convierte los avisos en errores y detiene la compilación; en un proyecto limpio, cualquier aviso nuevo se corrige antes de continuar:
$ gcc -std=c17 -Wall -Wextra -Werror -c avisos.c -o /dev/null; echo "rc=$?"
avisos.c:27:11: error: comparison of integer expressions of different signedness: ‘int’ and ‘unsigned int’ [-Werror=sign-compare]
avisos.c:31:5: error: format not a string literal and no format arguments [-Werror=format-security]
avisos.c:10:9: error: this statement may fall through [-Werror=implicit-fallthrough=]
rc=1
$ gcc -std=c17 -Wall -Wextra -Werror -Wno-error=implicit-fallthrough -c avisos.c -o /dev/null 2>&1 | grep -E 'error|warning'
avisos.c:27:11: error: comparison of integer expressions of different signedness: ‘int’ and ‘unsigned int’ [-Werror=sign-compare]
avisos.c:31:5: error: format not a string literal and no format arguments [-Werror=format-security]
avisos.c:10:9: warning: this statement may fall through [-Wimplicit-fallthrough=]
-Wno-error=nombre deja un aviso concreto como aviso (el fall-through de la segunda salida) sin quitar el -Werror del resto. Una advertencia: -Werror es una política para tu código y tu compilador. Si se distribuye el código fuente para que otros lo compilen, un compilador más nuevo traerá avisos nuevos y rompería la compilación, así que allí se usa solo en el desarrollo y la integración continua.
3. Endurecimiento: protector de pila y FORTIFY_SOURCE
Un desborde de búfer es el error más peligroso de C, y las distribuciones modernas compilan con defensas que lo hacen más ruidoso. Dos de ellas son especialmente útiles. El protector de pila (-fstack-protector-strong) coloca un valor secreto («canario») entre los arreglos locales y la dirección de retorno de la función, y lo comprueba antes de volver: si cambió, aborta. _FORTIFY_SOURCE reemplaza funciones como strcpy o memcpy por variantes que comprueban el tamaño del destino cuando el compilador lo conoce, y se activa con optimización. En este gcc (Ubuntu) ambas vienen activas por omisión:
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv)
{
char buf[8];
if (argc < 2)
return 2;
strcpy(buf, argv[1]);
printf("copiado: %s\n", buf);
return 0;
}
$ gcc -Q --help=common -O2 vacio.c | grep -E 'stack-protector-strong|stack-clash'
-fstack-clash-protection [enabled]
-fstack-protector-strong [enabled]
$ gcc -std=c17 -Wall -Wextra -O0 -o d0 desborde.c; ./d0 corto; echo "rc=$?"; ./d0 $LARGO; echo "rc=$?"
copiado: corto
rc=0
*** stack smashing detected ***: terminated
rc=134
$ gcc -std=c17 -Wall -Wextra -O2 -o d2 desborde.c; ./d2 $LARGO; echo "rc=$?"
*** buffer overflow detected ***: terminated
rc=134
$ gcc -std=c17 -O0 -fno-stack-protector -U_FORTIFY_SOURCE -o dn desborde.c
$ ./dn $LARGO; echo "rc=$?"
Segmentation fault
rc=139
$ ./dn 123456789012; echo "rc=$?"
copiado: 123456789012
rc=0
Lo importante, caso por caso:
- Con el compilador por defecto y
-O0, el canario detecta el desborde al volver demain:*** stack smashing detected ***y señalSIGABRT(código 134). - Con
-O2,FORTIFY_SOURCEconoce el tamaño debufy sustrcpyverificado aborta antes de escribir de más:*** buffer overflow detected ***. - Sin ninguna defensa, 56 letras producen un
Segmentation fault; pero 12 caracteres (que ya desbordan un búfer de 8) pasan sin ningún síntoma: el programa escribe fuera del arreglo y «funciona». Es la peor situación posible: un error latente que se manifestará en otro momento, con otros datos.
Estas defensas son una red de seguridad, no una solución: convierten una posible ejecución de código ajeno en una caída controlada, pero el programa sigue estando mal. La corrección sigue siendo comprobar tamaños (snprintf, strlen antes de copiar, tema 22). Y no cubren todo: un desborde de 12 bytes en el montículo, o de unos pocos bytes dentro de una estructura, no toca el canario. Para eso están los sanitizadores (tema 29).
4. assert: invariantes, no entradas
El tema 14 presentó assert(condición): si la condición es falsa, imprime un mensaje con archivo, línea y función, y aborta. La pregunta de diseño es cuándo usarlo:
assertpara lo que «nunca debería pasar»: un error del programador, una invariante interna violada (un puntero que por construcción nunca es nulo, un índice que ya se validó, un contador que no puede ser negativo). Documenta la suposición y la verifica durante las pruebas.- Comprobación y error normal para lo que puede pasar: entradas del usuario, archivos que faltan, memoria que se agota, respuestas de la red. Eso se maneja siempre (tema 37), también en el ejecutable que se entrega.
La razón de esa división es que assert desaparece al compilar con -DNDEBUG, y con él la comprobación y todo lo que haya dentro del paréntesis. Se ve con un programa que comete justo el error clásico: poner en el assert una llamada con efecto lateral.
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
static int dividir(int a, int b)
{
assert(b != 0);
return a / b;
}
static int leer(int *dst)
{
*dst = 42;
return 1;
}
int main(int argc, char **argv)
{
int v = 0;
assert(leer(&v)); /* ¡error! el efecto desaparece con NDEBUG */
printf("v = %d\n", v);
printf("%d\n", dividir(10, argc > 1 ? atoi(argv[1]) : 2));
return 0;
}
$ gcc -std=c17 -Wall -Wextra -o as_dbg aserto.c
$ ./as_dbg 5; echo "rc=$?"
v = 42
2
rc=0
$ ./as_dbg 0; echo "rc=$?"
as_dbg: aserto.c:7: dividir: Assertion `b != 0' failed.
rc=134
$ gcc -std=c17 -Wall -Wextra -DNDEBUG -o as_rel aserto.c
aserto.c:11:12: warning: ‘leer’ defined but not used [-Wunused-function]
$ ./as_rel 5; echo "rc=$?"
v = 0
2
rc=0
$ ./as_rel 0; echo "rc=$?"
Floating point exception
rc=136
La versión de depuración funciona y da un mensaje claro ante la división por cero (Assertion `b != 0' failed, con archivo, línea y función). En la de producción, compilada con -DNDEBUG, ocurren dos cosas: v vale 0, porque la llamada a leer dentro del assert no se ejecutó (el compilador, de paso, avisa de que leer no se usa), y la división por cero ya no se comprueba y el procesador la detecta con SIGFPE (código 136). Tres reglas se desprenden: (1) nunca poner efectos laterales dentro de un assert; (2) no confiar en un assert para validar datos externos; (3) probar también el programa compilado con NDEBUG, porque es el que se entrega. Para comprobaciones que se conocen al compilar, static_assert (tema 14) las verifica sin costo en ejecución y sin depender de NDEBUG.
5. Comportamiento indefinido: UBSan
El estándar de C declara indefinidos muchos errores: desbordar un entero con signo, desplazar más bits que el ancho del tipo, dividir por cero, indexar fuera de un arreglo, desreferenciar un puntero nulo. Cuando ocurren, el compilador puede hacer cualquier cosa —y muchas veces «funciona» por casualidad—. UndefinedBehaviorSanitizer (-fsanitize=undefined) inserta comprobaciones y avisa en el momento exacto. Un programa que elige un error según su argumento:
switch (caso) {
case 1: x += argc; printf("x = %d\n", x); break; /* x = INT_MAX */
case 2: printf("%d\n", 1 << (30 + argc + cero)); break;
case 3: printf("%d\n", v[3 + argc]); break; /* int v[4] */
case 4: printf("%d\n", 10 / cero); break;
case 5: printf("%d\n", p->b); break; /* p == NULL */
}
$ for c in 1 2 3 4 5; do echo "caso $c:"; ./ub $c 2>&1 | head -3; done
caso 1:
ub.c:16:15: runtime error: signed integer overflow: 2147483647 + 2 cannot be represented in type 'int'
x = -2147483647
caso 2:
ub.c:17:30: runtime error: shift exponent 32 is too large for 32-bit type 'int'
1
caso 3:
ub.c:18:29: runtime error: index 5 out of bounds for type 'int [4]'
ub.c:18:13: runtime error: load of address 0x7ffd82043bb4 with insufficient space for an object of type 'int'
0x7ffd82043bb4: note: pointer points here
caso 4:
ub.c:19:31: runtime error: division by zero
caso 5:
ub.c:20:13: runtime error: member access within null pointer of type 'struct par'
$ for c in 1 2 3 4; do printf "caso $c: "; ./ub_plain $c 2>&1 | head -1; done # sin sanitizador
caso 1: x = -2147483647
caso 2: 1
caso 3: 0
caso 4: (muere con SIGFPE, rc=136)
Sin el sanitizador, tres de los cuatro casos «funcionan» y dan resultados sin sentido: INT_MAX + 2 se vuelve negativo, 1 << 32 da 1, y v[5] lee basura (0). Con él, cada uno se informa con la línea y la columna, la operación y los valores. Observaciones:
- Por omisión UBSan informa y sigue (
x = -2147483647se imprime después del aviso). Con-fno-sanitize-recover=allse detiene en el primer error, y el programa termina con código 1, útil en pruebas automáticas, donde un informe ignorado no sirve:
$ gcc -std=c17 -g -fsanitize=undefined -fno-sanitize-recover=all -o ub_fatal ub.c
$ ./ub_fatal 1; echo "rc=$?"
ub.c:16:15: runtime error: signed integer overflow: 2147483647 + 2 cannot be represented in type 'int'
rc=1
$ UBSAN_OPTIONS=print_stacktrace=1 ./ub 1 2>&1 | head -3
ub.c:16:15: runtime error: signed integer overflow: 2147483647 + 2 cannot be represented in type 'int'
#0 0x571f087e7484 in main ub.c:16
#1 0x7a5fc602a600 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:59
- La variable
UBSAN_OPTIONS=print_stacktrace=1agrega la pila de llamadas al informe. - UBSan se combina con AddressSanitizer:
-fsanitize=address,undefined(la combinación de este curso). Los dos se compilan con-g(para tener líneas) y con-O0o-O1(con más optimización, el compilador puede reordenar y borrar código antes de que la comprobación se inserte). - Los casos de enteros sin signo no son comportamiento indefinido (se desbordan de forma cíclica y definida), así que no se informan por omisión; sí son una fuente frecuente de errores con tamaños.
6. Carreras de datos: ThreadSanitizer
Cuando dos hilos acceden a la misma variable y al menos uno escribe, sin sincronización, hay una carrera de datos: el resultado depende del azar. Dos hilos incrementan 100 000 veces el mismo contador sin protección:
static long contador;
static void *sumar(void *arg)
{
(void)arg;
for (int i = 0; i < 100000; i++)
contador++;
return NULL;
}
$ gcc -std=c17 -Wall -Wextra -g -pthread -o carrera carrera.c && ./carrera
contador = 129435 (esperado 200000)
$ gcc -std=c17 -Wall -Wextra -g -pthread -fsanitize=thread -o carrera_t carrera.c && ./carrera_t 2>&1 | head -9
==================
WARNING: ThreadSanitizer: data race (pid=568)
Read of size 8 at 0x555555558018 by thread T2:
#0 sumar carrera.c:11
Previous write of size 8 at 0x555555558018 by thread T1:
#0 sumar carrera.c:11
Location is global 'contador' of size 8 at 0x555555558018 (carrera_t+0x4018)
El programa sin vigilar da 129 435 en esta ejecución (y otro valor en la siguiente): se perdieron incrementos. ThreadSanitizer (-fsanitize=thread) detecta el acceso concurrente y lo describe: una lectura del hilo T2 y una escritura previa del T1, ambas en la línea 11, sobre la variable global contador. La corrección (un mutex o una operación atómica) y el estudio de los hilos son tema del curso Procesos e Hilos; lo que importa aquí es saber que existe la herramienta. TSan no se combina con ASan: son compilaciones distintas.
7. Análisis estático: -fanalyzer y cppcheck
Los sanitizadores solo ven lo que se ejecuta. El análisis estático examina el programa sin correrlo, siguiendo en teoría todos los caminos posibles, y señala patrones peligrosos. Un archivo con cinco errores de memoria clásicos:
static char *copiar(const char *s)
{
char *p = malloc(strlen(s) + 1);
strcpy(p, s); /* sin comprobar malloc */
return p;
}
static void libera_dos_veces(char *p)
{
free(p);
free(p); /* doble liberación */
}
static int usa_despues(char *p)
{
free(p);
return p[0]; /* uso tras liberar */
}
static void fuga(void)
{
char *p = copiar("fuga");
printf("%s\n", p); /* nunca se libera */
}
static int descriptor(const char *ruta)
{
int fd = open(ruta, O_RDONLY);
char c;
if (fd < 0)
return -1;
if (read(fd, &c, 1) != 1)
return -1; /* fd sin cerrar en este camino */
close(fd);
return c;
}
El compilador trae su propio analizador, -fanalyzer (disponible en gcc desde la versión 10; en C es el más completo). Se agrega a la compilación y los informes salen como avisos:
$ gcc -std=c17 -Wall -Wextra -fanalyzer -c estatico.c -o /dev/null 2>&1 | grep warning
estatico.c:18:5: warning: pointer ‘p’ used after ‘free’ [-Wuse-after-free]
estatico.c:24:13: warning: pointer ‘p’ used after ‘free’ [-Wuse-after-free]
estatico.c:11:5: warning: use of possibly-NULL ‘p’ where non-null expected [CWE-690] [-Wanalyzer-possible-null-argument]
estatico.c:18:5: warning: double-‘free’ of ‘p’ [CWE-415] [-Wanalyzer-double-free]
estatico.c:24:13: warning: use of uninitialized value ‘*p’ [CWE-457] [-Wanalyzer-use-of-uninitialized-value]
estatico.c:24:13: warning: use after ‘free’ of ‘p’ [CWE-416] [-Wanalyzer-use-after-free]
estatico.c:32:1: warning: leak of ‘p’ [CWE-401] [-Wanalyzer-malloc-leak]
estatico.c:42:16: warning: leak of file descriptor ‘fd’ [CWE-775] [-Wanalyzer-fd-leak]
Encontró los cinco: el malloc sin comprobar (línea 11), la doble liberación (18), el uso tras liberar (24), la fuga de memoria (32) y el descriptor sin cerrar en el camino de error (42), cada uno con su número CWE (el catálogo común de debilidades de software). Cada aviso trae, además, un recorrido paso a paso del camino que lleva al error (aquí se omite). Una segunda herramienta, cppcheck, es un analizador independiente, más rápido y sin necesidad de compilar, que se instala aparte (sudo apt install cppcheck). Sobre un archivo breve con un desborde de búfer y una fuga:
char buf[8];
int *p = malloc(sizeof *p);
strcpy(buf, "demasiado largo");
return *p;
$ cppcheck --enable=warning,style t.c 2>&1 | grep -E '^t.c:[0-9]+:[0-9]+: (error|warning|style)'
t.c:9:12: error: Buffer is accessed out of bounds: buf [bufferAccessOutOfBounds]
t.c:10:5: error: Memory leak: p [memleak]
t.c:10:13: warning: If memory allocation fails, then there is a possible null pointer dereference: p [nullPointerOutOfMemory]
t.c:7:10: style: Variable 'p' can be declared as pointer to const [constVariablePointer]
t.c:10:13: error: Memory is allocated but not initialized: p [uninitdata]
$ gcc -std=c17 -Wall -Wextra -fanalyzer -c t.c -o /dev/null 2>&1 | grep warning | cut -c1-140
t.c:9:5: warning: stack-based buffer overflow [CWE-121] [-Wanalyzer-out-of-bounds]
t.c:10:12: warning: use of uninitialized value ‘*p’ [CWE-457] [-Wanalyzer-use-of-uninitialized-value]
t.c:10:12: warning: dereference of possibly-NULL ‘p’ [CWE-690] [-Wanalyzer-possible-null-dereference]
t.c:9:5: warning: ‘__builtin_memcpy’ writing 16 bytes into a region of size 8 overflows the destination [-Wstringop-overflow=]
Cada analizador encontró cosas que el otro no: cppcheck detectó la fuga (memleak) y -fanalyzer no; -fanalyzer informó el desborde con el tamaño exacto (16 bytes en 8). Esa es la razón de combinar herramientas. Los analizadores estáticos tienen tres limitaciones: (1) los falsos positivos (avisan de caminos que, por razones que no ven, no ocurren); (2) los falsos negativos (no ven errores que dependen de datos o de muchas funciones); y (3) su costo de análisis, que crece con el código (por eso -fanalyzer se usa de vez en cuando y no en cada compilación). Los falsos positivos se tratan como las advertencias: se revisan y, si el aviso no tiene razón, se reescribe el código para que sea evidente, o se silencia de forma explícita y comentada. Existen otras herramientas del mismo tipo, como clang-tidy y el analizador de Clang (scan-build), que no hay en este equipo y no se prueban aquí.
8. Mirar desde afuera: strace
A veces no hay que analizar el código sino ver qué le pide el programa al sistema operativo. strace ejecuta un programa y muestra cada llamada al sistema, con sus argumentos y su resultado (incluido el errno de los fallos, tema 37). Resuelve preguntas del tipo «¿por qué no encuentra su configuración?» sin tocar el código ni recompilar. Un programa que busca su archivo de configuración en tres lugares y no lo halla:
const char *rutas[] = {"./app.conf", "/etc/app/app.conf", "/usr/local/etc/app.conf"};
/* … fopen de cada ruta; si ninguna abre: "no se encontro la configuracion", return EX_CONFIG … */
$ ./buscar; echo "rc=$?"
buscar: no se encontro la configuracion
rc=78
$ strace -e trace=openat ./buscar 2>&1 | grep -v -e '\.so' -e 'ld.so'
openat(AT_FDCWD, "./app.conf", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/app/app.conf", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/usr/local/etc/app.conf", O_RDONLY) = -1 ENOENT (No such file or directory)
buscar: no se encontro la configuracion
+++ exited with 78 +++
Las tres líneas openat responden la pregunta: las tres rutas probadas, en orden, todas con ENOENT. (Se filtran con grep las líneas de las bibliotecas dinámicas que el cargador abre antes de main, tema 33.) El último renglón informa el código de salida (78, EX_CONFIG). Es el equivalente, desde afuera, del perror que el programa no imprimió con detalle. Las opciones que más se usan:
| Opción | Qué hace |
|---|---|
-e trace=openat,read | Muestra solo esas llamadas. También hay grupos: %file, %network, %memory, %process. |
-o archivo | Escribe la traza en un archivo, para no mezclarla con la salida del programa. |
-c | No muestra las llamadas: imprime una tabla resumen con cuántas veces se llamó a cada una, tiempo y errores. |
-f | Sigue también a los procesos hijos (fork, clone). |
-T / -tt | Agrega el tiempo que tardó cada llamada / la hora de cada una. |
-s N | Muestra hasta N caracteres de las cadenas (por omisión, 32). |
-p PID | Se engancha a un proceso que ya corre (requiere permisos). |
La opción -c da un perfil de un programa sin instrumentarlo; con un programa mínimo de una sola línea (puts("hola")):
$ strace -c ./hola 2>&1 | head -14
hola
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
0.00 0.000000 0 1 read
0.00 0.000000 0 1 write
0.00 0.000000 0 2 close
0.00 0.000000 0 3 fstat
0.00 0.000000 0 8 mmap
0.00 0.000000 0 3 mprotect
0.00 0.000000 0 1 munmap
0.00 0.000000 0 3 brk
0.00 0.000000 0 2 pread64
0.00 0.000000 0 1 1 access
0.00 0.000000 0 1 execve
Un programa de una línea realiza decenas de llamadas, casi todas de arranque del cargador (mmap y mprotect para mapear libc), y una sola write con el texto del puts. La columna errors marca los fallos: el access inicial falla porque busca /etc/ld.so.preload, que normalmente no existe, y es lo esperado. Con -f, se sigue un programa que lanza otros procesos; aquí una cadena de sh que ejecuta dos veces /bin/true:
$ strace -f -e trace=execve -o f.txt sh -c '/bin/true; /bin/true'; cut -c1-70 f.txt
443 execve("/usr/bin/sh", ["sh", "-c", "/bin/true; /bin/true"], 0x7f
444 execve("/bin/true", ["/bin/true"], 0x5d655805c5d0 /* 23 vars */)
444 +++ exited with 0 +++
443 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=444, s
445 execve("/bin/true", ["/bin/true"], 0x5d655805c5d0 /* 23 vars */)
445 +++ exited with 0 +++
443 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=445, s
443 +++ exited with 0 +++
Otro uso muy práctico es el de un programa que parece colgado: strace muestra en qué llamada está bloqueado. Si la última línea es read(0, sin el = N de resultado, el programa espera datos en la entrada estándar; si es futex(…, espera un cerrojo; si es connect(…, una conexión de red. Para engancharse a un proceso que ya corre, strace -p PID; en muchos sistemas el núcleo lo restringe a los procesos descendientes del que lo invoca (ptrace_scope):
$ sleep 30 & strace -p $! -e trace=none
strace: attach: ptrace(PTRACE_SEIZE, 469): Operation not permitted
$ cat /proc/sys/kernel/yama/ptrace_scope
1
Con ptrace_scope en 1, solo se puede seguir a los descendientes directos; se evita ejecutando el programa dentro de strace desde el principio, o con permisos de administrador. strace enlentece el programa (usa el mismo mecanismo, ptrace, que gdb) y permite ver los datos que el programa lee o escribe, de modo que no debe usarse sobre procesos con secretos sin cuidado.
9. Código defensivo y pruebas de borde
Las herramientas encuentran errores; el estilo de programación evita que aparezcan. Principios que se aplican en todo el curso y que conviene recapitular:
- Comprobar todo lo que puede fallar (tema 37): resultados de
malloc,fopen,read,fclose… - Pasar siempre el tamaño junto con el puntero (
char *dst, size_t cap) y usarlo:snprintf,memcpycon un largo comprobado, nuncastrcpynigetssobre datos externos. - Validar en la frontera: lo que viene de fuera (argumentos, archivos, red) se comprueba al entrar, y dentro del programa se puede confiar en lo ya validado (y reforzarlo con
assert). - Inicializar todas las variables; poner a
NULLlos punteros liberados cuando seguirán a la vista; un dueño claro por cada bloque de memoria (tema 30). - Usar
consten los parámetros que no se modifican,staticen lo que no se exporta,size_tpara tamaños y tipos de ancho fijo (uint32_t) para formatos. - Reducir el ámbito: variables declaradas donde se usan, funciones cortas, estado global mínimo.
- Fallar de manera visible: un error claro y un código de salida distinto de cero son mejores que seguir con datos dudosos.
Y probar los casos límite, donde viven casi todos los errores: tamaño cero, uno, exacto, uno de más; entrada vacía; puntero nulo; el valor máximo del tipo. Un ejemplo que junta todo: una función que copia un nombre a un búfer de tamaño dado, devolviendo un código de error en lugar de desbordar, probada en sus bordes con los sanitizadores. Las pruebas ubican cada búfer con malloc del tamaño exacto, para que AddressSanitizer detecte cualquier byte de más:
int copiar_nombre(char *dst, size_t cap, const char *src)
{
if (dst == NULL || src == NULL || cap == 0)
return EINVAL;
size_t n = strlen(src);
if (n >= cap) { /* hace falta lugar para el '\0' */
dst[0] = '\0';
return ENAMETOOLONG;
}
memcpy(dst, src, n + 1);
return 0;
}
const struct caso casos[] = {
{0, "x", EINVAL},
{8, NULL, EINVAL},
{1, "", 0},
{1, "a", ENAMETOOLONG},
{4, "abc", 0}, /* cabe justo */
{4, "abcd", ENAMETOOLONG}, /* uno de más */
{4, "abcdefghijkl", ENAMETOOLONG},
};
$ gcc -std=c17 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -g -fsanitize=address,undefined -fno-sanitize-recover=all -o seguro seguro.c
$ ./seguro; echo "rc=$?"
cap=0 src=x -> Invalid argument
cap=8 src=(NULL) -> Invalid argument
cap=1 src= -> ok
cap=1 src=a -> File name too long
cap=4 src=abc -> ok
cap=4 src=abcd -> File name too long
cap=4 src=abcdefghijkl -> File name too long
rc=0
Sin avisos y sin informes: los siete casos pasan. Ahora el error más frecuente en esta clase de funciones, el de «uno de menos»: comparar con n > cap en lugar de n >= cap. Con esa sola diferencia, el código sigue pareciendo correcto y casi siempre funcionará; los casos de borde con búferes exactos lo delatan de inmediato:
$ gcc -std=c17 -Wall -Wextra -DBUG -g -fsanitize=address,undefined -fno-sanitize-recover=all -o seguro_bug seguro.c
$ ./seguro_bug 2>&1 | head -8
=================================================================
==493==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6fc0d1ee00b1 at pc 0x73a0d3b28d2d bp 0x7fff5ee69d20 sp …
WRITE of size 2 at 0x6fc0d1ee00b1 thread T0
#0 0x73a0d3b28d2c in memcpy …
#1 0x653708658542 in copiar_nombre seguro.c:24
#2 0x653708658f0a in main seguro.c:49
0x6fc0d1ee00b1 is located 0 bytes after 1-byte region [0x6fc0d1ee00b0,0x6fc0d1ee00b1)
AddressSanitizer señala el memcpy de la línea 24 escribiendo 2 bytes en una región de 1: es el caso cap=1, src="a", donde el error de uno hace que se copie la 'a' y también el terminador. Un detalle que explica el tema 38: las líneas de resultados que el programa alcanzó a imprimir antes no aparecen, porque con la salida redirigida están en el búfer de stdio y el sanitizador aborta el proceso sin vaciarlo. Para pruebas con sanitizadores conviene setvbuf(stdout, NULL, _IONBF, 0) o un fflush antes de las operaciones sospechosas.
10. Una receta de opciones
Resumiendo, tres conjuntos de opciones cubren las situaciones habituales. El tema 36 mostró cómo seleccionar variantes con una variable MODE y un directorio de compilación por variante; este es su complemento:
| Perfil | Opciones | Para qué |
|---|---|---|
| Desarrollo | -std=c17 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wstrict-prototypes -Wformat=2 -g -O0 | Compilar a diario, con avisos estrictos y símbolos para gdb. |
| Pruebas | Las anteriores más -O1 -fsanitize=address,undefined -fno-sanitize-recover=all | Ejecutar las pruebas y el programa con las comprobaciones de memoria y de comportamiento indefinido. |
| Integración continua | Las de desarrollo más -Werror, y de vez en cuando -fanalyzer y cppcheck | Que ningún aviso nuevo llegue al repositorio. |
| Entrega | -O2 -DNDEBUG y el endurecimiento por omisión de la distribución (protector de pila, FORTIFY_SOURCE, PIE), sin -g o con símbolos aparte | Rendimiento y defensas de ejecución. Se prueban también con estas opciones. |
CFLAGS = -std=c17 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wstrict-prototypes -Wformat=2 -fPIC
ifeq ($(MODE),release)
CFLAGS += -O2 -DNDEBUG
else ifeq ($(MODE),san)
CFLAGS += -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=all
LDFLAGS += -fsanitize=address,undefined
else
CFLAGS += -O0 -g
endif
El modo san necesita las opciones tanto al compilar (CFLAGS) como al enlazar (LDFLAGS), porque el sanitizador trae su propia biblioteca de ejecución. Se invoca con make MODE=san test, y como cada modo tiene su directorio build/san, no se recompila al alternar con los demás.
11. Errores frecuentes
| Error | Qué pasa | Corrección |
|---|---|---|
Compilar sin -Wall -Wextra (o ignorar los avisos) | Errores que el compilador conocía llegan a la ejecución. | Avisos estrictos desde el primer día y -Werror en integración continua. |
| Silenciar un aviso con un cast sin entenderlo | Se oculta el problema, no se resuelve. | Revisar la causa; si es intencional, comentar por qué. |
Poner una llamada con efecto lateral dentro de assert | Con -DNDEBUG desaparece y el programa de entrega se comporta distinto. | Ejecutar la llamada fuera y hacer el assert del resultado. |
Usar assert para validar entradas externas | En el ejecutable de entrega no comprueba nada. | Comprobación real con manejo de error (tema 37). |
Confiar en el protector de pila y en FORTIFY_SOURCE para no corregir el desborde | Solo convierten algunos desbordes en abortos; muchos pasan (el de 12 caracteres). | Corregir el código; usarlos como red de seguridad. |
| Suponer que «funciona» significa «es correcto» | El comportamiento indefinido produce a menudo un resultado plausible. | UBSan y ASan en las pruebas. |
| Ejecutar los sanitizadores solo de vez en cuando | Los errores se acumulan y se pierde el contexto. | Un modo san en el Makefile y las pruebas siempre con él. |
Compilar con sanitizadores y -O3, o sin -g | Informes imprecisos o sin líneas. | -g -O0 o -O1. |
| Combinar ASan con TSan | Error de enlace o de ejecución: son incompatibles. | Compilaciones separadas. |
| Ignorar un aviso del analizador por «falso positivo» sin comprobar | A veces es real y estaba bien explicado. | Leer el recorrido que el analizador describe; si es falso, reescribir o documentar. |
| Esperar que el análisis estático lo encuentre todo | Los falsos negativos existen: faltó la fuga que cppcheck sí vio, o al revés. | Combinar herramientas y ejecución real. |
| Depurar un «no encuentra el archivo» leyendo el código | Horas buscando lo que strace muestra en un segundo. | strace -e trace=openat. |
Dejar la traza de strace mezclada con la salida | Ilegible. | -o archivo. |
| Probar solo el caso «normal» | Los errores de uno de más o de menos se escapan. | Casos de borde: 0, 1, el límite exacto, uno más, vacío, nulo. |
Entregar un binario con -fsanitize | Más lento, más memoria y una superficie de ataque mayor. | Los sanitizadores son para pruebas, no para entrega. |
12. Resumen
- La defensa es en capas porque cada herramienta ve algo distinto: avisos del compilador, análisis estático, endurecimiento en ejecución, sanitizadores,
assert,stracey pruebas de borde. - Más allá de
-Wall -Wextra:-Wpedantic -Wshadow -Wconversion -Wstrict-prototypes -Wformat=2 -Wfloat-equal(de 3 a 9 avisos en el ejemplo).-Werrorlos vuelve errores en el desarrollo y la integración continua;-Wno-error=nombreexceptúa uno. - El compilador de las distribuciones activa el protector de pila (stack smashing detected) y
_FORTIFY_SOURCE(buffer overflow detected, con optimización). Son redes de seguridad que convierten algunos desbordes en abortos (código 134); un desborde de 12 bytes en un búfer de 8 pasó sin ningún aviso sin ellas. assertes para invariantes internas, no para entradas; desaparece con-DNDEBUG, y con él todo lo que contenga (los efectos laterales). El programa de entrega se prueba conNDEBUG.- UBSan (
-fsanitize=undefined) informa del desborde de enteros con signo, desplazamientos, índices fuera de rango, división por cero y punteros nulos con archivo, línea y valores;-fno-sanitize-recover=alllo vuelve fatal. TSan (-fsanitize=thread) detecta carreras de datos; no se combina con ASan. - Análisis estático:
gcc -fanalyzerencontró los cinco errores de memoria del ejemplo (nulo sin comprobar, doble liberación, uso tras liberar, fuga, descriptor sin cerrar) con su número CWE;cppcheckes otra herramienta, con aciertos distintos. Ambos tienen falsos positivos y falsos negativos. stracemuestra las llamadas al sistema de un programa:-e trace=openatmostró las tres rutas de configuración fallidas conENOENT;-cresume,-fsigue a los hijos,-oguarda la traza, y la última llamada sin resultado revela dónde está bloqueado un programa.- El código defensivo comprueba cada resultado, pasa tamaños junto a los punteros, valida en la frontera, inicializa, y se prueba en sus bordes (0, 1, justo, uno más, nulo) con sanitizadores. Un solo
>por>=lo detectó ASan de inmediato. Un modosanen el Makefile hace que las pruebas siempre corran con ellos.
13. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. ¿Por qué se usan varias herramientas de detección en lugar de una sola?
Porque cada una ve algo distinto: el compilador y el análisis estático examinan el código sin ejecutarlo (todos los caminos, pero no los datos reales); los sanitizadores y strace observan la ejecución real (datos reales, pero solo los caminos recorridos); el endurecimiento actúa siempre pero solo sobre ciertos errores. Lo que una deja pasar, otra puede atraparlo.
2. ¿Qué atrapan -Wconversion, -Wshadow y -Wformat=2?
-Wconversion: conversiones implícitas que pueden perder valor (size_t a int, double a float). -Wshadow: una variable que oculta a otra del mismo nombre en un ámbito exterior. -Wformat=2: usos inseguros de funciones tipo printf, como un formato que no es literal.
3. ¿Qué hace -Werror y por qué conviene limitarlo a desarrollo e integración continua?
Convierte los avisos en errores de compilación, de modo que el código con avisos no llega al repositorio. Si se distribuye el fuente, un compilador más nuevo puede traer avisos nuevos y romper la compilación de quien lo use, por lo que se evita en las entregas de código fuente. -Wno-error=nombre deja un aviso concreto como aviso.
4. Un programa aborta con *** stack smashing detected ***. ¿Qué significa y qué mecanismo lo produjo?
Que un desborde de un arreglo local sobrescribió el «canario» que el protector de pila (-fstack-protector-strong) coloca entre los arreglos y la dirección de retorno; al volver de la función se detectó el cambio y el programa abortó (señal 6, código 134). Es una red de seguridad: el desborde sigue siendo un error a corregir.
5. ¿Qué es _FORTIFY_SOURCE y cuándo actúa?
Reemplaza funciones como strcpy y memcpy por versiones que comprueban el tamaño del destino cuando el compilador lo conoce. Se activa con optimización (-O1 o más) y, al detectar un desborde, aborta con *** buffer overflow detected *** antes de escribir de más.
6. ¿Cuándo usar assert y cuándo un manejo de error normal?
assert para condiciones que, por construcción, no deberían ocurrir (invariantes internas, errores del programador). Manejo de error normal, también en el programa final, para lo que puede ocurrir (entradas del usuario, archivos, memoria, red).
7. ¿Qué pasa con assert(leer(&v)); al compilar con -DNDEBUG?
Toda la expresión desaparece: leer no se llama y v queda sin asignar (en el ejemplo valía 0 en lugar de 42). Nunca se deben poner efectos laterales dentro de un assert: se ejecuta la llamada aparte y se verifica el resultado.
8. ¿Qué detecta UBSan que no detecta el compilador? Da tres ejemplos.
Comportamiento indefinido que depende de los valores en ejecución: desbordamiento de enteros con signo (INT_MAX + 2), desplazamiento mayor que el ancho del tipo (1 << 32), índice fuera de los límites de un arreglo de tamaño conocido, división por cero, acceso a miembro a través de un puntero nulo. Informa archivo, línea y valores.
9. ¿Para qué sirve -fno-sanitize-recover=all?
Por omisión UBSan informa y deja que el programa continúe; con esta opción se detiene en el primer error con código de salida 1. Es lo adecuado en pruebas automáticas, donde un informe que nadie lee no sirve.
10. ¿Qué es una carrera de datos y qué herramienta la detecta?
Dos hilos acceden a la misma variable sin sincronización y al menos uno escribe: el resultado depende del azar (el contador dio 129 435 en vez de 200 000). ThreadSanitizer (-fsanitize=thread) la detecta e indica los dos accesos y la variable. No se combina con AddressSanitizer.
11. ¿Qué ventajas y qué límites tiene el análisis estático?
Examina todos los caminos sin ejecutar nada, y encuentra fugas, dobles liberaciones, usos tras liberar, nulos sin comprobar y descriptores sin cerrar con su recorrido. Pero hay falsos positivos (avisos de caminos imposibles) y falsos negativos (errores que no ve), y distintas herramientas (gcc -fanalyzer, cppcheck) encuentran cosas distintas, por lo que se combinan.
12. Un programa dice «no se encontró la configuración». ¿Cómo averiguas con strace dónde la busca?
Con strace -e trace=openat ./programa (filtrando las líneas de bibliotecas .so): muestra cada archivo que intenta abrir, en orden, con el resultado (-1 ENOENT si no existe). Con -o se guarda en un archivo y con -f se siguen los procesos hijos.
13. Un programa parece colgado. ¿Qué dice strace sobre eso?
La última línea es la llamada donde está bloqueado y aún no devolvió resultado: read(0, indica que espera datos de la entrada estándar; futex, que espera un cerrojo; connect o accept, que espera la red.
14. ¿Por qué la prueba con el error de «uno de menos» (n > cap en lugar de n >= cap) pasa desapercibida sin sanitizadores y se detecta con ellos?
Sin sanitizadores, copiar un byte de más en un bloque casi nunca produce un síntoma inmediato (queda en el relleno del bloque). Con ASan y búferes del tamaño exacto, el primer byte fuera del bloque se informa como heap-buffer-overflow con la línea del memcpy. Por eso las pruebas de borde, con tamaños exactos, más sanitizadores son tan eficaces.
Para practicar
- Compila un programa tuyo con el conjunto ampliado de avisos (
-Wshadow -Wconversion -Wformat=2 -Wstrict-prototypes) y corrige todos. Anota cuáles esperabas y cuáles te sorprendieron. - Escribe un programa con un
assertque contenga una llamada con efecto lateral, compílalo con y sin-DNDEBUGy compara el resultado. Corrígelo. - Reproduce
ub.ccon otros casos de comportamiento indefinido: desplazar un número negativo, convertir undoubleenorme aint, comparar punteros de arreglos distintos. ¿Cuáles detecta-fsanitize=undefined? - Escribe tu propio
carrera.cy corrígelo con unpthread_mutex_t. Comprueba con-fsanitize=threadque el informe desaparece y que el contador es siempre 200 000. - Pasa
-fanalyzera uno de los programas del curso. ¿Encuentra algo real? ¿Algún falso positivo? Si cppcheck está disponible, compara los resultados. - Usa
strace -csobrecpsys(tema 38) con búferes de 1 byte y de 4096 y compara las columnas de llamadas. Luego usastrace -e trace=openatsobregcc -cde un archivo con un#includeinexistente y mira las rutas donde busca encabezados. - Escribe las pruebas de borde de tu propia función de cadenas (por ejemplo, un
unir_ruta(dst, cap, dir, nombre)) con búferes de tamaño exacto, y ejecútalas con ASan y UBSan. Introduce un error de uno y comprueba que lo detectan. - Agrega el modo
sanal Makefile del tema 36 y un objetivocheckque lo compile y ejecute las pruebas. Verifica quebuild/sanybuild/debugconviven.