Google Translation API Hacking

Google bietet im Rahmen seiner Google Cloud die Google Translation API mit einer nutzungsbasierten Kostenstruktur an. Daneben gibt es eine undokumentierte, ohne Key nutzbare API, die jedoch schon nach wenigen Requests den Dienst verweigert. Bei der Benutzung der Webseiten-Übersetzungsfunktion von Google Chrome fällt auf, dass hier ohne spürbare Begrenzung Seiten in sehr guter Qualität übersetzt werden können.


Offenbar wird hier auch bereits das fortgeschrittene nmt-Model genutzt. Doch welche API nutzt Google Chrome hier intern, um den Inhalt zu übersetzen und kann man diese API auch direkt – sogar serverseitig – ansprechen? Um Netzwerktraffic zu analysieren, empfehlen sich Tools wie Wireshark oder Telerik Fiddler, die auch verschlüsselten Traffic analysieren können. Doch Chrome liefert die Requests, die es bei der Seitenübersetzung absetzt, sogar frei Haus: Sie sind leicht einsehbar über die Chrome DevTools:

Führt man eine Übersetzung durch, catcht anschließend den entscheidenden POST-Request an https://translate.googleapis.com via "Copy > Copy as cURL (bash)" und führt ihn beispielsweise in einem Tool wie Postman aus, kann man den Request ohne Probleme erneut absenden:

Auch die Bedeutung der URL-Parameter sind größtenteils offensichtlich:

KeyBeispiel-ValueBedeutung
anno3Annotation-Mode (hat Auswirkungen auf das Rückgabeformat)
clientte_libClient-Information (variiert, über das Webinterface von Google-Translate lautet der Wert "webapp"; Hat Auswirkungen auf das Rückgabeformat sowie auf das Rate Limiting)
formathtmlString-Format (wichtig für die Übersetzung von HTML-Tags)
v1.0Versionsnummer von Google Translate
keyAIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgwAPI-Key (siehe unten)
logldvTE_20200210_00Protokollversion
sldeQuellsprache
tlenZielsprache
spnmtML-Modell
tc1unbekannt
sr1unbekannt
tk709408.812158Token (siehe unten)
mode1unbekannt

Es werden ebenfalls einige Request Header gesetzt – diese können jedoch größtenteils ignoriert werden. Nach dem manuellen Abwählen aller Header, auch von User-Agent, stellt man bei der Eingabe von Sonderzeichen jedoch ein Encoding-Problem fest (hier bei der Übersetzung von „—Hallo Welt—“):

Aktiviert man den User-Agent wieder (das schadet generell nicht), liefert die API UTF-8 encodete Zeichen aus:

Sind wir nun schon am Ziel und haben alle Informationen, um diese API auch außerhalb von Google Chrome zu nutzen? Ändert man die zu übersetzende Zeichenkette (Datenfeld q des POST-Requests) von beispielsweise „Hallo Welt“ auf „Hallo Welt!“, erhalten wir jedoch eine Fehlermeldung:

Wir führen nun eine erneute Übersetzung dieses abgeänderten innerhalb von Google Chrome mit Hilfe der Webseiten-Übersetzungsfunktion durch und stellen fest, dass sich neben dem Parameter q auch der Parameter tk geändert hat (alle anderen Parameter sind gleichgeblieben):

Offenbar handelt es sich um ein vom String abhängiges Token, dessen Aufbau nicht einfach ersichtlich ist. Startet man die Webseitenübersetzung, werden die folgenden Dateien geladen:

  • 1 CSS-Datei: translateelement.css
  • 4 Grafiken: translate_24dp.png (2x), gen204 (2x)
  • 2 JS-Dateien: main_de.js, element_main.js

Die beiden JavaScript-Dateien sind verschleiert und minifiziert. Tools wie JS Nice und de4js helfen uns nun dabei, diese Dateien besser lesbar zu machen. Um sie überdies live zu debuggen, empfiehlt sich die Chrome Extension Requestly, die on-the-fly Remote-Dateien lokal tunnelt:

Nun können wir den Code debuggen (auf dem lokalen Server muss zuvor noch CORS aktiviert werden). Der relevante Code-Abschnitt für die Generierung des Tokens scheint sich in der Datei element_main.js in diesem Abschnitt zu verbergen:

b7739bf50b2edcf636c43a8f8910def9

Hier wird u.a. mit Hilfe von einigen Bitshifts der Text gehasht. Doch leider fehlt uns noch ein Puzzlestück: An die Funktion Bp() wird neben dem Argument a (das der zu übersetzende Text ist) ein weiteres Argument b übergeben – eine Art Seed, der sich von Zeit zu Zeit zu ändern scheint und der ebenfalls mit in das Hashing einfließt. Doch woher kommt er? Springen wir zum Funktionsaufruf von Bp(), finden wir folgenden Codeabschnitt:

b7739bf50b2edcf636c43a8f8910def9

Die Funktion Hq wird dabei vorher wie folgt deklariert:

b7739bf50b2edcf636c43a8f8910def9

Hier hat der Deobfuscater noch etwas Unrat hinterlassen; Nachdem wir String.fromCharCode('...') durch die jeweiligen Zeichenketten ersetzt haben, das obsolete a() entfernen und die Funktionsaufrufe [c(), c()] zusammenstückeln, ergibt sich:

b7739bf50b2edcf636c43a8f8910def9

Oder noch einfacher:

b7739bf50b2edcf636c43a8f8910def9

Die Funktion yq ist vorher definiert als:

b7739bf50b2edcf636c43a8f8910def9

Der Seed scheint also im globalen Object google.translate._const._ctkk zu stecken, das zur Laufzeit verfügbar ist. Doch wo wird es gesetzt? In der anderen, zuvor geladenen JS-Datei main_de.js zumindest ist es ebenfalls schon zu Beginn verfügbar. Wir fügen dazu am Anfang folgendes ein:

b7739bf50b2edcf636c43a8f8910def9

In der Konsole erhalten wir nun tatsächlich den aktuellen Seed:

Damit bleibt als letzte Möglichkeit noch Google Chrome selbst, der den Seed offenbar zur Verfügung stellt. Glücklicherweise ist dessen Source Code (Chromium, inkl. der Translate-Komponente) Open-Source und damit öffentlich einsehbar. Wir ziehen uns das Repository lokal und finden in der Datei translate_script.cc im Ordner components/translate/core/browser den Aufruf der Funktion TranslateScript::GetTranslateScriptURL:

b7739bf50b2edcf636c43a8f8910def9

Die Variable mit der URL ist in derselben Datei hart definiert:

b7739bf50b2edcf636c43a8f8910def9

Untersuchen wir nun die Datei element.js (nach erneutem deobfuskieren) genauer, finden wir den hart gesetzten Eintrag c._ctkk – auch das google.translate Objekt wird entsprechend gesetzt sowie das Laden aller relevanten Assets (die wir zuvor bereits entdeckt haben) wird getriggert:

b7739bf50b2edcf636c43a8f8910def9

Nun verbleibt zur Betrachtung noch der Parameter key (mit dem Wert AIzaSyBOti4mM-6x9WDnZIjIeyEU21OpBXqWBgw). Das scheint ein generischer Browser-API-Key zu sein (zu dem sich auch einige Google-Ergebnisse finden). Er wird in Chromium in der Datei translate_url_util.cc im Ordner components/translate/core/browser gesetzt:

b7739bf50b2edcf636c43a8f8910def9

Der Key wird in google_apis/google_api_keys.cc aus einem Dummy-Wert generiert:

b7739bf50b2edcf636c43a8f8910def9

Ein Test ergibt jedoch, dass die API-Aufrufe ohne diesen Key-Parameter genauso funktionieren. Experimentiert man nun mit der API, erhält man im Erfolgsfall den Status-Code 200 zurück. Läuft man dann in ein Limit, erhält man den Status-Code 411 mit der Meldung "POST requests require a Content-length header" zurück. Deshalb empfiehlt es sich, diesen (in Postman als Temporary Header automatisch gesetzten) Header ebenfalls mit zu setzen.

Das Rückgabeformat der übersetzten Strings ist bei mehreren Sätzen in einem Request ungewöhnlich. Die einzelnen Sätze sind durch die i-/b-HTML-Tags umschlossen:

Auch sendet Google Chrome nicht das komplette HTML an die API, sondern spart Attributwerte wie href bereits im Request aus (und setzt stattdessen Indizes, um die Tags später wieder clientseitig zuordnen zu können):

Ändert man den Wert des POST-Keys client von te_lib (Google Chrome) auf webapp (Google Translation Webseite), erhält man den final übersetzten String:

Das Problem ist nun aber, dass man deutlich eher in ein Rate Limiting läuft als über te_lib (zum Vergleich: mit webapp erreicht man dieses bereits nach 40.000 Chars, mit te_lib gibt es kein Rate Limiting). Deshalb müssen wir uns genauer ansehen, wie Chrome das Ergebnis parst. In der element_main.js werden wir hier fündig:

b7739bf50b2edcf636c43a8f8910def9

Sendet man den gesamten HTML-Code an die API, belässt diese die Attribute auch in der übersetzten Response. Wir müssen deshalb nicht das komplette Parse-Verhalten imitieren, sondern lediglich den finalen, übersetzten String aus der Response extrahieren. Dazu bauen wir uns einen kleinen HTML-Tag-Parser, der die jeweils äußersten <i>-Tags inkl. deren Inhalte verwirft und die äußersten <b>-Tags entfernt. Mit diesen Erkenntnissen können wir (nach der Installation von Dependencies mit composer require fzaninotto/faker vielhuber/stringhelper) nun eine serverseitige Version der Übersetzungs-API aufbauen:

b7739bf50b2edcf636c43a8f8910def9

Es folgen die Ergebnisse eines ersten Tests, die auf fünf unterschiedlichen Systemen mit unterschiedlichen Bandbreiten und IP-Adressen durchgeführt wurden:

ZeichenZeichen pro RequestDauerFehlerrateKosten über offizielle API
13.064.662~25003:36:17h0%237,78€
24.530.510~25011:09:13h0%446,46€
49.060.211~25020:39:10h0%892,90€
99.074.487~100061:24:37h0%1803,16€
99.072.896~100062:22:20h0%1803,13€
Σ284.802.766~Ø550Σ159:11:37h0%Σ5183,41€

Hinweis: Dieser Blogpost inkl. aller Scripte wurde nur zu Testzwecken verfasst. Verwenden Sie die Scripte nicht für den produktiven Einsatz, sondern arbeiten Sie stattdessen mit der offiziellen Google Translation API.

Zurück