À l’occasion d’un de mes derniers pentests, je récupérais les prérequis contenant l’ensemble des informations nécessaires au bon déroulement de l’audit.

Ces documents contiennent notamment :

  • Le périmètre afin de ne pas arroser les plate-bandes des voisins
  • Les comptes utilisateurs pour la partie boîte grise
  • D’autres données en lien avec le contexte de l’audit

Fort heureux d’être en possession de ces données avant le lundi matin, mon œil est attiré par une liste de token avant même que mes doigts aient la possibilité d’effectuer le ALT + F4 du vendredi 16h59.

Au lieu du traditionnel pentest/pentest1234, chaque utilisateur possède une liste de 10 tokens, correspondant à 1 par jour d’audit. Agréablement surpris, retour à la réalité, 17h03, ALT-F4, envoi de la fiche d’heure supp’ et mot doux pour mes collègues.

Contexte

Une partie de cet audit concerne des boîtiers physiques sous Android avec lesquels les clients finaux interagissent via un écran. Ces boîtiers sont administrés via une interface web accessible derrière une authentification.

Quelques vulnérabilités me permettent de passer root sur la machine. Comme toujours un audit ne s’arrête pas là, le but étant d’être le plus exhaustif possible concernant les vulnérabilités présentes. Quelques jours passent et me voilà enfin rendu à l’analyse des mécanismes d’authentification.

Ayant récupéré l’APK du service, il m’est facile de parcourir l’ensemble des classes et méthodes Java à la recherche de celles en lien avec l’authentification. Grâce à quelques références, j’identifie la fonction prenant le nom d’utilisateur et le token du jour ; cependant, la fonction de vérification appelée n’est pas présente dans l’APK.

Cette dernière est chargée depuis une librairie externe via l’appel suivant :

System.loadLibrary('client');

Cette pratique consiste à exécuter du code natif (C/C++) et de récupérer le résultat au sein du contexte d’une application Java. La Java Native Interface (JNI) est une partie du Java Development Kit (JDK) qui permet à la Java Virtual Machine (JVM) d’appeler et d’être appelée par des applications natives (développées en C/C++). Cette pratique peut ainsi être exploitée lors de développement Android.

Le but de ces opérations sont multiples :

  • Utiliser des librairies non disponibles en Java, pour de l’interaction avec le matériel par exemple
  • Dialoguer avec des applications développées nativement
  • Gagner en performance d’exécution (natif est par définition plus rapide qu’un langage s’exécutant via un autre programme)
  • Obfusquer / cacher des parties du code plus sensibles

Aujourd’hui, c’est cette dernière raison qui va nous pousser à découvrir les mécanismes liés à JNI. En effet, il y a fort à parier que le caractère sensible de la méthode de vérification des tokens a poussé les développeurs à concevoir une application JNI plutôt qu’une classe Java.

Afin de préserver le caractère confidentiel des résultats d’audit, j’ai recréé un environnement similaire en développant une APK et une fonctionnalité de vérification de tokens.

Analyse préliminaire

L’application cible se nomme ainsi "Acceis 8.6 connected beer tape" : cette application développée en Java en interne et s’exécutant directement sur une tireuse à bière connectée permet aux employés d’ACCEIS de récupérer leur quota quotidien de fine bière.

Afin de préserver ces précieux litres des collègues peu scrupuleux et de disposer d’un quota journalier de boisson, un mécanisme d’authentification avec un code quotidien a été mis en place. Ces codes sont générés uniquement pour les jours ouvrés, ce qui est problématique car cela m’empêche de passer au bureau récupérer mon quota lors de mes congés.

Interface de connexion de la cible

Le but est alors d’analyser la fonctionnalité de vérification du token afin de trouver une vulnérabilité me permettant d’outrepasser la vérification ou alors, trouver un moyen de générer moi-même des codes !

Récupération et analyse des sources

Des vulnérabilités matérielles me permettent de prendre la main sur la tireuse connectée et d’obtenir un shell. Je m’y connecte ainsi via adb et part à la recherche du service d’authentification.

En listant les processus, on s’aperçoit qu’un processus nommé com.example.acceis_auth existe. Une recherche des APK existant dans un répertoire possédant ce nom nous indique qu’une application est disponible dans le répertoire com.example.acceis_auth-pNQZdceAhD_3ReADOyzfaA==

Depuis Android Oreo, les APK sont installés dans des dossiers avec des noms générés en partie aléatoirement https://stackoverflow.com/questions/47958947/base64-apk-path

En listant les fichiers de ce répertoire on trouve ledit APK mais également une librairie native libacceis_auth.so.

récupération de l’APK cible via adb
Via adb pull, on transfert les deux fichiers sur notre machine et on commence la rétro-ingénierie. JADX est un decompiler avec une interface graphique qui va automatiser l’extraction des différentes ressources de l’APK étant donné que ce dernier est une archive contenant différents types de fichiers.
$ unzip -l data/base.apk | head
Archive:  data/base.apk
  Length      Date    Time    Name
---------  ---------- -----   ----
     4632  1981-01-01 01:01   classes4.dex
       56  1981-01-01 01:01   META-INF/com/android/build/gradle/app-metadata.properties
     2632  1981-01-01 01:01   classes3.dex
   210576  1981-01-01 01:01   lib/x86/libacceis_auth.so
   468088  1981-01-01 01:01   classes2.dex
     3168  1981-01-01 01:01   AndroidManifest.xml
L’analyse du format des données de l’APK n’est pas le propos de l’article et passe donc cette partie.

L’activité principale est très simpliste, il s’agit simplement d’un callback déclenché par le clic sur le bouton « connexion ». Lorsque le bouton est cliqué, les valeurs entrées par l’utilisateur dans les champs noms d’utilisateur et passwords sont récupérées.

Du pseudo-padding PKCS#5 est rajouté au username via la méthode pad afin que l’utilisateur soit toujours d’une longueur de 8 caractères. La date actuelle est également récupérée puis mise sous la forme d’un integer : 23/04/2022 => 230422.

décompilation de la classe MainActivity avec JADX, contenant le mécanisme d’authentification
La méthode de vérification est la suivante :
String message = mainActivity.checkAuth(mainActivity.pad(username).getBytes(), password, currentDate) == 0 ? "Welcome " + username : "Authentication failed";
Les paramètres passés sont de types bytes[], bytes[], int et la méthode retourne l’integer 0 si l’authentification est réussie. Dans ce cas positif, le message Welcome {username} est affiché sur le layout.
authentification avec token invalide
Un peu avant la méthode onCreate, on retrouve deux parties intéressantes :
public native int checkAuth(byte[] bArr, byte[] bArr2, int i);

static {
    System.loadLibrary("acceis_auth");
}
La première partie est le prototype de la fonction de vérification présente dans la librairie libacceis_auth.so. Le prototype doit être présent afin que l’APK possède la référence à la compilation bien que son adresse ne soit renseignée qu’au runtime.

La seconde partie est le chargement de la librairie libacceis_auth.so dans l’espace mémoire du service. Le nom utilisé est dit « non-décorée » car on ne mentionne que acceis_auth lors de l’appel de la fonction. C’est dû en partie au fait que la méthode System.loadLibrary soit indépendante de la plateforme (Linux, Windows) et, dans le cas présent, le fichier cherché est libacceis_auth.so mais aurait été acceis_auth.dll sous Windows.
https://docs.oracle.com/javase/7/docs/api/java/lang/System.html#loadLibrary(java.lang.String)

Analyse de la librairie native

L’application ne contient rien d’autre d’intéressant, il est temps d’analyser libacceis_auth.so. Une fois chargé dans notre décompilateur favori, on note la présence de la fonction Java_com_example_acceis_1auth_MainActivity_checkAuth.
Ce nom est important car il permet au loader de la librairie de résoudre le nom de fonctions demandées par l’application Java. Celui-ci est le résultat de la concaténation de plusieurs éléments :
  • Java_
  • Le nom complet décorée de la classe (name mangling)
  • _ Le nom décorée de la méthode
https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/design.html Il s’agit ainsi de la fonction dont le prototype est défini dans la classe Java.
décompilation de la fonction checkAuth dans la librairie native
Malheureusement pour nous, les développeurs ont compilé la librairie partagée avec l’option de compilation -fvisibility=hidden ! De ce fait, tous les symboles qui ne sont pas explicitement marqués avec un attribut de visibilité (comme default) ne seront pas présents dans le fichier ELF. La fonction Java_com_example_acceis_1auth_MainActivity_checkAuth utilise dans son code C++ la macro préprocesseur suivante JNIEXPORT qui correspond à #define JNIEXPORT __attribute__ ((visibility ("default"))). C’est la raison pour laquelle son symbole est présent dans la librairie.

Cette macro est obligatoire pour toutes les fonctions appelées par l’application car le symbole est utilisé pour enregistrer la fonction native via l’appel à RegisterNatives dans la JVM et ensuite pour résoudre l’adresse de la fonction.
$ nm --demangle --dynamic data/libacceis_auth.so | grep acceis
00008980 T Java_com_example_acceis_1auth_MainActivity_checkAuth
00008650 T Java_com_example_acceis_1auth_MainActivity_stringFromJNI
Avec les noms de variables récupérées dans la décompilation de la classe Java on peut mettre à jour la décompilation d’IDA.
renommage des symboles sous IDA via déduction et analyse de la classe Java
compute_today_password a été identifiée comme la fonction qui vérifie si le token du jour est valide et dont le résultat booléen est retourné à l’application Java. Malheureusement, nous ne connaissons pas encore les deux premiers arguments de cette fonction, v9 et v7. Ces deux arguments résultent de l’appel à la fonction sub_8AF0 et sub_8B60. En analysant ces deux fonctions on remarque un pattern :
int __cdecl sub_8AF0(int a1, int a2, int a3)
{
  return (*(int (__cdecl **)(int, int, int))(*(_DWORD *)a1 + 736))(a1, a2, a3);
}

int __cdecl sub_8B60(int a1, int a2)
{
  return (*(int (__cdecl **)(int, int))(*(_DWORD *)a1 + 684))(a1, a2);
}
Les deux fonctions utilisent un offset sur le premier paramètre afin d’accéder à une fonction puis de l’exécuter avec le reste des paramètres. Ce pattern en C est typique de l’appel d’une fonction présente dans une structure. a1 semble donc être une structure dont nous n’avons pas la définition, 736 et 684 deux offsets correspondant à deux fonctions différentes.

Il s’agit ici de l’appel à des fonctions JNI. Ces fonctions permettent d’interagir avec l’interface JNI comme par exemple les structures (types) définies par l’interface (jbyte, jarray, jshort, …).

Le premier paramètre, a1 (ou env déjà renommé dans la fonction principale), est un pointeur sur une instance de la structure JNIEnv. Cette instance contient notamment une structure répertoriant l’ensemble des fonctions JNI disponibles.

Ainsi les appels via les 736 et 684 correspondent à des appels à fonctions JNI spécifiques. Une table des correspondances offset:fonction est disponible ici : https://docs.google.com/spreadsheets/d/1yqjFaY7mqyVIDs5jNjGLT-G8pUaRATzHWGFUgpdJRq8/edit#gid=0. On détermine alors que la fonction sub_8AF0 appel jbyte* (*GetByteArrayElements)(JNIEnv*, jbyteArray, jboolean*) et sub_8B60 appel jsize (*GetArrayLength)(JNIEnv*, jarray).

Ajout des symboles JNI dans IDA

On est plus avancé que tout à l’heure mais il est cependant plus pratique de rajouter la définition de la structure JNIEnv dans IDA et de retyper les arguments. La première étape est de récupérer la définition C des structures JNI : https://gist.githubusercontent.com/Areizen/13eb7c7d0de7e0577a296a74508663b2/raw/4266b6b7b2746e0f1f2145b68085e8f04a235016/jni_ida_definitions.h
Dans IDA :
  1. File
  2. Load File
  3. Parse C Header File et sélection du fichier jni_ida_definitions.h
Puis ajouter cette structure dans le projet
  1. Structures
  2. Appuyer sur INS
  3. Add standard structure
  4. Selectionner _JNIEnv
Ajout de la structure JNIEnv dans IDA
Une fois la structure disponible on peut retyper les arguments dans la fonction principale et régénérer le pseudo-code.
le renommage de sub_8AF0 et sub_8B60 a été fait manuellement.

décompilation avec présence les types JNI

Du coté de l’appel des fonctions JNI, on remarque une certaine lisibilité retrouvée.

jbyte *__cdecl sub_8AF0(_JNIEnv *env, jbyteArray username, int isCopy)
{
  return env->functions->GetByteArrayElements(env, username, isCopy);
}

jsize __cdecl call_jni_GetArrayLength(_JNIEnv *env, jarray username)
{
  return env->functions->GetArrayLength((JNIEnv *)env, username);
}
Il s’agit bien des deux fonctions identifiées préalablement. Le code est maintenant plus clair et on identifie les deux premiers arguments qui sont respectivement l’username et sa longueur.

La fonction JNI GetByteArrayElements a permis de convertir le type jbyteArray vers le type jbyte * et GetArrayLength de récupérer la taille de ce tableau. Ces deux types peuvent être castés vers des types natifs sans problèmes comme dans la fonction compute_today_password.

Le processus de vérification de l’authentification parait maintenant simple, l’appareil génère le token du jour via l’username fourni et la date du système puis le compare avec le token fourni par l’utilisateur.
Cela nous facilite la tâche, il n’y a pas besoin de comprendre comment le token est vérifié, la librairie est capable d’en générer des valides !

Introspection avec Frida

Il existe maintenant plusieurs méthodes pour générer des tokens :
  • Implémenter la fonction compute_today_password en python
  • Écrire un programme C ou Java qui appel la méthode de la librairie
  • Émuler la fonction via unicorn engine en python
  • Utiliser Frida sur la machine et appeler directement la fonction via Javascript / Python
La première méthode est pratique mais sensible aux erreurs de développement, runtime et compréhension. La seconde est plus fiable mais moins pratique car la librairie est compilée pour une architecture autre que celle de notre machine. Il faudrait configurer un qemu, une toolchain cross compilation et trouver la bonne architecture ARM.
Dans le contexte originale l’architecture cible était ARMEABI 5, notre tireuse est en x86.

Frida est un outil permettant d’instrumentaliser dynamiquement le code d’applications natives ou non. De façon brève, il injecte une partie de son code dans la mémoire d’un autre processus et expose une interface en javascript pour interagir avec le processus dans lequel il est injecté.

Frida est ainsi parfait dans notre contexte, nous avons accès au processus et pouvons donc injecter Frida. Une fois injecté, nous pourrons développer un script JS permettant d’appeler la fonction native qui sera déjà chargée dans la mémoire du processus. Ainsi, nul besoin de compiler ou émuler quoi que ce soit.

La première étape est de télécharger le serveur Frida pour notre architecture : https://github.com/frida/frida/releases/download/15.1.17/frida-server-15.1.17-android-x86.xz
Puis de l’installer sur notre cybertireuse.

mise en place de frida via adb
Frida propose plusieurs façons de s’insérer dans le processus. Soit Frida lance le processus lui-même et s’insère, soit on spécifie le processus et Frida s’y attache. Dans notre cas le service est en cours d’utilisation par les collègues, hors de question de DOS la tireuse a bière, SPOF d’ACCEIS. Nous utiliserons ainsi la seconde méthode.

Une fois le serveur lancé, frida-ps nous permet de lister les processus actuellement en exécution. On indique alors à Frida de s’injecter dans le processus acceis_auth via frida -U acceis_auth.

Une fois injecté nous avons accès au processus via l’interface de Frida.
interaction avec le processus courant et liste des modules chargés
La fonction Process.enumerateModules de l’API Frida permet de lister les modules chargée et leurs informations. On remarque alors que la librairie libacceis_auth.so est chargée à l’adresse 0xc3f80000. On pourrait rebaser le segment .text dans IDA afin d’avoir les bonnes adresses de fonction mais passons.
Attention, si vous ne voyez pas le module cible dans la liste des modules c’est qu’il n’a pas été chargé. Le chargement du module n’est pas automatique et est réalisé lors de l’exécution System.loadLibrary. Si l’application crash et vient de redémarrer le code se chargeant de charger la librairie n’a peut être pas encore été déclenché.

Frida propose des fonctionnalités pour hooker des fonctions, cela permet d’exécuter du code avant et après l’appel à la fonction afin d’en détourner le fonctionnement ou simplement à des fins d’observation.

Pour hooker une fonction il est nécessaire de récupérer son adresse en mémoire. La fonction se trouve à l’adresse mémoire à laquelle est chargée sa librairie + l’offset de la fonction dans la libraire.

base_addr = 0xc3f80000
offset = 0x88E0 ; récupéré via IDA
compute_today_password = base_addr + offset = 0xc3f888e0
Normalement la méthode Module.findExportByName(module, fonction) permet de récupérer l’adresse de la fonction par son nom. Cependant, dans le cas présent le symbole de compute_today_password n’est pas présent.

Frida nous permet de faire ce calcul pour nous.

Java.perform(function () {

    var libacceis = Process.enumerateModules().filter((item) => { return item["name"] == "libacceis_auth.so" })[0]
    var addr = libacceis.base.add(0x88e0)

    console.log(addr)
})
Indiquons à Frida d’exécuter notre bout de code Javascript via : frida -U acceis_auth -l scripts/watcher.js
     ____
    / _  |   Frida 15.1.17 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to Android Emulator 5554 (id=emulator-5554)
Attaching...
0xc3f888e0
[Android Emulator 5554::acceis_auth ]->

Hook de la génération de tokens

Nous allons maintenant hooker la fonction compute_today_password afin de consulter ces paramètres et modifications effectuées en mémoire.
Java.perform(function () {

    var libacceis = Process.enumerateModules().filter((item) => { return item["name"] == "libacceis_auth.so" })[0]
    var addr = libacceis.base.add(0x88e0)

    Interceptor.attach(addr, {
        onEnter: function(args) {
            var username_addr = args[0]
            var username_size = args[1]
            var current_date = args[2]
            this.buffer = args[3]

            var username = Memory.readCString(username_addr, username_size.toUInt32())
            console.log('[before]')
            console.log("username: " + username)
            console.log("date: " + current_date.toUInt32())
            console.log('')

        },
        onLeave: function(retval) {
            var generated_code = Memory.readByteArray(this.buffer, 8);
            var code = Array.from(new Uint8Array(generated_code)).map((item) => item.toString(16)).join("")
            console.log('[before]')
            console.log("generated code: " + code)
            console.log(generated_code)
        }
    })

})
Une fois le script chargé, nous avons plus qu’à nous authentifier, peu importe le mot de passe, afin que le service génère le token et que Frida nous l’affiche gentiment.
récupération en mémoire du code générée par le service

Appel de la fonction native avec Frida

Nous avons volé le code du jour ! Cependant, il est souhaitable d’obtenir des codes à l’avance afin de ne pas nous connecter à la tireuse chaque jour (ça fait du bruit dans les logs).
Pour cela on peut passer par Frida pour effectuer nous même les appels à la fonction.
Java.perform(function () {

    var libacceis = Process.enumerateModules().filter((item) => { return item["name"] == "libacceis_auth.so" })[0]
    var addr = libacceis.base.add(0x88e0)

    var generate_new_code = new NativeFunction(addr, 'void', ['pointer', 'int', 'int', 'pointer'])

    var username = Memory.allocUtf8String("switch22");
    var buffer = Memory.alloc(8)
    var date = '042022'
    var today = 23

    for (var i = 0; i < 7; i++) {

        var day = today + i

        generate_new_code(username, 8, parseInt(day + date, 10), buffer)

        var generated_code = Memory.readByteArray(buffer, 8)
        var code = Array.from(new Uint8Array(generated_code)).map((item) => item.toString(16)).join("")
        console.log("code du " + day + "/04/2022 : " + code)
    }

})
Changeons la date du système pour le 26 Avril 2022 et demandons à Frida de nous générer les codes des 7 prochains jours.
appel de la fonction JNI directement depuis Frida
Frida a ainsi simplement généré 7 codes valides en appelant la fonction et incrémentant la date.
     ____
    / _  |   Frida 15.1.17 - A world-class dynamic instrumentation toolkit
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system
   . . . .       object?   -> Display information about 'object'
   . . . .       exit/quit -> Exit
   . . . .
   . . . .   More info at https://frida.re/docs/home/
   . . . .
   . . . .   Connected to Android Emulator 5554 (id=emulator-5554)
Attaching...   

code du 23/04/2022 : 95e0367585ff6d33
code du 24/04/2022 : 55ad77545b25c33
code du 25/04/2022 : 156b17755744c33
code du 26/04/2022 : d529e475c536bf33
code du 27/04/2022 : 95d7f57585c8ae33
code du 28/04/2022 : 5594c275458b9933
code du 29/04/2022 : 1552d27554d8933
Les codes générés sont peu aléatoires, un reverse engineering de la fonction de génération du code et sa cryptanalyse aurait pu nous mener simplement à ce même résultat. Il faut cependant garder à l’esprit que l’application originale possède une fonction bien plus velue. Nous voici ainsi avec de la bière pendant nos congés et avec une disclosure éthique et responsable à écrire.

Ressources

À propos de l’auteur

Article et présentation rédigés par Lucas Gasté aka switch, auditeur sécurité chez ACCEIS.