1 points par GN⁺ 6 시간 전 | Aucun commentaire pour le moment. | Partager sur WhatsApp
  • La sécurité mémoire s’améliore nettement, mais même dans du code Rust en production, les problèmes de gestion des frontières du système subsistent et peuvent toujours mener à des vulnérabilités
  • Les flux qui réinterprètent plusieurs fois le même chemin via plusieurs appels système, les approches qui modifient les permissions après création, et les comparaisons de chemins fondées sur des chaînes favorisent facilement des problèmes comme le TOCTOU et l’exposition de privilèges
  • Sous Unix, les chemins, variables d’environnement et données de flux circulent sous forme de bytes bruts ; un traitement centré sur String, ainsi que from_utf8_lossy, unwrap ou expect, peuvent donc conduire à une corruption des données ou à un DoS
  • Ignorer les erreurs peut faire passer un échec pour un succès, et les écarts de comportement avec GNU coreutils peuvent aussi se transformer immédiatement en problèmes de sécurité dans des scripts shell et des outils privilégiés
  • Lors de cet audit, aucun bug de la famille sécurité mémoire comme buffer overflow, use-after-free ou double-free n’a été relevé ; les principaux risques restants se concentraient sur les frontières en contact avec le monde extérieur plutôt qu’à l’intérieur de Rust

Les limites de Rust mises en lumière par l’audit

  • Les 44 CVE publiées par Canonical à propos de uutils montrent que, même dans du code Rust en production, des vulnérabilités peuvent subsister sans être détectées par le borrow checker, clippy ou cargo audit
  • Le cœur du problème se situait moins dans la sécurité mémoire que dans la gestion des frontières du système
    • Il existait un décalage temporel entre les chemins et les appels système
    • Les données bytes Unix et les chaînes UTF-8 n’étaient pas alignées
    • Il y avait des différences de comportement avec l’outil d’origine
    • Des traitements d’erreur manquants et des arrêts via panic! existaient
  • Cette liste de CVE montre de manière condensée où s’arrête la sécurité dans du code système Rust

Réinterpréter un chemin deux fois crée du TOCTOU

  • Vérifier un même chemin dans un appel système, puis retravailler ce même chemin dans l’appel suivant, mène facilement à une vulnérabilité TOCTOU
    • Entre les deux appels, un attaquant disposant de droits d’écriture sur le répertoire parent peut remplacer un composant du chemin par un lien symbolique
    • Lors du second appel, le noyau réinterprète entièrement le chemin, ce qui peut rediriger une opération privilégiée vers une cible choisie par l’attaquant
  • L’API std::fs de Rust repose par défaut sur une réinterprétation à partir de &Path, ce qui rend ce type d’erreur facile à commettre
  • Dans CVE-2026-35355, un flux qui supprimait un fichier puis en recréait un autre au même chemin a été exploité
    • Dans src/uu/install/src/install.rs, fs::remove_file(to)? était suivi de File::create(to)?
    • Si, entre la suppression et la création, to est remplacé par un lien symbolique pointant vers /etc/shadow ou une cible similaire, un processus privilégié peut écraser ce fichier
  • La correction a consisté à utiliser OpenOptions::create_new(true) afin de ne créer que de nouveaux fichiers
    • D’après la documentation, create_new n’autorise à l’emplacement cible ni fichier existant, ni dangling symlink
  • Lorsqu’il faut agir deux fois sur le même chemin, il est plus sûr de s’ancrer sur un descripteur de fichier
    • Hors création d’un nouveau fichier, il vaut mieux ouvrir le répertoire parent une seule fois et travailler ensuite avec des chemins relatifs à ce handle
    • Si une opération agit deux fois sur le même chemin, il faut la considérer comme un TOCTOU jusqu’à preuve du contraire

Les permissions doivent être définies à la création, pas modifiées après

  • Le flux qui crée un répertoire ou un fichier avec les permissions par défaut, puis exécute chmod ensuite, crée lui aussi une courte fenêtre d’exposition
    • Un code du type fs::create_dir(&path)? suivi de fs::set_permissions(&path, Permissions::from_mode(0o700))? laisse path exister avec ses permissions par défaut pendant cet intervalle
    • D’autres utilisateurs peuvent faire open() pendant cette fenêtre, et un chmod ultérieur ne révoquera pas les descripteurs de fichier déjà obtenus
  • Les permissions doivent être spécifiées au moment de la création
    • Il faut utiliser OpenOptions::mode() et DirBuilderExt::mode() pour que l’objet soit créé avec les permissions voulues
    • Le noyau y applique ensuite aussi le umask, donc si son impact compte, il faut le gérer explicitement

Comparer des chaînes de chemin ne prouve pas l’identité dans le système de fichiers

  • Le premier contrôle de --preserve-root dans chmod ne faisait qu’une comparaison de chaînes
    • recursive && preserve_root && file == Path::new("/")
    • Une entrée qui pointe en réalité vers la racine mais dont la chaîne n’est pas /, comme /../, /./, /usr/.. ou un lien symbolique vers /, contournait donc ce contrôle
  • La correction est passée à une comparaison après résolution du chemin réel absolu via fs::canonicalize
    • PR de correction
    • canonicalize renvoie le chemin réel après résolution de .., ., et des liens symboliques
  • Dans le cas de --preserve-root, cette méthode fonctionne parce que / n’a pas de répertoire parent
  • Pour comparer de manière générale si deux chemins arbitraires désignent le même objet du système de fichiers, il faut comparer non pas les chaînes, mais (dev, inode)
    • GNU coreutils procède lui aussi ainsi
  • Dans CVE-2026-35363, rm rejetait . et .., mais acceptait ./ et .///, ce qui permettait de supprimer le répertoire courant
    • Quand on ne traite que la forme textuelle de l’entrée, les contrôles se contournent facilement

Aux frontières Unix, il faut privilégier les bytes plutôt que les chaînes

  • Les types String et &str de Rust sont toujours en UTF-8, alors que sous Unix, chemins, variables d’environnement, arguments et contenus de flux appartiennent au monde des bytes bruts
  • Faire les mauvais choix au moment de franchir cette frontière mène à deux catégories de bugs
    • Une conversion avec perte comme from_utf8_lossy remplace les bytes invalides par U+FFFD et corrompt discrètement les données
    • Une conversion stricte comme unwrap ou ? peut rejeter l’entrée ou faire terminer le processus
  • Le CVE-2026-35346 de comm était un cas où la conversion avec perte dégradait la sortie
    • Dans src/uu/comm/src/comm.rs, les bytes d’entrée ra et rb étaient convertis par String::from_utf8_lossy avant un print!
    • GNU comm recopie les bytes tels quels, même avec des fichiers binaires, mais uutils remplaçait l’UTF-8 invalide par U+FFFD, ce qui altérait la sortie
    • La correction a consisté à écrire directement les bytes bruts vers stdout via BufWriter et write_all
  • print! impose un aller-retour UTF-8 via Display, alors que Write::write_all ne le fait pas
  • Dans du code système de type Unix, il faut choisir les bons types selon le contexte
    • Path, PathBuf pour les chemins de fichiers
    • OsString pour les variables d’environnement
    • Vec<u8> ou &[u8] pour le contenu des flux
  • Faire transiter les données par String pour des raisons de commodité de formatage introduit facilement une corruption des données

Tout panic peut devenir un déni de service

  • Dans un CLI, unwrap, expect, l’indexation de slices, l’arithmétique non vérifiée et from_utf8 peuvent devenir des points de DoS si un attaquant contrôle l’entrée
    • panic! déroule la pile puis interrompt le processus
    • Si l’outil tourne dans un cron job, une CI pipeline ou un script shell, cela peut arrêter toute la tâche
    • Dans un environnement relancé en boucle, cela peut même paralyser l’ensemble du système via une crash loop
  • Dans CVE-2026-35348 de sort --files0-from, la présence d’un nom de fichier non UTF-8 dans une liste de noms séparés par NUL faisait arrêter l’outil
    • Le parseur appelait std::str::from_utf8(bytes).expect(...) sur les bytes de chaque nom
    • GNU sort traite les noms de fichiers comme des bytes bruts, comme le noyau, alors que uutils imposait l’UTF-8 et arrêtait tout le processus au premier chemin non UTF-8
  • Dans le code qui traite des entrées non fiables, unwrap, expect, l’indexation et les conversions as doivent être considérés comme des CVE potentielles
    • Il faut préférer ?, get, checked_*, try_from et remonter les véritables erreurs à l’appelant
  • Des règles clippy à activer en CI sont aussi proposées
    • unwrap_used
    • expect_used
    • panic
    • indexing_slicing
    • arithmetic_side_effects
  • Dans le code de test, ces avertissements peuvent être excessifs ; il est donc pertinent de les limiter au périmètre cfg(test)

Ignorer une erreur peut faire passer un échec pour une réussite

  • Certaines CVE venaient de flux qui ignoraient des erreurs ou faisaient disparaître l’information d’erreur
  • chmod -R et chown -R ne renvoyaient que le code de sortie du dernier fichier traité
    • Même si de nombreux fichiers précédents échouaient, si le dernier réussissait, la commande pouvait se terminer avec 0
    • Un script pouvait alors croire à tort que l’opération entière s’était bien déroulée
  • dd appelait set_len() puis Result::ok() pour imiter le comportement GNU avec /dev/null
    • L’intention était de jeter l’erreur dans un cas limité, mais le même code s’appliquait aussi aux fichiers ordinaires
    • Même si le disque était plein, un fichier destination à moitié écrit pouvait rester silencieusement sur place
  • Jeter un Result avec .ok(), .unwrap_or_default() ou let _ = fait disparaître des causes d’échec importantes
  • Même s’il ne faut pas s’arrêter dès la première erreur, il faut au minimum mémoriser le code d’erreur le plus grave et terminer avec celui-ci
  • Si l’on doit vraiment jeter un Result, il faut laisser dans le code la raison pour laquelle cet échec peut être ignoré sans risque

La compatibilité exacte avec l’outil d’origine est aussi une fonction de sécurité

  • Plusieurs CVE n’étaient pas dues à des opérations dangereuses, mais au fait que le code se comportait différemment de GNU
    • En pratique, les scripts shell réels dépendent du comportement du GNU d’origine, et un écart sémantique peut donc se transformer en problème de sécurité
  • Le CVE-2026-35369 de kill -1 est emblématique
    • GNU interprète -1 comme le signal 1 et attend ensuite un PID
    • uutils l’interprétait comme l’envoi du signal par défaut au PID -1
    • Sous Linux, le PID -1 vise tous les processus visibles ; une simple faute de frappe pouvait donc tuer tout le système
  • Dans un outil réimplémenté, la compatibilité bug-for-bug devient une protection qui couvre aussi les codes de sortie, messages d’erreur, edge cases et sémantique des options
  • À chaque point où le comportement diverge de GNU, la probabilité qu’un script shell prenne une mauvaise décision augmente
  • uutils exécute désormais aussi en CI la suite de tests upstream de GNU coreutils
    • Cela semble une défense à l’échelle appropriée contre ce type d’écart

Il faut résoudre les éléments avant de franchir une frontière de confiance

  • CVE-2026-35368 concernait une exécution de code locale en root dans chroot
  • Le motif du bug venait du fait que, après chroot(new_root)?, le programme résolvait encore le nom d’utilisateur à l’intérieur du nouveau root contrôlé par l’attaquant
    • get_user_by_name(name)? amenait à lire les bibliothèques partagées du nouveau système de fichiers racine pour résoudre le nom
    • Si l’attaquant plaçait les bons fichiers dans le chroot, cela pouvait mener à une exécution de code avec l’uid 0
  • GNU chroot effectue la résolution de l’utilisateur avant le chroot
    • La correction a adopté le même ordre
  • Une fois une frontière de confiance franchie, chaque appel de bibliothèque peut potentiellement exécuter du code contrôlé par l’attaquant
  • Même l’édition de liens statique ne résout pas ce problème
    • get_user_by_name passe par NSS et peut dlopen des modules libnss_* à l’exécution

Les bugs que Rust a effectivement empêchés

  • Il est aussi clair quels types de bugs n’ont pas été trouvés lors de cet audit
    • Aucun buffer overflow
    • Aucun use-after-free
    • Aucun double-free
    • Aucune data race sur état mutable partagé
    • Aucun null-pointer dereference
    • Aucune lecture de mémoire non initialisée
  • Même quand les outils comportaient des bugs, l’audit n’a trouvé aucun cas exploitable en lecture arbitraire de mémoire
  • Ces dernières années, GNU coreutils a pourtant continué d’accumuler des CVE de sécurité mémoire de ce type
    • pwd deep path buffer overflow
    • numfmt out-of-bounds read
    • unexpand --tabs heap buffer overflow
    • od --strings -N écriture de NUL hors heap buffer
    • sort lecture d’1 byte avant heap buffer
    • split --line-bytes heap overwrite avec CVE-2024-0684
    • b2sum --check lecture de mémoire non allouée sur entrée mal formée
    • tail -f stack buffer overrun
  • Sur la même période, la réimplémentation Rust est restée à 0 bug dans cette catégorie
    • Avec la précision toutefois que l’audit ne prouve pas l’absence de bugs de sécurité mémoire ; il montre seulement qu’il n’en a pas trouvé
  • Les problèmes restants surgissent surtout aux frontières avec le monde extérieur plutôt qu’au sein même de Rust
    • chemins
    • bytes et chaînes
    • appels système
    • décalages temporels et changements d’état du système de fichiers

Un Rust correct est aussi un Rust idiomatique

  • Le Rust idiomatique ne se limite pas à passer le borrow checker et à produire un code silencieux dans clippy
  • La correction doit aussi faire partie de l’idiomaticité
    • Parce que les formes de code qui survivent au réel se sont consolidées par l’expérience collective de la communauté
  • Un système robuste doit non pas masquer le désordre du monde réel, mais le refléter tel quel
    • des descripteurs de fichier plutôt que des chemins
    • OsStr plutôt que String
    • ? plutôt que unwrap
    • la compatibilité bug-for-bug avec l’original plutôt qu’une sémantique plus élégante en apparence
  • Le système de types peut exprimer beaucoup de choses, mais pas des conditions hors de contrôle comme le temps qui s’écoule entre deux appels système
  • Le Rust idiomatique suppose que les types, noms et flux de contrôle du code révèlent la vérité de l’environnement d’exécution
    • Même si c’est moins esthétique qu’un joli code de tableau blanc, il faut une forme plus honnête

Références

Aucun commentaire pour le moment.

Aucun commentaire pour le moment.