Estándares de C, compiladores y parches: de K&R a C23

Estándares de C, compiladores y parches: qué trajo cada versión del lenguaje, de K&R a C23 y el futuro C29; qué estándar usa tu compilador; cómo se mantiene al día un programa escrito en C, con las opciones de endurecimiento del compilador; y siete fallos reales, del gusano de Morris a regreSSHion, que explican por qué todo esto importa.

En PHP o Python, «estar al día» significa usar una versión del intérprete que siga recibiendo parches. En C la pregunta es distinta, porque intervienen tres piezas: el estándar del lenguaje (qué se puede escribir), el compilador (GCC, Clang, MSVC) y la biblioteca de C (glibc, musl, la de Windows), además de las bibliotecas de terceros que use tu programa. Los fallos de seguridad casi nunca están en el estándar, sino en el código, el tuyo o el de esas bibliotecas. Esta página repasa las tres piezas y cuenta los fallos reales que explican por qué importa mantenerlas.

Los estándares, de K&R a C23

Línea de tiempo: K&R en 1978, C89 en 1989, C99 en 1999, C11 en 2011, C17 en 2018, C23 en 2024, que es el vigente, y C29, previsto para 2029Medio siglo de C: del libro de K&R a C29K&R1978C891989C991999C112011C172018C232024C292029C23 es el estándar vigente (ISO/IEC 9899:2024); C29 está en preparación.C17 solo corrigió erratas de C11: no trajo nada nuevo.

Los estándares de C. Cada uno se conoce por el año en que se cerró; el documento ISO puede salir meses después. Esquema: decodigo.com.
  • EstándarQué trajo
  • K&R (1978)No es un estándar: el libro de Kernighan y Ritchie, que sirvió de referencia durante una década. Funciones sin prototipos, sin void ni const.
  • C89 / C90El primer estándar (ANSI en 1989, ISO en 1990): prototipos de funciones, void, const, volatile y la biblioteca estándar tal como la conocemos.
  • C99Comentarios con //, declarar variables en cualquier sitio y dentro del for, <stdint.h>, _Bool, long long, inicializadores designados, arrays de tamaño variable (VLA), inline y snprintf.
  • C11Hilos (<threads.h>) y atómicos, _Static_assert, _Generic, estructuras anónimas, Unicode en cadenas (u8""), y se eliminó gets.
  • C17Solo correcciones de erratas de C11, sin novedades.
  • C23bool, true, false, nullptr y constexpr como palabras clave, typeof, auto para deducir tipos, atributos ([[nodiscard]], [[fallthrough]]), #embed, literales binarios y separadores (0b1010, 1'000), <stdckdint.h>, <stdbit.h>, strdup y memset_explicit, y se eliminaron las definiciones de funciones al estilo K&R.
  • C29 (en preparación)Previsto para finales de 2029. En el borrador ya están countof para contar los elementos de un array, declaraciones dentro del if y break con etiqueta para salir de bucles anidados.

C cambia despacio a propósito: el código de hace cuarenta años casi siempre sigue compilando, y eso es una de las razones de que el lenguaje siga en todas partes. El comité que lo mantiene, el WG14 de ISO, prefiere estandarizar lo que los compiladores ya han probado antes que inventar.

Qué estándar usa tu compilador

La macro __STDC_VERSION__ dice qué estándar está aplicando el compilador, como un número con el año y el mes:

#include <stdio.h>

int main(void)
{
#if __STDC_VERSION__ >= 202311L
    printf("C23 o posterior (%ld)\n", __STDC_VERSION__);
#elif __STDC_VERSION__ >= 201710L
    printf("C17 (%ld)\n", __STDC_VERSION__);
#else
    printf("Anterior a C17\n");
#endif
    return 0;
}
salidagcc (GCC) 15.3.0

Con la opción -std se elige otro. Estos son los valores que da GCC 15.3 con cada una (en C89 la macro ni siquiera existía), y lo que usan GCC y Clang si no se dice nada:

for std in c89 c99 c11 c17 c23 c2y; do gcc -std=$std -dM -E - </dev/null | grep __STDC_VERSION__; done
salidac89    
c99    199901L
c11    201112L
c17    201710L
c23    202311L
c2y    202500L
por omisióngcc   (por omisión) 202311L
clang (por omisión) 201710L

La diferencia importa: GCC 15 compila en C23 por omisión (en realidad gnu23, C23 con las extensiones de GNU), pero Clang 19 sigue en C17 si no le pides otra cosa. Un código con nullptr o constexpr compila con uno y falla con el otro. Si tu proyecto usa C23, dilo de forma explícita en el Makefile o en CMake (-std=c23, o set(CMAKE_C_STANDARD 23)). Esto pasa si compilas código de C23 con un estándar anterior:

#include <stdio.h>

constexpr int MAXIMO = 10;

int main(void)
{
    int *p = nullptr;
    printf("%d %d\n", MAXIMO, p == nullptr);
    return 0;
}
gcc -std=c17 c17.c -o c17
gcc: errorc17.c:3:10: error: expected ';' before 'int'
    3 | constexpr int MAXIMO = 10;
      |          ^~~~
      |          ;
c17.c: In function 'main':
c17.c:7:14: error: 'nullptr' undeclared (first use in this function)
    7 |     int *p = nullptr;
      |              ^~~~~~~

Los tres compiladores principales

  • GCC, de GNU: el compilador de casi todas las distribuciones de Linux. Saca una versión mayor al año, en primavera. GCC 15 fue el primero con C23 por omisión.
  • Clang, del proyecto LLVM: el de macOS (el gcc de Xcode es en realidad Clang), Android y FreeBSD. Sus mensajes de error suelen ser más claros y trae herramientas como clang-tidy y scan-build. Saca una versión mayor cada seis meses.
  • MSVC, de Microsoft, el de Visual Studio: durante años solo soportó C89 y partes de C99, y no admitió C11 y C17 hasta Visual Studio 2019. Su soporte de C23 va bastante por detrás de GCC y Clang. En Windows, muchos proyectos de C usan GCC (MSYS2, MinGW) o Clang.

La biblioteca de C

Las funciones como printf, malloc o fopen no son del compilador, sino de la biblioteca de C del sistema: glibc en la mayoría de los Linux, musl en Alpine y en muchos contenedores, la de Apple en macOS y la UCRT en Windows. La mayoría de los programas la enlazan de forma dinámica: el ejecutable no la lleva dentro, sino que usa la del sistema al arrancar. Para saber cuál tienes:

ldd --version | head -1
salidaldd (Debian GLIBC 2.41-12+deb13u4) 2.41

Esto tiene una consecuencia directa para los parches. Cuando se corrige un fallo en glibc, actualizar el paquete del sistema arregla todos los programas que la usan, sin recompilarlos (aunque hay que reiniciarlos, porque los que estaban en marcha siguen con la versión vieja en memoria). Si un programa se enlaza de forma estática, con la biblioteca dentro, cada fallo de la biblioteca obliga a recompilarlo y redistribuirlo. Por eso las distribuciones prefieren el enlace dinámico, y por eso conviene saber qué bibliotecas usa un programa:

ldd /usr/bin/curl

glibc publica una versión nueva cada seis meses, en febrero y agosto, y las distribuciones llevan los parches de seguridad a la versión que traen, sin cambiar el número. Como en PHP, el número de versión no siempre dice si un fallo está corregido: lo dice el registro de cambios del paquete de tu distribución.

Cómo se parchea un programa en C

En la práctica, mantener seguro un programa escrito en C supone cuatro cosas:

  1. Actualizar el sistema: glibc, OpenSSL, zlib y el resto de bibliotecas se corrigen con las actualizaciones normales de la distribución. Después, reiniciar los servicios afectados (en Debian y Ubuntu, needrestart dice cuáles).
  2. Vigilar las dependencias propias: si tu proyecto incluye bibliotecas copiadas en su código (algo muy habitual en C, que no tiene un gestor de paquetes estándar), nadie las va a actualizar por ti. Herramientas como OSV-Scanner o el análisis de dependencias de GitHub ayudan a saber si alguna tiene fallos conocidos.
  3. Compilar con las protecciones del compilador, para que un fallo que se escape sea mucho más difícil de aprovechar.
  4. Mantener el compilador al día: cada versión de GCC y Clang trae avisos y análisis nuevos que encuentran fallos que la anterior no veía.

Las opciones de endurecimiento

  • OpciónQué hace
  • -D_FORTIFY_SOURCE=3Sustituye strcpy, memcpy, sprintf y otras por versiones que comprueban el tamaño del destino; aborta en lugar de desbordar. Necesita -O1 o más.
  • -fstack-protector-strongPone un «canario» junto a los arrays locales y aborta si cambió al volver de la función.
  • -fstack-clash-protectionImpide que una reserva grande en la pila salte por encima de la zona de guarda.
  • -fcf-protectionProtección del flujo de control en x86 (CET): los saltos indirectos solo pueden ir a destinos marcados.
  • -fPIE -pieEjecutable independiente de la posición: se carga en una dirección aleatoria cada vez.
  • -Wl,-z,relro,-z,nowResuelve los enlaces dinámicos al arrancar y deja esas tablas de solo lectura.
  • -ftrivial-auto-var-init=zeroPone a cero las variables locales sin inicializar.
  • -fhardenedDesde GCC 14, activa todas las anteriores de una vez en sistemas GNU/Linux.

En la lección 8 compilamos el proyecto del curso con -fhardened y comprobamos con readelf y nm que las protecciones estaban en el ejecutable. Las distribuciones grandes ya compilan sus paquetes con casi todas estas opciones; tus programas, solo si se las pones.

Los fallos que lo explican

Siete casos reales, en orden. Todos tienen algo que enseñar, y los fallos concretos de código se explican en Seguridad en C.

1988: el gusano de Morris

El 2 de noviembre de 1988, un programa escrito por el estudiante Robert Tappan Morris se propagó por internet e infectó miles de máquinas Unix, una parte importante de la red de entonces. Una de sus vías de entrada fue un desbordamiento de búfer en el servicio fingerd, que leía la petición con gets, la función que no sabe cuánto cabe en el destino. Fue la primera vez que el público conoció un ataque así, y gets tardó todavía 23 años en desaparecer del estándar, en C11.

2014: Heartbleed (CVE-2014-0160)

OpenSSL, la biblioteca que cifraba buena parte de las conexiones HTTPS del mundo, respondía a un mensaje de la extensión heartbeat de TLS copiando tantos bytes como el propio mensaje decía tener, sin comprobarlo. Afectaba a OpenSSL de la 1.0.1 a la 1.0.1f, y un atacante podía leer trozos de la memoria del servidor, incluidas claves privadas, sin dejar rastro. Además de parchear, hubo que revocar y regenerar certificados en medio mundo. La lección: toda longitud que viene de fuera se valida.

2015: GHOST (CVE-2015-0235)

Un desbordamiento en el montón dentro de glibc, en el código que usaban gethostbyname y gethostbyname2 para resolver nombres. Estaba en glibc desde la versión 2.2 y se había corregido sin darle importancia en la 2.18, en 2013, pero muchas distribuciones con soporte largo seguían con versiones anteriores. Como glibc está en casi todo, cualquier programa que resolviera un nombre que viniera de fuera era un posible punto de entrada. La lección: una corrección que no se identifica como de seguridad no llega a los sistemas que más la necesitan.

2021: Baron Samedit (CVE-2021-3156)

Un error de uno (off-by-one) en sudo provocaba un desbordamiento en el montón al procesar ciertos argumentos que terminaban en una barra invertida. Cualquier usuario local, aunque no estuviera autorizado a usar sudo, podía hacerse root. El fallo llevaba casi diez años en el código y se corrigió en la versión 1.9.5p2. La lección: un solo byte de más basta, y por eso conviene probar con AddressSanitizer los programas que procesan argumentos.

2023: Looney Tunables (CVE-2023-4911)

Un desbordamiento de búfer en ld.so, el cargador de programas de glibc, al procesar la variable de entorno GLIBC_TUNABLES. Como el cargador se ejecuta al arrancar cualquier programa, incluidos los que tienen el bit SUID, un usuario local podía conseguir privilegios de root en la mayoría de las distribuciones. La lección: en un programa con privilegios, el entorno es una entrada del usuario.

2024: la puerta trasera de xz (CVE-2024-3094)

No fue un error, sino un sabotaje. Durante más de dos años, alguien con el nombre de «Jia Tan» se ganó la confianza del proyecto xz hasta convertirse en mantenedor, y en las versiones 5.6.0 y 5.6.1 escondió código malicioso dentro de unos archivos de prueba. El sistema de compilación lo extraía y modificaba la biblioteca liblzma, que en algunas distribuciones acababa cargada dentro del servidor de OpenSSH, para permitir el acceso remoto con una clave del atacante. Lo descubrió por casualidad, en marzo de 2024, un ingeniero de Microsoft, Andres Freund, al notar que SSH tardaba medio segundo más de lo normal, antes de que llegara a las versiones estables de las grandes distribuciones. La lección: el riesgo no está solo en tu código, sino en cómo se compila y en quién mantiene tus dependencias.

2024: regreSSHion (CVE-2024-6387)

Una condición de carrera en el servidor de OpenSSH: si un cliente no terminaba de autenticarse a tiempo, saltaba una alarma, y su manejador de señal llamaba a funciones que no son seguras en ese contexto. Un atacante remoto, sin cuenta, podía aprovechar la carrera para ejecutar código como root, aunque necesitaba muchos intentos. Lo más llamativo: era una regresión de un fallo de 2006 (CVE-2006-5051), que había vuelto con un cambio de 2020. La lección: las pruebas de regresión no son solo para la funcionalidad, también para los fallos de seguridad ya corregidos.

¿Y si el problema es el lenguaje?

Después de décadas de fallos como estos, la propia industria ha empezado a recomendar otros lenguajes para el código nuevo donde la seguridad es crítica. La NSA, la CISA y la Casa Blanca de Estados Unidos han publicado desde 2022 guías que piden pasar a lenguajes con seguridad de memoria, como Rust, Go, Java, C# o Python, que hacen imposible (o muy difícil) la mayoría de los fallos de esta página. El núcleo de Linux acepta controladores en Rust desde 2022, y Android y Windows escriben en Rust parte de su código nuevo.

Eso no significa que C vaya a desaparecer: hay miles de millones de líneas que funcionan, y C sigue siendo el lenguaje común de los sistemas operativos, el hardware y las bibliotecas que usan todos los demás. Pero sí que escribir C hoy exige las herramientas y las costumbres de este curso, y que vale la pena preguntarse, en cada proyecto nuevo, si C es la mejor opción.

Los programas y las órdenes de esta página se ejecutaron con GCC 15.3, Clang 19.1.7 y glibc 2.41 en un contenedor limpio, sin red. Los datos de cada fallo (versiones afectadas y fechas) se comprobaron en la base de datos de vulnerabilidades del NIST (NVD).