Reverse engineering : récupérer le niveau de batterie du casque sans fil SteelSeries Arctis 7

Il y a un peu plus d'une semaine, je m'étais lancé comme défi de faire du reverse engineering sur mon Arctis 7 (un casque de chez SteelSeries) dans le but de récupérer le niveau de la batterie depuis Linux, le tout pendant la durée de ma pause déjeuner (soit 2h, repas inclus 😉️). J'en avais d'ailleurs fait un thread sur Twitter et sur Mastodon.

Aujourd'hui je vais vous raconter tout ça, avec un peu plus de détails vus que je ne suis plus limité par le nombre de caractères de cuicui... 😁️

Pour faire le reverse engineering du casque, il va falloir le faire discuter avec le SteelSeries Engine 3 (c'est le nom de l'outil servant à le configurer). Et pendant que nos deux compères seront en pleine conversation, nous on va jouer les espions et intercepter tous leurs échanges. 🕵️

Caractéristique du casque

Si j'étais sponsorisé par SteelSeries, ça serait l'instant « placement de produit », mais comme ce n'est malheureusement pas le cas, on va se contenter de dire qu'il s'agit d'un casque sans fil composé d'un transmetteur connecté en USB au PC et du casque en lui-même. C'est important de le préciser car même si le casque est éteint, on peut toujours communiquer avec le transmetteur.

Il dispose d'une batterie qui peut tenir plus de 24h en utilisation, ce qui est cool en temps normal, mais un peu moins quand on cherche à faire du reverse engineering (du coup faut attendre pas mal de temps avant que le niveau de batterie ne change pour repérer cette valeur parmi les données envoyées par le casque...).

Casque SteelSeries Arctis 7 et son transmetteur

Bref vous avez un aperçu global du casque, passons au sujet qui nous intéresse : le reverse engineering. 😁️

Windows et SteelSeries Engine 3

Comme je l'ai dit, on va devoir faire communiquer le casque avec le logiciel fourni par SteelSeries... Problème : ce logiciel ne fonctionne que sous Windows et Mac OS... Mais bon c'est pas grave, on va utiliser une machine virtuelle pour faire tourner un Windows 10 sur lequel on installera le SteelSeries Engine 3.

J'ai donc lancé une VM Windows qui me servait à compiler des applications pour cette plateforme, et j'y ai installé l'application de SteelSeries... Une fois ceci fait, on connecte le casque à la machine virtuelle et le tour est joué : le SteelSeries Engine reconnait bien le casque, et il affiche son niveau de batterie.

C'est une bonne nouvelle, l'information remonte donc bien jusqu'au PC. Par contre l'application n'affiche qu'une petite icône en forme de batterie avec 4 barres dedans... Donc soit le casque ne remonte que 5 niveaux de charges, soit c'est l'application qui ne nous en affiche pas plus (ce qui va nous compliquer un poil la tâche vu qu'on ne pourra pas chercher de valeur précise dans les paquets que l'on va analyser).

SteelSeries Engine 3 tournant sur une machine virtuelle Windows 10

Capturer des paquets avec Wireshark

Maintenant que le casque et son outil de configuration communiquent, il est temps de commencer à capturer les paquets qui transitent sur le bus USB. Je vais pour cela utiliser Wireshark, un outil fort pratique qu'on utilise beaucoup pour capturer des paquets réseau, mais qui sait aussi très bien s'occuper de ceux transitant sur un bus USB 😊️.

Je ne vous donne pas plus de détails sur la mise en place de la capture avec Wireshark, je l'ai déjà très largement documenté dans un autre article publié en 2016.

Exemple d'une capture sur le bus USB avec Wireshark

Exemple d'un paquet USB HID capturé avec Wireshark

Comprendre ce que l'on voit

Si c'est la première fois que vous voyez Wireshark à l'action, il n'est pas forcément évident de comprendre ce que vous avez sous les yeux... Mais pas de panique, je vous explique. 😉️

La fenêtre de Wireshark est découpée en 3 volets :

  • Le premier liste tous les paquets capturés, en précisant leurs origines, destinations et protocoles. Étant donné qu'il y a pas mal de bordel qui transite sur le bus USB de nos ordinateurs modernes, j'y ai appliqué un filtre (usb.device_id == 2), qui nous permet de voir uniquement les paquets qui concernent le casque sur lequel on travaille.
  • Le volet du milieu nous détaille le contenu du paquet. Wireshark nous mâche ici le travail en nous nommant chaque champ du paquet et en faisant correspondre sa valeur.
  • Le troisième et dernier volet affiche lui aussi le contenu du paquet, mais brut de décoffrage (sans les explications de maître Wireshark quoi 😛️).

Je vous mets à présent côte à côte une capture d'écran montrant une requête faite par le SteelSeries Engine, et la réponse qu'il obtient du casque, histoire qu'on voit un peu comment ça se passe :

Exemple de la visualisation d'une requête et d'une réponse côte à côte dans Wireshark

Note

NOTE : Une requête n'engendre pas systématiquement une réponse de la part du périphérique. Des fois on lui envoie simplement une commande qui ne nécessite pas de réponse de sa part.

La requête

Sur la capture de gauche, on peut voir un paquet émis par le PC (host) à destination d'un endpoint situé à l'adresse 5.2.0.

Prenons déjà le temps d'expliquer un peu cette adresse. Elle est composée de la manière suivante : <Bus>.<Device>.<Endpoint>. Ici on parle donc au endpoint numéro 0 du périphérique numéro 2 (le casque) se trouvant sur le 5ème bus USB de ma machine.

Note

Mais au fait... C'est quoi un endpoint ?

Un endpoint (ou une terminaison en français) est un canal de communication monodirectionnel entre notre machine et l'une des fonctionnalités internes d'un périphérique USB qui y est connecté. Chaque périphérique USB est composé d'au moins un endpoint.

Si on prend l'Arctis 7 par exemple, il y a un endpoint pour chaque sortie audio du casque (il en possède deux), un autre endpoint pour le micro, un autre encore pour l'interface de contrôle, etc.

Schéma d'exemple des endpoints que l'on peut trouver dans le casque Arctis 7

Ce schéma est seulement un exemple des endpoints présents dans l'Arctis 7. Les numéros ne sont pas forcément les bons.

Maintenant qu'on sait tout ça, il ne nous reste plus qu'à repérer le champ Data Fragment, qui contient ce qui a été envoyé au casque par le SteelSeries Engine. Ici il s'agit de la commande 0x06 0x18.

Les commandes sont propres à chaque périphérique et sont définies par le fabricant. À ce stade on a encore aucune idée de ce que celle-ci signifie, c'est justement ce que l'on va devoir déterminer par la suite. 😉️

La réponse

Regardons à présent la capture de droite, qui contient la réponse émise par le casque (5.2.3) à destination du PC (host).

Déjà, on peut remarquer que l'endpoint qui a répondu n'est pas le même que celui auquel on avait adressé la requête. C'est normal, car comme on l'a vu plus tôt, les endpoints sont monodirectionnels, ils peuvent donc soit recevoir, soit envoyer des données, mais pas les deux.

Ensuite, on peut jeter un coup d'œil dans les données qui nous ont été répondues (ce que Wireshark qualifie de Leftover Capture Data, car il n'a aucune idée de ce à quoi elles correspondent).

On y retrouve les octets suivants:

06 18 63 03

Les deux premiers (0x06 0x18), on les connait bien : il s'agit de la commande que l'on a envoyée dans notre requête. Elle est répétée ici pour indiquer à quelle commande la réponse correspond.

Les deux suivants (0x63 0x03) correspondent à la réponse apportée à notre requête. À ce stade on ne sait pas non plus à quoi cela correspond.

Capture des paquets

Maintenant que l'on a mis en place la capture avec Wireshark et que l'on sait comment il fonctionne, il est temps de se lancer.

Capture casque éteint

On va commencer par capturer des paquets avec le casque éteint, pour voir un peu ce qu'il se passe dans ce cas là...

Très rapidement, on observe que le SteelSeries Engine envoie les deux mêmes commandes toutes les secondes environ, et qu'il reçoit toujours les mêmes réponses. Voici donc les échanges observés :

Requête: 06 14
Réponse: 06 14 01

Requête: 06 18
Réponse: 06 18 00 00 ...

Capture casque allumé

À présent on allume le casque, et... OH BORDAYL ! On se prend soudainement une avalanche de paquets avec des tas de nouvelles commandes différentes comme 0x06 0x28, 0x06 0x10, ou encore 0x06 0x33... 😵️

Mais heureusement, ça se calme très vite, et le SteelSeries Engine recommence à envoyer en boucle les mêmes commandes que tout à l'heure... mais cette fois-ci, les réponses sont différentes 😏️ :

Requête: 06 14
Réponse: 06 14 03

Requête: 06 18
Réponse: 06 18 63 03 [sur les premiers échanges il y a d'autres valeurs à la suite]

On a déjà un peu avancé, mais c'est pas encore suffisant. Laissons à présent le casque se décharger en écoutant un peu de musique et continuons à observer... Ou bout d'un certain temps, la réponse à la commande 0x06 0x18 change : le casque nous répond 0x62 au lieu de 0x63... puis quelque temps plus tard, cette valeur descend à 0x61... Intéressant ! 🤔️

Analyse des paquets

Avec les éléments que nous avons découverts, on peut émettre les suppositions suivantes...

La commande 0x06 0x14

Cette commande permet de demander au transmetteur si le casque est allumé.

On sait que lorsque la réponse est 0x01, le casque est éteint, et que lorsque cette réponse est 0x03, il est allumé.

Il faudrait encore déterminer s'il s'agit de constantes numériques ou de flags, mais en l'état, cette information est déjà intéressante.

La commande 0x06 0x18

Cette commande semble demander au casque son niveau de batterie, et potentiellement d'autres informations qui sont transmises en même temps quelques fois...

On suppose que le premier octet de la réponse (si on exclut la commande) est le niveau de batterie, compris entre 0 et 100 (soit 0x00 et 0x64 en hexadécimal).

Comment on en arrive à ces conclusions ?

C'est une question à laquelle il est difficile de répondre car il n'y a pas de réponse toute faite... Au départ j'avais posé comme hypothèse, que le casque pouvait remonter son niveau de batterie de 3 manières différentes :

  • Soit en fournissant un nombre entre 0 et 255 (soit 0x00 et 0xFF en hexa). Ça me semble assez logique d'utiliser toute la plage d'un entier 8 bit non signé pour coder ce genre d'information...
  • Soit en fournissant directement un pourcentage entre 0 et 100... Là encore c'est assez logique, ça donne une information directement exploitable par un autre logiciel.
  • Enfin, il aurait été possible qu'il retourne beaucoup moins de détails en ne fournissant que 5 niveaux de batteries (pour les 5 représentations possibles de l'icône de pile affichée par le SteelSeries Engine).

Et bien sûr, ça aurait pu être un truc totalement différent, ce qui m'aurait compliqué la tâche, étant donné qu'il s'agirait là d'une possibilité à laquelle je ne m'étais pas préparée.

Il s'agit au final d'un travail de détective, l'expérience, l'observation et la déduction sont la clef. 🕵️

Comment s'assurer que nos déductions sont justes ?

La seule solution c'est l'expérimentation. Il faut émettre des hypothèses et voir si elle se vérifie.

Dans mon cas j'ai émis l'hypothèse que je devrais recevoir un nombre inférieur à 0x32 (soit 50 %) lorsque la LED du casque commencerait à clignoter en orange.

Il faut en effet savoir que le casque affiche une lumière verte lorsqu'il est allumé et qu'il reste plus de 50 % de batterie, puis elle devient orange, et enfin rouge lorsqu'il reste moins de 20 % de batterie. C'est ce qui est écrit dans le manuel (comme quoi lire le manuel c'est utile quelques fois 😉️) :

Page du manuel d'utilisation du SteelSeries Arctis 7 concernant la recharge de la batterie

J'ai donc laissé le casque allumé pendant de longues heures (en fait j'ai simplement écouté de la musique sur mon casque plutôt que sur mes enceintes en bossant l'après-midi...) et lorsque la lumière du casque s'est enfin mise à clignoter en orange plus tard dans la soirée, j'ai regardé quelle valeur il retournait : 0x29 (oui j'ai pas forcément vu tout de suite qu'il clignotait en orange puisque je l'avais sur la tête). Cool ! Ça correspond à mes prévisions ! 😁️

Note

Pour mon défi du midi, j'étais parti du principe que j'avais raison hein, mais j'ai quand même tenu à vérifier par la suite que je ne m'étais pas complètement planté. 😉️

Reproduire les commandes du SteelSeries Engine

On à apprit plein de trucs grâce à notre travail de reverse engineering... Reste à mettre ces connaissances en pratique ! On déconnecte donc le casque de la VM Windows, et on va maintenant essayer de lui parler directement.

Je vais pour cela utiliser la console interactive Python (ou plus précisément ipython, c'est quand même plus pratique quand on a des couleurs et de l'autocomplétion...), et la bibliothèque hidapi. Pourquoi ces choix ? Principalement par ce que c'est un langage et une bibliothèque que je connais bien et avec lesquels je suis à l'aise : c'est ceux que j'utilise déjà dans l'un de mes projets, rivalcfg.

Voici le résumé commenté de ma session sur ipython :

# On importe le module hid fourni par la bibliothèque hidapi
In [1]: import hid

# On liste les endpoints du casque. NOTE: seuls les endpoints de classe
# HID sont listés par hidapi, car il ne supporte que ceux-là. Ça tombe
# bien, le seul endpoint qui nous intéresse est justement de ce type ;)
In [2]: hid.enumerate(0x1038, 0x12ad)
Out[2]:
[{'path': b'0005:0002:05',
  'vendor_id': 4152,
  'product_id': 4781,
  'serial_number': '',
  'release_number': 281,
  'manufacturer_string': 'SteelSeries ',
  'product_string': 'SteelSeries Arctis 7',
  'usage_page': 0,
  'usage': 0,
  'interface_number': 5}]  # ← Ici on peut remarquer que le numéro
                           # du endpoint n'est plus le même que
                           # celui que l'on avait sous Windows.
                           # Ça peut arriver à cause de différences
                           # dans la manière d'initialiser les
                           # périphériques entre les deux OS...

# On instancie la class device...
In [3]: device = hid.device()

# ... puis on ouvre le périphérique grâce au chemin trouvé précédemment.
# On peut avoir une erreur à cette étape... Voir ci-dessous.
In [4]: device.open_path(b'0005:0002:05')

# On envoie la commande servant à demander le niveau de batterie
In [5]: device.write(b"\x06\x18")
Out[5]: 2

# ... Puis on lit la réponse. J'ai arbitrairement décidé de lire
# 31 octets, car c'était la taille des paquets affichés dans
# Wireshark... mais j'aurais pu en lire moins.
In [6]: device.read(31)
Out[6]:
[6, 24, 83, 1, 12, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

#       ↑↑ ici on récupère bien notre niveau de batterie (83 %).
#
# On aperçoit également d'autres données à la suite, mais je ne
# sais pas à quoi elles correspondent.

# On a terminé, on ferme le périphérique avant de quitter ipython
# pour ne pas qu'il reste bloqué (seul 1 programme à la fois peut
# parler à un endpoint).
In [7]: device.close()

Note

NOTE : Il est possible que l'instruction device.open_path() lève une erreur. Il faut savoir que sous Linux, un simple utilisateur n'a pas le droit de s'adresser directement à un périphérique. Il va donc falloir, soit faire tourner ipython en root, soit rajouter des règles udev pour autoriser les utilisateurs à accéder à ce périphérique.

Pour rajouter des règles udev, créez le fichier /etc/udev/rules.d/99-steelseries-arctis7.rule, et copiez-y les lignes suivantes :

SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="12ad", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="12ad", MODE="0666"

Une fois ceci fait, demandez à udev de mettre à jour ses règles :

sudo udevadm trigger

Ça devrait régler vos soucis. 😉️

Conclusion

J'ai au final réussi mon petit défi de récupérer le niveau de batterie de mon casque en moins de 2h. J'ai bien sûr l'avantage d'avoir déjà fait du reverse engineering sur les des souris SteelSeries, et sans cette expérience, ça m'aurait pris bien plus de 2h de savoir comment m'y prendre, comment utiliser Wireshark, etc.

J'ai développé un petit script que vous pouvez utiliser pour récupérer le niveau de batterie d'un Arctis 7. Il se trouve sur Github avec toute la documentation nécessaire à son utilisation :

Capture d'écran du script arctis7.py en fonctionnement.

Je vous remercie d'avoir lu cet article, n'hésitez pas à poster un commentaire si vous avez des questions ou si quelque chose n'est pas clair. 😁️