

1/1/1970
Πώς έκανα το Foony να δουλεύει πίσω από proxies
Γεια χαρά! Ξέρω εδώ και πολύ καιρό ότι τα web proxies μπορούν να δημιουργήσουν προβλήματα συμβατότητας σε ιστοσελίδες. Παρ' όλα αυτά, η υποστήριξη του Foony μέσα από proxies ήταν διαβόητα κακή, και το να λύσω τη συμβατότητα του Foony με proxies ήταν αρκετά ζόρικο.
Δεν είναι καν πρόβλημα τύπου “το Foony χρησιμοποιεί εξωτικές APIs” (αν και όντως το κάνουμε). Ήταν ένας συνδυασμός από:
- Proxies που κάνουν επιθετική αντικατάσταση σε strings σε σημεία όπου πραγματικά δεν θα έπρεπε.
- Proxies που αντιμετωπίζουν το κύριο domain του site διαφορετικά από τα «άλλα» domains (CDNs, hosts για assets κτλ.).
- Και τη σκληρή πραγματικότητα ότι κάποια proxies απλά δεν μπορούν να υποστηρίξουν σύγχρονες web εφαρμογές (σωστό HTTPS, WebSockets κτλ.).
Δεν δουλεύουμε με κάθε proxy, αλλά πλέον δουλεύουμε τουλάχιστον με τα croxyproxy και proxyorb, που ήταν και ο στόχος.
Παρακάτω εξηγώ τι έσπαγε, γιατί έσπαγε και ποιες διορθώσεις είχαν πραγματικά σημασία.
Πέρασμα 1: Έγκυρα, αλλά σπασμένα Three.js shaders
Το σύμπτωμα
Όταν δοκίμασα το croxyproxy, δεν μπορούσα να παίξω 8 Ball Pool ή κάποιο από τα άλλα three.js παιχνίδια του Foony. Έπαιρνα συνέχεια αποτυχία μεταγλώττισης shaders στο Three.js με σφάλματα όπως:
- “Shader Error 1282 - VALIDATE_STATUS false”
Αυτό το μήνυμα ήταν σχεδόν τελείως άχρηστο. Συνήθως σημαίνει “το shader σου είναι άκυρο, καλή τύχη”. Τέλεια. Αν ποτέ αναρωτηθείς γιατί στο Foony χρησιμοποιώ πάντα μοναδικά μηνύματα για κάθε σφάλμα, να γιατί. Βοηθάει να εντοπίζεις συγκεκριμένα προβλήματα αντί για ένα γενικό «έσπασε ο κώδικας, φτιάξ’ τον».
Αλλά γιατί έσπαγαν απολύτως έγκυρα three.js shaders; Τι στο καλό γινόταν;
Η πραγματική αιτία: proxies που χαλάνε το layout(location = N)
Το Three.js παράγει GLSL με layout qualifiers όπως:
layout(location = 0) in vec3 position;
Κάποια proxies προσπαθούν να ξαναγράψουν οτιδήποτε μοιάζει με την JavaScript API location, κάνοντας μια απλοϊκή global αντικατάσταση strings. Αυτό είναι ήδη κακό στην ίδια τη JS, αλλά εδώ το έκαναν και μέσα στα source strings των shaders. Υποθέτω ότι το AST parsing τους φάνηκε πολύ «βαρύ».
Έτσι ο κώδικας του shader κατέληγε χαλασμένος, κάπως έτσι:
layout(__cpLocation = 0) in vec3 position;
Εκεί ο identifier πρέπει να είναι location. Οτιδήποτε άλλο είναι άκυρο GLSL και ο μεταγλωττιστής το απορρίπτει. (Layout Qualifiers στο GLSL)
Αυτό είναι «πρόβλημα Three.js» μόνο με την έννοια ότι το Three.js παράγει τα shaders δυναμικά και τα περνάμε στο WebGL την ώρα που τρέχει η σελίδα. Το πραγματικό bug είναι η στρατηγική rewriting του proxy.
Γιατί δεν «έφτιαξα το proxy»
Μια απλοϊκή προσέγγιση θα ήταν να ψάχνω για το string αντικατάστασης του croxyproxy για το location, δηλαδή το __cpLocation, και να το γυρίζω πίσω σε location. Όμως διαφορετικά proxies χρησιμοποιούν διαφορετικά ονόματα αντικατάστασης. Κάποια έχουν __cpLocation, άλλα έχουν άλλα περίεργα identifiers. Οπότε το να βάλω hardcoded λύση τύπου “αντικατάστησε το __cpLocation ξανά με location” θα ήταν πολύ εύθραυστο.
Χρειαζόμουν:
- Μια γενική λύση (χωρίς hardcoded proxy identifiers).
- Μια λύση που να δουλεύει ακόμα κι αν το proxy ξαναγράφει τη λέξη
locationκαι μέσα στη δική μου JavaScript.
Το κόλπο με το base64: κρύβοντας τη λέξη location από το proxy
Αν το proxy ξαναγράφει κάθε κυριολεκτικό location που βλέπει, η πιο απλή κίνηση είναι να μην χρησιμοποιείς καν το location. Τόσο απλό. Είχα ξαναδεί κόλπα για αυτό σε Lua όταν είχα κάνει reverse-engineering το σύστημα προστασίας οδηγών του RestedXP (αν θυμάμαι καλά, θόλωναν τη χρήση τους για το BNGetInfo, π.χ. _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F")).
Αυτό το κόλπο δουλεύει φυσικά και σε JavaScript. Στο client/index.html κάνω decode το παρακάτω την ώρα που τρέχει η σελίδα:
// 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
Αυτό το atob() τρέχει αφού το proxy έχει ήδη κάνει το HTML/JS rewriting του, οπότε δεν μπορεί να «προ-χαλάσει» το string. Έσπασα το string σε δύο κομμάτια για να είναι ακόμα πιο δύσκολο να εντοπιστεί, και χρησιμοποιώ το 'atob' επειδή μπορώ, αλλά και το String.fromCharCode ή ένα hex-escaped window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] θα μπορούσε να δουλέψει.
Το μοτίβο του χαλασμένου shader είναι δομικά πάντα το ίδιο:
layout(<something> = <number>)
Οπότε το εντοπίζω γενικά με ένα pattern και αντικαθιστώ το <something> με τον σωστό identifier:
source.replace(/layout\s*\(\s*[^=)]+\s*=\s*(\d+)\s*\)/g, 'layout(' + locStr + ' = $1)');
Το WebGL hook: patch στο shaderSource (WebGL1 + WebGL2)
Επειδή το Three.js καλεί gl.shaderSource(shader, source), κάνω patch κατευθείαν το 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,
});
Και εφαρμόζω το ίδιο patch και στο WebGL2RenderingContext, αν υπάρχει.
Μόλις έβαλα αυτό, τα σφάλματα μεταγλώττισης shaders εξαφανίστηκαν. Σε αυτό το σημείο το croxyproxy δούλευε, αλλά το proxyorb συνέχιζε να αποτυγχάνει. Γιατί; Δεν θα έπρεπε να δουλεύουν με τον ίδιο τρόπο;
Πέρασμα 2: Το δεύτερο πρόβλημα (domains) και γιατί το να αφαιρέσω το foony.io έκανε τα πάντα πιο απλά
Ιστορικά το Foony χρησιμοποιούσε δύο domains, τουλάχιστον τον τελευταίο μήνα:
foony.comγια το κύριο sitefoony.ioγια τα static assets
Ο αρχικός λόγος ήταν πρακτικός: σερβίροντας assets από domain χωρίς cookies αποφεύγεις το «φούσκωμα» των cookie headers σε κάθε αίτημα για static αρχείο. Αυτό είναι ωραίο, αλλά δεν είναι τόσο απαραίτητο όσο θα νόμιζες, μιας και το HTTP/2 χρησιμοποιεί το HPACK για να μειώσει τα bytes που στέλνονται για headers.
Είναι μια απολύτως λογική βελτιστοποίηση στο κανονικό browsing.
Πίσω από proxies όμως, έγινε βασική πηγή για σπασίματα. Και οι χρήστες του Foony λατρεύουν τα proxies. αναστεναγμός
Τα proxies βλέπουν το «κύριο site» αλλιώς από τα «άλλα sites»
Πολλά proxies είναι φτιαγμένα ώστε να «προξάρουν αυτή τη μία σελίδα / αυτό το ένα domain». Φορτώνουν κανονικά το βασικό HTML, κάνουν inject τα δικά τους scripts, κάνουν register τον δικό τους ServiceWorker κτλ.
Αλλά όταν η εφαρμογή αρχίζει να τραβάει assets από διαφορετικό origin (όπως το foony.io), τότε μπαίνεις σε κάθε λογής ευχάριστα σπασίματα:
- Αποτυχίες interception από ServiceWorker όπως:
- “ServiceWorker intercepted the request and encountered an unexpected error”
- “Loading failed for the module with source”
- Τα query params να είναι απαραίτητα για την υποδομή του proxy (και πολύ εύκολο να σβηστούν κατά λάθος).
- Αιτήματα για assets που χάνουν τα εσωτερικά routing metadata του proxy.
- Παράξενα proxies που αντικαθιστούν ολόκληρο το αίτημα με κάτι τύπου
generic-php-slug.php?someQueryParam=hugeEncodedString(ναι, δεν μπήκα καν στον κόπο να το υποστηρίξω αυτό).
Οι εσωτερικοί μηχανισμοί αυτών των proxies βασίζονται στα query params και στο URL encoding, και είναι αρκετά εύθραυστοι.
Ένα χαρακτηριστικό παράδειγμα ήταν ένα asset URL όπως:
https://<proxy-ip>/assets/firebase-<hash>.js?__pot=aHR0cHM6Ly9mb29ueS5jb20
Αυτό το ?__pot=... είναι το δικό του routing/state του proxy, που του λέει για ποιο domain είναι το αίτημα. Αν το αφαιρέσεις, τα proxies δεν μπορούν να λύσουν σωστά το αίτημα και καταλήγεις στη διαδρομή σφάλματος του ServiceWorker.
Το «resource swapping» ως σωτηρία (και γιατί μπερδεύτηκε πολύ γρήγορα)
Κάποια στιγμή δοκίμασα ένα workaround: να ανιχνεύω ότι «τρέχουμε πίσω από proxy» και μετά να αλλάζω κάθε URL πόρου σε foony.io προς το τρέχον origin, ώστε το proxy να τα βλέπει όλα ως same-origin.
Ακούγεται λογικό και όντως δούλεψε στο croxyproxy, αλλά πρόσθεσε πολλή πολυπλοκότητα:
- Πρέπει να αντικαθιστάς
linkκαιscripttags που υπάρχουν ήδη στο HTML. - Χρειάζεσαι ένα
MutationObserverγια να πιάσεις tags που μπαίνουν δυναμικά (modulepreload, stylesheets κτλ.). - Πρέπει να κρατάς τα query parameters που βάζει το proxy, αλλιώς σπας το routing του. Και διαφορετικά proxies το κάνουν αυτό με διαφορετικό τρόπο. Φυσικά.
- Και πρέπει όλα αυτά να μείνουν γενικά (χωρίς proxy-specific globals), ώστε ο κώδικας να μη γίνει ένα φουσκωμένο χάος.
Εδώ ξαναμπήκε στο παιχνίδι και το «κόλπο με το base64»: ακόμα και στη δική μου JavaScript έπρεπε να προσέχω το κυριολεκτικό string location, γιατί το proxy μπορεί να το ξαναγράψει.
Reverse-engineering του injected script του CroxyProxy
Σε αυτό το σημείο μου κίνησε την περιέργεια: τι ακριβώς κάνει το proxy στη σελίδα μου; Κάνει inject δικές του διαφημίσεις; Κάτι χειρότερο;
Το client-side script του CroxyProxy είναι γερά obfuscated.
(new Function(new TextDecoder('utf-8').decode(new Uint8Array((atob('NjY3NTZlN...')).match(/.{1,2}/g).map(b => parseInt(b, 16))))))();
Που όταν τρέχει, καταλήγει σε κάτι σαν:
function a0_0x5ebf(_0x213dc9,_0x1c49b6){var _0x4aa7c1=a0_0x4274();return a0_0x5ebf=function(_0x159600,_0x51d898){_0x159600=...
Από αυτό φαίνεται ότι το croxyproxy χρησιμοποιεί το Obfuscator.io για το obfuscation. Ευτυχώς είναι αρκετά εύκολο να το κάνεις deobfuscate με το webcrack.
Αυτό βγάζει πολύ πιο ευανάγνωστο 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)) {
Ωραία. Τώρα μπορούμε να δούμε τι κάνει. Και... δείχνει ως επί το πλείστον εντάξει. Νομίζω ότι το obfuscation υπάρχει κυρίως για να δυσκολεύει τον εντοπισμό του proxy. Κυρίως.
Υπάρχει λίγο ad / UI injection:
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;
}
Υπάρχουν κι άλλα σημεία, αλλά βασικά δείχνει διαφημίσεις, συμπεριλαμβανομένων και pop-under διαφημίσεων. Χρησιμοποιεί επίσης το FuckAdBlock.
Η πραγματική αντικατάσταση των strings όμως γίνεται στην πλευρά του server. Και ποιος ξέρει τι άλλο κάνει εκεί.
Όπως και να ’χει, αν σε νοιάζει η ασφάλεια του λογαριασμού σου, καλό είναι να μην χρησιμοποιείς web proxies. Αν πρέπει οπωσδήποτε, τουλάχιστον απόφυγε να βάζεις οπουδήποτε προσωπικά στοιχεία / στοιχεία λογαριασμού / πληρωμών.
Το «resource swapping» στα σκουπίδια
Αποφάσισα ότι η πολυπλοκότητα από το resource swapping, μαζί με την πολυπλοκότητα σε άλλα σημεία του κώδικα για την υποστήριξη του foony.io, δεν άξιζε τα μικρά network κέρδη από ωραία, χωρίς cookies αιτήματα. Βλέπαμε επίσης μια ανεξήγητη πτώση στα gameplay conversions μας από τότε που βάλαμε το foony.io, οπότε υποψιάζομαι ότι υπήρχαν κι άλλα προβλήματα με το foony.io που δεν είχαμε καταλάβει.
Οπότε έβγαλα τελείως το foony.io. Τουλάχιστον προς το παρόν.
Μόλις πέταξα όλη τη λογική του CDN για το foony.io και τυποποίησα τα πάντα στο foony.com, η υποστήριξη για proxies απλοποιήθηκε δραματικά:
- Φορτώματα assets από το ίδιο origin.
- Λιγότερα «ειδικά cases» που έπρεπε να εξηγήσω σε έναν proxy ServiceWorker.
- Λιγότερο rewriting.
- Λιγότερος εύθραυστος κώδικας.
Με λίγα λόγια, το να βγάλω το foony.io ήταν μια αρχιτεκτονική απλοποίηση που μείωσε την επιφάνεια στην οποία μπορούν να εμφανιστούν περίεργες συμπεριφορές από proxies.
Πέρασμα 3: Τι δουλεύει, τι όχι και γιατί
Proxies που δουλεύουν σίγουρα
Σε αυτό το σημείο, το Foony δουλεύει πίσω από:
- croxyproxy
- proxyorb
Μάλλον δουλεύουν και μερικά άλλα proxies. Βάζω στοίχημα ότι τα περισσότερα ακόμα όχι. Τουλάχιστον όμως φαίνεται να λειτουργούν αυτά που ο κόσμος χρησιμοποιεί για να παίζει παιχνίδια.
Γιατί όχι «όλα τα proxies»;
Κάποια proxies απλά δεν μπορούν να υποστηρίξουν μια σύγχρονη multiplayer web εφαρμογή. Παραδείγματα:
- Proxies που δεν υποστηρίζουν σωστά το HTTPS.
- Proxies που σπάνε ή μπλοκάρουν WebSockets (το Foony χρησιμοποιεί real-time networking). Τεχνικά θα μπορούσες να το παρακάμψεις, αλλά θα πρόσθετε αρκετή πολυπλοκότητα.
- Proxies με πάρα πολλούς περιορισμούς γύρω από cross-origin αιτήματα, headers ή ServiceWorkers.
Βασικά συμπεράσματα
Τα web proxies είναι πολύ ανασφαλή
Είναι ένα είδος middleware που:
- ξαναγράφει HTML
- ξαναγράφει JavaScript
- μερικές φορές κάνει inject δικό του ServiceWorker
- συχνά βασίζεται σε query params και URL encoding για να δρομολογεί αιτήματα
- μπορεί να πειράξει τις σελίδες σου με άπειρους τρόπους
Με εξέπληξε το πόσο βαθιά φτάνουν κάποια proxies: ξαναγράφουν shader source strings, σχόλια, και ο Θεός ξέρει τι άλλο.
Μερικές φορές η καλύτερη λύση είναι αρχιτεκτονική
Το WebGL patch ξανάκανε τα παιχνίδια να κάνουν render, αλλά η αφαίρεση της στρατηγικής multi-domain CDN ήταν αυτό που έκανε την υποστήριξη για proxies να μείνει σταθερή.
Είναι μια καλή υπενθύμιση πως οι έξυπνες βελτιστοποιήσεις μπορεί να είναι απόλυτα λογικές μέχρι τη στιγμή που θα συγκρουστούν με εχθρικό middleware. Ή με browser extensions των χρηστών. Ή με το Safari. Ή με ρυθμίσεις γλώσσας. Ή με δυνατότητες προσβασιμότητας. Ή με ηλιακές καταιγίδες. Ή με οτιδήποτε, πραγματικά.
Συμπέρασμα
Το Foony πλέον δουλεύει πίσω από τα proxies που μετράνε (croxyproxy και proxyorb), χωρίς να μετατρέψω τον κώδικα σε proxy-specific χάος:
- Μια γενική διόρθωση για Three.js shaders (χωρίς proxy-specific identifiers).
- Μια πιο απλή στρατηγική για τα domains (foony.com παντού).