Bluelinky, la signature de requêtes

Bluelinky, la signature de requêtes

Bluelinky est un package Nodejs qui encapsule l'API Hyundai Bluelink et Kia UVO. Elle permet de se connecter à un véhicule, d'en connaître l'état et de réaliser des actions spécifiques. C'est une librairie que j'utilise dans le cadre de la construction du plugin Jeedom que je développe. En janvier, ce module a cessé de fonctionner pour l'ensemble de la zone europe suite à une mise à jour des systèmes de Kia et Hyundai.

Le développeur principal étant dans la zone US et dans les autres mainteneurs n'ayant pas le temps de s'en charger (he oui, c'est aussi une des joies Open Source), je me suis lancé dans la compréhension et éventuelle correction du problème.

Nature du problème

Un des membres de la communauté a déjà réussi à identifier une source potentielle, en utilisant un proxy et en analysant les requêtes HTTP qui transitent entre l'application mobile et les serveurs du fabricant, il remarque qu'une nouvelle ente est apparue dans les requêtes : Stamp.

C'est mon point de départ. Je télécharge APKExtractor sur mon téléphone et récupère le fichier de l'application sur mon PC. Les .apk étant une simple archive compressée, j'en extrais le contenu. On y retrouve les éléments classiques d'une application Android, avec en plus un répertoire lib contenant probablement des intégration bas niveaux.

Pour décompiler le code, je lance jadx, un outil permettant de décompiler et de lire du byte code. En faisant une recherche rapide sur le mot-clé "Stamp", je tombe directement sur les lignes qui m'intéressent.

On y trouve premièrement, l'inclusion d'une librairie native 1 en Java cela s'appelle un JNI (Java Native Interface). Ensuite un appel à la méthode native du JNI 2 avec une clé suivi de la date courante en millisecondes. Et finalement une conversion en base64 pour l'injecter dans l'en-tête de la requête 3.

Dans le répertoire lib évoqué ci-dessus, on trouve plusieurs répertoires; un par architecture CPU ; et dans chaque, fichier libnative-lib.so correspondant au binaire associé au JNI.

L'utilisation de la commande file me permet d'en savoir plus sur le fichier :

> file x86_64/libnative-lib.so 
x86_64/libnative-lib.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=978b498e094fd7cc53102d36bbaad39837bc5e7e, stripped

C'est une librairie pour laquelle les informations de débogage ont été supprimées (stripped) et qui utilise d'autres librairies externes (dynamically linked).

En lisant l'en-tête de la librairie avec readelf, je trouve les éléments suivants :

> readelf -a libnative-lib.so
 0x0000000000000001 (NEEDED)             Shared library: [liblog.so]
 0x0000000000000001 (NEEDED)             Shared library: [libm.so]
 0x0000000000000001 (NEEDED)             Shared library: [libdl.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so]

Les librairies liblog, libm, libdl et libc sont nécessaire à son fonctionnement, et elle expose plusieurs fonctions, la quatrième semblant être celle qui nous intéresse.

     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __cxa_atexit@LIBC (2)
     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __cxa_finalize@LIBC (2)
     3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __stack_chk_fail@LIBC (2)
     4: 0000000000001360   370 FUNC    GLOBAL DEFAULT   13 Java_stationdm_euapi_header_RemoteHttpHeader_stringFromJNI
     5: 0000000000001070   150 FUNC    GLOBAL DEFAULT   13 aes_whitebox_decrypt_cfb
     6: 00000000000011a0   140 FUNC    GLOBAL DEFAULT   13 aes_whitebox_decrypt_ofb
     7: 00000000000007b0   143 FUNC    GLOBAL DEFAULT   13 aes_whitebox_encrypt_cfb
     8: 0000000000001110   140 FUNC    GLOBAL DEFAULT   13 aes_whitebox_encrypt_ofb
     9: 00000000000ba000     0 NOTYPE  GLOBAL DEFAULT  ABS _edata
    10: 00000000000ba000     0 NOTYPE  GLOBAL DEFAULT  ABS _end
    11: 0000000000001350     5 FUNC    GLOBAL DEFAULT   13 aes_whitebox_decrypt_ctr
    12: 0000000000001230   282 FUNC    GLOBAL DEFAULT   13 aes_whitebox_encrypt_ctr
    13: 00000000000ba000     0 NOTYPE  GLOBAL DEFAULT  ABS __bss_start

Sans chercher plus, je lance Ghidra pour voir ce que fait la fonction Java_stationdm_euapi_header_RemoteHttpHeader_stringFromJNI et manque en passant le fait que cette librairie propose des fonctionnalités AES de type whitebox (on verra ensuite ce que ça signifie).

N'ayant pas souvent l'occasion de faire de l'assembleur, j'arrive tant bien que mal à trouver une boucle de xor sur les blocks de la chaîne passé en paramètre, qui appelle lui-même une fonction interne de la librairie internalAES, qui elle-même en appelle plusieurs... Et surtout, impossible de trouver une éventuelle clé. J'ai perdu un temps considérable en tentant de comprendre l'intégralité de ce code décompilé avant d'aller me renseigner sur ce que signifiait whitebox dans le mode AES.

En chiffrement symétrique (comme avec AES), une même clé est utilisée pour chiffrer et déchiffrer le contenu du message. On s'attend donc à trouver cette clé quelque part dans le code ou dans la librairie. Afin d'éviter que la clé soit si facilement identifiable, la méthode dite de "boîte blanche" (whitebox) permet de prédéfinir la suite d'algorithmes à exécuter pour arriver au même résultat sans pour autant avoir la clé.

En résumé

  • Pour chaque appel HTTP réalisé par l'application mobile,
  • Une chaîne de caractères contenant une identifiant et une date sont,
    • Chiffrés via un algorithme AES en boîte blanche,
    • Converti en Base 64,
  • Puis sont ajoutés dans un en-tête de la requête.

On peut légitiment imaginer que le serveur utilise l'identifiant de l'application pour déchirer l'en-tête Stamp et vérifier que le contenu ainsi que la date sont valides.

L'hypothèse de la validation de la date par les serveurs n'a été implémentée par Kia/Hyundai que quelques mois plus tard.

Solution et contournement

L'idée est dans un premier temps de réussir à isoler cette partie du code et de la faire fonctionner sur un ordinateur. N'ayant pas l'expérience en C requise pour tenter un chargement directe de la librairie, je fabrique une application Java simplissime qui a pour but de charger de prendre une chaîne en entrée, de gérer l'intégration d'un JNI et de rendre le résultat.

Le code ne fonctionne pas dans un premier temps, et à juste titre, les librairies liblog, libm, libdl et libc de ma station de travail Linux ne sont pas compatibles avec la signature attendue par libnative. Un petit coup de moteur de recherche me permet de tomber sur un dépot git contenant plusieurs versions du NDK, et après plusieurs tests, je trouve finalement le bon.

Reste à les charger pour ce programme sans altérer mon système d'exploitation. Le runtime Java met à disposition une paramètre java.library.path permettant de définir le lieu où se trouvent les JNI (Le fichier libnative-lib.so en ce qui me concerne). Ensuite, il est nécessaire de spécifier au système d'exploitation, où il doit chercher les dépendances externes (ici liblog, libm, libdl et libc), ce que Linux permet de faire facilement en utilisant la variable d'environnement LD_LIBRARY_PATH.

Une fois compilé, j'obtiens donc cette exécution qui fonctionne à merveille :

> libs="./lib/x64"
> LD_LIBRARY_PATH=$libs java -Djava.library.path=$libs -jar ./target/main-1.0.jar "test"

Le résultat produit est effectivement compatible avec ce qu'attend l'API et l'authentification passe. Avant toute chose, j'adapte mon projet pour le faire fonctionner dans une image Docker. Reste à savoir comment on peut intégrer ce résultat à la librairie Node.js existante.

Intégration

La solution doit pouvoir fonctionner sur tous les systèmes d'exploitation et sans dépendances particulières, donc node-gyp ou une intégration Docker sont malheureusement exclus.

Janvier 2021

Le choix a été fait de générer une grande quantité de signatures en incrémentant la partie timestamp. Les informations sont ensuite intégrées au module dans un fichier JSON, le code piochant au hasard une signature dans la liste.

Juin 2021

Au mois de juin, le fichier de signatures aléatoires n'a plus suffi. Hyundai et Kia ont légitimement commencé à valider la partie date de la signature, la limitant à 24 h. Le choix a donc été fait de récupérer dynamiquement une liste de signatures générées pour les heures à venir et de l'utiliser. Cette liste est stockée sur un autre dépôt GIT, et un simple accès http permet de la lire. Elle est générée et mit à jour via un CRON.

Juillet 2021

En juillet Kia à mi à disposition sa nouvelle application mobile. La clé utilisée pour la signature change et les fichiers libnative-lib.so ne sont disponible que pour les architectures ARM. J'ai utilisé le même procédé que celui décris ci-dessus, en faisant tourner le tout dans un docker sur ARM et en utilisant l'émulateur Qemu pour faire tourner le tout.

Janvier 2022

En janvier Kia à changé son système de validation de token en allant plus loin et en validant le timestamp de ce dernier. Ils ont par la même occasion validé la période de temps associée (~15 minutes). Pour y remédier, j'ai fait évoluer le script de génération (voir Juin 2021) pour y ajouter la date de génération ainsi que la fréquence. Il m'a ensuite suffi d'identifier l'indice du tableau de stamps pré-généré qui correspond au delta entre la date de génération et la date actuelle pour en obtenir un valide.

Mai 2022

J'ai été contacté par ManuBatBat sur un exploit C pour libnative. Il fonctionne en extrayant les données de la bibliothèque et en les utilisant pour générer des clés valides à 100 % et ceux indépendamment de l'architecture système. De fait l'émulation ARM n'est plus nécessaire, mais surtout la surcouche Java non plus.

Cette nouvelle intégration est plus élégante, légère et permet surtout de débugger et d'extraire un certain nombre d'informations pouvant êtres réutilisés dans d'autres packages et notamment directement en JS.

Conlusion

Même si ce compte-rendu semble simple à répliquer, les phases d'analyse et de contournement n'ont pas été instantanées. Elles ont requis la découverte et la prisent en main d'un bon nombre d'outils, un bon nombre de tentatives pas toujours concluantes ni réjouissantes et finalement un temps de simplification, d'adaptation et d'encapsulation afin que ces travaux puissent être repris par la communauté.

Il est aussi probable qu'a votre prochain passage sur cet article, de nouvelles phases dans la rubrique intégration soient présentes. Au fil des mises à jour de l'application mobile, de nouveaux comportements peuvent apparaître.