Le client Steam a éliminé une vulnérabilité dangereuse qui s'y cachait depuis dix ans

image

Le chercheur principal Tom Court of Context, une société de sécurité de l'information, explique comment il a réussi à détecter un bogue potentiellement dangereux dans le code client Steam.

Les joueurs de PC soucieux de la sécurité ont remarqué que Valve a récemment publié une nouvelle mise à jour du client Steam.

Dans cet article, je veux trouver des excuses pour jouer à des jeux au travail pour raconter l'histoire d'un bogue connexe qui existait dans le client Steam depuis au moins dix ans, ce qui, jusqu'en juillet de l'année dernière, pourrait conduire à l'exécution de code à distance (exécution de code à distance, RCE) au total 15 millions de clients actifs.

Depuis juillet, lorsque Valve a (enfin) compilé son code avec la protection contre les exploits moderne activée, cela ne pouvait que conduire à une défaillance du client, et RCE n'était possible qu'en combinaison avec une vulnérabilité de fuite d'informations distincte.

Nous avons déclaré Valve une vulnérabilité le 20 février 2018 et, au crédit de la société, il l'a corrigé dans la branche bêta moins de 12 heures plus tard. Le correctif a été déplacé vers la succursale stable le 22 mars 2018.

image

Brève revue


La vulnérabilité était basée sur des dommages au tas à l'intérieur de la bibliothèque client Steam, qui pouvait être appelée à distance, dans la partie du code impliquée dans la récupération du datagramme fragmenté à partir de plusieurs paquets UDP reçus.

Le client Steam échange des données via son propre protocole (protocole Steam), qui est implémenté par-dessus UDP. Il y a deux domaines dans ce protocole qui sont particulièrement intéressants en raison de la vulnérabilité:

  • Longueur du paquet
  • La longueur totale du datagramme reconstruit

L'erreur a été causée par l'absence d'un simple contrôle. Le code n'a pas vérifié que la longueur du premier datagramme fragmenté est inférieure ou égale à la longueur totale du datagramme. Cela semble être une erreur courante étant donné que pour tous les paquets suivants qui transmettent des fragments du datagramme, la vérification est effectuée.

Sans bogues de fuite de données supplémentaires, les dommages au tas sur les systèmes d'exploitation modernes sont très difficiles à contrôler, de sorte que l'exécution de code à distance est difficile à implémenter. Cependant, dans ce cas, grâce à l'allocateur de mémoire et à l'ASLR de Steam, qui étaient absents dans le fichier binaire steamclient.dll (jusqu'en juillet dernier), ce bogue pourrait être utilisé comme base pour un exploit très fiable.

Vous trouverez ci-dessous une description technique de la vulnérabilité et de son exploit associé jusqu'à
implémentations d'exécution de code.

Détails de vulnérabilité


Informations nécessaires à la compréhension


Protocole


Des tiers (par exemple, https://imfreedom.org/wiki/Steam_Friends ), sur la base de l'analyse du trafic généré par le client Steam, ont effectué une rétro-ingénierie et créé une documentation détaillée du protocole Steam. Initialement, le protocole a été documenté en 2008 et n'a pas beaucoup changé depuis lors.

Le protocole est implémenté comme un protocole de transmission avec l'établissement d'une connexion sur un flux de datagrammes UDP. Les packages, conformément à la documentation du lien ci-dessus, ont la structure suivante:

image

Aspects importants:

  • Tous les paquets commencent par 4 octets de " VS01 "
  • packet_len décrit la longueur des informations utiles (pour les datagrammes non fragmentés, la valeur est égale à la longueur des données)
  • type décrit le type de package, qui peut avoir les valeurs suivantes:
    • Authentification d'appel 0x2
    • 0x4 Accepter la connexion
    • 0x5 Réinitialiser la connexion
    • 0x6 Un paquet est un fragment d'un datagramme
    • Le package 0x7 est un datagramme distinct
  • Les champs source et destination sont des identifiants attribués pour acheminer correctement les paquets sur plusieurs connexions dans le client Steam
  • Dans le cas où le paquet est un fragment d'un datagramme:
    • split_count indique le nombre de fragments dans lesquels le datagramme est divisé
    • data_len indique la longueur totale du datagramme récupéré
  • Le traitement initial de ces paquets UDP se produit dans la fonction CUDPConnection :: UDPRecvPkt à l' intérieur de steamclient.dll

Cryptage


Les informations utiles du paquet datagramme sont cryptées par AES-256 à l'aide d'une clé, qui est négociée entre le client et le serveur à chaque session. La négociation des clés s'effectue comme suit:

  • Le client génère une clé aléatoire AES de 32 octets et RSA la chiffre avec la clé publique Valve avant de l'envoyer au serveur.
  • Le serveur, possédant une clé privée, peut déchiffrer cette valeur et l'accepter comme clé AES-256, qui sera utilisée dans la session
  • Une fois la clé acceptée, toutes les informations utiles de la session en cours sont cryptées avec cette clé.

Vulnérabilité


Une vulnérabilité est présente dans la méthode RecvFragment de la classe CUDPConnection . Il n'y a pas de symboles dans la version finale de la bibliothèque steamclient, cependant, lors de la recherche dans les lignes binaires dans une fonction qui nous intéresse, un lien vers " CUDPConnection :: RecvFragment " est trouvé. La saisie de cette fonction s'effectue lorsque le client reçoit un paquet UDP contenant un datagramme Steam de type 0x6 (un «fragment de datagramme»).

1. La fonction commence par vérifier l'état de la connexion pour s'assurer qu'elle est à l'état " Connecté ".
2. Ensuite, le champ data_len du datagramme Steam est vérifié pour s'assurer qu'il contient moins de 0x20000060 octets (il semble que cette valeur soit choisie arbitrairement).
3. Si la vérification est réussie, la fonction vérifie si la connexion collecte des fragments d'un datagramme ou s'il s'agit du premier paquet du flux.

image

4. S'il s'agit du premier paquet du flux, le champ split_count est vérifié pour voir combien de paquets ce flux va étirer.
5. Si le flux est divisé en plusieurs paquets, le champ seq_no_of_first_pkt est vérifié pour s'assurer qu'il correspond au numéro de série du paquet actuel. Cela garantit que le paquet est le premier du flux.
6. Le champ data_len est à nouveau vérifié par rapport à la limite de 0x20000060 octets. De plus, il est vérifié que split_count est inférieur à 0x709b paquets.

image

7. Si ces conditions sont remplies, une valeur booléenne est définie pour indiquer que nous collectons maintenant des fragments. Il vérifie également que nous n'avons pas encore de tampon alloué pour stocker des fragments.

image

8. Si le pointeur vers le tampon de collecte de fragments n'est pas nul, le tampon de collecte de fragments actuel est libéré et un nouveau tampon est alloué (voir le rectangle jaune dans la figure ci-dessous). C'est là que l'erreur apparaît. Le tampon de collecte de fragments devrait être alloué dans la taille des octets data_len . Si tout a réussi (et que le code ne vérifie pas - une erreur mineure), les informations utiles du datagramme sont copiées dans ce tampon à l'aide de memmove , en faisant confiance que le nombre d'octets à copier est indiqué dans packet_len .

La surveillance la plus importante du développeur était que la vérification " packet_len est inférieur ou égal à data_len " n'est pas effectuée. Cela signifie qu'il est possible de transférer data_len moins que packet_len et d'avoir jusqu'à 64 Ko de données (car le champ packet_len fait 2 octets de large) copiés dans un très petit tampon, ce qui permet d'exploiter la corruption de tas.

image

Exploitation de la vulnérabilité


Cette section suppose qu'il existe une solution de contournement pour ASLR. Cela conduit au fait qu'avant de commencer l'opération, l'adresse de départ de steamclient.dll est connue.

Usurpation de paquets


Pour que les paquets UDP attaquants soient reçus par le client, il doit examiner le datagramme sortant (client -> serveur), qui est envoyé afin de trouver les identifiants de la connexion client / serveur, ainsi que le numéro de série. Ensuite, l'attaquant doit usurper les adresses IP et les ports source / destination ainsi que les identifiants client / serveur et augmenter d'un numéro le numéro de série appris.

Gestion de la mémoire


Pour allouer plus de 1024 octets (0x400) de mémoire, un allocateur système standard est utilisé. Pour allouer de la mémoire inférieure ou égale à 1024 octets, Steam utilise son propre allocateur qui fonctionne de la même sur toutes les plateformes prises en charge. Cet article ne traitera pas en détail de ce distributeur, à l'exception des aspects clés suivants:

  1. De grands blocs de mémoire sont demandés à l'allocateur système, qui sont ensuite divisés en fragments de taille fixe pour une utilisation dans les demandes d'allocation de mémoire du client Steam.
  2. La sélection est effectuée séquentiellement, entre les fragments utilisés, aucune métadonnée ne les sépare.
  3. Chaque grand bloc stocke sa propre liste de mémoire libre, implémentée sous la forme d'une liste liée individuellement.
  4. Le haut de la liste de mémoire libre indique le premier fragment libre en mémoire et les 4 premiers octets de ce fragment indiquent le prochain fragment libre (s'il en existe un).

Allocation de mémoire


Lors de l'allocation de mémoire, le premier bloc libre est déconnecté du haut de la liste de mémoire libre et les 4 premiers octets de ce bloc, correspondant à next_free_block , sont copiés dans la variable membre freelist_head à l'intérieur de la classe d' allocateur .

Mémoire libre


Lorsqu'un bloc est libéré, le champ freelist_head est copié dans les 4 premiers octets du bloc libéré ( next_free_block ) et l'adresse du bloc libéré est copiée dans la variable membre freelist_head de la classe de distributeur.

Comment obtenir une primitive d'enregistrement


Un débordement de tampon se produit sur le tas, et selon la taille des paquets à l'origine de la corruption, l'allocation de mémoire peut être contrôlée soit par l'allocateur Windows standard (lorsque l'allocation de mémoire est supérieure à 0x400 octets) ou par le propre allocateur de Steam (lorsque l'allocation de mémoire est inférieure à 0x400 octets). En raison du manque de mesures de sécurité dans mon propre distributeur Steam, j'ai décidé qu'il était plus facile de l'utiliser pour un exploit.

Revenons à la section sur la gestion de la mémoire: il est connu que le haut de la liste de mémoire libre des blocs d'une taille donnée est stocké en tant que variable membre de la classe de distributeur, et le pointeur sur le bloc libre suivant dans la liste est stocké comme les 4 premiers octets de chaque bloc libre de la liste.

S'il y a un bloc libre à côté du bloc dans lequel le débordement s'est produit, les dommages au tas nous permettent d'écraser le pointeur next_free_block . Si vous considérez qu'un groupe peut être préparé pour cela, le pointeur next_free_block réécrit peut être défini sur une adresse pour l'écriture, après quoi l'allocation de mémoire suivante sera écrite à cet endroit.

Quoi utiliser: datagrammes ou fragments


Une erreur de corruption de mémoire se produit dans le code responsable du traitement des fragments de datagrammes (paquets de type 6). Après l'occurrence de dommages, la fonction RecvFragment () est dans un état dans lequel elle s'attend à recevoir d'autres fragments. Cependant, s'ils arrivent, une vérification est effectuée:

fragment_size + num_bytes_already_received < sizeof(collection_buffer)

Mais évidemment, ce n'est pas le cas, car notre premier package a déjà violé cette règle (l'existence d'une erreur est possible de sauter cette vérification) et une erreur se produira. Pour éviter cela, vous devez éviter la méthode CUDPConnection :: RecvFragment () après une corruption de mémoire.

Heureusement, CUDPConnection :: RecvDatagram () peut toujours recevoir et traiter les paquets envoyés de type 7 (datagrammes) jusqu'à ce que RecvFragment () soit valide, et cela peut être utilisé pour démarrer la primitive d'enregistrement.

Problèmes de cryptage


Les paquets reçus par RecvDatagram () et RecvFragment () devraient être chiffrés. Dans le cas de RecvDatagram (), le déchiffrement est effectué presque immédiatement après sa réception. Dans le cas de RecvFragment (), cela se produit après avoir reçu le dernier fragment de la session.

Le problème de l'exploitation de la vulnérabilité se pose car nous ne connaissons pas la clé de chiffrement créée à chaque session. Cela signifie que tout code OP / code shell que nous envoyons sera «déchiffré» en utilisant AES256, ce qui transformera nos données en ordures. Par conséquent, il est nécessaire de trouver une méthode de fonctionnement, qui est possible presque immédiatement après la réception du paquet, avant que les procédures de déchiffrement puissent traiter les informations utiles contenues dans le tampon de paquets.

Comment réaliser l'exécution de code


Compte tenu de la restriction de déchiffrement décrite ci-dessus, l'opération doit être effectuée avant le déchiffrement des données entrantes. Cela impose des restrictions supplémentaires, mais la tâche est toujours réalisable: vous pouvez réécrire le pointeur afin qu'il pointe vers l'objet CWorkThreadPool stocké dans un endroit prévisible à l'intérieur de la section de données du fichier binaire. Bien que les détails et les fonctionnalités internes de cette classe soient inconnus, son nom peut supposer qu'elle prend en charge un pool de threads que vous pouvez utiliser lorsque vous avez besoin de "travailler". Après avoir étudié plusieurs lignes de débogage dans un fichier binaire, vous pouvez comprendre que parmi ces travaux, il y a le chiffrement et le déchiffrement ( CWorkItemNetFilterEncrypt , CWorkItemNetFilterDecrypt ), donc lorsque ces tâches sont mises en file d'attente, la classe CWorkThreadPool est utilisée . En écrasant ce pointeur et en y écrivant l'emplacement souhaité, nous pouvons simuler le pointeur vtable et la vtable qui lui est associée, ce qui nous permet d'exécuter du code, par exemple, lorsque CWorkThreadPool :: AddWorkItem () est appelé, ce qui doit se produire avant tout processus de décryptage.

La figure ci-dessous montre l'exploitation réussie de la vulnérabilité jusqu'au stade de la prise de contrôle du registre EIP.

image

À partir de maintenant, vous pouvez créer une chaîne ROP qui mène à l'exécution de code arbitraire. La vidéo ci-dessous montre comment un attaquant démarre à distance une calculatrice Windows dans une version entièrement corrigée de Windows 10.


Pour résumer


Si vous arrivez à cette partie de l'article, merci de votre persévérance! J'espère que vous comprenez qu'il s'agit d'un bogue très simple qui a été assez facile à exploiter en raison du manque de moyens modernes de protection contre les exploits. Le code vulnérable était probablement très ancien, mais sinon il fonctionnait bien, donc les développeurs n'ont pas vu la nécessité de l'examiner ou de mettre à jour ses scripts de construction. La leçon ici est qu'il est important pour les développeurs de revoir périodiquement l'ancien code et de construire des systèmes pour s'assurer qu'ils sont conformes aux normes de sécurité modernes, même si la fonctionnalité du code lui-même reste inchangée. C'était incroyable de trouver en 2018 un bug aussi simple avec des conséquences aussi graves sur une plate-forme logicielle très populaire. Cela devrait être une incitation à rechercher de telles vulnérabilités pour tous les chercheurs!

Enfin, il convient de parler du processus de divulgation responsable des informations. Nous avons signalé ce bogue à Valve dans une lettre à son équipe de sécurité ( security@valvesoftware.com ) vers 16 heures GMT et seulement 8 heures plus tard, un correctif a été créé et lancé dans le client bêta Steam. Grâce à cela, Valve est désormais en première place dans notre tableau (imaginaire) du concours «Qui corrigera la vulnérabilité plus rapidement» - une exception agréable par rapport à la divulgation d'erreurs à d'autres entreprises, ce qui entraîne souvent un long processus d'approbation.

Une page qui décrit les détails de toutes les mises à jour du client

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


All Articles