background blurbackground mobile blur

1/1/1970

Wie ich Foony hinter Proxys zum Laufen gebracht habe

Howdy! Ich weiß schon lange, dass Web-Proxys bei Websites für Kompatibilitätsprobleme sorgen. Aber die Unterstützung von Foony hinter Proxys war berüchtigt schlecht, und Foony halbwegs proxy-kompatibel zu bekommen, war ziemlich knifflig.

Dabei ist das nicht mal ein „Foony benutzt exotische APIs“-Problem (auch wenn wir das tun). Es war eine Kombination aus:

  • Proxys, die sehr aggressiv Strings umschreiben, an Stellen, wo sie es auf keinen Fall tun sollten.
  • Proxys, die die Haupt-Domain der Seite anders behandeln als „andere“ Domains (CDNs, Asset-Hosts usw.).
  • Und der harten Realität, dass manche Proxys moderne Web-Apps einfach nicht verkraften (korrektes HTTPS, WebSockets usw.).

Wir funktionieren nicht mit jedem Proxy, aber immerhin jetzt mit croxyproxy und proxyorb, und genau das war das Ziel.

Unten erkläre ich, was kaputtging, warum es kaputtging und welche Fixes am Ende wirklich wichtig waren.


Durchgang 1: Gültige, aber kaputte Three.js-Shader

Das Symptom

Als ich croxyproxy ausprobiert habe, konnte ich weder 8 Ball Pool noch Foony's andere three.js Spiele spielen. Ich bekam ständig einen Shader-Compiler-Fehler in Three.js mit Meldungen wie:

  • „Shader Error 1282 - VALIDATE_STATUS false“

Diese Meldung war fast komplett nutzlos. Sie heißt im Grunde nur „Dein Shader ist ungültig, viel Erfolg.“ Super. Falls du dich jemals fragst, warum ich bei Foony für jeden einzelnen Fehler eine eigene Fehlermeldung verwende: genau deshalb. So kann man Probleme eingrenzen, statt nur „Code ist kaputt, mach mal“.

Aber warum sind perfekt gültige Three.js-Shader kaputtgegangen? Was war da los?

Die eigentliche Ursache: Proxys zerstören layout(location = N)

Three.js erzeugt GLSL mit Layout-Qualifizierern wie:

layout(location = 0) in vec3 position;

Manche Proxys versuchen alles umzuschreiben, was irgendwie nach der JavaScript-API location aussieht, und machen dafür ein naives globales String-Replacing. Das ist schon in JS ziemlich übel, aber sie machen es auch innerhalb von Shader-Quelltext-Strings. Offenbar ist AST-Parsing zu teuer für sie.

Dadurch wurde der Shader-Quelltext etwa so korrumpiert:

layout(__cpLocation = 0) in vec3 position;

Der Bezeichner dort muss location heißen. Alles andere ist ungültiges GLSL, und der Compiler lehnt es ab. (Layout Qualifiers in GLSL)

Das ist nur insofern ein Three.js-Problem, als Three.js Shader dynamisch generiert und wir sie zur Laufzeit an WebGL übergeben. Der eigentliche Bug liegt in der Umschreib-Strategie des Proxys.

Warum ich nicht einfach „den Proxy repariert“ habe

Ein naiver Ansatz wäre, nach croxyproxys location-Ersatzstring __cpLocation zu suchen und ihn wieder in location zurückzuwandeln. Aber unterschiedliche Proxys verwenden unterschiedliche Ersatznamen. Manche nutzen __cpLocation, andere irgendwelche anderen schrägen Bezeichner. So etwas wie „ersetze __cpLocation wieder durch location“ wäre also extrem fragil.

Ich brauchte:

  • Einen generischen Fix (kein Hardcoding von Proxy-Bezeichnern).
  • Einen Fix, der auch funktioniert, wenn der Proxy das Wort location in meinem JavaScript ebenfalls umschreibt.

Der Base64-Trick: Das Wort location vor dem Proxy verstecken

Wenn der Proxy jedes Literal location umschreibt, ist der einfachste Trick, das Wort location einfach nicht direkt zu verwenden. Klingt simpel genug. Solche Tricks habe ich schon mal in Lua gesehen, als ich RestedXPs Guide-Schutzsystem rückentwickelt habe (wenn ich mich richtig erinnere, verschleiern sie ihren Gebrauch von BNGetInfo, z. B. _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F")).

Das funktioniert natürlich auch in JavaScript. In client/index.html dekodiere ich zur Laufzeit Folgendes:

// Weil diese Proxys versuchen, jedes `location` zu ersetzen, benutzen wir einen Base64-kodierten String.
const suffix = 'pb24=';
const locStr = atob('bG9jYXR' + suffix); // "location"
const loc = window[locStr]; // window.location

Das atob() läuft nachdem der Proxy sein HTML/JS-Rewriting bereits gemacht hat, er kann den String also nicht „vorab kaputtmachen“. Ich teile den String in zwei Teile, um ihn noch schwerer erkennbar zu machen, und verwende 'atob', einfach weil ich es kann. String.fromCharCode oder ein Hex-Escaping wie window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] würden aber genauso gehen.

Das kaputte Shader-Muster ist strukturell immer gleich:

layout(<irgendwas> = <Zahl>)

Also matche ich das generisch und ersetze <irgendwas> durch den richtigen Bezeichner:

source.replace(/layout\s*\(\s*[^=)]+\s*=\s*(\d+)\s*\)/g, 'layout(' + locStr + ' = $1)');

Der WebGL-Hook: shaderSource patchen (WebGL1 + WebGL2)

Da Three.js gl.shaderSource(shader, source) aufruft, patche ich direkt shaderSource:

const originalShaderSource = WebGLRenderingContext.prototype.shaderSource;

Object.defineProperty(WebGLRenderingContext.prototype, 'shaderSource', {
  value: function (shader, source) {
    return originalShaderSource.call(this, shader, fixCorruptedShaderSource(source));
  },
  writable: true,
  configurable: true,
});

Und denselben Patch wende ich auch auf WebGL2RenderingContext an, falls es existiert.

Sobald das drin war, waren die Shader-Compiler-Fehler weg. An diesem Punkt funktionierte croxyproxy, aber proxyorb scheiterte immer noch. Warum?! Sollte das nicht genauso funktionieren?


Durchgang 2: Das zweite Problem (Domains) und warum das Entfernen von foony.io alles einfacher gemacht hat

Foony hat historisch zwei Domains benutzt, zumindest im letzten Monat:

  • foony.com für die Hauptseite
  • foony.io für statische Assets

Der ursprüngliche Grund war praktisch: Assets von einer Cookie-freien Domain zu liefern verhindert Cookie-Header-Aufblähung bei jeder Anfrage nach statischen Dateien. Das ist super, aber nicht ganz so wichtig, wie man denkt, da HTTP/2 mit HPACK die gesendeten Header-Bytes reduziert.

Im normalen Browsing ist das eine sinnvolle Optimierung.

Hinter Proxys wurde es zu einer großen Fehlerquelle. Und die Nutzerbasis von Foony liebt Proxys. seufz

Proxys behandeln „die Hauptseite“ anders als „andere Seiten“

Viele Proxys sind darauf optimiert, „diese eine Seite / Domain zu proxen“. Sie laden das Haupt-HTML, injizieren Skripte, registrieren ihren eigenen ServiceWorker usw.

Aber sobald die App anfängt, Assets von einem anderen Origin zu laden (wie foony.io), wird es schnell unterhaltsam fehlerhaft:

  • ServiceWorker-Abfangfehler wie:
    • „ServiceWorker intercepted the request and encountered an unexpected error“
    • „Loading failed for the module with source“
  • Query-Parameter, die für die Proxy-Infrastruktur zwingend sind (und die man leicht versehentlich entfernt).
  • Asset-Anfragen verlieren die internen Routing-Metadaten des Proxys.
  • Seltsame Proxys, die die komplette Anfrage durch etwas wie generic-php-slug.php?someQueryParam=hugeEncodedString ersetzen (ja, das habe ich nicht auch noch unterstützt).

Diese internen Mechanismen der Proxys hängen stark von Query-Parametern / URL-Encoding ab, und sie sind ziemlich fragil.

Eines der verräterischen Beispiele war eine Asset-URL wie:

https://<proxy-ip>/assets/firebase-<hash>.js?__pot=aHR0cHM6Ly9mb29ueS5jb20

Dieses ?__pot=... ist das eigene Routing/State des Proxys, das ihm sagt, für welche Domain die Anfrage gedacht ist. Wenn du es entfernst, können Proxys die Anfrage nicht mehr korrekt auflösen, und du landest im ServiceWorker-Fehlerpfad.

„Resource Swapping“ zur Rettung (und warum es schnell kompliziert wurde)

Zwischendurch habe ich einen Workaround probiert: erkennen, „wir sind hinter einem Proxy“, und dann alle foony.io-Ressourcen-URLs auf den aktuellen Origin umbiegen, damit der Proxy alles als Same-Origin sieht.

Klingt vernünftig, und bei croxyproxy hat es auch funktioniert, aber es hat jede Menge Komplexität eingeführt:

  • Du musst link- und script-Tags ersetzen, die schon im HTML existieren.
  • Du brauchst einen MutationObserver, um dynamisch injizierte Tags zu erwischen (modulepreload, Stylesheets usw.).
  • Du musst die Query-Parameter des Proxys erhalten, sonst brichst du dessen Routing. Und unterschiedliche Proxys machen das auf unterschiedliche Art. Natürlich.
  • Und der ganze Kram soll trotzdem generisch bleiben (keine Proxy-spezifischen Globals), damit der Code nicht zu einem aufgeblähten Müllhaufen wird.

Hier ist auch wieder der „Base64-Trick“ aufgetaucht: Selbst in meinem eigenen JavaScript musste ich mit dem Literal location aufpassen, weil der Proxy es vielleicht umschreibt.

Reverse Engineering des von CroxyProxy injizierten Skripts

An diesem Punkt war ich neugierig: Was genau macht der Proxy eigentlich mit meiner Seite? Injektiert er eigene Werbung? Etwas Schlimmeres?

Das Client-Skript von CroxyProxy ist stark verschleiert.

(new Function(new TextDecoder('utf-8').decode(new Uint8Array((atob('NjY3NTZlN...')).match(/.{1,2}/g).map(b => parseInt(b, 16))))))();

Ausgeführt ergibt das etwa:

function a0_0x5ebf(_0x213dc9,_0x1c49b6){var _0x4aa7c1=a0_0x4274();return a0_0x5ebf=function(_0x159600,_0x51d898){_0x159600=...

Daran sieht man, dass croxyproxy offenbar Obfuscator.io für die Verschleierung nutzt. Zum Glück lässt sich das mit webcrack recht bequem wieder lesbar machen.

So bekommt man deutlich lesbareres JavaScript:

((_0x15ca2c, _0x489eaa) => {
  if (typeof module == "object" && module.exports) {
    module.exports = _0x489eaa(require("./punycode"), require("./IPv6"), require("./SecondLevelDomains"));
  } else if (typeof define == "function" && define.amd) {
    define(["./punycode", "./IPv6", "./SecondLevelDomains"], _0x489eaa);
  } else {
    _0x15ca2c.URI = _0x489eaa(_0x15ca2c.punycode, _0x15ca2c.IPv6, _0x15ca2c.SecondLevelDomains, _0x15ca2c);
  }
})(this, function (_0x724467, _0x275183, _0x219d84, _0x2dcc2c) {
  var _0x114c1e = _0x2dcc2c && _0x2dcc2c.URI;
  function _0x5a9187(_0x2fd4ea, _0x3bd460) {
    var _0x23a83f = arguments.length >= 1;
    if (!(this instanceof _0x5a9187)) {

Schön. Jetzt sehen wir, was es macht. Und … es wirkt größtenteils okay. Ich vermute, die Verschleierung soll vor allem helfen, den Proxy schwerer erkennbar zu machen. Größtenteils.

Wir haben ein bisschen Ad-/UI-Injektion:

    Bi(_0x308e2f) {
      console.log("Ads: " + _0x331b11.showAds);
      console.log("Ad codes: " + !!_0x331b11.adsJson);
      console.log("Adblock: " + _0x308e2f);
      _0x34fa18.document.body.insertAdjacentHTML("afterbegin", _0x331b11.modal);
      if (_0x331b11.header) {
        if (_0x331b11.header) {
          this.Ri(_0x308e2f);
        }
        [...document.querySelectorAll("#__cpsHeader a")].forEach(_0xcce595 => {
          _0xcce595.addEventListener("click", function (_0x3ef5f1) {
            if (this.target === "_blank") {
              _0x34fa18.open(this.href, "_blank").focus();
            } else {
              _0x34fa18.location.href = this.href;
            }
            _0x3ef5f1.stopImmediatePropagation();
            _0x3ef5f1.preventDefault();
          }, true);
        });
      }
      return this;
    }

Es gibt noch ein paar andere Stellen, aber im Grunde zeigt es einfach Werbung an, inklusive Pop-under-artiger Ads. Außerdem nutzt es FuckAdBlock.

Das eigentliche Umschreiben der Strings passiert allerdings serverseitig. Und wer weiß, was da sonst noch alles gemacht wird.

So oder so solltest du auf keinen Fall Web-Proxys benutzen, wenn dir die Sicherheit deiner Accounts wichtig ist. Wenn es gar nicht anders geht, gib dort keine deiner personenbezogenen Daten (PII) / Account-Daten / Kaufdaten ein.

„Resource Swapping“ in die Tonne

Ich habe entschieden, dass die Komplexität durch Resource Swapping, zusammen mit der Komplexität im restlichen Code für die Unterstützung von foony.io, den kleinen Netzwerksparvorteil durch schöne, Cookie-freie Requests nicht wert war. Seit der Einführung von foony.io haben wir außerdem einen unerklärlichen Rückgang bei unseren Gameplay-Conversions gesehen, daher vermute ich, dass es noch andere Probleme mit foony.io gab, die wir gar nicht kannten.

Also habe ich foony.io rausgeworfen. Zumindest vorerst.

Nachdem ich die gesamte foony.io-CDN-Logik entfernt und alles auf foony.com vereinheitlicht hatte, wurde die Proxy-Unterstützung dramatisch einfacher:

  • Same-Origin-Asset-Loads.
  • Weniger „Spezialfälle“, die man einem Proxy-ServiceWorker erklären muss.
  • Weniger Rewriting.
  • Weniger fragiler Code.

Kurz gesagt: Das Entfernen von foony.io war eine architektonische Vereinfachung, die die Angriffsfläche für seltsames Proxy-Verhalten deutlich reduziert hat.


Durchgang 3: Was funktioniert, was nicht, und warum

Bestätigt funktionierende Proxys

Aktuell funktioniert Foony hinter:

  • croxyproxy
  • proxyorb

Ein paar andere Proxys funktionieren wahrscheinlich auch. Ich wette, die meisten tun es trotzdem nicht. Aber zumindest die wichtigen, über die Leute tatsächlich Spiele spielen, scheinen jetzt zu laufen.

Warum nicht „alle Proxys“?

Manche Proxys können eine moderne Multiplayer-Web-App einfach nicht vernünftig unterstützen. Beispiele:

  • Proxys, die HTTPS nicht richtig unterstützen.
  • Proxys, die WebSockets kaputtmachen oder blockieren (Foony nutzt Echtzeit-Netzwerk). Technisch könnte man das umgehen, aber das würde einiges an Komplexität hinzufügen.
  • Proxys mit zu vielen Beschränkungen bei Cross-Origin-Anfragen, Headern oder ServiceWorkern.

Wichtige Erkenntnisse

Web-Proxys sind sehr unsicher

Sie sind Middleware, die:

  • HTML umschreibt
  • JavaScript umschreibt
  • manchmal einen eigenen ServiceWorker injiziert
  • oft auf Query-Parameter / URL-Encoding für ihr Routing angewiesen ist
  • auf jede erdenkliche Weise an deinen Seiten herumfummeln kann

Ich war überrascht, wie tief manche Proxys gehen: Sie schreiben Shader-Quellstrings um, Kommentare und wer weiß, was noch alles.

Manchmal ist die beste Lösung architektonisch

Der WebGL-Patch hat dafür gesorgt, dass die Spiele wieder rendern, aber das Entfernen der Multi-Domain-CDN-Strategie hat dafür gesorgt, dass die Proxy-Unterstützung dauerhaft stabil blieb.

Eine gute Erinnerung: Clevere Optimierungen sind völlig okay, bis sie auf feindliche Middleware treffen. Oder auf Browser-Erweiterungen der Nutzer. Oder auf Safari. Oder Spracheinstellungen. Oder Barrierefreiheitsfunktionen. Oder Sonnenstürme. Oder einfach irgendetwas.


Fazit

Foony funktioniert jetzt hinter den Proxys, die wichtig sind (croxyproxy und proxyorb), ohne dass der Code zu einem Proxy-spezifischen Chaos verkommt:

  • Ein generischer Three.js-Shader-Fix (keine Proxy-spezifischen Bezeichner).
  • Eine einfachere Domain-Strategie (überall foony.com).
8 Ball Pool online multiplayer billiards icon