Why NextWhy Next
Zur Übersicht
💻 Entwicklung17 Min. Lesezeit

Wir haben die 9 aufgespürt, die das TLS unserer App abfingen - es war Google

Kaum hatten wir das Certificate Pinning im Report-only-Modus eingeschaltet, sammelten sich in Sentry 74 SPKI-Mismatches. Es sah aus wie ein MITM-Angriff, und die KI schloss eine Store-Prüfung kategorisch aus: 'Google fängt dein HTTPS nicht ab.' Die echten IPs aus den Access-Logs der Produktions-API kippten diese Annahme.

Das Wichtigste

In der ersten Woche mit Certificate Pinning im Report-only-Modus landeten 74 SPKI-Mismatches von 9 Nutzern in Sentry. Das Zertifikat, das die App erhielt, deckte sich mit keinem einzigen unseres Servers - jemand fing also das TLS ab. Der Gerätefingerabdruck zeigte bei allen 74 Fällen ein 'OnePlus 8 Pro', das auf x86 lief und einen Bildschirm von 288x448 hatte. Eine Emulator-Farm, die sich als physisches Gerät ausgab. Während wir die Identität eingrenzten, schloss der KI-Agent die automatische Store-Prüfung mit dem Argument aus, Google mache kein MITM auf dein HTTPS. Doch die echten IPs, die zum Zeitpunkt der Mismatches an der Produktions-API ankamen, gehörten samt und sonders zu Google-Bereichen. Eine aus Prinzipien abgeleitete Annahme fiel binnen drei Minuten in sich zusammen, sobald eine Messung vorlag.

Auf dieser Seite

Unsere App ist schon einmal am Certificate Pinning gestorben. Am 5. April 2026 hatten wir die SPKI des Leaf-Zertifikats als Pin einbetoniert, ACM erneuerte das Zertifikat automatisch, der öffentliche Schlüssel des Leafs änderte sich, und in diesem Moment war die API für alle Nutzer dicht. Einen Remote-Killswitch gab es auch nicht, also mussten wir eine Hotfix-App neu in den Store laden. Seitdem stand im Repo die Regel: "Kein Certificate Pinning".

Zwei Monate später haben wir das Pinning neu gebaut. Diesmal nicht mit dem Leaf, sondern mit den SPKIs der vier Amazon Root CAs. Die Roots ändern sich nicht, wenn ACM erneuert, derselbe Unfall kann also nicht wieder passieren. Dazu kam ein Killswitch, der sich per Remote Config ein- und ausschalten lässt. Am 29. Juni schalteten wir aus der Ferne den Report-only-Modus ein, der nichts blockiert, sondern nur meldet.

Zwei Tage später lag in Sentry ein Issue.

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

Zwei Hashes, die wir nie ausgeliefert haben

Im Event standen die beiden SPKIs des Zertifikats, das die App tatsächlich erhalten hatte.

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

Mit openssl s_client zog ich die echte Serverkette und verglich sie. Weder das Leaf, das unser Server ausliefert, noch das Intermediate, noch die Root deckten sich mit auch nur einem dieser beiden Hashes. Diese 9 Nutzer haben das echte Zertifikat unseres Servers nie zu sehen bekommen. Jemand brach das TLS in der Mitte auf, signierte es mit einem eigenen Zertifikat neu und reichte es an die App weiter.

Auch dass served_spki nur zwei Einträge enthielt, war ein Hinweis. Auf dem normalen Pfad meldet die App eine Kette mit drei Einträgen bis hinauf zur Root. Stehen nur zwei darin (Leaf + Intermediate), stammt dieser Report aus einem anderen Zweig.

Hier zog ich meinen ersten Schluss. Genauer gesagt zog ihn der KI-Agent, und ich fand ihn plausibel. "Das ist kein Bug, den man fixen muss, sondern ein Report-only-Pinning, das genau wie entworfen arbeitet und eine Interception in fremden Netzen aufdeckt. Firmen- oder Schulproxys, der Webschutz eines Virenscanners, Captive Portals. Wir können das nicht kontrollieren, also muten wir es in Sentry und beobachten es nur noch als Kennzahl."

Und wenn wir schon dabei seien, kam der Vorschlag hinterher, könnten wir gleich einen Backup-Pin ergänzen. Ich entschied mich dafür.

Der Backup-Pin senkt dieses Issue um keinen einzigen Fall

Kurz nachdem die Arbeit am neuen Pin begonnen hatte, kam mir das Ganze widersprüchlich vor, und ich fragte zurück.

Dürfte ein Mismatch nicht von vornherein gar nicht auftreten?

Diese eine Zeile brachte die gerade gewählte Richtung zum Einsturz. Der Agent las den Android-Probe-Code noch einmal und korrigierte sich.

val verified = verifiedChainSpki(offered, host)
if (verified == null) {
    // Trust-Store-Pruefung fehlgeschlagen = Interception/Faelschung -> Meldung ohne Pin-Vergleich
    report(host, offered.mapNotNull(::spkiSha256))
    return
}
// Der Pin-Vergleich findet nur bei einer validierten Kette statt
if (verified.none(pins::contains)) { report(host, verified) }

Die Probe hat zwei Stufen. Zuerst wird die Kette gegen den Trust Store des Betriebssystems verifiziert, und nur eine Kette, die das besteht, wird mit der Pin-Liste verglichen. Unsere 74 Fälle scheiterten schon an der ersten Stufe. Da verified == null ist, wird ohne jeden Pin-Vergleich sofort gemeldet. Ein Backup-Pin wirkt erst auf der zweiten Stufe, also nur im Fall "verifiziert, aber Pin passt nicht".

Fazit: Selbst wenn wir Starfield G2 zu den Pins hinzufügen, sinkt die Fallzahl dieses Issues um null.

Damals bestätigte sich auch, dass dieser Report bei normalen Nutzern gar nicht auftauchen kann. Die Live-Kette passt exakt zu unseren Pins. Also sind diese 9 per Definition keine normalen Nutzer. Ein Bug zum Fixen war hier nicht. Zu klären war, wer diese 9 sind.

Wer das ignoriert, kann es nie einschalten

Auch die Empfehlung, es zu ignorieren und nur zu monitoren, konnte ich nicht annehmen.

Das muss richtig gefixt werden. Wir wollen das später ja wirklich scharf schalten, und so wie es jetzt ist, können wir es nie einschalten.

Das Endziel dieses Pinnings ist Enforce. Das Gate, an dem entschieden wird, ob Enforce eingeschaltet werden darf, stand längst im Designdokument. Zwei Wochen lang eine Mismatch-Rate unter 0.5% bei echten Nutzern. Solange die Identität der Mismatches ungeklärt bleibt, vergiften diese 74 Fälle das Gate weiter. Mutet man sie, verschwinden die 74 Fälle zwar vom Dashboard, aber das Gate bleibt vergiftet.

Also grub ich tiefer in den Daten. Kaum hatte ich die Tag-Verteilung gezogen, kam etwas Merkwürdiges zum Vorschein.

Alle 74 Fälle stammen vom selben Gerät, und dieses Gerät existiert nicht

Der device-Tag der 74 Events war zu 100% ein einziger: OnePlus8Pro. Nur war der Fingerabdruck dieses Geräts seltsam.

FeldWert im EventEchtes OnePlus 8 Pro
archsenthält x86_64, x86nur arm64 (Snapdragon 865)
Bildschirm288 x 448 px, 106 dpi1440 x 3168, 513 dpi
CPU2 Kerne, Frequenz 08 Kerne
simulatorfalse-

Ein ARM-Smartphone für Endkunden bewirbt keine x86-ABI. Und ein Android-Phone mit 288x448 bei 106 dpi gibt es auf der Welt nicht. Das ist eine virtuelle x86-Maschine, bei der einzig Build.MODEL als echter Gerätename getarnt ist. Mit simulator: false gab sie sich obendrein als physisches Gerät aus.

Auch die 9 Nutzer-IDs waren keine Menschen. Alle 9 existierten ausschließlich am 29. Juni, und an diesem einen Tag ließen sie die drei Versionen 1.7.0, 1.8.0 und 1.8.1 durchlaufen. Kein Mensch, sondern eine Pipeline. Als Region stand bei allen 74 Fällen die USA, die Stadt war jedoch durchweg null. So sieht es aus, wenn Anfragen aus einem Rechenzentrumsbereich kommen.

Hier fragte ich: Wer nimmt unsere App eigentlich auseinander? Der Agent setzte die automatischen APK-Analyse-Sandboxen von Sicherheitsanbietern auf Platz eins, dahinter kamen App-Intelligence-Firmen und APK-Mirror-Seiten. Die automatische Store-Prüfung stand auf Platz vier.

Aus der Hypothese auf Platz vier wurde mit drei IPs Platz eins

Mich beschäftigte Platz vier. Also fragte ich.

Kann es auch die Google Play Console oder der Apple Store sein?

Die Antwort kam kategorisch.

Google Play pre-launch report / Test Lab - so gut wie ausgeschlossen. Der entscheidende Grund: Google fängt dein HTTPS nicht ab. Play pre-launch benutzt den Standard-Trust-Store und bekommt dein echtes Zertifikat unverändert. Store-Crawler fangen keinen Traffic ab und signieren ihn nicht neu. Das ist das Verhalten von Analyse-Sandboxen.

Vom Prinzip her ist das eine tadellose Argumentation. Und sie war falsch.

Was fehlte, waren IP-Belege. Sentry scrubbt IPs als PII, deshalb war user.ip in allen Events null, und die Original-IP vergangener Events lässt sich nicht wiederherstellen. Um die Identität zu klären, brauchte es IPs, und kein Werkzeug hatte welche.

Dafür lagen die Access-Logs der Produktions-API vor. Sie hielten pro Anfrage X-Forwarded-For und User-Agent fest, und der User-Agent unserer App war mit Dart/3.12 (dart:io) eindeutig. Und der 29. Juni lag noch innerhalb der Aufbewahrungsfrist der Logs (7 Tage).

Zuerst zog ich aus Sentry die Zeitpunkte der 74 Events und bündelte sie zu drei Blöcken. 02:12-02:20, 11:30-11:41, 17:32-17:40 (UTC). Danach fragte ich in CloudWatch die IPs der Anfragen ab, die in diesen drei Fenstern mit Dart/3.12 hereinkamen.

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 ist der Bereich, den Googlebot benutzt, und 66.102.x.x gehört ebenfalls Google. In allen drei Fenstern lag eine Google-IP. In zwei davon war die Google-IP die einzige App-Anfrage in diesem Zeitraum. Im verbleibenden Fenster gab es daneben auch IPs, die nach echten Nutzern aussahen, aber die Google-IP war auch dort.

Drei Minuten später korrigierte sich der Agent selbst.

Ich korrigiere mich. Ich hatte vorhin gesagt, es sei wohl nicht Google, weil Google kein MITM macht. Der IP-Beleg kippt diese Annahme. Es ist tatsächlich Google. Eine gemessene IP ist ein stärkerer Beleg als eine Ableitung aus Prinzipien.

Was unsere App auseinandernahm, war der ganz gewöhnliche automatische Scan von Google, den jede App bekommt, die im Play Store liegt. Kein Angreifer, kein Wettbewerber, kein Sicherheitsanbieter. Diese Scan-Umgebung instrumentiert den Traffic. Deshalb sah unsere Probe eine Kette, die sich nicht verifizieren ließ.

Was wir also gefixt haben

Das Pinning blieb, geändert haben wir, wo die Probe überhaupt greift. In Scanner-Umgebungen schalten wir das Pinning gar nicht erst ein.

Blieb die Frage, woran wir einen Scanner erkennen. Dass simulator: false nicht zu trauen ist, hatte sich bereits gezeigt, wir brauchten also ein Signal, das sich nicht spoofen lässt. Die CPU-Architektur lässt sich nicht verbergen.

// Entscheidendes Signal: Ein ARM-Consumer-Telefon meldet niemals eine x86-ABI.
// Googles Scanner kann Build.MODEL mit einem echten Geraetenamen tarnen (beobachtet: "OnePlus8Pro" auf x86),
// aber die Tatsache x86_64 laesst sich nicht verbergen.
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
  return true;
}

Zusätzlich prüfen wir Hardwarenamen aus der Cuttlefish/GCE-Familie und test-keys-Fingerprints. Der Detektor ist absichtlich in Richtung "das ist ein Emulator" verzerrt. Bei einem Fehlalarm wird lediglich in dieser einen Umgebung das Pinning übersprungen, und ein Angreifer, der die App im Emulator laufen lässt, kann sie ohnehin patchen und das Client-Pinning aushebeln, wir verlieren also nichts. Umgekehrt ist ein übersehener Scanner deutlich schlimmer, denn dann sperren wir ihn in den Blockbildschirm.

Wichtig ist das, weil bei einfach eingeschaltetem Enforce Folgendes passiert wäre. Der Pre-launch-Crawler von Google läuft in den Bildschirm "Unsichere Verbindung" unserer App. Das landet unverändert als Fehlschlag im Pre-launch-Report der Play Console. Am Code ist nichts falsch, und trotzdem bekommt der Prüfbildschirm des Stores einen roten Strich.

Was bleibt

Sentry scrubbt IPs als PII. Das ist der richtige Default. Nur war dadurch ausgerechnet das eine Feld, das den Fall aufklären konnte, aus dem Event verschwunden. Am Ende kreuzten wir zwei Dinge, den User-Agent der App und den Zeitpunkt der Ereignisse, und holten uns die von Sentry verworfene Information aus den Access-Logs zurück. Was in einem Werkzeug fehlt, fehlt nicht überall.

Der Satz "Google fängt dein HTTPS nicht ab" war als Argument glatt. Zweck des Store-Crawlers, Verhalten des Standard-Trust-Stores, Unterschied zum Vorgehen einer Sandbox: Punkt für Punkt wurde die Hypothese auf Platz vier geschoben. Und dann kippte der Schluss binnen drei Minuten, als drei IPs auf dem Tisch lagen. Fragt man eine KI nach dem Prinzip, bekommt man das Prinzip zurück. Ob dieses Prinzip zur Realität passt, sieht man nur in den Logs.

Hätten wir diese 74 Fälle in Sentry gemutet, wäre das Dashboard sauber gewesen, und an dem Tag, an dem wir Enforce einschalten, hätten wir ohne zu wissen warum einen roten Strich in der Store-Prüfung kassiert.

Enforce haben wir noch nicht eingeschaltet. Das Gate verlangt zwei Wochen lang eine Mismatch-Rate unter 0.5% bei echten Nutzern. Jetzt, wo die Scanner herausgefiltert sind, müssen wir diese zwei Wochen von vorn zählen.

Häufige Fragen

Ich habe Certificate Pinning im Report-only-Modus eingeschaltet und bekomme Mismatch-Reports. Sinken die, wenn ich einen Backup-Pin ergänze?

Zuerst musst du schauen, aus welchem Codepfad der Report stammt. Eine Pinning-Probe arbeitet meist zweistufig. Erst wird die Zertifikatskette gegen den Trust Store des Betriebssystems verifiziert, und erst wenn diese Prüfung bestanden ist, wird die Kette mit der Pin-Liste verglichen. Scheitert die Kettenprüfung selbst (Interception, Fälschung), wird ohne jeden Pin-Vergleich sofort gemeldet. Unsere Reports waren durchweg der zweite Fall, und deshalb sinkt die Zahl der Reports um keine einzige Meldung, egal wie viele Backup-Pins man hinzufügt. Ein Backup-Pin wirkt nur, wenn die Kette verifiziert wurde, der Pin aber nicht passt.

In den Sentry-Events fehlt die IP. Kann man die Herkunft der Zugriffe trotzdem herausfinden?

Sentry scrubbt IPs als PII, sie fehlen also oft im Event, und die Original-IP vergangener Events lässt sich nicht wiederherstellen. Stattdessen kann man andere Logs über den Zeitpunkt kreuzen. Unsere Server-Access-Logs hielten pro Anfrage X-Forwarded-For und User-Agent fest, und der User-Agent unserer App war mit 'Dart/3.12 (dart:io)' eindeutig. Wir zogen aus Sentry die exakten Zeitfenster der Mismatches und fragten die IPs der Anfragen ab, die in diesen Fenstern mit genau diesem User-Agent hereinkamen. Damit lag die Herkunft offen.

Bereitet Certificate Pinning im Enforce-Modus Probleme bei der Google-Play-Prüfung?

Das kann passieren. Die automatische Scan-Umgebung von Play instrumentiert offenbar den Traffic, und wenn die App in dieser Umgebung einen Pinning-Blockbildschirm zeigt, kann das im Pre-launch-Report als Fehlschlag protokolliert werden. Sicherer ist es, die Pinning-Probe in Scanner- oder Emulator-Umgebungen von vornherein zu überspringen. Ein Angreifer, der die App ohnehin im Emulator laufen lässt, kann sie patchen und das Client-Pinning aushebeln - diese Umgebung auszunehmen verkleinert den Schutzumfang also nicht.

Ähnliche Beiträge

0 Kommentare