BreizhCTF 2022 - Floppy 1 & 2 (Android)

Reversing flappy bird application.

 April 4, 2022 -  12 min read

Floppy 1 & 2

Les challenges Floppy 1 et 2 sont des challenges Android proposés lors du Breizh CTF 2022. Ces derniers reposent sur une unique application mobile semblable au célèbre jeu Flappy Bird. L'application proposée était la suivante : game.apk

APK et Anti-Root

Architecture APK

Les applications Android au format APK sont des archives contenant un ensemble de fichiers (classes et packages compilés, certificats, checksums, licences, …).

Le fichier classes.dex compris dans ces archives contient l’ensemble des classes compilées nécessaire au fonctionnement de l’application. Comme l’indique le schéma ci-dessous, celui-ci est généralement le résultat d’un enchainement de compilation en bytecode Java puis en Bytecode Dalvik.

java.png

Figure 1 - Schéma simplifié d’une archive APK

Plusieurs outils permettent la décompilation de fichiers dex. Notamment par l’intermédiaire du langage smali qui permet une lecture simplifiée du bytecode Dalvik.

La plupart du temps, les applications Android sont développées en Java, et des outils comme dex2jar, jd-gui, ou jadx peuvent proposer une conversion de l’archive APK en code source navigable. Il est cependant important de noter que ces outils disposent de certaines limitations et ne proposent pas de restitution du code source original (les noms de variables sont généralement modifiés, et certains mécanismes parfois obfusqué).

Jadx-GUI

Le chargement de l’application au format APK dans l’outil jadx-gui nous permet de retrouver une navigation plus accessible dans le code :

Jadx1

Figure 2 - Chargement de l’application sur l’outil Jadx

L’entrypoint de l’application est relativement facile à identifier étant donné le nom du package fishi.flappybird.

Comme l’indique le site de jadx-gui, le code est facilement navigable et propice au reverse-engineering d’application type APK: les classes, méthodes et objets sont renommables et la propagation se fait automatiquement sur l’ensemble du code.

Anti-Root

On y observe une première sécurité “anti root” à la ligne 21. Ce mécanisme permet généralement de ralentir l’analyse dynamique dans des environnements virtuels où le contexte d’exécution pourrait être supervisé et/ou altéré.

L’introspection des commandes permet de vérifier le fonctionnement du mécanisme anti root : celui-ci vérifie la présence de binaire su ou busybox sur le système de fichier, ainsi que la présence de la clé test-keys dans le package android.os.Build.TAGS.

Une fois renommé, le code ressemble à l’extrait suivant :

    public void onCreate(Bundle bundle) {
        if (this.protect.containt_test_keys() || this.protect.search_su_bin() || this.protect.su_exist() || this.protect.busybox_exist()) {
            startActivity(new Intent("android.intent.action.VIEW", Uri.parse("https://c.tenor.com/61bhySndZ_cAAAAd/nope-nope-button.gif")));
            Log.i("ROOT", "Root detected !!! Redirection in progress...");
            System.exit(0);
        } else {
            Log.i("ROOT", "Root not detected !");
        }
        super.onCreate(bundle);
        setContentView(R.layout.activity_main);
        m2210e().mo2107a().mo2028d(R.id.frame, new C0512f()).mo2029c();
    }

On en déduit rapidement que si l’appareil satisfait les conditions de détections “root”, l’exécution du programme est interrompue et une vue de l’image suivante est proposée:

nope.png

Figure 3 - GIF affiché en cas d’environnement “root”

Dans le cas où le téléphone n’est pas rooté, les lignes 28 à 30 vont venir lancer le comportement normal de l’application.

Programme principal

La classe gameView contient le cœur de l’application. On y retrouve notamment l’équivalent au “main” : java.lang.Runnable.run() :

Jadx2

Figure 4 - Contenu de la classe gameView sous Jadx

Une lecture rapide du code permet d’identifier l’affichage du score, l’incrémentation de ce dernier ainsi que la détection des collisions de “l’oiseau”.

La classe gameView contient également une méthode intéressante :

@Override // android.view.SurfaceHolder.Callback
public void surfaceCreated(SurfaceHolder surfaceHolder) {
    String l = Long.valueOf(System.currentTimeMillis() / 1000).toString();
    Log.i("Flag", C0509d.m797b(C0509d.m797b("GZMC]AuC5NTQD2WN4VVVD3ZMKLUBS6GF9UO:SGOIVz", C0509d.m798a(l.substring(0, 3))), l.substring(0, 5)));
    Canvas lockCanvas = surfaceHolder.lockCanvas();
    if (lockCanvas != null) {
        gameView.this.draw(lockCanvas);
        surfaceHolder.unlockCanvasAndPost(lockCanvas);
    }
}

En suivant les appels successifs des méthodes, ainsi que les surcharges d’interfaces, on se rend compte que la méthode est appelée lors du lancement de l’application. Cette dernière affiche un message de log avec l’intitulé “Flag” au travers de la fonctionnalité android.util.Log. Malheureusement, les résultats de ces fonctions de logs ne sont pas visible directement sur l’application Android. De plus les droits nécessaires pour accéder à ces logs sont particuliers et nécessitent la mise en place d’un environnement de débug outrepassant le mécanisme anti-root.

Flag 1 - Méthode “Dynamique”

Une des manières d’accéder aux résultats de la méthode Log consiste à activer le mode debug USB de son téléphone et de s’y connecter avec l’outil Android Debug Bridge, plus communément appelé adb.

La commande suivante permet de vérifier la liste des appareils connectés en USB.

$ adb devices

List of devices attached
163062a8        device

ADB fonctionne également avec les environnements virtuels, mais ces derniers peuvent ne pas satisfaire les mécanismes d’anti-root.

J’ai donc fait le choix d’installer l’application game.apk sur mon téléphone personnel.

Dans le cas d’une analyse malware, il est important d’utiliser un matériel adapté, dédié et de prendre en compte le risque de détection de la part d’un attaquant.

La commande adb logcat permet ensuite d’afficher les logs de l’appareil en temps réel. Nous lançons donc la commande sur notre commande linux, puis l’application game.apk sur notre téléphone.

$ adb logcat -b main | grep 'Flag'

04-05 11:23:43.427  1155  1155 I OplusLayer: setFlags AOD 1
04-05 11:23:59.404  1411  2615 D WindowManager: interceptKeyTq keycode=26 interactive=false keyguardActive=true policyFlags=2000000
04-05 11:23:59.530  1411  2615 D WindowManager: interceptKeyTq keycode=26 interactive=true keyguardActive=true policyFlags=22000000
04-05 11:23:59.601  1155  1155 I OplusLayer: setFlags AOD 0
04-05 11:23:59.857  1140 14538 D APM_AudioPolicyManager: getInputForAttr() source 1999, sampling rate 16000, format 0x1, channel mask 0x10, session 20833, flags 0 attributes={ Content type: AUDIO_CONTENT_TYPE_UNKNOWN Usage: AUDIO_USAGE_UNKNOWN Source: AUDIO_SOURCE_HOTWORD Flags: 0x800 Tags:  }, uid=10156
04-05 11:23:59.918  1031 26568 D msm8974_platform: platform_set_echo_reference:g_NoiseReduce_Aec_Flag = 0
04-05 11:24:01.597 14609 16590 I DiscoveryManager: Filter criteria(CC1AD845,CC32E753) scannerFlags(2)
04-05 11:24:04.863  1411  3324 D WindowManager: interceptKeyTq keycode=4 interactive=true keyguardActive=false policyFlags=2b000002
04-05 11:24:04.975  1411  6617 D WindowManager: interceptKeyTq keycode=4 interactive=true keyguardActive=false policyFlags=2b000002
04-05 11:24:05.059  7659 16762 I OpenGLRenderer: Davey! duration=147093ms; Flags=1, FrameTimelineVsyncId=7537123, IntendedVsync=195286173889985, Vsync=195286173889985, InputEventId=1379349935, HandleInputStart=195286174663991, AnimationStart=195286174667793, PerformTraversalsStart=195286174670137, DrawStart=195286191492689, FrameDeadline=195286207223317, FrameInterval=195286174638210, FrameStartTime=16666666, SyncQueued=195286191799512, SyncStart=195286191867272, IssueDrawCommandsStart=195286192076960, SwapBuffers=195286194316126, FrameCompleted=195433267530237, DequeueBufferDuration=886510, QueueBufferDuration=1119271, GpuCompleted=195433267530237, SwapBuffersCompleted=195286195985189, DisplayPresentTime=0, 
04-05 11:24:11.552  9777  9777 I Flag    : BZHCTF{D0NT_F0RG3T_TH3_DEBUG_1NF0RM4TIONS}
04-05 11:24:12.782  1411  1513 D WindowManager: interceptKeyTq keycode=4 interactive=true keyguardActive=false policyFlags=2b000002
04-05 11:24:12.958  1411  1513 D WindowManager: interceptKeyTq keycode=4 interactive=true keyguardActive=false policyFlags=2b000002

On récupère la chaine de caractère (et donc le premier flag) :

I Flag    : BZHCTF{D0NT_F0RG3T_TH3_DEBUG_1NF0RM4TIONS}

Flag 1 - Méthode “Statique”

La méthode statique est sans doute la méthode la plus simple à utiliser pour ce genre de contexte. En effet, le flag semble être contenu de manière chiffrée dans la méthode surfaceCreated() précédemment abordée. En utilisant l’introspection du code, ainsi que la fonctionnalité de renommage et de propagation de jadx, on obtient la fonction suivante :

@Override // android.view.SurfaceHolder.Callback
public void surfaceCreated(SurfaceHolder surfaceHolder) {
    String l = Long.valueOf(System.currentTimeMillis() / 1000).toString();
    Log.i("Flag", CryptoESNA.customXor(CryptoESNA.customXor("GZMC]AuC5NTQD2WN4VVVD3ZMKLUBS6GF9UO:SGOIVz", CryptoESNA.revString(l.substring(0, 3))), l.substring(0, 5)));
    Canvas lockCanvas = surfaceHolder.lockCanvas();
    if (lockCanvas != null) {
        gameView.this.draw(lockCanvas);
        surfaceHolder.unlockCanvasAndPost(lockCanvas);
    }
}

La classe CryptoESNA est définie ci-dessous :

public class CryptoESNA {

    /* renamed from: a */
    private String[] keys = {"jess000000eeica", "654300000021e", "mich000000ael", "ashl000000ey", "qwert000000y", "111100000011", "ilove000000u", "000000000000", "mich000000elle", "tigge000000r", "sunsh000000ine", "chocol000000ate", "password1", "soccer", "anthony", "friends", "butterfly", "purple", "angel", "jordan", "liverpool", "justin", "loveme", "fuckyou", "123123", "football", "secret", "andrea", "carlos", "jennifer", "joshua", "bubbles", "1234567890", "superman", "hannah", "amanda", "loveyou", "pretty", "basketball", "andrew", "angels"};

    /* renamed from: a */
    public static String revString(String str) {
        byte[] bytes = str.getBytes();
        byte[] bArr = new byte[bytes.length];
        for (int i = 0; i < bytes.length; i++) {
            bArr[i] = bytes[(bytes.length - i) - 1];
        }
        return new String(bArr);
    }

    /* renamed from: b */
    public static String customXor(String str, String str2) {
        byte[] bytes = str.getBytes();
        byte[] bytes2 = str2.getBytes();
        int i = 0;
        for (int i2 = 0; i2 < bytes.length; i2++) {
            byte b = bytes[i2];
            bytes[i2] = (byte) (bytes[i2] ^ bytes2[i]);
            i = (i + b) % bytes2.length;
        }
        return new String(bytes);
    }
}

La variable “keys” ne semble jamais utilisée dans le programme.

Pour récupérer le Flag de manière statique, il suffit de réimplémenter les méthodes dans un fichier Java et de modifier la méthode Log() par la méthode System.out.println().

J’ai donc écrit le fichier Flappy.java suivant :

public class Flappy{
  public static void main(String arg[]){

    String l = Long.valueOf(System.currentTimeMillis() / 1000).toString();
    l = "1648875600";  // J'ai reset la valeur correspondant à la date de l'evenement.
    System.out.println(Flappy.customXor(Flappy.customXor("GZMC]AuC5NTQD2WN4VVVD3ZMKLUBS6GF9UO:SGOIVz", Flappy.revString(l.substring(0, 3))), l.substring(0, 5)));
          
  }

  public static String customXor(String str, String str2) {
        byte[] bytes = str.getBytes();
        byte[] bytes2 = str2.getBytes();
        int i = 0;
        for (int i2 = 0; i2 < bytes.length; i2++) {
            byte b = bytes[i2];
            bytes[i2] = (byte) (bytes[i2] ^ bytes2[i]);
            i = (i + b) % bytes2.length;
        }
        return new String(bytes);
    }

  public static String revString(String str) {
      byte[] bytes = str.getBytes();
      byte[] bArr = new byte[bytes.length];
      for (int i = 0; i < bytes.length; i++) {
          bArr[i] = bytes[(bytes.length - i) - 1];
      }
      return new String(bArr);
  }
}

La compilation s’effectue ensuite avec la commande javac et l’exécution avec java:

$ javac Flappy.java
$ java Flappy
BZHCTF{D0NT_F0RG3T_TH3_DEBUG_1NF0RM4TIONS}

On récupère donc bien le premier flag :).

Flag 2 - Méthode “Statique”

N’ayant plus en tête l’énoncé des challenges, j’ai simplement poursuivi mon investigation sur l’application mobile avec l’outil Jadx. Il me semble cependant que la description du challenge indiquait qu’il fallait dépasser 35000 points.

Une des fonctionnalités présente dans les outils de reverse engineering est la fonctionnalité de recherche de référence. En effet, pour une variable, méthode, objet ou classe donnée, il est possible de demander à Jadx de chercher l’ensemble des références (clic droit –> “Find Usage”).

J’ai donc décidé de chercher les différents appels à la méthode de chiffrement (renommée ici customXor) :

Jadx3.png

Figure 5 - Références de la fonction customXor sous Jadx

On peut donc voir 3 résultats avec la chaine GZMC]AuC5NTQD2WN4VVVD3ZMKLUBS6GF9UO:SGOIVz correspondant au premier flag. Nous retrouvons également 3 résultats pour une chaine différente : KSJAXHwP0P[V=ZW:D@Z]RPXJM\\ME[S[=NL_H>BE!s.

En double cliquant sur la référence nous tombons sur la classe suivante :

package p005b0;

import android.util.Log;

/* renamed from: b0.b */
/* loaded from: classes.dex */
public class C0507b {

    /* renamed from: a */
    public CryptoESNA f2134a = new CryptoESNA();

    /* renamed from: a */
    public String m804a(int i) {
        if (i < 35000) {
            return "Try again ;)";
        }
        String l = Long.valueOf(System.currentTimeMillis() / 1000).toString();
        String b = CryptoESNA.customXor(CryptoESNA.customXor("KSJAXHwP0P[V=ZW:D@Z]RPXJM\\ME[S[=NL_H>BE!s", CryptoESNA.revString(l.substring(0, 5))), l.substring(0, 5));
        Log.i("", b);
        return b;
    }
}

Encore une fois, la méthode de reverse engineering statique est similaire à celle de la première partie, seule la clé et l’entier “3” changent. On peut donc reconstruire le script Flappy2.java suivant :

public class Flappy2{
  public static void main(String arg[]){

    String l = Long.valueOf(System.currentTimeMillis() / 1000).toString();
    l = "1648875600";  // J'ai reset la valeur correspondant à la date de l'evenement.
    System.out.println(Flappy.customXor(Flappy.customXor("KSJAXHwP0P[V=ZW:D@Z]RPXJM\\ME[S[=NL_H>BE!s", Flappy.revString(l.substring(0, 5))), l.substring(0, 5)));
          
  }

  public static String customXor(String str, String str2) {
        byte[] bytes = str.getBytes();
        byte[] bytes2 = str2.getBytes();
        int i = 0;
        for (int i2 = 0; i2 < bytes.length; i2++) {
            byte b = bytes[i2];
            bytes[i2] = (byte) (bytes[i2] ^ bytes2[i]);
            i = (i + b) % bytes2.length;
        }
        return new String(bytes);
    }

  public static String revString(String str) {
      byte[] bytes = str.getBytes();
      byte[] bArr = new byte[bytes.length];
      for (int i = 0; i < bytes.length; i++) {
          bArr[i] = bytes[(bytes.length - i) - 1];
      }
      return new String(bArr);
  }
}

On recompile et exécute le script :

$ javac Flappy2.java
$ java Flappy2
BZHCTF{Y0UR_4_R3AL_TRY_HARDER_W3LL_D0NE!}

Nous sommes maintenant en possession du 2ème flag 😄 !

Flag 2 - Méthode “Byte Patching”

La méthode du byte patching consiste à générer une nouvelle version de l’APK, légèrement corrigée. L’analyse de la classe C0507b indique que le score minimal pour afficher le flag numéro 2 doit être de 35000. Nous allons donc remplacer cette valeur 35000 par une valeur inférieure comme 0.

Cette méthode est diviser en plusieurs étapes: 1. Décomprésser l’APK 2. Décompiler le fichier classes.dex 3. Modifier le code (smali/class) 4. Recompiler le fichier classes.dex 5. Recalculer les signatures d’intégrité (META-INF)

Afin de nous simplifier la tâche nous allons utiliser l’outil Apktool.

Commençons par décompiler l’APK et le fichier dex:

apktool d game.apk

Patch du score 35000

En ouvrant le dossier “game” généré par l’outil apktool, nous recherchons la valeur 35000. Celle-ci ne semble pas présente dans le projet. En revanche, sa valeur en hexadécimale 0x88b8 présente une unique référence :

apktool1.png

Figure 6 - Récupération de la valeur 35000 de l’application sur VSCode

Nous modifions alors cette valeur par 0x0 et sauvegardons le fichier.

Nous allons maintenant recompiler le nouvel APK à l’aide d’Apktool:

$ apktool b game -o game.apk

Comme mentionné précédemment, les archive APK fonctionnent avec un mécanisme de signature. N’étant pas en possession de la clé utilisée pour signer les ressources de l’application, nous allons générer notre propre signature et signer l’application avec cette dernière :

$ keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000

Ressaisissez le nouveau mot de passe : 
Quels sont vos nom et prénom ?
  [Unknown]:  zeecka
Quel est le nom de votre unité organisationnelle ?
  [Unknown]:  zeecka
Quel est le nom de votre entreprise ?
  [Unknown]:  zeecka
Quel est le nom de votre ville de résidence ?
  [Unknown]:  zeecka
Quel est le nom de votre état ou province ?
  [Unknown]:  zeecka
Quel est le code pays à deux lettres pour cette unité ?
  [Unknown]:  FR   
Est-ce CN=zeecka, OU=zeecka, O=zeecka, L=zeecka, ST=zeecka, C=FR ?
  [non]:  

Génération d'une paire de clés RSA de 2 048 bits et d'un certificat auto-signé (SHA256withRSA) d'une validité de 10 000 jours
        pour : CN=zeecka, OU=zeecka, O=zeecka, L=zeecka, ST=zeecka, C=FR
[Stockage de my-release-key.keystore]

Nous pouvons maintenant signer l’application avec notre clé. L’utilisation de apksigner, jarsigner et/ou zipalign peut être nécessaire en fonction du type d’application et du device. Pour être franc, je n’ai pas eu le temps de recompiler un APK satisfaisant l’ensemble des conditions de sécurité.

A titre d’exemple, nous allons utiliser signer l’APK avec la commande jarsigner:

$ jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore my-release-key.keystore game.apk alias_name
Enter Passphrase for keystore: 
 updating: META-INF/MANIFEST.MF
   adding: META-INF/ALIAS_NA.SF
   adding: META-INF/ALIAS_NA.RSA
...

Nous pouvons maintenant installer notre nouvelle application avec adb:

$ adb install game.apk

L’ouverture de l’application avec l’introspection de log nous valide le 2e flag dès le 1er point :) :

$ adb logcat -b main | grep 'BZH'
BZHCTF{Y0UR_4_R3AL_TRY_HARDER_W3LL_D0NE!}

Flag 2 - BONUS - Bypass 🤡

L’analyse de la classe C0507b indique que le score minimal pour afficher le flag numéro 2 doit être de 35000. A raison d’un point toute les 2 secondes, il faudrait près de 20h pour résoudre le challenge de manière légitime.

Cette solution est cependant possible ! (mais ne rentre pas dans les 12h imparties du CTF). En effet, en cliquant de manière abusive, il est possible de passer au dessus de l’ensemble des obstacles. Une application Android comme Auto Clicker peut être utilisée pour effectuer le travail à notre place:

Logiquement, après 20h d’exécution, on récupère la chaine de caractère (et donc le second flag) :

$ adb logcat -b main | grep 'BZH'
BZHCTF{Y0UR_4_R3AL_TRY_HARDER_W3LL_D0NE!}

Il est également envisageable de réduire la valeur 35000 avec des outils équivalents à CheatEngine, d’augmenter directement son score ou d’accélérer l’horloge du mobile.

Zeecka