Why NextWhy Next
Torna all'elenco
💻 SviluppoLettura di 17 min

Ho dato la caccia ai 9 che intercettavano il TLS della nostra app: era Google

Appena attivato il certificate pinning in modalità report-only, su Sentry si sono accumulati 74 SPKI mismatch. Sembrava un attacco MITM, e l'AI escludeva categoricamente la revisione dello store: 'Google non intercetta il tuo HTTPS'. Gli IP reali estratti dai log dell'API di produzione hanno ribaltato quella deduzione.

In sintesi

Nella prima settimana con il certificate pinning attivo in modalità report-only, su Sentry sono comparsi 74 SPKI mismatch (9 utenti). Il certificato ricevuto dall'app non coincideva con nessuno di quelli del nostro server, quindi qualcuno stava intercettando il TLS. Guardando l'impronta dei dispositivi, tutti e 74 gli eventi dicevano 'OnePlus 8 Pro', ma con architettura x86 e uno schermo da 288x448. Era una emulator farm camuffata da dispositivo fisico. Mentre restringevo il campo, l'agente AI escludeva la scansione automatica dello store sostenendo che 'Google non fa MITM sul tuo HTTPS', ma gli IP reali arrivati all'API di produzione nei momenti dei mismatch erano tutti di intervalli Google. Una deduzione basata sui princìpi è crollata in tre minuti davanti a una misura reale.

In questa pagina

La nostra app una volta è morta proprio a causa del certificate pinning. Il 5 aprile 2026 avevamo fissato come pin l'SPKI del certificato leaf; ACM ha rinnovato il certificato in automatico, la chiave pubblica del leaf è cambiata e in quell'istante l'API si è chiusa in faccia a tutti gli utenti. Non c'era nemmeno un kill switch remoto, quindi abbiamo dovuto ricaricare sullo store una app di hotfix. Da allora nel repo è rimasta una regola: "vietato introdurre il certificate pinning".

Due mesi dopo il pinning lo abbiamo rifatto. Stavolta non abbiamo fissato il leaf, ma gli SPKI di quattro Amazon Root CA. Le root non cambiano quando ACM rinnova, quindi lo stesso incidente non si ripete. Abbiamo aggiunto anche un kill switch, attivabile e disattivabile da remoto. Il 29 giugno abbiamo acceso da remoto la modalità report-only, quella che non blocca nulla e si limita a segnalare.

Due giorni dopo, su Sentry c'era una issue.

[CertPinning] SPKI mismatch (report-only)
74 events · 9 users · 2026-06-29

Due hash che non abbiamo mai inviato

Aprendo l'evento, c'erano stampati i due SPKI del certificato che l'app aveva effettivamente ricevuto.

mode: "reportOnly"
host: "web.example.com"
expected_pin_count: 4
served_spki: [
  "sha256/9hqPsoMiyQMwLCoRPk6FoCYmOsPiGqzQqUcpuZIfvgs=",
  "sha256/r9mjYco6rQO8YkTqr/XXGsQlDUuQqz2mGr67S0imt7M="
]

Ho estratto la catena reale del server con openssl s_client e l'ho confrontata. Né il leaf che invia il nostro server, né il certificato intermedio, né la root coincidevano con questi due hash. Questi 9 utenti non avevano proprio ricevuto il vero certificato del nostro server. Qualcuno stava interrompendo il TLS a metà strada e lo rifirmava con il proprio certificato prima di passarlo all'app.

Anche il fatto che served_spki contenesse solo due voci era un indizio. Sul percorso normale la catena che l'app riporta ne contiene tre, root compresa. Se ne compaiono solo due (leaf + intermedio), vuol dire che questo report esce da un altro ramo del codice.

Qui ho tratto la prima conclusione. Per l'esattezza l'ha tratta l'agente AI e io l'ho trovata plausibile. "Questo non è un bug da correggere: è il pinning in report-only che funziona esattamente come progettato e sta intercettando intercettazioni che avvengono su reti altrui. Proxy aziendali o scolastici, la protezione web di un antivirus, captive portal. Non possiamo controllarli, quindi mettiamoli in mute su Sentry e teniamoli solo come metrica."

Già che c'eravamo, è arrivata anche la proposta di aggiungere un pin di backup. Ho scelto quella strada.

Il pin di backup non toglie nemmeno un evento da questa issue

Subito dopo l'inizio del lavoro per aggiungere il pin, mi è venuto il dubbio che la cosa non tornasse, e ho ribattuto.

Ma di base il mismatch non dovrebbe proprio avvenire, no?

Una riga, e la direzione appena scelta è crollata. L'agente ha riletto il codice della probe Android e si è corretto.

val verified = verifiedChainSpki(offered, host)
if (verified == null) {
    // Validazione del trust store fallita = intercettazione/falsificazione -> segnalazione senza confronto dei pin
    report(host, offered.mapNotNull(::spkiSha256))
    return
}
// Il confronto dei pin avviene solo per una catena che ha superato la validazione
if (verified.none(pins::contains)) { report(host, verified) }

La probe ha due fasi. Prima verifica la catena con il trust store del sistema operativo, poi confronta con la lista dei pin solo le catene che hanno superato la verifica. I nostri 74 eventi fallivano già alla prima fase. Con verified == null la segnalazione parte subito, senza passare dal confronto con i pin. Il pin di backup ha effetto solo nella seconda fase, cioè quando "la verifica passa ma il pin non corrisponde".

Conclusione: aggiungere Starfield G2 ai pin non riduce di un solo evento il conteggio di questa issue.

In quel momento è stato confermato anche che a un utente normale questo report non può comparire. La catena live corrisponde esattamente ai nostri pin. Quindi questi 9 utenti, per definizione, non sono utenti normali. Il bug da correggere non era qui. Bisognava scoprire chi fossero questi 9.

Se lo ignoro, non lo accenderò mai

Nemmeno la raccomandazione "ignoriamolo e monitoriamo" potevo accettarla.

Va sistemato per davvero, questa cosa poi dovremo accenderla sul serio e così com'è non si può accendere

L'obiettivo finale di questo pinning è enforce. Il gate che decide se possiamo accendere enforce era già scritto nel documento di design: per due settimane il tasso di mismatch degli utenti reali deve restare sotto lo 0.5%. Se lascio i mismatch senza identità, questi 74 eventi continuano a inquinare il gate. Se li metto in mute, i 74 spariscono dalla dashboard ma il gate resta inquinato com'era.

Così ho scavato più a fondo nei dati. Appena estratta la distribuzione dei tag, è saltato fuori qualcosa di strano.

Tutti e 74 gli eventi dallo stesso dispositivo, e quel dispositivo non esiste

Il tag device dei 74 eventi era al 100% uno solo: OnePlus8Pro. Ma l'impronta di quel dispositivo era anomala.

CampoValore nell'eventoOnePlus 8 Pro reale
archsinclude x86_64, x86solo arm64 (Snapdragon 865)
Schermo288 x 448 px, 106 dpi1440 x 3168, 513 dpi
CPU2 core, frequenza 08 core
simulatorfalse-

Un telefono ARM di consumo non dichiara ABI x86. E al mondo non esiste un telefono Android da 288x448 a 106 dpi. È una macchina virtuale x86 che ha camuffato solo Build.MODEL con il nome di un dispositivo reale. Con simulator: false si spacciava perfino per un dispositivo fisico.

Nemmeno i 9 ID utente erano persone. Tutti e 9 sono esistiti solo nella giornata del 29 giugno, e in quel solo giorno hanno fatto girare tre versioni: 1.7.0, 1.8.0 e 1.8.1. Non sono persone, è una pipeline. La regione era Stati Uniti per tutti e 74 gli eventi, ma la città era sempre null. È così che risultano le connessioni provenienti da intervalli di datacenter.

A quel punto ho chiesto: allora chi sta analizzando la nostra app? L'agente ha messo al primo posto le sandbox di analisi automatica di APK dei vendor di sicurezza, seguite dalle aziende di app intelligence e dai siti mirror di APK. La revisione automatica dello store era al quarto posto.

L'ipotesi al quarto posto è diventata la prima grazie a tre IP

Il quarto posto mi era rimasto in gola. Così ho chiesto.

Potrebbe essere anche la Google Play Console o l'Apple Store?

La risposta è arrivata netta.

Google Play pre-launch report / Test Lab - quasi certamente no. Motivo decisivo: Google non fa MITM sul tuo HTTPS. Il pre-launch di Play riceve il tuo vero certificato così com'è, con il trust store standard. I crawler dello store non intercettano il traffico per rifirmarlo. Quello è il comportamento delle sandbox di analisi.

Sul piano dei princìpi è un ragionamento impeccabile. Ed è sbagliato.

Quello che mancava erano le prove sugli IP. Sentry rimuove l'IP come PII, quindi lo user.ip degli eventi era sempre null, e l'IP originale di un evento passato non si recupera. Per identificare la provenienza serviva l'IP, ma nessuno strumento a disposizione ce l'aveva.

Restavano però i log di accesso dell'API di produzione. Registravano X-Forwarded-For e User-Agent a ogni richiesta, e lo User-Agent della nostra app era univoco: Dart/3.12 (dart:io). E il 29 giugno rientrava ancora nella finestra di retention dei log (7 giorni).

Prima ho estratto da Sentry gli orari dei 74 eventi e li ho raggruppati in tre blocchi. 02:12-02:20, 11:30-11:41, 17:32-17:40 (UTC). Poi ho cercato su CloudWatch gli IP delle richieste arrivate in quei tre intervalli con Dart/3.12.

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 è l'intervallo che usa Googlebot, e anche 66.102.x.x è di Google. In tutti e tre gli intervalli c'erano IP Google. In due di essi l'unica richiesta dell'app arrivata in quella fascia oraria veniva da un IP Google. Nel terzo c'erano anche IP che sembravano di utenti reali, ma l'IP Google era presente pure lì.

Tre minuti dopo, l'agente si è corretto da solo.

Mi correggo. Prima avevo detto "Google non fa MITM, quindi non sarà lui", ma le prove sugli IP ribaltano quella deduzione. È proprio Google. Un IP misurato è una prova più forte di una deduzione.

Chi stava smontando la nostra app era la normale scansione automatica che Google fa a qualsiasi app pubblicata su Play. Non un attaccante, non un concorrente, non un vendor di sicurezza. Quell'ambiente di scansione strumenta il traffico. Ecco perché agli occhi della nostra probe appariva come una catena non verificabile.

Che cosa abbiamo corretto, allora

Il pinning è rimasto com'era: abbiamo cambiato dove applicare la probe. Negli ambienti scanner abbiamo deciso di non accendere affatto il pinning.

Restava da capire da cosa riconoscere uno scanner. Dato che era già emerso che a simulator: false non si può credere, serviva un segnale non falsificabile. L'architettura della CPU non si può nascondere.

// Segnale decisivo: un telefono ARM di consumo non dichiara mai una ABI x86.
// Lo scanner di Google puo mascherare Build.MODEL con il nome di un device reale (osservato: "OnePlus8Pro" su x86),
// ma non puo nascondere di essere x86_64.
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
  return true;
}

A questo aggiungiamo il controllo dei nomi hardware della famiglia Cuttlefish/GCE e delle fingerprint test-keys. Il rilevatore è deliberatamente sbilanciato verso il verdetto "è un emulatore". Un falso positivo si limita a far saltare il pinning in quell'unico ambiente, e comunque un attaccante che esegue l'app su un emulatore può patchare l'app e neutralizzare il pinning lato client, quindi non perdiamo nulla. Un falso negativo, al contrario, è molto peggio: significa chiudere lo scanner dentro una schermata di blocco.

Perché tutto questo conta? Perché se avessimo semplicemente acceso enforce, sarebbe successo questo. Il crawler pre-launch di Google finisce nella schermata di blocco "connessione non sicura" della nostra app. Il fatto viene registrato come fallimento nel pre-launch report della Play Console. Il codice non ha alcun problema, eppure sulla schermata di revisione dello store compare una riga rossa.

Quel che resta

Sentry rimuove l'IP come PII. È il default giusto. Ma proprio per questo l'unico campo capace di svelare l'identità dell'evento era sparito dall'evento stesso. Alla fine, incrociando due sole cose - lo User-Agent dell'app e l'orario degli eventi - ho recuperato dai log di accesso l'informazione che Sentry aveva buttato via. Che un dato non ci sia in uno strumento non vuol dire che non ci sia da nessuna parte.

La frase "Google non intercetta l'HTTPS" era, come argomentazione, impeccabile. Passando in rassegna lo scopo dei crawler dello store, il funzionamento del trust store standard e la differenza di comportamento rispetto alle sandbox, l'ipotesi era stata spinta al quarto posto. Poi, di fronte a tre IP, la conclusione si è ribaltata in tre minuti. Se chiedi i princìpi a un'AI, ti tornano indietro i princìpi. Se quei princìpi corrispondano alla realtà lo si scopre solo guardando i log.

Se avessimo messo in mute questi 74 eventi su Sentry, la dashboard sarebbe diventata pulita, e il giorno in cui avessimo acceso enforce ci saremmo presi una riga rossa alla revisione dello store senza sapere perché.

Enforce non lo abbiamo ancora acceso. Il gate richiede che per due settimane il tasso di mismatch degli utenti reali resti sotto lo 0.5%. Ora che abbiamo filtrato lo scanner, quelle due settimane vanno contate di nuovo da capo.

Domande frequenti

Ho attivato il certificate pinning in report-only e mi arrivano report di mismatch. Aggiungere un pin di backup li riduce?

Prima bisogna capire da quale percorso di codice esce il report. Una probe di pinning ha di solito due fasi. Prima verifica la catena di certificati con il trust store del sistema operativo, e solo se la verifica passa confronta quella catena con la lista dei pin. Se è la verifica della catena a fallire (intercettazione, contraffazione), il confronto con i pin non avviene affatto e la segnalazione parte subito. I nostri report erano tutti del secondo tipo, quindi per quanti pin di backup si aggiungano non se ne toglie nemmeno uno. Il pin di backup serve solo nel caso in cui 'la verifica passa ma il pin non corrisponde'.

Negli eventi Sentry non c'è l'IP. Posso comunque risalire alla provenienza delle connessioni?

Sentry tratta l'IP come PII e lo rimuove, quindi spesso non resta nell'evento, e l'IP originale di un evento passato non è recuperabile. Si può però incrociare l'orario con altri log. Nel nostro caso i log di accesso del server registravano X-Forwarded-For e User-Agent a ogni richiesta, e lo User-Agent dell'app era univoco: 'Dart/3.12 (dart:io)'. Ho estratto da Sentry gli intervalli di tempo esatti dei mismatch e ho cercato gli IP delle richieste arrivate in quegli intervalli con quello User-Agent: la provenienza è saltata fuori.

Attivare il certificate pinning in modalità enforce crea problemi con la revisione su Google Play?

Può crearne. L'ambiente di scansione automatica di Play sembra strumentare il traffico, e se in quell'ambiente l'app mostra la schermata di blocco del pinning, il pre-launch report può registrarlo come fallimento. È più prudente far saltare del tutto la probe di pinning negli ambienti scanner o emulatore. In fondo un attaccante che esegue l'app su un emulatore può comunque patchare l'app e neutralizzare il pinning lato client, quindi escludere quell'ambiente non riduce ciò che stiamo proteggendo.

Articoli correlati

0 commenti