← Programación en C para Sistemas Operativos Bloque 9 · Errores, archivos y depuración · Tema 40 de 42

Tema 40

Programar a la defensiva

El tema 39 enseñó a investigar un error cuando ya ocurrió. Este tema trata de lo contrario: hacer que los errores aparezcan antes, y que cuando aparezcan hagan ruido en lugar de corromper datos en silencio. C no ayuda en eso por sí mismo: un desborde de búfer, un entero que se desborda o una condición de carrera pueden «funcionar» durante meses. La defensa es en capas, y cada capa detecta cosas distintas: el compilador con sus advertencias, el código con comprobaciones y assert, las herramientas de ejecución (los sanitizadores) que vigilan el programa mientras corre, el análisis estático que lo examina sin ejecutarlo, y strace, que muestra lo que el programa le pide en realidad al sistema operativo. Se ven aquí con ejemplos reales, con lo que cada herramienta atrapa y lo que deja pasar, y se cierra con las pruebas de casos límite, la forma más barata de encontrar los errores que quedan. Todas las salidas son de Linux con gcc 15.

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:

CapaCuándo actúaQué atrapa bienQué se le escapa
Advertencias del compiladorAl compilarConversiones, 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áticoAl compilar o aparteFugas, 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, siempreAlgunos 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 pruebasMemoria, 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 propiasAl ejecutarViolaciones de las suposiciones que el programador escribió.Lo que nadie pensó en escribir.
straceAl ejecutarQué 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:

avisos.c
#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();
}
terminal · el conjunto básico y el ampliado
$ 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:

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:

terminal
$ 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:

desborde.c
#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;
}
terminal · el mismo programa con distintas defensas (LARGO son 56 letras A)
$ 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:

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:

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.

aserto.c
#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;
}
terminal
$ 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:

ub.c (el interruptor y los casos)
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 */
}
terminal · cada caso con y sin UBSan (gcc -std=c17 -g -fsanitize=undefined)
$ 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:

terminal
$ 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

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:

carrera.c (extracto) y terminal · el informe de TSan está recortado
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:

estatico.c
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:

terminal
$ 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:

t.c y terminal
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:

buscar.c (extracto) y terminal
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ónQué hace
-e trace=openat,readMuestra solo esas llamadas. También hay grupos: %file, %network, %memory, %process.
-o archivoEscribe la traza en un archivo, para no mezclarla con la salida del programa.
-cNo muestra las llamadas: imprime una tabla resumen con cuántas veces se llamó a cada una, tiempo y errores.
-fSigue también a los procesos hijos (fork, clone).
-T / -ttAgrega el tiempo que tardó cada llamada / la hora de cada una.
-s NMuestra hasta N caracteres de las cadenas (por omisión, 32).
-p PIDSe 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")):

terminal
$ 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:

terminal · cada línea lleva el PID del proceso
$ 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):

terminal
$ 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:

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:

seguro.c (la función y sus casos)
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},
};
terminal · con -Wpedantic -Wshadow -Wconversion y los sanitizadores de dirección y de comportamiento indefinido
$ 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:

terminal · la misma prueba con el error (compilado con -DBUG, que usa >)
$ 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:

PerfilOpcionesPara qué
Desarrollo-std=c17 -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wstrict-prototypes -Wformat=2 -g -O0Compilar a diario, con avisos estrictos y símbolos para gdb.
PruebasLas anteriores más -O1 -fsanitize=address,undefined -fno-sanitize-recover=allEjecutar las pruebas y el programa con las comprobaciones de memoria y de comportamiento indefinido.
Integración continuaLas de desarrollo más -Werror, y de vez en cuando -fanalyzer y cppcheckQue 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 aparteRendimiento y defensas de ejecución. Se prueban también con estas opciones.
Makefile · fragmento (se agrega al del tema 36 un modo "san")
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

ErrorQué pasaCorrecció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 entenderloSe oculta el problema, no se resuelve.Revisar la causa; si es intencional, comentar por qué.
Poner una llamada con efecto lateral dentro de assertCon -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 externasEn 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 desbordeSolo 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 cuandoLos 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 -gInformes imprecisos o sin líneas.-g -O0 o -O1.
Combinar ASan con TSanError de enlace o de ejecución: son incompatibles.Compilaciones separadas.
Ignorar un aviso del analizador por «falso positivo» sin comprobarA 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 todoLos 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ódigoHoras buscando lo que strace muestra en un segundo.strace -e trace=openat.
Dejar la traza de strace mezclada con la salidaIlegible.-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 -fsanitizeMás lento, más memoria y una superficie de ataque mayor.Los sanitizadores son para pruebas, no para entrega.

12. Resumen

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

  1. 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.
  2. Escribe un programa con un assert que contenga una llamada con efecto lateral, compílalo con y sin -DNDEBUG y compara el resultado. Corrígelo.
  3. Reproduce ub.c con otros casos de comportamiento indefinido: desplazar un número negativo, convertir un double enorme a int, comparar punteros de arreglos distintos. ¿Cuáles detecta -fsanitize=undefined?
  4. Escribe tu propio carrera.c y corrígelo con un pthread_mutex_t. Comprueba con -fsanitize=thread que el informe desaparece y que el contador es siempre 200 000.
  5. Pasa -fanalyzer a uno de los programas del curso. ¿Encuentra algo real? ¿Algún falso positivo? Si cppcheck está disponible, compara los resultados.
  6. Usa strace -c sobre cpsys (tema 38) con búferes de 1 byte y de 4096 y compara las columnas de llamadas. Luego usa strace -e trace=openat sobre gcc -c de un archivo con un #include inexistente y mira las rutas donde busca encabezados.
  7. 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.
  8. Agrega el modo san al Makefile del tema 36 y un objetivo check que lo compile y ejecute las pruebas. Verifica que build/san y build/debug conviven.