Secure coding en C embebido: cinco errores que vemos siempre
Longitudes sin comprobar, desbordamientos de enteros, cadenas de formato, aleatoriedad débil y restos de depuración: lo que más vemos en firmware y cómo evitarlo.
Zyberum Security Team · Publicado · 7 min de lectura
El firmware es sobre todo C, y C hace exactamente lo que se le dice. Cuando revisamos código embebido o hacemos ingeniería inversa de un dispositivo, aparecen siempre las mismas cinco clases de errores. Ninguna es nueva. Todas siguen funcionando.
1. El campo de longitud en el que confió
Llega un mensaje con un campo de longitud y el código copia esa cantidad de bytes:
void handle_frame(const uint8_t *frame) {
uint8_t payload[64];
uint8_t len = frame[1];
memcpy(payload, &frame[2], len); /* len lo controla el atacante */
}
Si len puede valer 200, la copia escribe mucho más allá del búfer. En un microcontrolador sin protección de memoria, eso suele ser ejecución de código directa.
Solución: compruebe cada longitud frente al tamaño del destino y frente al número de bytes que ha recibido realmente, antes de usarla.
if (len > sizeof(payload) || len > received - 2) {
return ERR_LENGTH;
}
2. Aritmética de enteros que se desborda
Las propias comprobaciones de longitud fallan cuando la aritmética se desborda:
uint16_t total = header_len + body_len; /* se desborda en 65535 */
if (total > sizeof(buffer)) return ERR;
Con header_len = 65530 y body_len = 10, total pasa a valer 4 y la comprobación se supera. Las comparaciones entre valores con signo y sin signo provocan el mismo tipo de sorpresa.
Solución: compruebe cada operando antes de sumar, utilice tipos más anchos para los resultados intermedios y trate como errores los avisos del compilador sobre conversiones de signo.
3. Cadenas de formato y otros atajos
Registrar datos externos directamente en el log sigue siendo habitual en el código de diagnóstico:
printf(device_name); /* mal */
printf("%s", device_name); /* bien */
A la misma familia pertenecen strcpy, sprintf y gets. No tienen ni idea del tamaño del destino.
Solución: prohíba las funciones sin límite en sus guías de codificación y haga que la compilación falle cuando aparezcan.
4. Aleatoriedad que no es aleatoria
Tokens de sesión, valores de challenge para el acceso de diagnóstico, nonces para el cifrado: todos necesitan números impredecibles. Con frecuencia los encontramos generados con rand(), con el tiempo de funcionamiento como semilla, o tomados de un generador de hardware sin inicializar.
Si un atacante puede predecir el challenge, el mejor algoritmo que haya detrás no sirve de nada.
Solución: utilice el generador de números aleatorios verdaderos por hardware de su MCU, compruebe sus flags de estado y, si necesita muchos valores, alimente con él un generador determinista adecuado.
5. Restos de depuración
Los hallazgos más productivos muchas veces no son errores, sino funciones que nadie retiró:
- una consola serie con una shell de root
- un comando de diagnóstico oculto que se salta la autenticación
- claves de prueba y contraseñas por defecto en la imagen de producción
- mensajes de error detallados que filtran direcciones de memoria
Solución: haga explícita la configuración de compilación de producción, revise en qué se diferencia de la de desarrollo y compruebe la imagen que se entrega, no el árbol de código fuente.
Lo que ayuda de verdad
Las reglas y las herramientas son necesarias, pero no sustituyen a personas que piensan como atacantes:
- Guías de codificación (MISRA C, SEI CERT C) aplicadas mediante análisis estático en la CI.
- Fuzzing para cada parser y cada manejador de protocolo. La mayoría de los errores anteriores aparecen en cuestión de horas.
- Revisión de código centrada en los puntos por los que entran los datos externos.
- Formación con su propio código. Los desarrolladores recuerdan el error que explotaron ellos mismos.
En torno a esto giran nuestro trabajo de desarrollo seguro y nuestra formación en secure coding.
FAQ
Preguntas frecuentes
¿Basta MISRA C para tener código seguro?
MISRA C es una buena base porque elimina muchas fuentes de comportamiento indefinido. Pero se escribió pensando en la seguridad funcional, no en los atacantes. Para la ciberseguridad, añada SEI CERT C, revisión de código con mentalidad de atacante y fuzzing de todas las interfaces que aceptan datos externos.
¿Deberíamos pasarnos a Rust?
Para componentes nuevos que procesan entradas no confiables, los lenguajes con seguridad de memoria eliminan clases enteras de errores y merece la pena considerarlos. La mayor parte del firmware seguirá conteniendo C durante muchos años, así que el secure coding en C y la revisión siguen siendo necesarios.
¿Cómo encontramos estos errores en el código existente?
Combine tres cosas: análisis estático para los patrones evidentes, fuzzing para los parsers y los manejadores de protocolo, y revisión manual del código que gestiona las entradas externas, la autenticación y las actualizaciones.