Protection sans peur. Sécurité du fil dans la rouille

Il s'agit de la deuxième partie de la série d'articles Fearless Protection. Dans le premier, nous avons parlé de la sécurité de la mémoire

Les applications modernes sont multithreads: au lieu d'exécuter des tâches séquentiellement, le programme utilise des threads pour effectuer simultanément plusieurs tâches. Nous observons tous un travail simultané et simultané tous les jours:

  • Les sites Web sont servis par plusieurs utilisateurs en même temps.
  • L'interface utilisateur effectue un travail d'arrière-plan qui ne dérange pas l'utilisateur (imaginez que chaque fois que vous tapez un caractère, l'application se fige pour vérifier l'orthographe).
  • Un ordinateur peut exécuter plusieurs applications en même temps.

Les flux parallèles accélèrent le travail, mais introduisent un ensemble de problèmes de synchronisation, à savoir les blocages et les conditions de concurrence. Du point de vue de la sécurité, pourquoi nous soucions-nous de la sécurité des threads? Parce que la sécurité de la mémoire et des threads a un seul et même problème principal: une utilisation inappropriée des ressources. Les attaques ici ont les mêmes effets que les attaques de mémoire, y compris l'escalade de privilèges, l'exécution de code arbitraire (ACE) et le contournement des contrôles de sécurité.

Les erreurs de concurrence, comme les erreurs d'implémentation, sont étroitement liées à l'exactitude du programme. Bien que les vulnérabilités de mémoire soient presque toujours dangereuses, les erreurs d'implémentation / logique n'indiquent pas toujours un problème de sécurité si elles ne se produisent pas dans la partie du code liée à la conformité aux contrats de sécurité (par exemple, autorisation de contourner un contrôle de sécurité). Mais les bugs de concurrence ont une particularité. Si des problèmes de sécurité dus à des erreurs logiques apparaissent souvent à côté du code correspondant, des erreurs de concurrence se produisent souvent dans d'autres fonctions, et non dans celle où l'erreur a été directement causée , ce qui rend difficile leur suivi et leur élimination. Une autre difficulté est un certain chevauchement entre un traitement de mémoire incorrect et des erreurs de concurrence, que nous constatons dans les courses de données.

Les langages de programmation ont développé diverses stratégies de concurrence pour aider les développeurs à gérer les problèmes de performances et de sécurité des applications multi-thread.

Problèmes de concurrence


Il est généralement admis que la programmation parallèle est plus difficile que d'habitude: notre cerveau est mieux adapté au raisonnement séquentiel. Le code parallèle peut avoir des interactions inattendues et indésirables entre les threads, y compris les blocages, les conflits et les courses de données.

Un blocage se produit lorsque plusieurs threads s'attendent à ce que l'autre exécute certaines actions pour continuer à fonctionner. Bien que ce comportement indésirable puisse provoquer une attaque par déni de service, il ne provoquera pas de vulnérabilités telles que ACE.

Une condition de concurrence est une situation dans laquelle le temps ou l'ordre des tâches peut affecter l'exactitude d'un programme. La course aux données se produit lorsque plusieurs flux essaient d'accéder simultanément au même emplacement mémoire avec au moins une tentative d'écriture. Il arrive qu'une condition de concurrence critique et une course de données se produisent indépendamment l'une de l'autre. Mais les courses de données sont toujours dangereuses .

Conséquences potentielles des erreurs de concurrence


  1. Impasse
  2. Perte d'informations: un autre thread écrase les informations
  3. Perte d'intégrité: les informations de plusieurs flux sont entrelacées
  4. Perte de viabilité: problèmes de performances dus à un accès inégal aux ressources partagées

Le type d'attaque simultanée le plus connu est appelé TOCTOU (heure de vérification à l'heure d'utilisation): en substance, l'état d'une course se situe entre la vérification des conditions (par exemple, les informations d'identification de sécurité) et l'utilisation des résultats. Une attaque TOCTOU entraîne une perte d'intégrité.

Les verrous mutuels et la perte de survie sont considérés comme des problèmes de performances, et non comme des problèmes de sécurité, tandis que la perte d'informations et la perte d'intégrité sont probablement liées à la sécurité. Un article sur Red Balloon Security examine certains des exploits possibles. Un exemple est une corruption de pointeur suivie d'une escalade de privilèges ou d'une exécution de code à distance. Dans l'exploit, une fonction qui charge la bibliothèque partagée ELF (Executable and Linkable Format) n'initie correctement un sémaphore que lors du premier appel, puis limite incorrectement le nombre de threads, ce qui provoque une corruption de la mémoire du noyau. Cette attaque est un exemple de perte d'informations.

La partie la plus difficile de la programmation simultanée est le test et le débogage, car les erreurs de concurrence sont difficiles à reproduire. Timing des événements, décisions du système d'exploitation, trafic réseau et autres facteurs ... tout cela change le comportement du programme à chaque démarrage.


Parfois, il est vraiment plus facile de supprimer tout le programme que de rechercher un bogue. Heisenbugs

Non seulement le comportement change à chaque démarrage, mais même l'insertion d'opérateurs de sortie ou de débogage peut changer le comportement, ce qui entraîne des «bugs Heisenberg» (erreurs non déterministes, difficiles à reproduire, typiques de la programmation parallèle) qui surviennent et disparaissent mystérieusement.

La programmation parallèle est difficile. Il est difficile de prédire comment le code parallèle va interagir avec un autre code parallèle. Lorsque des erreurs apparaissent, elles sont difficiles à trouver et à corriger. Au lieu de compter sur des testeurs, examinons les moyens de développer des programmes et l'utilisation de langages qui facilitent l'écriture de code parallèle.

Tout d'abord, nous formulons le concept de «sécurité des threads»:

"Un type de données ou une méthode statique est considéré comme sûr pour les threads s'il se comporte correctement lorsqu'il est appelé à partir de plusieurs threads, quelle que soit la façon dont ces threads sont exécutés, et ne nécessite pas de coordination supplémentaire à partir du code appelant." MIT

Comment les langages de programmation fonctionnent avec le parallélisme


Dans les langues sans sécurité de thread statique, les programmeurs doivent surveiller en permanence la mémoire partagée avec un autre thread et pouvant changer à tout moment. En programmation séquentielle, on nous apprend à éviter les variables globales si une autre partie du code les modifie discrètement. Il est impossible d'exiger des programmeurs qu'ils garantissent une modification sûre des données partagées, ainsi qu'une gestion manuelle de la mémoire.


"Vigilance constante!"

En règle générale, les langages de programmation sont limités à deux approches:

  1. Limitation de la mutabilité ou restriction de l'accès partagé
  2. Sécurité du filetage manuel (p. Ex. Verrous, sémaphores)

Les langues avec restriction de threads imposent une limite de 1 thread pour les variables mutables ou nécessitent que toutes les variables communes soient immuables. Les deux approches abordent le problème fondamental de la course aux données - modification incorrecte des données partagées - mais les restrictions sont trop sévères. Pour résoudre le problème, les langages ont créé des primitives de synchronisation de bas niveau, telles que les mutex. Ils peuvent être utilisés pour créer des structures de données thread-safe.

Python et verrouillage global par interprète


L'implémentation de référence en Python et Cpython a une sorte de mutex appelé Global Interpreter Lock (GIL), qui bloque tous les autres threads lorsqu'un thread accède à un objet. Python multithread est connu pour son inefficacité en raison de la latence GIL. Par conséquent, la plupart des programmes Python simultanés fonctionnent dans plusieurs processus afin que chacun ait son propre GIL.

Java et exceptions d'exécution


Java prend en charge la programmation simultanée via un modèle de mémoire partagée. Chaque thread a son propre chemin d'exécution, mais il peut accéder à n'importe quel objet du programme: le programmeur doit synchroniser l'accès entre les threads à l'aide des primitives Java intégrées.

Bien que Java ait des blocs de construction pour créer des programmes thread-safe, la sécurité des threads n'est pas garantie par le compilateur (par opposition à la sécurité de la mémoire). Si l'accès à la mémoire non synchronisé se produit (c'est-à-dire la course aux données), Java lèvera une exception d'exécution, mais les programmeurs doivent utiliser correctement les primitives de concurrence.

C ++ et le cerveau du programmeur


Alors que Python évite les conditions de concurrence avec le GIL et que Java lève des exceptions au moment de l'exécution, C ++ attend du programmeur qu'il synchronise manuellement l'accès à la mémoire. Avant C ++ 11, la bibliothèque standard n'incluait pas de primitives de concurrence .

La plupart des langues fournissent des outils pour écrire du code thread-safe, et il existe des méthodes spéciales pour détecter la race des données et le statut de la race; mais il ne donne aucune garantie de sécurité des threads et ne protège pas contre la course aux données.

Comment résoudre le problème de la rouille?


Rust adopte une approche à multiples facettes pour éliminer les conditions de course en utilisant des règles de tenure et des types sûrs pour se protéger complètement contre les conditions de course au moment de la compilation.

Dans le premier article, nous avons introduit le concept de propriété, c'est l'un des concepts de base de Rust. Chaque variable a un propriétaire unique et la propriété peut être transférée ou empruntée. Si un autre thread souhaite modifier la ressource, nous transférons la propriété en déplaçant la variable vers un nouveau thread.

Le déplacement lève une exception: plusieurs threads peuvent écrire dans la même mémoire, mais jamais en même temps. Puisque le propriétaire est toujours seul, que se passe-t-il si un autre thread emprunte une variable?

Dans Rust, vous avez soit un emprunt mutable, soit plusieurs emprunts immuables. Il n'est pas possible d'introduire simultanément des emprunts mutables et immuables (ou plusieurs emprunts mutables). Dans la sécurité de la mémoire, il est important que les ressources soient correctement libérées, et dans la sécurité des threads, il est important qu'un seul thread ait le droit de modifier une variable à un moment donné. De plus, dans une telle situation, aucun autre flux ne fera référence à un emprunt obsolète: soit l'enregistrement, soit le partage lui est possible, mais pas les deux.

Le concept de propriété est conçu pour corriger les vulnérabilités de la mémoire. Il s'est avéré que cela empêche également la course aux données.

Bien que de nombreuses langues disposent de méthodes de sécurité de la mémoire (telles que le comptage de liens et la récupération de place), elles reposent généralement sur une synchronisation manuelle ou des interdictions de partage simultané pour empêcher la course aux données. L'approche Rust aborde les deux types de sécurité, essayant de résoudre le problème principal de déterminer l'utilisation acceptable des ressources et d'assurer cette validité au moment de la compilation.



Mais attends! Ce n'est pas tout!


Les règles de propriété empêchent plusieurs threads d'écrire des données dans le même emplacement de mémoire et interdisent l'échange simultané de données entre les threads et la mutabilité, mais cela ne fournit pas nécessairement des structures de données thread-safe. Chaque structure de données dans Rust est thread-safe ou non. Ceci est transmis au compilateur à l'aide d'un système de type.

"Un programme bien tapé ne peut pas faire d'erreur." - Robin Milner, 1978

Dans les langages de programmation, les systèmes de types décrivent un comportement acceptable. En d'autres termes, un programme bien typé est bien défini. Tant que nos types sont suffisamment expressifs pour capturer le sens voulu, un programme bien typé se comportera comme prévu.

Rust est un langage de type sécurisé, ici le compilateur vérifie la cohérence de tous les types. Par exemple, le code suivant ne compile pas:

let mut x = "I am a string"; x = 6; 

  error[E0308]: mismatched types --> src/main.rs:6:5 | 6 | x = 6; // | ^ expected &str, found integral variable | = note: expected type `&str` found type `{integer}` 

Toutes les variables de Rust sont de type souvent implicite. Nous pouvons également définir de nouveaux types et décrire les capacités de chaque type à l' aide du système de traits . Les traits fournissent une abstraction de l'interface. Deux caractéristiques intégrées importantes sont Send et Sync , qui sont fournies par défaut par le compilateur pour chaque type:

  • Send indique que la structure peut être transférée en toute sécurité entre les threads (requis pour transférer la propriété)
  • Sync indique que les threads peuvent utiliser la structure en toute sécurité.

L'exemple ci-dessous est une version simplifiée du code de la bibliothèque standard qui génère des threads:

  fn spawn<Closure: Fn() + Send>(closure: Closure){ ... } let x = std::rc::Rc::new(6); spawn(|| { x; }); 

La fonction d' spawn prend un seul argument, la closure et nécessite un type pour ce dernier qui implémente les traits Send et Fn . Lorsque vous essayez de créer un flux et de transmettre la valeur de closure avec la variable x compilateur renvoie une erreur:

  erreur [E0277]: `std :: rc :: Rc <i32>` ne peut pas être envoyé entre les threads en toute sécurité
      -> src / main.rs: 8: 1
       |
     8 |  spawn (move || {x;});
       |  ^^^^^ `std :: rc :: Rc <i32>` ne peut pas être envoyé entre les threads en toute sécurité
       |
       = aide: dans `[fermeture@src/main.rs: 8: 7: 8:21 x: std :: rc :: Rc <i32>]`, le trait `std :: marker :: Send` n'est pas implémenté pour `std :: rc :: Rc <i32>`
       = note: obligatoire car il apparaît dans le type `[fermeture@src/main.rs: 8: 7: 8:21 x: std :: rc :: Rc <i32>]`
     note: requis par `spawn` 

Les traits d' Send et de Sync permettent au système de type Rust de comprendre quelles données peuvent être partagées. En incluant ces informations dans le système de types, la sécurité des threads fait partie de la sécurité des types. Au lieu de la documentation, la sécurité des threads est implémentée par la loi du compilateur .

Les programmeurs voient clairement les objets communs entre les threads, et le compilateur garantit la fiabilité de cette installation.



Bien que des outils de programmation parallèles soient disponibles dans de nombreux langages, la prévention des conditions de concurrence n'est pas facile. Si vous avez besoin que les programmeurs alternent de manière complexe les instructions et interagissent entre les threads, les erreurs sont inévitables. Bien que les violations de sécurité des threads et de la mémoire entraînent des conséquences similaires, les protections de mémoire traditionnelles, telles que le comptage de liens et la récupération de place, n'empêchent pas les conditions de concurrence. En plus de la garantie statique de la sécurité de la mémoire, le modèle de propriété Rust empêche également les modifications de données non sécurisées et le partage incorrect des objets entre les threads, tandis que le système de type assure la sécurité des threads au moment de la compilation.

Source: https://habr.com/ru/post/fr441370/


All Articles