J'ai traqué les 9 personnes qui interceptaient le TLS de notre app - c'était Google
En activant l'épinglage de certificats en mode report-only, 74 mismatches SPKI se sont accumulés dans Sentry. Ça ressemblait à une attaque MITM, et l'IA a catégoriquement écarté la piste de l'analyse du store en affirmant que « Google n'intercepte pas votre HTTPS ». Les IP réelles extraites des logs de l'API de production ont renversé cette supposition.
L'essentiel
La première semaine après l'activation de l'épinglage de certificats en mode report-only, 74 mismatches SPKI (9 utilisateurs) sont remontés dans Sentry. Le certificat reçu par l'app ne recoupait en rien celui de notre serveur : quelqu'un interceptait donc le TLS. L'empreinte des appareils montrait que les 74 événements venaient tous d'un « OnePlus 8 Pro » - en architecture x86, avec un écran de 288x448. Une ferme d'émulateurs déguisée en appareils physiques. Pendant que je resserrais l'identification, l'agent IA a écarté l'hypothèse de l'analyse automatique du store au motif que « Google ne fait pas de MITM sur votre HTTPS ». Mais les IP réellement arrivées sur l'API de production à l'heure des mismatches appartenaient toutes à des plages Google. Une déduction tirée des principes s'est effondrée en trois minutes face à la mesure.
Sur cette page
Notre app est déjà morte une fois à cause de l'épinglage de certificats. Le 5 avril 2026, nous avions épinglé le SPKI du certificat leaf ; ACM a renouvelé automatiquement le certificat, la clé publique du leaf a changé, et à cet instant l'API s'est fermée pour tous les utilisateurs. Sans kill switch distant, il a fallu republier une app de hotfix sur les stores. Depuis, une règle traînait dans le repo : « interdiction d'introduire l'épinglage de certificats ».
Deux mois plus tard, nous l'avons refait. Cette fois, au lieu du leaf, nous avons épinglé les SPKI des quatre Amazon Root CA. Les racines ne changent pas quand ACM renouvelle, le même accident ne peut donc pas se reproduire. Nous avons aussi ajouté un kill switch pilotable par configuration distante. Le 29 juin, nous avons activé à distance le mode report-only : aucun blocage, seulement des rapports.
Deux jours plus tard, une issue attendait dans Sentry.
[CertPinning] SPKI mismatch (report-only)
74 events · 9 users · 2026-06-29
Deux hashs que nous n'avons jamais envoyés
En ouvrant l'événement, on y trouvait les deux SPKI du certificat réellement reçu par l'app.
mode: "reportOnly"
host: "web.example.com"
expected_pin_count: 4
served_spki: [
"sha256/9hqPsoMiyQMwLCoRPk6FoCYmOsPiGqzQqUcpuZIfvgs=",
"sha256/r9mjYco6rQO8YkTqr/XXGsQlDUuQqz2mGr67S0imt7M="
]
J'ai extrait la vraie chaîne du serveur avec openssl s_client pour comparer. Ni le leaf que notre serveur envoie, ni l'intermédiaire, ni la racine ne recoupaient ces deux hashs. Ces 9 personnes n'avaient tout simplement jamais reçu le vrai certificat de notre serveur. Quelqu'un coupait le TLS en chemin, resignait avec son propre certificat et le passait à l'app.
Le fait que served_spki ne contienne que deux entrées était un indice de plus. Sur le chemin normal, la chaîne rapportée par l'app en contient trois, racine comprise. Deux seulement (leaf + intermédiaire), cela veut dire que ce rapport sort d'une autre branche.
C'est là que j'ai posé une première conclusion. Plus exactement, l'agent IA l'a posée et je l'ai trouvée plausible. « Ce n'est pas un bug à corriger : l'épinglage report-only fonctionne comme prévu et détecte des interceptions qui se produisent sur le réseau de quelqu'un d'autre. Proxys d'entreprise ou d'école, protection web d'un antivirus, portails captifs. Nous ne pouvons rien y faire, alors mettons l'issue en mute dans Sentry et gardons-la comme simple métrique. »
Tant qu'à y toucher, une proposition suivait : ajouter une pin de secours. C'est l'option que j'ai choisie.
Une pin de secours ne fait pas baisser cette issue d'un seul événement
Juste après le début du travail d'ajout de la pin, quelque chose ne collait pas et j'ai relancé la question.
Au départ, un mismatch n'est pas censé se produire, si ?
Cette seule ligne a fait tomber la direction que je venais de choisir. L'agent a relu le code de la sonde Android et s'est corrigé.
val verified = verifiedChainSpki(offered, host)
if (verified == null) {
// Echec de la validation par le magasin de confiance = interception/falsification -> signalement sans comparaison de pins
report(host, offered.mapNotNull(::spkiSha256))
return
}
// La comparaison des pins n'a lieu que pour une chaine qui a passe la validation
if (verified.none(pins::contains)) { report(host, verified) }
La sonde procède en deux étapes. Elle valide d'abord la chaîne avec le magasin de confiance de l'OS, et ne compare à la liste de pins que les chaînes qui passent. Nos 74 événements échouaient déjà à la première étape. Comme verified == null, ils sont signalés immédiatement, sans passer par la comparaison de pins. Une pin de secours n'agit qu'à la seconde étape, c'est-à-dire dans le cas « la chaîne est validée mais la pin ne correspond pas ».
Conclusion : ajouter Starfield G2 aux pins ne réduira cette issue d'aucun événement.
C'est à ce moment-là qu'on a aussi confirmé qu'un utilisateur normal ne peut pas déclencher ce rapport. La chaîne servie en live correspond exactement à nos pins. Donc ces 9 personnes ne sont, par définition, pas des utilisateurs normaux. Il n'y avait aucun bug à corriger ici. Ce qu'il fallait, c'était découvrir qui étaient ces 9 personnes.
Ignorer, c'est ne jamais pouvoir l'activer
Je ne pouvais pas non plus accepter la recommandation « ignorons et surveillons ».
Il faut corriger ça pour de bon, on doit vraiment l'activer plus tard, et dans cet état on ne pourra pas.
L'objectif final de cet épinglage, c'est le mode enforce. Le critère qui décide si on peut l'activer était déjà écrit dans le document de conception : un taux de mismatch chez les vrais utilisateurs inférieur à 0,5 % pendant deux semaines. Tant que la nature de ces mismatches reste inconnue, ces 74 événements continuent de polluer le critère. Un mute les fait disparaître du tableau de bord, mais le critère, lui, reste pollué.
Alors j'ai creusé les données. Dès la première distribution de tags, quelque chose de bizarre est sorti.
Les 74 événements viennent du même appareil, sauf que cet appareil n'existe pas
Le tag device des 74 événements valait à 100 % OnePlus8Pro. Sauf que l'empreinte de cet appareil était étrange.
| Champ | Valeur de l'événement | Vrai OnePlus 8 Pro |
|---|---|---|
| archs | inclut x86_64, x86 | arm64 uniquement (Snapdragon 865) |
| Écran | 288 x 448 px, 106 dpi | 1440 x 3168, 513 dpi |
| CPU | 2 cœurs, fréquence 0 | 8 cœurs |
| simulator | false | - |
Un téléphone ARM grand public n'annonce pas d'ABI x86. Et il n'existe nulle part au monde de téléphone Android en 288x448 à 106 dpi. C'est une machine virtuelle x86 dont seul le Build.MODEL est maquillé en nom d'appareil réel. Avec simulator: false, elle allait jusqu'à se faire passer pour un appareil physique.
Les 9 identifiants utilisateurs n'étaient pas des humains non plus. Les 9 n'ont existé que le 29 juin, et ce seul jour-là ils ont fait tourner les trois versions 1.7.0, 1.8.0 et 1.8.1. Ce n'est pas une personne, c'est un pipeline. La région, sur les 74 événements, était toujours les États-Unis, mais la ville était systématiquement null. C'est la signature d'un trafic venant de plages de datacenter.
Là, j'ai demandé : alors qui analyse notre app ? L'agent a placé en tête les sandboxes d'analyse automatique d'APK des éditeurs de sécurité, suivies des sociétés d'app intelligence et des sites miroirs d'APK. La revue automatique des stores arrivait en quatrième position.
L'hypothèse classée quatrième est passée première grâce à trois IP
C'est cette quatrième place qui me gênait. Alors j'ai demandé.
Est-ce que ça pourrait aussi être la Google Play Console ou l'App Store d'Apple ?
La réponse a été catégorique.
Rapport pre-launch Google Play / Test Lab - très peu probable. Raison décisive : Google ne fait pas de MITM sur votre HTTPS. Le pre-launch de Play utilise le magasin de confiance standard et reçoit votre vrai certificat tel quel. Les crawlers des stores n'interceptent pas le trafic pour le resigner. Ça, c'est le comportement d'une sandbox d'analyse.
Vu sous l'angle des principes, l'argumentation est irréprochable. Et elle est fausse.
Ce qui manquait, c'était la preuve par l'IP. Sentry scrubant les IP en tant que PII, le champ user.ip était null sur tous les événements, et l'IP d'origine d'un événement passé est irrécupérable. Pour identifier la source, il fallait une IP, et aucun outil à disposition n'en avait.
En revanche, les logs d'accès de l'API de production étaient toujours là. Ils enregistraient X-Forwarded-For et User-Agent à chaque requête, et le User-Agent de notre app, Dart/3.12 (dart:io), était unique. Et le 29 juin tenait encore dans la fenêtre de rétention des logs (7 jours).
J'ai d'abord extrait de Sentry les horodatages des 74 événements et je les ai regroupés en trois blocs. 02:12-02:20, 11:30-11:41, 17:32-17:40 (UTC). Puis j'ai cherché dans CloudWatch les IP des requêtes arrivées en Dart/3.12 pendant ces trois fenêtres.
02:12-02:20 ... 66.249.84.132, 66.249.84.141
11:30-11:41 ... 66.102.7.69
17:32-17:40 ... 66.249.84.141
66.249.x.x est la plage utilisée par Googlebot, et 66.102.x.x est également à Google. Les trois fenêtres contenaient une IP Google. Dans deux d'entre elles, la seule requête app arrivée sur ce créneau venait d'une IP Google. La fenêtre restante contenait aussi des IP qui ressemblaient à de vrais utilisateurs, mais l'IP Google y était également présente.
Trois minutes plus tard, l'agent s'est corrigé de lui-même.
Je me corrige. Je vous ai dit plus haut que « Google ne fait pas de MITM, donc ce n'est probablement pas ça », mais les preuves IP renversent cette supposition. C'est bien Google. Une IP mesurée est une preuve plus forte qu'une déduction.
Ce qui décortiquait notre app, c'était le scan automatique de routine que Google fait subir à n'importe quelle app publiée sur Play. Ni un attaquant, ni un concurrent, ni un éditeur de sécurité. Cet environnement de scan instrumente le trafic. Voilà pourquoi, aux yeux de notre sonde, la chaîne apparaissait comme non validable.
Qu'avons-nous corrigé, alors ?
Nous avons gardé l'épinglage tel quel et changé l'endroit où la sonde s'applique. Décision : ne pas activer du tout l'épinglage dans les environnements de scan.
Restait à savoir à quoi reconnaître un scanner. Puisqu'on venait de montrer qu'on ne peut pas se fier à simulator: false, il fallait un signal non falsifiable. L'architecture CPU, elle, ne se cache pas.
// Signal decisif: un telephone ARM grand public n'annonce jamais une ABI x86.
// Le scanner de Google peut deguiser Build.MODEL en nom d'appareil reel (observe: "OnePlus8Pro" sur x86),
// mais il ne peut pas cacher qu'il est en x86_64.
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
return true;
}
À cela s'ajoutent la vérification des noms de matériel de la famille Cuttlefish/GCE et des empreintes test-keys. Le détecteur est délibérément biaisé du côté « c'est un émulateur ». Un faux positif se contente de sauter l'épinglage dans cet unique environnement, et comme un attaquant qui fait tourner l'app dans un émulateur peut de toute façon la patcher pour neutraliser l'épinglage côté client, on n'y perd rien. À l'inverse, un faux négatif enferme le scanner dans un écran de blocage, ce qui est bien pire.
Pourquoi est-ce important ? Parce que si nous avions simplement activé enforce, voici ce qui serait arrivé. Le crawler pre-launch de Google se serait pris l'écran de blocage « connexion non sécurisée » de notre app. Cela aurait été consigné tel quel comme un échec dans le rapport pre-launch de la Play Console. Aucun problème dans le code, et pourtant une ligne rouge en travers de l'écran de revue du store.
Ce qu'il reste
Sentry scrube les IP en tant que PII. C'est le bon comportement par défaut. Mais du coup, le seul champ capable de révéler la nature de l'incident avait disparu des événements. Il a fallu croiser deux choses, le User-Agent de l'app et l'heure des faits, pour aller rechercher dans les logs d'accès l'information que Sentry avait jetée. Ce qui manque dans un outil n'est pas absent partout.
La phrase « Google n'intercepte pas le HTTPS » était impeccable comme argumentation. Elle passait en revue l'objectif d'un crawler de store, le fonctionnement du magasin de confiance standard, la différence de comportement avec une sandbox - et reléguait l'hypothèse en quatrième position. Puis, trois IP posées sur la table, la conclusion s'est renversée en trois minutes. Quand on demande un principe à une IA, on reçoit un principe. Savoir si ce principe correspond à la réalité, ça, il faut regarder les logs.
Si j'avais mis ces 74 événements en mute dans Sentry, le tableau de bord serait devenu propre, et le jour où nous aurions activé enforce, nous aurions récolté une ligne rouge en revue du store sans en comprendre la raison.
Nous n'avons toujours pas activé enforce. Le critère exige un taux de mismatch chez les vrais utilisateurs inférieur à 0,5 % pendant deux semaines. Maintenant que le scanner est filtré, ces deux semaines sont à recompter depuis le début.
Questions fréquentes
J'ai activé l'épinglage de certificats en report-only et je reçois des rapports de mismatch. Est-ce qu'ajouter une pin de secours va les faire baisser ?
Il faut d'abord voir de quel chemin de code sort le rapport. Une sonde d'épinglage fonctionne généralement en deux étapes. Elle valide d'abord la chaîne de certificats avec le magasin de confiance de l'OS, et ce n'est qu'une fois cette validation passée qu'elle compare la chaîne à la liste de pins. Si la validation de la chaîne échoue elle-même (interception, falsification), la sonde saute purement et simplement la comparaison de pins et signale immédiatement. Nos rapports étaient tous de ce second type, et c'est pourquoi ajouter des pins de secours n'en supprime pas un seul. Une pin de secours n'agit que dans le cas « la chaîne est validée mais la pin ne correspond pas ».
Les événements Sentry n'ont pas d'IP. Peut-on quand même savoir d'où viennent les connexions ?
Sentry scrube les IP en tant que PII, elles sont donc souvent absentes des événements, et l'IP d'origine d'un événement passé est irrécupérable. En revanche, on peut croiser d'autres logs par l'horodatage. Chez nous, les logs d'accès du serveur enregistraient X-Forwarded-For et User-Agent à chaque requête, et le User-Agent de l'app, « Dart/3.12 (dart:io) », était unique. En extrayant de Sentry les fenêtres horaires exactes des mismatches, puis en cherchant les IP des requêtes arrivées avec ce User-Agent pendant ces fenêtres, l'origine est apparue.
Passer l'épinglage de certificats en mode enforce pose-t-il problème pour la revue Google Play ?
C'est possible. L'environnement de scan automatique de Play semble instrumenter le trafic, et si votre app y affiche un écran de blocage d'épinglage, cela peut être consigné comme un échec dans le rapport pre-launch. Le plus sûr est de faire en sorte que la sonde d'épinglage soit tout simplement ignorée dans les environnements de scan et d'émulation. De toute façon, un attaquant qui fait tourner l'app dans un émulateur peut la patcher pour neutraliser l'épinglage côté client : exclure cet environnement ne réduit donc pas la surface réellement protégée.
Articles similaires
- 💻 Dev
Une alerte disait que la base était morte. La base n'est jamais morte
En une journée, quatre alertes CRITICAL se sont empilées. Un 504, un Prisma P2028, un 500 sur l'admin, et « service DATABASE down ». J'ai d'abord ouvert les métriques RDS : 21 heures durant, le CPU a plafonné à 19,7 %, la base allait très bien. Ce qui était lent, ce n'était pas la base, c'était un aller-retour qui traversait l'Atlantique vingt-cinq fois. Quant à l'alerte « down », c'est le health check qui se l'était infligée à lui-même.
- 💻 Dev
Le pipeline où une seconde IA doit réfuter le code corrigé par la première
Demandez à un modèle de relire son propre code : il ré-approuve ses propres conclusions. L'histoire d'un développeur solo, qui écrit presque tout son code avec l'IA, et qui a transformé en pipeline permanent une boucle de vérification croisée où une IA doit "réfuter" le travail d'une autre. Le cas réel où trois rounds successifs ont sorti des bugs que l'auto-revue avait manqués, et la conception des prompts.
- 💻 Dev
J'ai arrêté de croire le "terminé" de l'agent - le gate verify.sh, prove-it
Un agent de codage IA annonce "tests passés" sans avoir lancé les tests. Ce n'est pas un mensonge, c'est un problème structurel. Ce que j'ai appris en construisant prove-it, un gate open source qui transforme le rapport en contrôle - et les quatre façons dont un hook maison de cinq lignes échoue en silence.