background blurbackground mobile blur

1/1/1970

Mình đã làm Foony chạy được phía sau proxy như thế nào

Chào bạn! Mình đã biết từ lâu là web proxy thường gây đủ kiểu vấn đề tương thích cho website. Nhưng khả năng chạy qua proxy của Foony thì tệ nổi tiếng luôn, và việc xử lý cho Foony tương thích với proxy khá là đau đầu.

Và chuyện này không phải kiểu “Foony dùng mấy API dị quá nên proxy không chịu” (dù thực ra tụi mình cũng có). Nó là sự kết hợp của:

  • Proxy thay thế chuỗi một cách cực kì hung hãn ở những chỗ lẽ ra không bao giờ nên đụng vào.
  • Proxy xử lý domain chính của site khác với các domain “phụ” (CDN, host asset, v.v.).
  • Và sự thật hơi phũ là một số proxy đơn giản là không gánh nổi web app hiện đại (HTTPS chuẩn chỉnh, WebSocket, v.v.).

Foony không chạy được trên mọi loại proxy, nhưng giờ ít nhất cũng hoạt động ổn trên croxyproxyproxyorb, vậy là đạt mục tiêu ban đầu rồi.

Bên dưới mình sẽ kể chi tiết: cái gì hỏng, vì sao hỏng, và những cách sửa nào thực sự có tác dụng.


Lượt 1: Shader Three.js hợp lệ nhưng vẫn hỏng

Triệu chứng

Khi thử dùng croxyproxy, mình không thể chơi 8 Ball Pool hay bất kì trò chơi three.js khác của Foony. Lần nào cũng dính lỗi biên dịch shader trong Three.js với thông báo kiểu:

  • “Shader Error 1282 - VALIDATE_STATUS false”

Cái thông báo đó gần như vô dụng hoàn toàn. Thường nó chỉ có nghĩa là “shader của bạn không hợp lệ, tự lo nhé.” Tuyệt vời ghê. Nếu bạn từng thắc mắc vì sao mình luôn dùng thông báo lỗi riêng cho từng loại lỗi trên Foony, thì đây chính là lý do. Nó giúp khoanh vùng vấn đề, thay vì chỉ kiểu “code hỏng rồi, tự sửa đi.”

Nhưng tại sao những shader three.js hoàn toàn hợp lệ lại bị hỏng chứ? Có chuyện gì ở đây?

Nguyên nhân thật sự: proxy làm hỏng layout(location = N)

Three.js sinh ra mã GLSL với layout kiểu như:

layout(location = 0) in vec3 position;

Một số proxy cố gắng thay thế bất cứ thứ gì trông giống như API location của JavaScript bằng cách tìm-và-thay toàn bộ chuỗi một cách cực kì ngây thơ. Làm vậy ngay trong JS đã tệ lắm rồi, nhưng tụi nó còn làm ngay cả trong chuỗi nguồn shader. Chắc phân tích AST tốn tài nguyên quá nên họ bỏ qua luôn.

Thế là mã nguồn shader bị biến dạng thành kiểu như:

layout(__cpLocation = 0) in vec3 position;

location bắt buộc phải là tên dùng ở đó. Bất cứ thứ gì khác đều là GLSL không hợp lệ, và trình biên dịch sẽ thẳng tay từ chối. (Layout Qualifiers in GLSL)

Đây chỉ được xem là “vấn đề của Three.js” ở chỗ Three.js tạo shader một cách động và chúng mình truyền chúng vào WebGL lúc chạy. Lỗi thật sự nằm ở chiến lược thay thế chuỗi của proxy.

Vì sao mình không “sửa proxy”

Cách nghĩ đơn giản nhất là đi tìm chuỗi thay thế location của croxyproxy, tức __cpLocation, rồi đổi nó lại thành location. Nhưng mỗi loại proxy lại dùng một tên thay thế khác nhau. Có cái dùng __cpLocation, có cái dùng những identifier kỳ cục khác. Nên nếu mình “đóng đinh” bản vá kiểu “thấy __cpLocation thì đổi lại thành location” thì sẽ rất mỏng manh, dễ vỡ.

Mình cần:

  • Một cách sửa tổng quát (không phụ thuộc vào tên riêng của từng proxy).
  • Một cách vẫn chạy được ngay cả khi proxy cũng thay luôn từ location trong chính JavaScript của mình.

Mẹo base64: giấu từ location khỏi proxy

Nếu proxy cứ thấy chữ location ở đâu là nhảy vào sửa ở đó, thì cách đơn giản nhất là… đừng dùng location trực tiếp nữa. Nghe khá hợp lý. Trước đây mình đã từng thấy mấy trò kiểu này trong Lua khi mổ xẻ hệ thống bảo vệ guide của RestedXP (nếu nhớ không nhầm thì họ làm rối cách dùng BNGetInfo, kiểu _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F")).

Mẹo này tất nhiên cũng dùng được trong JavaScript. Ở client/index.html, mình giải mã chuỗi này lúc runtime:

// 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

Lệnh atob() đó chạy sau khi proxy đã làm xong màn băm bổ HTML/JS, nên nó không thể “làm hỏng trước” chuỗi này được. Mình chia chuỗi thành hai phần cho càng khó phát hiện, và dùng 'atob' vì… thích thì dùng thôi, chứ String.fromCharCode hoặc viết hexa kiểu window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] chắc cũng ổn.

Mẫu shader bị hỏng thì về cấu trúc lúc nào cũng giống nhau:

layout(<something> = <number>)

Vậy nên mình chỉ cần bắt mẫu chung đó rồi thay <something> bằng identifier đúng là xong:

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

Gắn móc vào WebGL: vá shaderSource (WebGL1 + WebGL2)

Vì Three.js gọi gl.shaderSource(shader, source), nên mình vá thẳng vào 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,
});

Và mình áp dụng cùng một bản vá cho WebGL2RenderingContext nếu môi trường có hỗ trợ.

Sau khi làm vậy, lỗi biên dịch shader biến mất luôn. Đến đây thì croxyproxy chạy ngon rồi, nhưng proxyorb vẫn tiếp tục lăn ra chết. Tại sao chứ?! Lẽ ra chúng phải hoạt động giống nhau mà?


Lượt 2: Vấn đề thứ hai (domain) và vì sao bỏ foony.io khiến mọi thứ dễ hơn hẳn

Trước giờ Foony dùng hai domain, ít nhất là trong khoảng một tháng trở lại đây:

  • foony.com cho trang chính
  • foony.io cho tài nguyên tĩnh (static assets)

Lý do ban đầu khá thực dụng: phục vụ asset từ một domain không có cookie sẽ tránh được việc gửi kèm đống cookie header cồng kềnh trong mỗi request tới file tĩnh. Ý tưởng này khá hay, nhưng không cần thiết đến mức đó như bạn tưởng, nhất là khi HTTP/2 dùng HPACK để giảm số byte header gửi qua mạng.

Trong bối cảnh duyệt web bình thường, đây vẫn là một tối ưu hợp lý.

Nhưng khi đi qua proxy, nó lại trở thành nguồn gốc của cả đống lỗi. Và người dùng Foony thì cực kì thích xài proxy. thở dài

Proxy đối xử với “site chính” khác hẳn “các site khác”

Nhiều proxy được tối ưu theo kiểu “proxy cho một trang / một domain này thôi”. Chúng sẽ tải HTML chính, chèn script, đăng ký ServiceWorker của chúng, v.v. rất trơn tru.

Nhưng khi app bắt đầu kéo asset từ một origin khác (như foony.io) thì đủ loại trò vui mà vỡ tung xuất hiện:

  • Lỗi ServiceWorker chặn request kiểu:
    • “ServiceWorker intercepted the request and encountered an unexpected error”
    • “Loading failed for the module with source”
  • Hạ tầng proxy bắt buộc phải có query param (và rất dễ vô tình làm mất).
  • Request asset bị mất metadata định tuyến nội bộ mà proxy gắn vào.
  • Một số proxy dị còn thay cả request thành kiểu generic-php-slug.php?someQueryParam=hugeEncodedString (vâng, mình không thèm hỗ trợ kiểu đó luôn).

Cơ chế bên trong của mấy proxy này phụ thuộc rất nhiều vào query param / cách mã hóa URL, nên cực kì mỏng manh.

Một ví dụ khá rõ ràng là URL asset trông như thế này:

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

Phần ?__pot=... đó là thông tin định tuyến / trạng thái riêng của proxy, dùng để cho nó biết request thực sự thuộc về domain nào. Nếu bạn làm mất đoạn này, proxy sẽ không xử lý đúng request nữa, và bạn sẽ rơi vào nhánh lỗi ServiceWorker ngay.

“Đảo tài nguyên” cứu nguy (và vì sao nó nhanh chóng thành mớ bòng bong)

Có lúc mình thử một lối đi vòng: phát hiện “à, mình đang chạy sau proxy”, rồi thay mọi URL tài nguyên trỏ tới foony.io sang origin hiện tại để proxy nhìn vào thấy tất cả đều cùng một origin.

Nghe thì hợp lý, và thực tế nó chạy được với croxyproxy, nhưng cái giá phải trả là độ phức tạp tăng vọt:

  • Bạn phải thay thế các thẻ linkscript đã có sẵn trong HTML.
  • Bạn cần một MutationObserver để xử lý các thẻ được chèn động (modulepreload, stylesheet, v.v.).
  • Bạn phải giữ nguyên query param mà proxy gắn vào, nếu không sẽ phá hỏng định tuyến của nó. Và dĩ nhiên, mỗi proxy lại làm chuyện này theo một kiểu khác nhau. Tất nhiên rồi.
  • Và bạn vẫn phải giữ logic đủ tổng quát (không dựa vào các biến toàn cục riêng của từng proxy), nếu không codebase sẽ thành một bãi rác khổng lồ.

Đây cũng là chỗ “mẹo base64” quay lại: ngay trong chính JavaScript của mình, mình cũng phải cẩn thận với chuỗi chữ location vì proxy có thể nhảy vào sửa bất cứ lúc nào.

Mổ xẻ script mà CroxyProxy chèn vào trang

Đến đoạn này thì mình bắt đầu tò mò: rốt cuộc proxy làm gì với trang của mình vậy? Nó chỉ chèn quảng cáo thôi à? Hay còn thứ gì ghê gớm hơn?

Script phía client của CroxyProxy bị làm rối rất nặng.

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

Khi chạy lên thì ra dạng thế này:

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

Nhìn vào đây thì có vẻ croxyproxy đang dùng Obfuscator.io để làm rối. May mắn là việc gỡ rối cũng khá dễ với webcrack.

Sau khi gỡ rối thì ta có JavaScript dễ đọc hơn nhiều:

((_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)) {

Ngon. Giờ thì ta thấy nó đang làm gì rồi. Và… trông phần lớn là ổn. Mình đoán việc làm rối này chủ yếu để khó bị phát hiện là đang dùng proxy. Chủ yếu thế thôi.

Có một ít đoạn chèn quảng cáo / giao diện:

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

Còn vài chỗ khác nữa, nhưng về cơ bản nó chỉ là để hiển thị quảng cáo, kể cả kiểu pop-under. Nó cũng dùng cả FuckAdBlock.

Còn phần thay thế chuỗi “xịn” thì lại diễn ra ở phía server. Và ai mà biết đằng sau đó họ còn làm những gì nữa.

Dù thế nào đi nữa, nếu bạn quan tâm đến độ an toàn của tài khoản thì tuyệt đối đừng dùng web proxy. Nếu bắt buộc phải dùng, hãy tránh nhập bất kì thông tin cá nhân / tài khoản / thanh toán nào.

Cho “resource swapping” vào thùng rác

Cuối cùng mình quyết định rằng độ phức tạp do trò resource swapping gây ra, cộng với những phần code khác phải viết riêng để hỗ trợ foony.io, hoàn toàn không xứng đáng với chút lợi về mạng của mấy request “đẹp, không cookie”. Bọn mình cũng thấy tỉ lệ người bấm vào chơi game rồi thực sự vào game giảm một cách khó hiểu từ khi chuyển sang foony.io, nên mình nghi còn có những vấn đề khác với foony.io mà tụi mình chưa kịp phát hiện.

Thế là mình cho foony.io nghỉ. Ít nhất là tạm thời.

Khi mình xóa hết logic CDN foony.io và đưa mọi thứ về chung foony.com, việc hỗ trợ proxy tự nhiên đơn giản hơn hẳn:

  • Tải asset cùng origin.
  • Ít “trường hợp đặc biệt” phải giải thích cho ServiceWorker của proxy hơn.
  • Ít phải rewrite URL / mã hơn.
  • Code bớt mong manh, dễ gãy.

Tóm lại, bỏ foony.io là một bước đơn giản hóa kiến trúc, giúp giảm đáng kể “diện tích tiếp xúc” với những hành vi quái đản của proxy.


Lượt 3: Cái gì chạy được, cái gì không, và vì sao

Các proxy đã kiểm tra chạy ổn

Đến thời điểm hiện tại, Foony chạy ổn phía sau:

  • croxyproxy
  • proxyorb

Một số proxy khác chắc cũng chạy được. Nhưng mình dám chắc là phần lớn vẫn không. Ít ra thì những proxy phổ biến mà mọi người hay dùng để chơi game đã hoạt động ổn.

Vì sao không phải “mọi proxy”?

Một số proxy đơn giản là không thể gánh nổi một web app nhiều người chơi hiện đại. Ví dụ:

  • Proxy không hỗ trợ HTTPS cho đàng hoàng.
  • Proxy chặn hoặc làm hỏng WebSocket (Foony dùng kết nối thời gian thực). Về mặt kĩ thuật thì vẫn có thể tìm đường vòng, nhưng độ phức tạp sẽ tăng mạnh.
  • Proxy đặt quá nhiều giới hạn quanh cross-origin request, header hoặc ServiceWorker.

Những điểm rút ra chính

Web proxy cực kì thiếu an toàn

Chúng là lớp trung gian có thể:

  • sửa lại HTML
  • sửa lại JavaScript
  • đôi khi chèn thêm ServiceWorker riêng
  • thường dựa vào query param / cách mã hóa URL để định tuyến request
  • và có thể nghịch trang của bạn theo đủ mọi cách

Mình khá bất ngờ vì một số proxy can thiệp sâu đến mức nào: chúng sẵn sàng sửa cả chuỗi nguồn shader, comment, và trời biết còn những gì nữa.

Đôi khi cách sửa tốt nhất là thay đổi kiến trúc

Bản vá WebGL giúp game render lại được, nhưng việc bỏ chiến lược CDN nhiều domain mới là thứ giúp hỗ trợ proxy ổn định lâu dài.

Đây là một lời nhắc khá hay: những tối ưu “thông minh” có thể hoàn toàn hợp lý, cho đến khi chúng đụng phải một lớp trung gian “khó ở”. Hoặc extension trên trình duyệt người dùng. Hoặc Safari. Hoặc thiết lập ngôn ngữ. Hoặc tính năng hỗ trợ tiếp cận. Hoặc bão mặt trời. Hoặc… bất cứ thứ gì, thật ra là vậy.


Kết luận

Hiện tại Foony đã chạy được phía sau những proxy quan trọng (croxyproxy và proxyorb) mà không biến codebase thành một đống thứ rối rắm riêng cho từng proxy:

  • Một bản vá shader Three.js mang tính tổng quát (không phụ thuộc vào identifier riêng của proxy nào).
  • Chiến lược domain đơn giản hơn (mọi thứ đều trên foony.com).
8 Ball Pool online multiplayer billiards icon