Secure coding en C embarqué : cinq bugs récurrents
Longueurs non vérifiées, dépassements d’entiers, chaînes de format, aléa faible et restes de debug : les cinq classes de bugs les plus fréquentes dans les firmwares.
Zyberum Security Team · Publié le · 7 min de lecture
Le firmware, c’est surtout du C, et le C fait exactement ce que vous lui dites. Quand nous passons en revue du code embarqué ou que nous faisons la rétro-ingénierie d’un appareil, les cinq mêmes classes de bugs reviennent sans cesse. Aucune n’est nouvelle. Toutes fonctionnent encore.
1. Le champ de longueur auquel vous avez fait confiance
Un message arrive avec un champ de longueur, et le code copie ce nombre d’octets :
void handle_frame(const uint8_t *frame) {
uint8_t payload[64];
uint8_t len = frame[1];
memcpy(payload, &frame[2], len); /* len est contrôlé par l'attaquant */
}
Si len peut valoir 200, la copie écrit bien au-delà du tampon. Sur un microcontrôleur sans protection mémoire, c’est souvent une exécution de code directe.
Correctif : vérifiez chaque longueur par rapport à la taille de la destination et au nombre d’octets réellement reçus, avant de l’utiliser.
if (len > sizeof(payload) || len > received - 2) {
return ERR_LENGTH;
}
2. L’arithmétique entière qui reboucle
Les contrôles de longueur eux-mêmes échouent quand l’arithmétique déborde :
uint16_t total = header_len + body_len; /* reboucle à 65535 */
if (total > sizeof(buffer)) return ERR;
Avec header_len = 65530 et body_len = 10, total vaut 4 et le contrôle passe. Les comparaisons entre valeurs signées et non signées réservent le même genre de surprise.
Correctif : vérifiez chaque opérande avant d’additionner, utilisez des types plus larges pour les résultats intermédiaires et traitez comme des erreurs les avertissements du compilateur sur les conversions de signe.
3. Les chaînes de format et autres raccourcis
Journaliser directement des données externes reste courant dans le code de diagnostic :
printf(device_name); /* incorrect */
printf("%s", device_name); /* correct */
La même famille comprend strcpy, sprintf et gets. Elles n’ont aucune idée de la taille de la destination.
Correctif : interdisez les fonctions non bornées dans vos règles de codage et faites échouer le build lorsqu’elles apparaissent.
4. Un aléa qui n’a rien d’aléatoire
Jetons de session, valeurs de challenge pour l’accès au diagnostic, nonces pour le chiffrement : tous ont besoin de nombres imprévisibles. Nous les trouvons régulièrement générés avec rand(), initialisés avec le temps écoulé depuis le démarrage, ou tirés d’un générateur matériel non initialisé.
Si un attaquant peut prédire le challenge, le meilleur algorithme placé derrière ne sert à rien.
Correctif : utilisez le générateur matériel de nombres réellement aléatoires de votre MCU, vérifiez ses indicateurs d’état et servez-vous-en pour alimenter un générateur déterministe digne de ce nom si vous avez besoin de nombreuses valeurs.
5. Les restes de debug
Les résultats les plus fructueux ne sont souvent pas des bugs du tout, mais des fonctions que personne n’a retirées :
- une console série avec un shell root
- une commande de diagnostic cachée qui contourne l’authentification
- des clés de test et des mots de passe par défaut dans l’image de production
- des messages d’erreur verbeux qui divulguent des adresses mémoire
Correctif : rendez explicite la configuration du build de production, passez en revue ce qui diffère du build de développement, et contrôlez l’image livrée, pas l’arborescence des sources.
Ce qui aide vraiment
Les règles et les outils sont nécessaires, mais ils ne remplacent pas des personnes qui pensent comme des attaquants :
- Des règles de codage (MISRA C, SEI CERT C) appliquées par l’analyse statique dans la CI.
- Le fuzzing de chaque parseur et de chaque gestionnaire de protocole. La plupart des bugs ci-dessus tombent en quelques heures.
- La revue de code concentrée sur les endroits où entrent les données externes.
- La formation sur votre propre code. Les développeurs se souviennent du bug qu’ils ont exploité eux-mêmes.
C’est autour de cela que sont construits notre travail de développement sécurisé et nos formations au secure coding.
FAQ
Questions fréquentes
MISRA C suffit-il pour un code sécurisé ?
MISRA C est une bonne base, car il élimine de nombreuses sources de comportement indéfini. Mais il a été écrit pour la sûreté, pas contre des attaquants. Pour la sécurité, ajoutez SEI CERT C, une revue de code menée avec un regard d’attaquant et le fuzzing de chaque interface qui accepte des données externes.
Devons-nous passer à Rust ?
Pour les nouveaux composants qui analysent des entrées non fiables, les langages à mémoire sûre suppriment des classes entières de bugs et méritent d’être envisagés. La plupart des firmwares contiendront du C pendant encore de nombreuses années, si bien que le secure coding en C et la revue de code restent nécessaires.
Comment trouver ces bugs dans du code existant ?
Combinez trois choses : l’analyse statique pour les schémas évidents, le fuzzing pour les parseurs et les gestionnaires de protocole, et la revue manuelle du code qui traite les entrées externes, l’authentification et les mises à jour.