

1/1/1970
Jak sprawiłem, że Foony działa za proxy
Cześć! Od dawna wiem, że webowe proxy potrafią powodować problemy z kompatybilnością stron. Jednak działanie Foony za proxy było od zawsze fatalne, a ogarnięcie kompatybilności Foony z proxy okazało się całkiem podchwytliwe.
To też nie jest problem pod tytułem „Foony używa egzotycznych API” (chociaż tak, używamy). To była mieszanka:
- Proxy agresywnie podmieniających ciągi znaków tam, gdzie w ogóle nie powinny.
- Proxy traktujących główną domenę strony inaczej niż „inne” domeny (CDN-y, hosty assetów itd.).
- I brutalnego faktu, że niektóre proxy po prostu nie są w stanie obsłużyć nowoczesnych aplikacji webowych (poprawne HTTPS, WebSockety itd.).
Nie działamy ze wszystkimi proxy, ale działamy przynajmniej z croxyproxy i proxyorb, a właśnie o to chodziło.
Niżej opowiem, co się psuło, dlaczego się psuło i jakie poprawki naprawdę miały znaczenie.
Podejście 1: Poprawne, ale zepsute shadery Three.js
Objawy
Gdy przetestowałem croxyproxy, nie byłem w stanie zagrać w 8 Ball Pool ani w żadne inne gry Foony oparte na three.js gry. Ciągle dostawałem błąd kompilacji shaderów w Three.js, w stylu:
- „Shader Error 1282 - VALIDATE_STATUS false”
Ten komunikat był prawie kompletnie bezużyteczny. Zwykle oznacza „twój shader jest nieprawidłowy, powodzenia”. Super. Jeśli kiedyś zastanawiasz się, czemu w Foony każdy błąd ma swój własny, unikalny komunikat, to właśnie dlatego. To pomaga namierzyć problem, zamiast tylko stwierdzić „coś się wywaliło, napraw to”.
Ale czemu zupełnie poprawne shadery three.js się sypały? O co chodziło?
Prawdziwa przyczyna: proxy psują layout(location = N)
Three.js generuje GLSL z kwalifikatorami layout, na przykład:
layout(location = 0) in vec3 position;
Niektóre proxy próbują przepisać wszystko, co wygląda jak API JavaScript location, robiąc naiwne, globalne podmiany tekstu. To już samo w sobie jest kiepskie w JS, ale one robiły to też wewnątrz ciągów z kodem shaderów. Widocznie parsowanie AST jest dla nich za drogie.
W efekcie kod shadera był psuty do czegoś takiego:
layout(__cpLocation = 0) in vec3 position;
W tym miejscu identyfikator musi brzmieć location. Cokolwiek innego jest niepoprawnym GLSL, więc kompilator to odrzuca. (Layout Qualifiers in GLSL)
To jest „problem Three.js” tylko w tym sensie, że Three.js generuje shadery dynamicznie i przekazuje je do WebGL w czasie działania. Prawdziwy błąd leży w strategii przepisywania kodu przez proxy.
Dlaczego nie „naprawiłem proxy”
Naiwne podejście wyglądałoby tak: znaleźć w kodzie podstawienie croxyproxy dla location, czyli __cpLocation, i zamienić je z powrotem na location. Problem w tym, że różne proxy używają różnych nazw zastępczych. Jedne używają __cpLocation, inne jeszcze czegoś dziwniejszego. Wbijanie na sztywno poprawki typu „zamień __cpLocation z powrotem na location” byłoby więc bardzo kruche.
Potrzebowałem:
- Ogólnej poprawki (bez wpisywania na sztywno identyfikatorów konkretnych proxy).
- Poprawki, która zadziała nawet wtedy, gdy proxy przepisuje samo słowo
locationrównież w moim JavaScripcie.
Sztuczka z base64: ukrywanie słowa location przed proxy
Jeśli proxy przepisywało każdy dosłowny location, jaki zobaczy, najprostszy ruch to po prostu go nie używać. Proste. Widziałem już wcześniej podobne sztuczki w Lua, kiedy rozbierałem na części system ochrony poradników RestedXP (o ile dobrze pamiętam, zaciemniają tam użycie BNGetInfo, np. _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F")).
Ta sztuczka działa oczywiście także w JavaScripcie. W client/index.html w czasie działania dekoduję coś takiego:
// Ponieważ te proxy próbują podmieniać każde `location`, używamy ciągu zakodowanego w base64.
const suffix = 'pb24=';
const locStr = atob('bG9jYXR' + suffix); // "location"
const loc = window[locStr]; // window.location
To atob() wywołuje się po tym, jak proxy skończy już przepisywanie HTML/JS, więc nie może „zepsuć z wyprzedzeniem” tego ciągu. Rozdzieliłem ciąg na dwie części, żeby był jeszcze trudniejszy do wychwycenia, i używam 'atob' bo mogę, ale String.fromCharCode albo zapis szesnastkowy w stylu window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] też pewnie by zadziałał.
Zepsuty wzorzec w shaderze miał zawsze tę samą strukturę:
layout(<something> = <number>)
Więc dopasowuję to w sposób ogólny i podmieniam <something> na właściwy identyfikator:
source.replace(/layout\s*\(\s*[^=)]+\s*=\s*(\d+)\s*\)/g, 'layout(' + locStr + ' = $1)');
Hak na WebGL: podmiana shaderSource (WebGL1 + WebGL2)
Ponieważ Three.js wywołuje gl.shaderSource(shader, source), podmieniam samo 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,
});
Tę samą podmianę stosuję do WebGL2RenderingContext, jeśli istnieje.
Kiedy to już było na miejscu, błędy kompilacji shaderów zniknęły. W tym momencie croxyproxy działało, ale proxyorb nadal się sypał. Dlaczego?! Przecież to powinno działać tak samo, prawda?
Podejście 2: Drugi problem (domeny) i dlaczego usunięcie foony.io wszystko uprościło
Historycznie Foony używało dwóch domen, przynajmniej przez ostatni miesiąc:
foony.comdla głównej stronyfoony.iodla statycznych assetów
Powód był czysto praktyczny: serwowanie assetów z domeny bez ciasteczek pozwala uniknąć wożenia nagłówka Cookie przy każdym żądaniu statycznego pliku. To fajne, ale nie aż tak potrzebne, jak mogłoby się wydawać, biorąc pod uwagę, że HTTP/2 używa HPACK do zmniejszania liczby bajtów wysyłanych w nagłówkach.
To sensowna optymalizacja przy zwykłym przeglądaniu.
Za proxy stało się to jednak jednym z głównych źródeł problemów. A użytkownicy Foony uwielbiają proxy. ech
Proxy traktują „główną stronę” inaczej niż „inne strony”
Wiele proxy jest zoptymalizowanych pod „zaproxyuj tę jedną stronę / domenę”. Ładują poprawnie główny HTML, wstrzykują swoje skrypty, rejestrują własnego ServiceWorkera itd.
Ale gdy aplikacja zaczyna pobierać assety z innego originu (na przykład foony.io), zaczyna się cała masa wesołych awarii:
- Błędy przechwytywania przez ServiceWorkera, w stylu:
- „ServiceWorker przechwycił żądanie i napotkał nieoczekiwany błąd”
- „Wczytywanie modułu z podanego źródła nie powiodło się”
- Parametry zapytania wymagane przez infrastrukturę proxy (i bardzo łatwe do przypadkowego usunięcia).
- Żądania assetów tracące wewnętrzne metadane routingu proxy.
- Dziwne proxy, które zamieniają całe żądanie na
generic-php-slug.php?someQueryParam=hugeEncodedString(tak, nie próbowałem nawet tego wspierać).
Wewnętrzne mechanizmy takich proxy mocno opierają się na parametrach zapytania i kodowaniu URL, i są dość kruche.
Jednym z charakterystycznych przykładów był adres assetu w stylu:
https://<proxy-ip>/assets/firebase-<hash>.js?__pot=aHR0cHM6Ly9mb29ueS5jb20
To ?__pot=... to ich wewnętrzny stan/routing, który mówi proxy, do jakiej domeny jest to żądanie. Jeśli to obetniesz, proxy nie potrafi poprawnie rozwiązać żądania i lądujesz w ścieżce błędu ServiceWorkera.
„Resource swapping” na ratunek (i dlaczego szybko zrobiło się skomplikowanie)
W pewnym momencie spróbowałem obejścia: wykryć, że „jesteśmy za proxy”, a potem podmieniać wszystkie adresy zasobów z foony.io na bieżący origin, żeby proxy widziało wszystko jako ten sam origin.
Brzmi rozsądnie i faktycznie działało z croxyproxy, ale dołożyło mnóstwo złożoności:
- Trzeba podmieniać istniejące już w HTML tagi
linkiscript. - Potrzebny jest
MutationObserver, żeby ogarniać dynamicznie wstrzykiwane tagi (modulepreload, stylesheet itd.). - Musisz zachować parametry zapytania proxy, inaczej rozwalasz ich routing. A różne proxy robią to w różny sposób. Bo oczywiście że tak.
- A mimo to musisz utrzymać logikę ogólną (bez globali specyficznych dla danej usługi), żeby kod nie zamienił się w przerośnięte śmietnisko.
W tym miejscu wróciła też „sztuczka z base64”: nawet w moim własnym JavaScripcie musiałem uważać na dosłowny ciąg location, bo proxy mogło go przepisać.
Reverse engineering skryptu wstrzykiwanego przez CroxyProxy
W tym momencie zacząłem się zastanawiać: co tak naprawdę proxy robi z moją stroną? Wstrzykuje własne reklamy? Coś gorszego?
Skrypt po stronie klienta w CroxyProxy jest mocno zaciemniony.
(new Function(new TextDecoder('utf-8').decode(new Uint8Array((atob('NjY3NTZlN...')).match(/.{1,2}/g).map(b => parseInt(b, 16))))))();
Po uruchomieniu zamienia się to w coś takiego:
function a0_0x5ebf(_0x213dc9,_0x1c49b6){var _0x4aa7c1=a0_0x4274();return a0_0x5ebf=function(_0x159600,_0x51d898){_0x159600=...
Na tej podstawie wygląda na to, że croxyproxy używa Obfuscator.io do zaciemniania. Na szczęście da się to dość łatwo odkodować przy pomocy webcrack.
To daje znacznie bardziej czytelny 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)) {
Fajnie. Teraz widać, co ten skrypt robi. I... w większości wygląda to okej. Myślę, że zaciemnianie służy głównie temu, żeby utrudnić wykrycie samego proxy. Głównie.
Mamy trochę wstrzykiwania reklam / UI:
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;
}
Jest jeszcze kilka innych miejsc, ale zasadniczo chodzi tylko o wyświetlanie reklam, w tym w stylu pop-under. Korzysta też z FuckAdBlock.
Prawdziwa podmiana ciągów tekstowych dzieje się jednak po stronie serwera. I kto wie, co dokładnie tam się dzieje.
Tak czy inaczej, absolutnie nie powinieneś korzystać z webowych proxy, jeśli zależy ci na bezpieczeństwie konta. A jeśli już musisz, unikaj wpisywania jakichkolwiek swoich danych osobowych, danych konta czy informacji zakupowych.
„Resource swapping” do kosza
Doszedłem do wniosku, że złożoność wynikająca z resource swappingu, połączona z dodatkowymi komplikacjami w innych częściach kodu potrzebnymi do obsługi foony.io, nie jest warta tych drobnych oszczędności w sieci z ładnych, pozbawionych ciasteczek żądań. Widzieliśmy też niewyjaśniony spadek konwersji do rozgrywek od momentu wdrożenia foony.io, więc podejrzewam, że były tam jeszcze inne problemy, o których nie wiedzieliśmy.
Więc usunąłem foony.io. Przynajmniej na razie.
Kiedy wyrzuciłem logikę CDN dla foony.io i wszystko ujednoliciłem na foony.com, wsparcie dla proxy stało się drastycznie prostsze:
- Assety ładowane z tego samego originu.
- Mniej „specjalnych przypadków” do tłumaczenia ServiceWorkerowi proxy.
- Mniej przepisywania czegokolwiek.
- Mniej kruchego kodu.
Krótko mówiąc, usunięcie foony.io było uproszczeniem architektury, które zmniejszyło pole do popisu dla dziwnych zachowań proxy.
Podejście 3: Co działa, co nie działa i dlaczego
Proxy, z którymi na pewno działamy
Na tym etapie Foony działa za:
- croxyproxy
- proxyorb
Pewnie część innych proxy też zadziała. Obstawiam, że większość wciąż nie. Ale przynajmniej te ważne, z których ludzie faktycznie korzystają, żeby grać, wyglądają na działające.
Dlaczego nie „wszystkie proxy”?
Niektóre proxy po prostu nie są w stanie obsłużyć nowoczesnej sieciowej gry wieloosobowej. Przykłady:
- Proxy, które nie obsługują poprawnie HTTPS.
- Proxy, które psują albo blokują WebSockety (Foony używa komunikacji w czasie rzeczywistym). Technicznie dałoby się to obejść, ale dołożyłoby to sporo złożoności.
- Proxy zbyt mocno ograniczające żądania cross-origin, nagłówki albo ServiceWorkerów.
Najważniejsze wnioski
Webowe proxy są bardzo niebezpieczne
To elementy pośrednie, które:
- przepisują HTML
- przepisują JavaScript
- czasem wstrzykują własnego ServiceWorkera
- często polegają na parametrach zapytania / kodowaniu URL do routowania żądań
- mogą grzebać w twoich stronach na niezliczoną liczbę sposobów
Zaskoczyło mnie, jak głęboko niektóre proxy potrafią się wbić: przepisują ciągi z kodem shaderów, komentarze i Bóg wie co jeszcze.
Czasem najlepszą poprawką jest zmiana architektury
Łatka na WebGL sprawiła, że gry znów się renderowały, ale dopiero rezygnacja ze strategii wielu domen na CDN sprawiła, że wsparcie dla proxy zostało stabilne.
To dobra przypominajka: sprytne optymalizacje mogą być całkowicie sensowne, dopóki nie zderzą się z wrogim oprogramowaniem pośredniczącym. Albo z rozszerzeniami przeglądarki użytkownika. Albo z Safari. Albo z ustawieniami języka. Albo z funkcjami dostępności. Albo z rozbłyskami słonecznymi. Albo w zasadzie z czymkolwiek.
Podsumowanie
Foony działa teraz za tymi ważnymi proxy (croxyproxy i proxyorb) i nie zamieniło to bazy kodu w jeden wielki, proxy-specyficzny bałagan:
- Ogólna poprawka shaderów Three.js (bez identyfikatorów specyficznych dla danego proxy).
- Prostsza strategia domen (wszędzie foony.com).