Rastreei os 9 que interceptavam o TLS do nosso app - era o Google
Ligamos o certificate pinning em modo report-only e o Sentry acumulou 74 eventos de SPKI mismatch. Parecia ataque MITM, e a IA descartou de forma categórica a hipótese da revisão da loja, alegando que 'o Google não intercepta HTTPS'. Os IPs reais, tirados dos logs da API em produção, derrubaram essa suposição.
Resumo
Na primeira semana com o certificate pinning em modo report-only, o Sentry registrou 74 eventos de SPKI mismatch (9 usuários). O certificado que o app recebia não tinha nenhuma sobreposição com o do nosso servidor - ou seja, alguém estava interceptando o TLS. Olhando a impressão digital dos aparelhos, os 74 eventos vinham todos de um 'OnePlus 8 Pro' com arquitetura x86 e tela de 288x448. Era uma fazenda de emuladores disfarçada de aparelho físico. Enquanto eu fechava o cerco, o agente de IA descartou a hipótese da varredura automática da loja dizendo que 'o Google não faz MITM no seu HTTPS'. Mas os IPs reais que chegaram à API de produção no horário dos mismatches eram todos de faixas do Google. Uma suposição tirada dos primeiros princípios ruiu em três minutos diante da medição.
Nesta página
Nosso app já morreu uma vez por causa de certificate pinning. Em 5 de abril de 2026, tínhamos fixado como pin o SPKI do certificado leaf; o ACM renovou o certificado automaticamente, a chave pública do leaf mudou e, naquele instante, a API travou para todos os usuários. Também não havia kill switch remoto, então tivemos que subir um app de hotfix na loja. Depois disso, ficou uma regra no repositório: "proibido adotar certificate pinning".
Dois meses depois, refizemos o pinning. Desta vez, em vez do leaf, fixamos como pins os SPKI das quatro Amazon Root CA. A raiz não muda quando o ACM renova, então o mesmo acidente não se repete. Também colocamos um kill switch para ligar e desligar por configuração remota. Em 29 de junho, ligamos remotamente o modo report-only, que não bloqueia nada, só reporta.
Dois dias depois, havia uma issue no Sentry.
[CertPinning] SPKI mismatch (report-only)
74 events · 9 users · 2026-06-29
Dois hashes que nunca enviamos
Abri o evento e lá estavam os dois SPKI do certificado que o app tinha realmente recebido.
mode: "reportOnly"
host: "web.example.com"
expected_pin_count: 4
served_spki: [
"sha256/9hqPsoMiyQMwLCoRPk6FoCYmOsPiGqzQqUcpuZIfvgs=",
"sha256/r9mjYco6rQO8YkTqr/XXGsQlDUuQqz2mGr67S0imt7M="
]
Extraí a cadeia real do servidor com openssl s_client e comparei. Nem o leaf que nosso servidor envia, nem o intermediário, nem a raiz batiam com esses dois hashes. Nada. Essas 9 pessoas simplesmente não estavam recebendo o certificado de verdade do nosso servidor. Alguém no meio do caminho encerrava o TLS e reassinava com o próprio certificado antes de entregar ao app.
O fato de served_spki ter só dois itens também era uma pista. No caminho normal, a cadeia que o app reporta traz três, até a raiz. Se só dois apareceram (leaf + intermediário), esse relatório veio de outro ramo do código.
Foi aqui que tirei a primeira conclusão. Para ser exato, quem tirou foi o agente de IA, e eu achei plausível. "Isso não é um bug para corrigir: é o pinning em report-only funcionando como projetado, flagrando interceptações que acontecem na rede alheia. Proxy corporativo ou de escola, proteção web de antivírus, portal cativo. Como não temos controle sobre isso, é dar mute no Sentry e acompanhar só como métrica."
Junto veio a sugestão de, já que estávamos ali, adicionar um pin de backup. Eu escolhi essa opção.
Um pin de backup não reduz um único evento dessa issue
Logo depois que o trabalho de adicionar o pin começou, achei que aquilo não fechava e voltei a perguntar.
Mas o mismatch não deveria acontecer de jeito nenhum, não é?
Essa única linha derrubou a direção que eu tinha acabado de escolher. O agente releu o código da sonda no Android e se corrigiu.
val verified = verifiedChainSpki(offered, host)
if (verified == null) {
// Falha na validacao pelo trust store = interceptacao/falsificacao -> reporta sem comparar pins
report(host, offered.mapNotNull(::spkiSha256))
return
}
// A comparacao de pins so acontece para a cadeia que passou na validacao
if (verified.none(pins::contains)) { report(host, verified) }
A sonda tem duas etapas. Primeiro valida a cadeia com o trust store do sistema; só a cadeia que passa é comparada com a lista de pins. Nossos 74 eventos já falhavam na primeira etapa. Com verified == null, o relatório sai direto, sem passar pela comparação de pins. Pin de backup só faz efeito na segunda etapa, isto é, no caso "a cadeia foi validada, mas o pin não bate".
Conclusão: adicionar o Starfield G2 aos pins não reduz nem um evento dessa issue.
Foi ali também que ficou confirmado que um usuário normal não consegue gerar esse relatório. A cadeia em produção bate exatamente com os nossos pins. Logo, essas 9 pessoas, por definição, não são usuários normais. Não havia bug a corrigir ali. O que havia era descobrir quem eram essas 9.
Se eu ignorasse, nunca conseguiria ligar
A recomendação de "ignorar e monitorar" também não dava para aceitar.
Tem que consertar de verdade, porque em algum momento eu vou precisar ligar isso - e assim eu não consigo ligar
O objetivo final desse pinning é o modo enforce. O portão que decide se dá para ligar o enforce já estava escrito no documento de design: taxa de mismatch de usuários reais abaixo de 0,5% por duas semanas. Se deixar a natureza dos mismatches sem explicação, esses 74 eventos seguem contaminando o portão. Dar mute faz os 74 sumirem do dashboard, mas o portão continua contaminado do mesmo jeito.
Então fui cavar mais dados. E, assim que puxei a distribuição das tags, apareceu algo estranho.
Os 74 eventos eram do mesmo aparelho - e esse aparelho não existe
A tag device dos 74 eventos era 100% uma só: OnePlus8Pro. Só que a impressão digital desse aparelho era esquisita.
| Campo | Valor no evento | OnePlus 8 Pro de verdade |
|---|---|---|
| archs | inclui x86_64, x86 | só arm64 (Snapdragon 865) |
| Tela | 288 x 448 px, 106 dpi | 1440 x 3168, 513 dpi |
| CPU | 2 núcleos, frequência 0 | 8 núcleos |
| simulator | false | - |
Celular ARM de consumo não anuncia ABI x86. E não existe no mundo celular Android de 288x448 com 106 dpi. É uma máquina virtual x86 que só disfarçou o Build.MODEL com o nome de um aparelho real. E ainda se passava por aparelho físico, com simulator: false.
Os IDs dos 9 usuários também não eram de gente. Os 9 existiram apenas no dia 29 de junho e, nesse único dia, rodaram as três versões: 1.7.0, 1.8.0 e 1.8.1. Não é gente, é pipeline. A região dos 74 eventos era toda os EUA, mas a cidade vinha sempre null. É assim que fica quando o acesso vem de faixas de datacenter.
Aqui eu perguntei: então quem está analisando nosso app? O agente colocou em primeiro lugar sandboxes automatizadas de análise de APK de fornecedores de segurança, seguidas por empresas de app intelligence e sites espelho de APK. A revisão automática da loja ficou em quarto.
A hipótese de quarto lugar virou a primeira por causa de três IPs
O quarto lugar me incomodou. Então perguntei.
Pode ser também o Google Play Console ou a Apple Store?
A resposta veio categórica.
Google Play pre-launch report / Test Lab - quase certamente não. Motivo decisivo: o Google não faz MITM no seu HTTPS. O pre-launch do Play usa o trust store padrão e recebe o seu certificado de verdade, sem alteração. Crawlers de loja não interceptam nem reassinam tráfego. Isso é comportamento de sandbox de análise.
Como argumento de princípio, é impecável. E está errado.
O que faltava era prova de IP. Como o Sentry faz scrub de IP por ser PII, o user.ip de todos os eventos estava null, e o IP original de eventos passados não é recuperável. Para revelar a identidade eu precisava do IP, e a ferramenta que eu tinha não guardava IP.
Em compensação, os logs de acesso da API de produção estavam lá. Cada requisição registrava X-Forwarded-For e User-Agent, e o User-Agent do nosso app era único: Dart/3.12 (dart:io). E o dia 29 de junho ainda estava dentro da janela de retenção dos logs (7 dias).
Primeiro peguei no Sentry os horários dos 74 eventos e os agrupei em três blocos. 02:12-02:20, 11:30-11:41, 17:32-17:40 (UTC). Depois consultei no CloudWatch os IPs das requisições que chegaram com Dart/3.12 nessas três janelas.
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 é a faixa usada pelo Googlebot, e 66.102.x.x também é do Google. Havia IP do Google nas três janelas. Em duas delas, o único IP de app que apareceu naquele horário era o IP do Google. Na terceira, havia também IPs que pareciam de usuários reais, mas o IP do Google estava ali igualmente.
Três minutos depois, o agente se corrigiu sozinho.
Retifico. Antes eu disse "o Google não faz MITM, então provavelmente não é ele", mas a evidência de IP derruba essa suposição. É o Google mesmo. IP medido é evidência mais forte do que suposição.
Quem estava dissecando nosso app era a varredura automática rotineira do Google, a mesma que qualquer app publicado no Play recebe. Não era atacante, nem concorrente, nem fornecedor de segurança. Esse ambiente de varredura instrumenta o tráfego. Por isso, aos olhos da nossa sonda, a cadeia aparecia como não validada.
Então o que foi corrigido
Mantivemos o pinning e mudamos onde aplicar a sonda. Decidimos simplesmente não ligar o pinning em ambientes de scanner.
Faltava definir por qual sinal reconhecer o scanner. Já tinha ficado claro que não dá para confiar no simulator: false, então precisávamos de um sinal que não seja falsificável. Arquitetura de CPU não tem como esconder.
// Sinal decisivo: um celular ARM de consumo nunca anuncia uma ABI x86.
// O scanner do Google pode disfarcar Build.MODEL com um nome de aparelho real (observado: "OnePlus8Pro" em x86),
// mas nao consegue esconder que e x86_64.
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
return true;
}
Além disso, checamos nomes de hardware da família Cuttlefish/GCE e a fingerprint test-keys. O detector é intencionalmente enviesado para o lado do "é emulador". Se der falso positivo, o efeito é apenas pular o pinning naquele ambiente - e um atacante que roda o app num emulador consegue, de qualquer forma, fazer patch no app e neutralizar o pinning do cliente, então não se perde nada. Já o falso negativo é bem pior: significa prender o scanner na tela de bloqueio.
Por que isso importa? Porque, se tivéssemos simplesmente ligado o enforce, teria acontecido o seguinte. O crawler de pre-launch do Google esbarraria na nossa tela de bloqueio de "conexão não segura". Isso seria registrado como falha no pre-launch report do Play Console. Sem nenhum problema no código, uma linha vermelha apareceria na tela de revisão da loja.
O que ficou
O Sentry faz scrub de IP porque trata IP como PII. É o padrão correto. Só que, por causa disso, o único campo capaz de revelar a identidade do episódio tinha sumido do evento. No fim, cruzando duas coisas - o User-Agent do app e o horário dos eventos - recuperamos nos logs de acesso a informação que o Sentry havia descartado. Não estar em uma ferramenta não significa não estar em lugar nenhum.
A frase "o Google não intercepta HTTPS" era elegante como argumento. Passava pelo propósito de um crawler de loja, pelo funcionamento do trust store padrão, pela diferença de comportamento em relação a uma sandbox, e assim empurrava a hipótese para o quarto lugar. Aí bastaram três IPs para a conclusão virar em três minutos. Quando você pergunta o princípio à IA, ela devolve o princípio. Se esse princípio bate com a realidade, só o log responde.
Se tivéssemos dado mute nesses 74 eventos, o dashboard teria ficado limpo, e no dia de ligar o enforce teríamos levado uma linha vermelha na revisão da loja sem saber por quê.
Ainda não ligamos o enforce. O portão exige taxa de mismatch de usuários reais abaixo de 0,5% por duas semanas. Como filtramos o scanner, é hora de contar essas duas semanas de novo, do zero.
Perguntas frequentes
Liguei o certificate pinning em report-only e estão chegando relatórios de mismatch. Adicionar um pin de backup reduz esse número?
Antes de tudo, veja de qual caminho do código o relatório saiu. A sonda de pinning costuma ter duas etapas. Primeiro ela valida a cadeia de certificados contra o trust store do sistema; só depois de passar nessa validação é que compara a cadeia com a lista de pins. Se a validação da cadeia falha (interceptação, falsificação), ela reporta na hora, sem nem chegar à comparação de pins. Todos os nossos relatórios eram do segundo tipo, e por isso nenhum pin de backup reduziria um único evento. Pin de backup só resolve o caso 'a cadeia foi validada, mas o pin não bate'.
Os eventos do Sentry não têm IP. Ainda assim dá para saber a origem do acesso?
O Sentry trata IP como PII e faz o scrub, então na maioria das vezes ele não fica no evento, e o IP original de eventos passados não tem como ser recuperado. A saída é cruzar horários com outros logs. No nosso caso, o log de acesso do servidor guardava X-Forwarded-For e User-Agent a cada requisição, e o User-Agent do app era único: 'Dart/3.12 (dart:io)'. Peguei no Sentry as janelas exatas em que os mismatches aconteceram e consultei os IPs das requisições que chegaram com aquele User-Agent nessas janelas. A origem apareceu.
Ligar o certificate pinning em modo enforce cria problema na revisão da Google Play?
Pode criar. O ambiente de varredura automática do Play aparentemente instrumenta o tráfego, e se o app exibir a tela de bloqueio do pinning nesse ambiente, isso pode ser registrado como falha no pre-launch report. É mais seguro fazer o app pular a própria sonda de pinning em ambientes de scanner ou emulador. De todo jeito, um atacante que roda o app num emulador consegue fazer patch no app e neutralizar o pinning do cliente, então abrir exceção para esse ambiente não reduz o que você está protegendo.
Artigos relacionados
- 💻 Dev
Chegou um alerta dizendo que o banco tinha caído. O banco nunca caiu
Em um único dia acumularam-se quatro alertas CRITICAL. 504, P2028 do Prisma, 500 no admin e 'serviço DATABASE fora do ar'. Abri primeiro as métricas do RDS: durante 21 horas seguidas, a CPU não passou de 19,7%. O lento não era o banco, eram as idas e voltas atravessando o Atlântico vinte e cinco vezes - e o alerta de 'fora do ar' foi o próprio health check que o fabricou.
- 💻 Dev
Os hooks de shutdown rodavam duas vezes - quando o SIGTERM tem dois donos
A cada deploy, um erro P2028 do Prisma caía no Sentry. Fui corrigir a ordem de shutdown e descobri que o enableShutdownHooks do NestJS e a biblioteca de graceful shutdown estavam, os dois, capturando o SIGTERM - fazendo os hooks de shutdown rodarem duas vezes a cada desligamento.
- 💻 Dev
O pipeline que põe outra IA para refutar o código que uma IA corrigiu
Se você pede ao mesmo modelo para revisar o próprio código, ele aprova de novo a conclusão que ele mesmo tirou. Um desenvolvedor solo, que escreve quase todo o código com IA, conta como transformou em pipeline permanente o loop de verificação cruzada em que uma IA manda a outra 'tentar refutar' o trabalho. O caso real em que três rodadas seguidas pegaram bugs que o self-review deixou passar, e o desenho dos prompts.