background blurbackground mobile blur

1/1/1970

Hur jag fick Foony att funka bakom proxyservrar

Hallå där! Jag har vetat länge att webbproxys kan skapa kompatibilitetsproblem för webbplatser. Men Foonys stöd bakom proxys har varit ökänd dåligt, och att lösa Foonys proxy-kompatibilitet var rätt knepigt.

Det här är inte heller ett ”Foony använder konstiga API:er”-problem (även om vi gör det). Det var en kombination av:

  • Proxys som aggressivt skriver om strängar på ställen där de absolut inte borde göra det.
  • Proxys som behandlar den huvudsakliga domänen annorlunda än ”andra” domäner (CDN:er, asset-servrar osv).
  • Och den hårda verkligheten att vissa proxys bara inte klarar moderna webbappar (korrekt HTTPS, WebSockets osv).

Vi funkar inte med varje proxy, men nu funkar vi åtminstone med croxyproxy och proxyorb, och det var målet.

Här nedan förklarar jag vad som gick sönder, varför det gick sönder och vilka fixar som faktiskt spelade roll.


Pass 1: Giltiga men trasiga Three.js-shaders

Symptomet

När jag testade croxyproxy kunde jag inte spela 8 Ball Pool eller några av Foonys andra three.js spel. Jag fick hela tiden shader-kompilieringsfel i Three.js med felmeddelanden som:

  • ”Shader Error 1282 - VALIDATE_STATUS false”

Det meddelandet var i princip helt värdelöst. Det betyder oftast ”din shader är ogiltig, lycka till”. Kul. Om du någonsin undrar varför jag alltid har unika felmeddelanden för varenda fel på Foony, så är det därför. Det hjälper att ringa in problemet i stället för bara ”koden är trasig, fixa den.”

Men varför gick helt korrekta three.js-shaders sönder? Vad var det som hände?

Den verkliga orsaken: proxys som sabbar layout(location = N)

Three.js genererar GLSL med layout-qualifiers som:

layout(location = 0) in vec3 position;

Vissa proxys försöker skriva om allt som liknar JavaScripts location-API genom att göra naiva globala strängersättningar. Det är redan illa i JS, men de gjorde det även inuti shader-källkoden. Gissar att AST-parsning är för dyrt för dem.

Så shader-koden blev korrupt och såg ut ungefär så här:

layout(__cpLocation = 0) in vec3 position;

Identifieraren måste vara location där. Vad som helst annat är ogiltig GLSL, och kompilatorn vägrar den. (Layout-qualifiers i GLSL)

Det här är ett Three.js-problem bara i den meningen att Three.js genererar shaders dynamiskt, och vi skickar dem till WebGL vid körning. Den riktiga buggen är proxyns strategi för omskrivning.

Varför jag inte ”fixade proxyn”

En naiv lösning vore att leta efter croxyproxys ersättningssträng för location, alltså __cpLocation, och byta tillbaka den till location. Men olika proxys använder olika ersättningsnamn. Vissa använder __cpLocation, andra använder andra konstiga identifierare. Så att hårdkoda en fix som ”ersätt __cpLocation med location igen” är väldigt skört.

Jag behövde:

  • En generell fix (inga hårdkodade proxy-identifierare).
  • En fix som funkar även om proxyn skriver om ordet location i min JavaScript också.

Base64-tricket: gömma ordet location för proxyn

Om proxyn skriver om varje bokstavlig location den ser, är det enklaste att bara inte använda location. Lätt nog. Jag har sett tricks för det här tidigare i Lua när jag reverse-engineerade RestedXP:s skyddssystem för guider (om jag minns rätt obfuskerar de sin användning av BNGetInfo, t.ex. _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F")).

Det här tricket funkar förstås i JavaScript också. I client/index.html avkodar jag följande vid körning:

// Because these proxies try to replace every `location`, we use a base64 encoded string.
const suffix = 'pb24=';
const locStr = atob('bG9jYXR' + suffix); // "location"
const loc = window[locStr]; // window.location

Det där atob() körs efter att proxyn redan har gjort sin HTML/JS-omskrivning, så den kan inte ”för-korrupta” strängen. Jag delar upp strängen i två delar för att göra den ännu svårare att upptäcka, och jag använder 'atob' bara för att jag kan, men String.fromCharCode eller hex-escaping av window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] skulle också kunna funka.

Det trasiga shader-mönstret är alltid strukturellt detsamma:

layout(<something> = <number>)

Så jag matchar det generellt och ersätter <something> med rätt identifierare:

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

WebGL-hooken: patcha shaderSource (WebGL1 + WebGL2)

Eftersom Three.js anropar gl.shaderSource(shader, source) patchar jag shaderSource direkt:

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,
});

Och jag lägger samma patch på WebGL2RenderingContext om det finns.

När det väl var på plats försvann shader-kompilieringsfelen. Vid det här laget funkade croxyproxy, men proxyorb kraschade fortfarande. Varför?! Borde inte det funka likadant?


Pass 2: Det andra problemet (domäner) och varför jag tog bort foony.io och allt blev enklare

Foony har historiskt använt två domäner, åtminstone den senaste månaden:

  • foony.com för huvudsajten
  • foony.io för statiska tillgångar

Den ursprungliga anledningen var praktisk: om man serverar tillgångar från en cookie-fri domän slipper man svullna cookie-headers som följer med varje statisk filförfrågan. Det är toppen, men inte nödvändigt som man kan tro eftersom HTTP/2 använder HPACK för att minska antalet header-byte som skickas över nätet.

Det är en vettig optimering vid normalt surfande.

Bakom proxys blev det en stor källa till strul. Och Foonys användare älskar proxys. suck

Proxys behandlar ”huvudsajten” annorlunda än ”andra sajter”

Många proxys är optimerade för ”proxya just den här sidan/domänen”. De laddar snällt in huvud-HTML:en, injicerar sina skript, registrerar sin egen ServiceWorker osv.

Men när appen börjar hämta tillgångar från en annan origin (som foony.io) får du alla möjliga roliga, trasiga beteenden:

  • ServiceWorker-intercept-fel som:
    • ”ServiceWorker intercepted the request and encountered an unexpected error”
    • ”Loading failed for the module with source”
  • Query-parametrar som krävs av proxy-infrastrukturen (och som är lätta att råka ta bort).
  • Asset-förfrågningar som tappar proxyns interna routing-metadata.
  • Konstiga proxys som ersätter hela förfrågan med generic-php-slug.php?someQueryParam=hugeEncodedString (ja, jag orkade inte ens försöka stödja det).

De här proxys interna mekanismer beror på query-parametrar/URL-kodning, och de är rätt sköra.

Ett tydligt exempel var en asset-URL som:

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

Det där ?__pot=... är proxyns egen routing/state som talar om för proxyn vilken domän förfrågan egentligen gäller. Om du tar bort det kan proxyn inte resolva förfrågan korrekt, och du hamnar på ServiceWorker-felstigen.

”Resource swapping” till undsättning (och varför det snabbt blev krångligt)

Vid ett tillfälle testade jag en workaround: upptäck ”vi är proxade”, och byt sedan alla foony.io-resource-URL:er till den aktuella origin:en så att proxyn ser allt som same-origin.

Det låter rimligt, och det funkade i croxyproxy, men det lade till en massa komplexitet:

  • Du måste ersätta link- och script-taggar som redan finns i HTML:en.
  • Du behöver en MutationObserver för att hantera dynamiskt injicerade taggar (modulepreload, stylesheets osv).
  • Du måste bevara proxyns query-parametrar, annars bryter du deras routing. Och olika proxys gör det här på olika sätt. Självklart gör de det.
  • Och du måste ändå hålla logiken generell (inga proxy-specifika globals) så att koden inte blir en uppsvälld soptipp.

Det var också här som ”base64-tricket” dök upp igen: till och med i min egen JavaScript var jag tvungen att vara försiktig med den bokstavliga strängen location eftersom proxyn kanske skriver om den.

Reverse-engineera CroxyProxys injicerade skript

Nu blev jag nyfiken: vad gör proxyn egentligen med min sida? Injicerar den sina egna annonser? Något värre?

CroxyProxys klientskript är hårt obfuskerat.

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

Vilket, när det körs, resulterar i:

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

Utifrån det här ser det ut som att croxyproxy använder Obfuscator.io för obfuskeringen. Som tur är går det ganska lätt att deobfuskera med webcrack.

Det ger mycket mer läsbar 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)) {

Snyggt. Nu kan vi se vad det faktiskt gör. Och... det ser mestadels okej ut. Jag tror att obfuskeringen mest är där för att göra proxyn svårare att upptäcka. Mest.

Vi har lite annons-/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;
    }

Det finns några andra ställen också, men i princip handlar det om att visa annonser, inklusive pop-under-annonser. Den använder även FuckAdBlock.

Den riktiga strängersättningen sker däremot på serversidan. Och vem vet vad allt som händer där.

Oavsett vilket bör du absolut inte använda webbproxys om du bryr dig om säkerheten för dina konton. Om du måste, undvik att skriva in någon PII/kontouppgifter/köpinformation.

”Resource swapping” i sopkorgen

Jag kom fram till att komplexiteten från resource swapping, ihop med komplexiteten i andra delar av koden för att stödja foony.io, inte var värd de små nätverksvinsterna av vackra, cookie-lösa förfrågningar. Vi såg också en oförklarlig nedgång i hur många som faktiskt började spela efter klick sedan vi tog in foony.io, så jag misstänker att det fanns andra foony.io-problem som vi inte ens kände till.

Så jag tog bort foony.io. Åtminstone för tillfället.

När jag tog bort CDN-logiken för foony.io och standardiserade allt på foony.com blev proxy-stödet dramatiskt enklare:

  • Same-origin-laddning av tillgångar.
  • Färre ”specialfall” att förklara för en proxy-ServiceWorker.
  • Mindre omskrivning.
  • Mindre skör kod.

Kort sagt, att ta bort foony.io var en arkitektonisk förenkling som minskade angreppsytan för konstigt proxybeteende.


Pass 3: Vad som funkar, vad som inte gör det, och varför

Proxys som är bekräftat fungerande

Just nu funkar Foony bakom:

  • croxyproxy
  • proxyorb

Några andra proxys funkar säkert också. Jag gissar att de flesta fortfarande inte gör det. Men åtminstone de viktiga som folk faktiskt använder för att spela verkar funka.

Varför inte ”alla proxys”?

Vissa proxys kan helt enkelt inte stödja en modern multiplayer-webbapp. Exempel:

  • Proxys som inte har korrekt stöd för HTTPS.
  • Proxys som bryter eller blockerar WebSockets (Foony använder realtidsnätverk). Tekniskt sett skulle man kunna jobba runt det, men det skulle göra allt mycket krångligare.
  • Proxys som har för hårda begränsningar kring cross-origin-förfrågningar, headers eller ServiceWorkers.

Viktiga lärdomar

Webbproxys väldigt osäkra

De är mellannivå som:

  • skriver om HTML
  • skriver om JavaScript
  • ibland injicerar en ServiceWorker
  • ofta är beroende av query-parametrar/URL-kodning för att routa förfrågningar
  • kan pilla med dina sidor på alla möjliga sätt

Jag blev förvånad över hur djupt vissa proxys går: de skriver om shader-källsträngar, kommentarer och Gud vet vad mer.

Ibland är den bästa lösningen arkitektonisk

WebGL-patchen gjorde att spelen visades igen, men att ta bort multi-domän-CDN-upplägget gjorde att proxystödet förblev stabilt.

En bra påminnelse: smarta optimeringar kan vara helt rimliga tills de krockar med fientlig middleware. Eller användarens browser-tillägg. Eller Safari. Eller språkinställningar. Eller tillgänglighetsfunktioner. Eller solstormar. Eller egentligen vad som helst.


Slutsats

Foony funkar nu bakom de proxys som spelar roll (croxyproxy och proxyorb), utan att koden förvandlats till en proxy-specifik röra:

  • En generell Three.js-shaderfix (inga proxy-specifika identifierare).
  • En enklare domänstrategi (foony.com överallt).
8 Ball Pool online multiplayer billiards icon