Why NextWhy Next
Volver a la lista
💻 Desarrollo17 min de lectura

Rastreé a los 9 que interceptaban el TLS de nuestra app y resultó ser Google

Al encender el certificate pinning en modo report-only, en Sentry se acumularon 74 eventos de SPKI mismatch. Parecía un ataque MITM, y la IA descartó de plano la revisión automática de la tienda: 'Google no intercepta tu HTTPS'. Las IPs reales sacadas de los logs de la API en producción tumbaron esa suposición.

Lo esencial

La primera semana con el certificate pinning en modo report-only, en Sentry aparecieron 74 eventos de SPKI mismatch (9 usuarios). El certificado que recibía la app no coincidía en nada con el de nuestro servidor, así que alguien estaba interceptando el TLS. Al mirar la huella de los dispositivos, los 74 eventos eran de un 'OnePlus 8 Pro' con arquitectura x86 y una pantalla de 288x448. Era una granja de emuladores disfrazada de hardware real. Mientras acotaba la identidad, el agente de IA descartó la revisión automática de la tienda con un 'Google no hace MITM a tu HTTPS', pero las IPs reales que entraron en la API de producción a la hora de los mismatch eran todas de rangos de Google. Una suposición derivada de los principios se derrumbó en tres minutos ante una medición.

En esta página

Nuestra app ya se cayó una vez por culpa del certificate pinning. El 5 de abril de 2026 teníamos clavado como pin el SPKI del certificado leaf, ACM renovó el certificado de forma automática, la clave pública del leaf cambió y en ese instante la API se bloqueó para todos los usuarios. Tampoco había un kill switch remoto, así que hubo que volver a subir a la tienda una app con el hotfix. Desde entonces quedó una regla escrita en el repo: "prohibido introducir certificate pinning".

Dos meses después volvimos a construirlo. Esta vez no clavamos el leaf, sino el SPKI de las cuatro Amazon Root CA. Las raíces no cambian aunque ACM renueve, así que el mismo accidente no puede repetirse. Le añadimos también un kill switch para encenderlo y apagarlo por configuración remota. El 29 de junio activamos en remoto el modo report-only, que no bloquea nada y se limita a reportar.

Dos días después había una issue en Sentry.

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

Dos hashes que nunca enviamos

Al abrir el evento, ahí estaban los dos SPKI del certificado que la app había recibido de verdad.

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

Saqué la cadena real del servidor con openssl s_client y la comparé. Ni el leaf que envía nuestro servidor, ni el intermedio, ni la raíz coincidían con ninguno de esos dos hashes. Estos 9 usuarios no llegaron a recibir en ningún momento el certificado auténtico de nuestro servidor. Alguien estaba cortando el TLS por el medio y volviendo a firmarlo con su propio certificado antes de entregárselo a la app.

Que served_spki tuviera solo dos entradas también era una pista. Por la ruta normal, la cadena que reporta la app lleva tres elementos, raíz incluida. Si aquí solo aparecen dos (leaf + intermedio), este reporte sale de otra rama del código.

Aquí saqué mi primera conclusión. Para ser exactos, la sacó el agente de IA y a mí me pareció plausible. "Esto no es un bug que arreglar: es el pinning en report-only funcionando según lo diseñado y cazando interceptaciones que ocurren en redes ajenas. Proxies de empresa o de colegio, la protección web de un antivirus, portales cautivos. No están bajo nuestro control, así que silenciémoslo en Sentry y mirémoslo solo como métrica."

Ya puestos, venía adjunta la propuesta de añadir un pin de respaldo. Elegí eso.

Un pin de respaldo no reduce ni un solo evento de esta issue

Nada más arrancar el trabajo de añadir el pin, me pareció que aquello no cuadraba y repregunté.

¿Pero no era que el mismatch no debería producirse en primer lugar?

Esa sola línea tumbó la dirección que acababa de elegir. El agente releyó el código de la sonda de Android y se corrigió.

val verified = verifiedChainSpki(offered, host)
if (verified == null) {
    // Fallo de validacion contra el almacen de confianza = interceptacion/falsificacion -> se reporta sin comparar pins
    report(host, offered.mapNotNull(::spkiSha256))
    return
}
// La comparacion de pins solo ocurre con una cadena que paso la validacion
if (verified.none(pins::contains)) { report(host, verified) }

La sonda tiene dos fases. Primero valida la cadena contra el almacén de confianza del sistema operativo y solo las cadenas que pasan se comparan con la lista de pines. Nuestros 74 eventos ya fallaban en la primera fase. Como verified == null, se reportan de inmediato sin llegar siquiera a la comparación de pines. El pin de respaldo solo tiene efecto en la segunda fase, es decir, en el caso de "la cadena valida, pero el pin no coincide".

Conclusión: aunque añadas Starfield G2 a los pines, el número de eventos de esta issue no baja ni en uno.

También quedó confirmado entonces que a un usuario normal no le puede saltar este reporte, porque la cadena en vivo encaja exactamente con nuestros pines. Así que estos 9, por definición, no son usuarios normales. Aquí no había ningún bug que arreglar. Lo que había que averiguar era quiénes eran estos 9.

Si lo ignoro, no lo podré activar nunca

Tampoco podía aceptar la recomendación de "ignorarlo y monitorizar".

Hay que arreglarlo bien, porque esto hay que activarlo de verdad más adelante y así no lo vamos a poder activar.

El objetivo final de este pinning es enforce. La puerta que decide si se puede encender enforce ya estaba escrita en el documento de diseño: durante dos semanas, la tasa de mismatch de usuarios reales debe quedar por debajo del 0.5%. Si dejo el mismatch sin identificar, estos 74 eventos siguen contaminando la puerta. Si los silencio, los 74 desaparecen del dashboard pero la puerta se queda contaminada igual.

Así que cavé más en los datos. Nada más sacar la distribución de tags apareció algo raro.

Los 74 eventos son del mismo dispositivo, y ese dispositivo no existe

El tag device de los 74 eventos era en el 100% de los casos OnePlus8Pro. Pero la huella de ese dispositivo era extraña.

CampoValor del eventoOnePlus 8 Pro real
archsincluye x86_64, x86solo arm64 (Snapdragon 865)
pantalla288 x 448 px, 106 dpi1440 x 3168, 513 dpi
CPU2 núcleos, frecuencia 08 núcleos
simulatorfalse-

Un teléfono ARM de consumo no anuncia ABIs x86. Tampoco existe en el mundo un móvil Android de 288x448 a 106 dpi. Es una máquina virtual x86 que solo ha disfrazado Build.MODEL con el nombre de un dispositivo real. Y encima se hacía pasar por hardware físico con simulator: false.

Los IDs de los 9 usuarios tampoco eran personas. Los nueve existieron únicamente durante el día 29 de junio y, en ese único día, ejecutaron las tres versiones 1.7.0, 1.8.0 y 1.8.1. Eso no es una persona, es un pipeline. La región de los 74 eventos era Estados Unidos, pero la ciudad estaba a null en todos. Así es como queda registrado lo que entra desde un rango de centro de datos.

Aquí pregunté quién estaba analizando nuestra app. El agente puso en primer lugar los sandbox de análisis automático de APK de los proveedores de seguridad, seguidos de las empresas de app intelligence y los sitios espejo de APK. La revisión automática de la tienda quedó en cuarto lugar.

La hipótesis que iba cuarta pasó a primera con tres IPs

A mí me escamaba la cuarta. Así que pregunté.

¿Puede darse el caso de que sea Google Play Console o la Apple Store?

La respuesta que volvió fue tajante.

Google Play pre-launch report / Test Lab - casi seguro que no. Razón decisiva: Google no hace MITM a tu HTTPS. El pre-launch de Play recibe tu certificado auténtico tal cual, con el almacén de confianza estándar. Los crawlers de la tienda no interceptan el tráfico ni lo vuelven a firmar. Eso es lo que hacen los sandbox de análisis.

Visto solo desde los principios, es un razonamiento impecable. Y está equivocado.

Lo que faltaba era evidencia de IPs. Como Sentry limpia las IPs por PII, el user.ip de todos los eventos estaba a null, y la IP original de un evento pasado no se puede recuperar. Para destapar la identidad hacía falta la IP, y ninguna herramienta la tenía.

A cambio, quedaban los logs de acceso de la API de producción. Registraban X-Forwarded-For y User-Agent en cada petición, y el User-Agent de nuestra app era único: Dart/3.12 (dart:io). Y el 29 de junio todavía caía dentro del periodo de retención de los logs (7 días).

Primero saqué de Sentry las marcas de tiempo de los 74 eventos y las agrupé en tres bloques. 02:12-02:20, 11:30-11:41, 17:32-17:40 (UTC). Después consulté en CloudWatch las IPs de las peticiones que entraron con Dart/3.12 en esos tres intervalos.

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 es el rango que usa Googlebot, y 66.102.x.x también es de Google. Había IPs de Google en los tres intervalos. En dos de ellos, la única IP de la app que entró en esa franja era la de Google. En el intervalo restante también aparecía una IP que parecía de un usuario real, pero la IP de Google estaba ahí igualmente.

Tres minutos después, el agente se corrigió a sí mismo.

Me corrijo. Antes le dije que "no puede ser Google, porque Google no hace MITM", pero la evidencia de IPs tumba esa suposición. Esto sí es Google. Una IP medida es una evidencia más fuerte que una suposición.

Lo que estaba destripando nuestra app era el escaneo automático rutinario de Google, ese que recibe cualquier app publicada en Play. Ni un atacante, ni un competidor, ni un proveedor de seguridad. Ese entorno de escaneo instrumenta el tráfico. Por eso, a ojos de nuestra sonda, la cadena aparecía como no validada.

Qué arreglamos entonces

Dejamos el pinning donde estaba y cambiamos dónde se aplica la sonda. Decidimos no encender el pinning en absoluto en entornos de escáner.

Quedaba decidir con qué reconocer al escáner. Ya había quedado claro que no podíamos fiarnos de simulator: false, así que hacía falta una señal que no se pudiera falsificar. La arquitectura de la CPU no se puede esconder.

// Senal decisiva: un telefono ARM de consumo nunca anuncia una ABI x86.
// El escaner de Google puede disfrazar Build.MODEL con el nombre de un equipo real (observado: "OnePlus8Pro" sobre x86),
// pero no puede ocultar que es x86_64.
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
  return true;
}

A eso se le añade la comprobación de los nombres de hardware de la familia Cuttlefish/GCE y de la huella test-keys. El detector está sesgado a propósito hacia el lado de "esto es un emulador". Si hay un falso positivo, lo único que ocurre es que en ese entorno se salta el pinning, y un atacante que ejecute la app en un emulador puede parchearla igualmente para anular el pinning del cliente, así que no perdemos nada. Un falso negativo, en cambio, es mucho peor: deja al escáner encerrado en la pantalla de bloqueo.

Esto importa porque, si hubiéramos encendido enforce sin más, habría pasado lo siguiente. El crawler pre-launch de Google se topa con la pantalla de bloqueo de "conexión no segura" de nuestra app. Eso queda registrado tal cual como fallo en el informe pre-launch de Play Console. No hay nada mal en el código y, aun así, aparece una raya roja en la pantalla de revisión de la tienda.

Lo que queda

Sentry limpia las IPs por PII. Es el valor por defecto correcto. Pero, por eso mismo, el único campo capaz de destapar la identidad del caso había desaparecido del evento. Al final crucé dos cosas, el User-Agent de la app y la hora de los sucesos, y recuperé de los logs de acceso la información que Sentry había tirado. Que algo no esté en una herramienta no significa que no esté en ninguna parte.

La frase "Google no intercepta tu HTTPS" era, como argumento, impecable. Repasaba el propósito de los crawlers de la tienda, el funcionamiento del almacén de confianza estándar y la diferencia de conducta respecto a un sandbox, y con todo eso empujó la hipótesis al cuarto puesto. Y bastó ponerle delante tres IPs para que la conclusión se diera la vuelta en tres minutos. Si le preguntas a una IA por los principios, te devuelve los principios. Si esos principios encajan con la realidad, eso solo lo dicen los logs.

Si hubiéramos silenciado estos 74 eventos en Sentry, el dashboard habría quedado limpio y, el día que activáramos enforce, nos habríamos llevado una raya roja en la revisión de la tienda sin saber por qué.

Todavía no hemos encendido enforce. La puerta exige que durante dos semanas la tasa de mismatch de usuarios reales quede por debajo del 0.5%. Ahora que hemos filtrado al escáner, hay que volver a contar esas dos semanas desde cero.

Preguntas frecuentes

Encendí el certificate pinning en report-only y me llegan reportes de mismatch. ¿Se reducen si añado un pin de respaldo?

Lo primero es ver de qué rama del código sale el reporte. Una sonda de pinning suele tener dos fases: primero valida la cadena de certificados contra el almacén de confianza del sistema operativo y, solo después de que esa validación pase, compara la cadena con la lista de pines. Si la validación de la cadena falla (interceptación, falsificación), reporta de inmediato sin pasar siquiera por la comparación de pines. Nuestros reportes eran todos de este segundo tipo, así que por muchos pines de respaldo que añadamos no baja ni un solo evento. El pin de respaldo solo sirve cuando 'la cadena valida, pero el pin no coincide'.

Los eventos de Sentry no traen IP. ¿Aun así puedo saber de dónde vienen las conexiones?

Sentry limpia las IPs por considerarlas PII, así que muchas veces no quedan en el evento, y la IP original de un evento pasado no se puede recuperar. Lo que sí puedes hacer es cruzar otros logs con las marcas de tiempo. En nuestro caso, los logs de acceso del servidor guardaban X-Forwarded-For y User-Agent en cada petición, y el User-Agent de la app era único: 'Dart/3.12 (dart:io)'. Saqué de Sentry los intervalos exactos en los que se produjeron los mismatch, consulté las IPs de las peticiones que entraron con ese User-Agent en esos intervalos, y el origen quedó al descubierto.

Si activo el certificate pinning en modo enforce, ¿me dará problemas la revisión de Google Play?

Puede. Todo apunta a que el entorno de escaneo automático de Play instrumenta el tráfico y, si en ese entorno la app muestra la pantalla de bloqueo del pinning, eso puede quedar registrado como fallo en el informe pre-launch. Lo más seguro es hacer que la sonda de pinning se salte por completo en entornos de escáner o emulador. Al fin y al cabo, un atacante que ejecute la app en un emulador puede parchearla para anular el pinning del cliente, así que dejar ese entorno como excepción no reduce lo que estás protegiendo.

Artículos relacionados

0 comentarios