Les bugs que Rust ne détecte pas
(corrode.dev)- 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 quefrom_utf8_lossy,unwrapouexpect, 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::fsde Rust repose par défaut sur une réinterprétation à partir de&Path, ce qui rend ce type d’erreur facile à commettrefs::metadata,File::create,fs::remove_fileetfs::set_permissionsréinterprètent le chemin à chaque appel- Dans les outils privilégiés qui doivent résister à des attaquants locaux, ce comportement par défaut devient dangereux
- 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 deFile::create(to)? - Si, entre la suppression et la création,
toest remplacé par un lien symbolique pointant vers/etc/shadowou une cible similaire, un processus privilégié peut écraser ce fichier
- Dans
- La correction a consisté à utiliser
OpenOptions::create_new(true)afin de ne créer que de nouveaux fichiers- D’après la documentation,
create_newn’autorise à l’emplacement cible ni fichier existant, ni dangling symlink
- D’après la documentation,
- 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
chmodensuite, crée lui aussi une courte fenêtre d’exposition- Un code du type
fs::create_dir(&path)?suivi defs::set_permissions(&path, Permissions::from_mode(0o700))?laissepathexister avec ses permissions par défaut pendant cet intervalle - D’autres utilisateurs peuvent faire
open()pendant cette fenêtre, et unchmodultérieur ne révoquera pas les descripteurs de fichier déjà obtenus
- Un code du type
- Les permissions doivent être spécifiées au moment de la création
- Il faut utiliser
OpenOptions::mode()etDirBuilderExt::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
- Il faut utiliser
Comparer des chaînes de chemin ne prouve pas l’identité dans le système de fichiers
- Le premier contrôle de
--preserve-rootdanschmodne faisait qu’une comparaison de chaînesrecursive && 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
canonicalizerenvoie 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,rmrejetait.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
Stringet&strde 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_lossyremplace les bytes invalides parU+FFFDet corrompt discrètement les données - Une conversion stricte comme
unwrapou?peut rejeter l’entrée ou faire terminer le processus
- Une conversion avec perte comme
- Le
CVE-2026-35346decommétait un cas où la conversion avec perte dégradait la sortie- Dans
src/uu/comm/src/comm.rs, les bytes d’entréeraetrbétaient convertis parString::from_utf8_lossyavant unprint! - GNU
commrecopie les bytes tels quels, même avec des fichiers binaires, mais uutils remplaçait l’UTF-8 invalide parU+FFFD, ce qui altérait la sortie - La correction a consisté à écrire directement les bytes bruts vers
stdoutviaBufWriteretwrite_all
- Dans
print!impose un aller-retour UTF-8 viaDisplay, alors queWrite::write_allne le fait pas- Dans du code système de type Unix, il faut choisir les bons types selon le contexte
- Faire transiter les données par
Stringpour 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 etfrom_utf8peuvent devenir des points de DoS si un attaquant contrôle l’entréepanic!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-35348desort --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
sorttraite 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
- Le parseur appelait
- Dans le code qui traite des entrées non fiables,
unwrap,expect, l’indexation et les conversionsasdoivent être considérés comme des CVE potentielles- Il faut préférer
?,get,checked_*,try_fromet remonter les véritables erreurs à l’appelant
- Il faut préférer
- Des règles clippy à activer en CI sont aussi proposées
unwrap_usedexpect_usedpanicindexing_slicingarithmetic_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 -Retchown -Rne 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
- Même si de nombreux fichiers précédents échouaient, si le dernier réussissait, la commande pouvait se terminer avec
ddappelaitset_len()puisResult::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
Resultavec.ok(),.unwrap_or_default()oulet _ =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-35369dekill -1est emblématique- GNU interprète
-1comme 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
- GNU interprète
- 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-35368concernait une exécution de code locale en root danschroot- 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’attaquantget_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
chrooteffectue la résolution de l’utilisateur avant lechroot- 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_namepasse par NSS et peutdlopendes moduleslibnss_*à 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
pwddeep path buffer overflownumfmtout-of-bounds readunexpand --tabsheap buffer overflowod --strings -Nécriture de NUL hors heap buffersortlecture d’1 byte avant heap buffersplit --line-bytesheap overwrite avec CVE-2024-0684b2sum --checklecture de mémoire non allouée sur entrée mal forméetail -fstack 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
OsStrplutôt queString?plutôt queunwrap- 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
- An update on rust-coreutils : publication des résultats de l’audit
- Patterns for Defensive Programming in Rust : motifs de Rust défensif à lire en complément
- Pitfalls of Safe Rust : erreurs fréquentes possibles même en safe Rust
- Sharp Edges In The Rust Standard Library : comportements surprenants de
std - uutils/coreutils on GitHub : réimplémentation de GNU coreutils en Rust
1 commentaires
Commentaires sur Hacker News
En tant que mainteneur de GNU Coreutils, j’ai trouvé l’article intéressant, mais dans le peu de Rust que j’ai utilisé, il était bien trop facile de créer une TOCTOU race avec
std::fsJ’espère qu’une API de type
openatfinira par entrer dans la bibliothèque standardEt je ne suis pas d’accord avec la règle résoudre les chemins avant de les comparer
En général, il vaut mieux appeler
fstatet comparerst_devetst_ino, et l’article le mentionnait un peu aussiUn effet secondaire moins souvent pris en compte, c’est le coût en performances
Dans un exemple concret, sur un chemin de répertoires très profond,
cpprenait 0,010 seconde alors queuu_cpmettait 12,857 secondesDans la réalité, on crée rarement ce genre de chemin exprès, mais les logiciels GNU font de gros efforts pour éviter les limites arbitraires
https://www.gnu.org/prep/standards/standards.html#Semantics
Et l’article disait aussi que la réécriture en Rust avait eu zéro bug de sécurité mémoire sur une période comparable, mais ce n’est pas vrai :)
https://github.com/advisories/GHSA-w9vv-q986-vj7x
Oui,
std::fssouffre d’un problème de lowest common denominatorIl fallait bien mettre quelque chose dans Rust 1.0, et malheureusement cet état s’est figé très longtemps
Je pense que
uutilsest un bon endroit pour essayer de concevoir une API de remplacement à std::fs sur laquelle il est plus difficile de se tromperMerci d’avoir expliqué ce point de vue avec autant de concision depuis l’autre camp
J’aimerais demander ce qu’il faut retenir de tout ça
C’est une question volontairement assez agressive pour un échange sur Internet, parce qu’un contraste net aide à mieux voir les différences et les erreurs
Bien sûr, tu n’as absolument aucune obligation d’y consacrer du temps ou de l’énergie mentale
Je me demande pourquoi la vitesse, les performances, les race conditions et
st_inoreviennent toujours ensembleOn dirait que la latence, l’écriture réelle sur le support de stockage, l’atomicité, ACID et la vitesse finie de transmission de l’information convergent au fond vers une même nature
J’ai l’impression que les systèmes à forte fiabilité, comme la comptabilité, finissent forcément par aller vers ACID, et que les systèmes peu fiables sont oubliés si vite que les différences entre ordinateurs paraissent moins importantes
Je me demande aussi si, dans les applications du quotidien, le throughput est vraiment plus important que la latency
Et je comprends qu’on se concentre sur les numéros d’inode à cause de l’histoire de C, des OS de type Unix et de GNU coreutils,
mais je me demande ce que ça donnerait si on prenait un exemple très simple comme faire en sorte qu’une clé USB fonctionne simplement correctement pour stocker des fichiers
sans éviter la complexité du buffering I/O de
libc, defflush, du buffering du noyau, du multicœur, du partage du temps processeur et de l’exécution simultanée de plusieurs applicationsJe suis complètement débutant, mais je me demandais pourquoi il fallait une boucle
whileau lieu de faire simplementcddirectement avec$(yes a/ | head -n $((32 * 1024)) | tr -d '\n')Édition : j’ai compris. C’était à cause de
-bash: cd: a/a/a/....../a/a/: File name too longJe ne sais pas si vous l’avez vu, mais il existe une démo qui convertit automatiquement des utilitaires GNU comme
wgetvers un sous-ensemble C++ sûr pour la mémoirehttps://duneroadrunner.github.io/scpp_articles/PoC_autotranslation_of_wget
L’idée consiste à remplacer presque à l’identique les éléments dangereux de C par des éléments C++ sûrs ayant un comportement correspondant, ce qui semble moins susceptible d’introduire de nouveaux bugs et de nouveaux écarts de comportement qu’une réécriture
Si on nettoie un peu le code source d’origine, la conversion peut être totalement automatisée, ce qui permettrait de produire à l’étape de build un exécutable sûr pour la mémoire, un peu plus lent, à partir des sources C d’origine
C’est peut-être une question un peu bête, mais je me demande si le côté GNU Coreutils envisage ou prévoit une réécriture en Rust de son côté
Ils savaient peut-être utiliser Rust, mais ils n’étaient pas vraiment assez familiers avec l’API Unix, sa sémantique et ses pièges
La plupart de ces erreurs paraissent assez débutantes du point de vue de développeurs issus de GNU coreutils, BSD ou Solaris
Beaucoup de ces problèmes ont déjà été découverts et traités il y a des décennies, et même si les bases de code existantes traînent encore une longue queue de correctifs, on est maintenant plutôt à un rythme où il n’en arrive plus qu’un petit nombre
En lisant ce thread Canonical, j’étais sidéré
L’idée générale, c’était à peu près : « Rust est plus sûr, la sécurité est la priorité absolue, donc déployer d’urgence une réécriture complète de coreutils est nécessaire. Même si ça casse des choses, on corrigera plus tard. »
Je n’ai aucune envie d’exécuter sur ma machine du code produit par des gens qui pensent comme ça
Moi aussi je suis favorable à Rust, mais dire que Rust est plus sûr n’est vrai que toutes choses égales par ailleurs
Ici, les autres conditions ne sont absolument pas égales
Une réécriture aura inévitablement bien plus de bugs et de vulnérabilités qu’un code maintenu depuis des décennies, donc l’argument de sécurité peut avoir du sens dans une stratégie de transition à long terme, mais ne justifie pas un déploiement précipité
Minimiser l’impact sur les utilisateurs après déploiement, ou dire « c’est comme ça qu’on fait ressortir les bugs » ou « les coreutils existants n’avaient pas non plus de vrais tests », c’est bien trop irresponsable
Les utilisateurs ne sont pas des cobayes
À mon avis, les mainteneurs ont une responsabilité morale de ne pas nuire à la fiabilité des systèmes de leurs utilisateurs
Plus fondamentalement, on dirait que la bibliothèque standard de Rust pousse les développeurs vers une API propre au mauvais niveau d’abstraction
Par exemple vers des opérations sur fichiers basées sur les chemins plutôt que sur des handles
J’espère me tromper
Pour moi, l’intérêt de Rust est justement de faire en sorte qu’on n’ait pas à se soucier des plus gros pièges, ceux dans lesquels il est le plus facile de tomber
Le cœur de cet article semble justement être que les API du système de fichiers devraient jouer ce rôle
Quelqu’un a déjà forgé une expression similaire : disassembler rage
L’idée, c’est que si on regarde d’assez près, toute erreur finit par avoir l’air amateur
L’expression vient aussi de cette tendance à ne regarder qu’un désassembleur et à reprocher à un programmeur de haut niveau d’avoir utilisé un
ifau lieu d’unswitchdans une fonction 100 frames plus bas dans la pile d’appelsLà, on ne voit que quelques-unes de leurs erreurs, et on ne voit presque pas les milliers de lignes de code correctement écrit autour
Qu’un tel utilitaire fasse
panic, même selon les critères de Rust, c’est une erreur assez amateurPour une erreur d’allocation irrécupérable, passe encore, mais
expectetunwrapsont difficiles à défendre sauf si on garantit vraiment de façon très stricte l’invariant qui empêche absolument ce chemin de code d’être exécutéUne des difficultés d’une réécriture, c’est que le code d’origine a été transformé progressivement en réponse à des problèmes qui n’apparaissaient qu’en conditions réelles d’exploitation
Les leçons tirées de ce processus s’infusent discrètement dans le code, et si elles ne sont pas documentées, il y a énormément de travail caché avant d’atteindre un niveau équivalent
Le texte original montre justement très bien ce type de liste
Cela dit, avant de traiter tout de suite ça d’amateurisme, il faut aussi voir que c’est l’un des phénomènes les plus typiquement logiciels du logiciel
Sauf s’ils ont ignoré une excellente documentation technique de coreutils et des tests couvrant ces cas, c’était presque inévitable
Le bon exemple donné dans l’article, c’est le CVE chroot + NSS
Le fait que NSS soit dynamique et qu’il
dlopendes bibliothèques à l’intérieur d’unchrootn’est écrit nulle part de manière visibleC’est plutôt quelque chose que les administrateurs système ont appris à force de s’y heurter pendant plus de 25 ans, et une réécriture en clean room le redécouvre en général sous la forme d’un nouveau CVE
Ce serait pareil en portant le même code avec un LLM
On peut lire les signatures de fonctions, mais ce qu’il faut vraiment, ce sont les blessures et cicatrices laissées dans ce code
Si on fait ce travail sans même lire le code source original pour éviter la GPL, c’est encore plus difficile
À mon avis,
uutilsaurait été bien meilleur s’il avait été sous GPL et avait pu s’inspirer directement du code source original de coreutilsIl faut aussi dire clairement que ne pas documenter ces leçons, ou au moins les bugs et vulnérabilités qu’on a essayé d’éviter, est une mauvaise pratique
Bien sûr, il est difficile de documenter tous les bugs implicitement évités juste en écrivant bien le code au départ,
mais pour les futurs lecteurs, il est important de laisser des explications du genre : « on utilise
fooici au lieu debar, parce qu’avecbardans la condition ABC, on crée unbazdangereux pour telle raison XYZ »Même si cela semble gaspiller un peu de temps et d’espace documentaire, je pense que c’est préférable
J’ai l’impression qu’un bon nombre des points soulevés dans cet article, surtout en les comparant au code source GNU coreutils, auraient dû être détectés par des unit tests ou une relecture manuelle raisonnable
Cette réécriture de coreutils semble être une très mauvaise idée, et
https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/
elle semble avoir été menée de travers, sans reprendre suffisamment les connaissances accumulées par le logiciel précédent
Si on veut réécrire, il faut comprendre et apprendre complètement de ce qui existait avant
Sinon, on répète les mêmes erreurs, et franchement c’est assez embarrassant
Pour être clair, j’aime Rust, je l’utilise dans plusieurs projets et c’est excellent
Mais Rust ne sauve pas une mauvaise ingénierie
Fait intéressant,
uutilsutilise la suite de tests GNU coreutilsEt ils précisent aussi qu’ils n’accepteront pas de contributions écrites après lecture du code source GPL
De la part de ceux qui ont créé
unity,upstartetsnap, ce genre de chose reste tout à fait prévisibleIl faudrait peut-être accueillir les nouveaux programmeurs système avec ce message :
Unix est cassé, et au final il faut écrire soi-même des contournements laids et peu pédagogiques, et faire aussi des tests empiriques
C’est comme ça que fonctionnent les logiciels fiables et la bonne ingénierie logicielle
Je me demande pourquoi le differential fuzzing n’a pas permis d’attraper ce genre de bugs
https://github.com/uutils/coreutils/tree/main/fuzz/uufuzz
Le schéma qui consiste à vérifier un chemin par un syscall, puis à refaire un syscall sur le même chemin pour agir ensuite dessus, mène toujours au même problème
Un attaquant qui a le droit d’écrire dans le répertoire parent peut remplacer entre-temps un composant du chemin par un lien symbolique, et le noyau résoudra de nouveau tout le chemin depuis le début lors du second appel, de sorte que l’opération privilégiée s’appliquera à la cible choisie par l’attaquant
Un attaquant qui a le droit d’écrire dans le répertoire parent peut aussi jouer avec des liens physiques
Même si on ne peut toucher qu’aux fichiers ordinaires, il n’existe en pratique presque aucune vraie mesure d’atténuation
Voir cet exemple : https://michael.orlitzky.com/articles/posix_hardlink_heartache.xhtml
Il semble que la cause profonde de plusieurs de ces bugs soit l’opacité excessive des API Unix
Par exemple, le fait que
get_user_by_namecharge des bibliothèques partagées dans le nouveau système de fichiers racine pour résoudre un nom d’utilisateur, et qu’un attaquant capable de déposer des fichiers dans lechrootpuisse ainsi exécuter du code avec l’uid 0, ressemble presque à un piège explosifQu’une fonction de récupération de données utilisateur se mette soudain à charger aussi des bibliothèques partagées ressemble à un mélange des responsabilités dans la conception
À mon avis, il faudrait séparer, au niveau des fonctions, la consultation des données utilisateur et le chargement de bibliothèques, ou au moins rendre ce comportement évident rien qu’au nom de la fonction
C’est peut-être vrai en partie, mais si on a décidé de réécrire coreutils à partir de zéro, comprendre les API POSIX fait littéralement partie du cœur du travail
Et si, en plus, le code qui vérifie si un chemin pointe vers la racine du système de fichiers faisait
file == Path::new("/"), alors ce n’est pas un problème d’APILa personne qui a écrit ça n’avait à peu près pas sa place sur ce projet
Au contraire, je pense qu’utiliser un langage sûr fonctionnel peut donner l’illusion que les données manipulées sont elles aussi sans état
Mais dans un système d’exploitation, énormément de choses changent en permanence
Tant qu’on n’a pas un système de fichiers fournissant des snapshots, il faut continuellement revérifier
Au final, il nous faut des API qui, quand on leur donne une entrée, ne renvoient qu’un résultat de succès ou un échec
pas des API qui renvoient l’un des trois états succès, échec ou erreur
Oui,
musl libcsupprime justement l’un de ces élémentsÀ mon avis, la cause profonde n’est pas tant l’opacité des API Unix que le fait de ne pas avoir vraiment réfléchi au cas où root fait un chroot dans un répertoire qu’il ne contrôle pas
Tout ce qui est ciblé par
chrootest sous le contrôle de celui qui l’a préparé, et si on ne comprend pas ça, on n’a rien à faire avecchroot()get_user_by_namepeut sembler piégeux, mais en réalité il n’y a presque pas de différence de fond entre utilisernewroot/etc/passwdet utilisernewroot/usr/lib/x86_64-linux-gnu/libnss_compat.so,newroot/bin/sh, etc.C’est pour ça qu’à mon avis
/usr/sbin/chrootn’a aucune raison d’aller consulter un identifiant utilisateur au départtoybox chrootne le fait pas non plusAu fond, le bug n’était pas d’avoir mal fait quelque chose, mais d’avoir fait cette chose tout court
Unix et POSIX sont fractals : quel que soit l’endroit où on coupe, c’est rempli de pièges
Même en supposant que les gens côté Rust aient réécrit coreutils sans expérience Linux, ce que je comprends encore moins, c’est comment Ubuntu a pu accepter ça en mainline
On dirait presque qu’Ubuntu suit une politique consistant à remplacer à chaque version ou presque un élément fondamental du système par une expérimentation bancale et inachevée
À mon avis, l’essentiel ici n’est pas « mince, il y avait des bugs dans du code Rust », mais bien cela
L’original est sous licence GPL, et la réécriture sous licence MIT
Si c’est vrai que « ces bugs provenaient de code Rust réellement déployé, et que leurs auteurs savaient ce qu’ils faisaient »,
alors je me demande si cela signifie que les utilitaires d’origine n’avaient pas de test harness, et que la réécriture n’a pas commencé par en construire un
Même s’il y a beaucoup de cas limites, on devrait pouvoir abstraire au moins en partie l’OS et le FS pour vérifier qu’un
rm .//ne supprime pas réellement le répertoire courant, par exempleCe n’est pas tant un problème de code sale ou une critique du langage qu’une nouvelle manifestation de cette vieille attitude selon laquelle on ne teste pas en programmation système
À l’inverse, si les utilitaires d’origine avaient bien des tests et qu’il y avait malgré tout autant de trous, il se pourrait que la suite de tests d’origine soit elle-même très insuffisante
C’est probable
Mais je suis moins convaincu qu’on puisse abstraire suffisamment l’OS et le FS pour tout valider
Des gens essaient ça depuis avant ma naissance, et ils n’ont apparemment toujours pas réussi
Rien que décider combien de
/il faut ajouter dans les essais n’est déjà pas évidentPlus loin encore, si on suppose que
rmrefuse de supprimer un fichier lorsque ses 9 premiers octets valentimportant,il est difficile d’imaginer comment un test pourrait découvrir ce comportement sans connaître à l’avance cette chaîne
Et ce serait encore plus difficile si le mot magique n’était même pas un mot du dictionnaire
Je n’ai presque jamais vu quelqu’un dire sérieusement « on ne teste pas en programmation système »
En revanche, j’entends souvent dire que les tests ne jouent pas toujours le rôle que les gens attendent d’eux
Si j’ai bien compris, le développement de
uutilscomprenait de vastes tests de comparaison de comportement avec les utilitaires d’origine, allant jusqu’à essayer de préserver leurs bugsC’est aussi pour ce genre de raison que Windows désactive les symlinks par défaut
Au lieu de résoudre le problème par abstraction, ils retirent pratiquement la fonctionnalité elle-même
Les systèmes de type Unix ne peuvent pas faire ça, parce qu’ils ont des décennies de logiciels dépendant des symlinks
MacOS a des réponses du même ordre
Par exemple, le bug
chroot()n’y pose généralement pas un vrai problème dans la configuration par défaut, parce que MacOS bloquechroot()par défautPour l’utiliser, il faut désactiver system integrity protection
Le problème fondamental se trouve dans les angles vifs des API POSIX, et la solution n’est pas tant de les abstraire que de les faire disparaître
Je pense que laisser les gens expérimenter et tâtonner maladroitement, c’est très bien
C’est comme ça qu’on apprend et qu’on grandit
Ce qui m’intrigue vraiment, c’est de savoir à quel point la chaîne de décision d’Ubuntu a dysfonctionné pour que ça arrive en production