background blurbackground mobile blur

1/1/1970

ผมทำให้ Foony ใช้งานผ่านพร็อกซีได้ยังไง

เฮ้! ผมรู้มานานแล้วว่าเว็บพร็อกซีชอบทำให้เว็บมีปัญหาเรื่องความเข้ากันได้ แต่การที่ Foony ไปอยู่หลังพร็อกซีนี่คือแย่มากเป็นพิเศษ แล้วการแก้ให้ Foony ใช้งานกับพร็อกซีได้ก็ค่อนข้างหินเลย

ปัญหานี้ก็ไม่ใช่แนว “Foony ใช้ API พิสดาร” อะไรแบบนั้นเหมือนกันนะ (ถึงแม้ว่าเราจะใช้จริงๆ) แต่มันเป็นการผสมกันของหลายอย่างแบบนี้ต่างหาก:

  • พร็อกซีไปแก้สตริงแบบดุเดือดในที่ที่ไม่ควรแตะเลย
  • พร็อกซีปฏิบัติกับโดเมนของเว็บ หลัก ไม่เหมือนโดเมน “อื่นๆ” (พวก CDN, โฮสต์ asset ต่างๆ ฯลฯ)
  • แล้วก็ความจริงอันโหดร้ายที่ว่าพร็อกซีบางเจ้าก็แค่รองรับเว็บแอปยุคใหม่ไม่ได้เลย (HTTPS ให้ถูกต้อง, WebSockets ฯลฯ)

Foony ยังใช้ได้ไม่ครบทุกพร็อกซีหรอก แต่อย่างน้อยตอนนี้ใช้กับ croxyproxy และ proxyorb ได้แล้ว ซึ่งนั่นแหละคือเป้าหมาย

ข้างล่างนี้ผมจะเล่าว่าอะไรพัง ทำไมมันถึงพัง แล้วสุดท้ายอะไรที่ช่วยแก้ได้จริงๆ


ด่านที่ 1: Shader ของ Three.js ที่ถูกต้องแต่ดันพัง

อาการที่เห็น

ตอนผมลองใช้ croxyproxy ผมเล่นเกม 8 Ball Pool ไม่ได้เลย รวมถึงเกม อื่นๆ ของ Foony ที่ใช้ three.js เหมือนกัน ด้วย
เจอ error ตอนคอมไพล์ shader ใน Three.js ตลอด แบบนี้:

  • “Shader Error 1282 - VALIDATE_STATUS false”

ข้อความนี้แทบจะไม่มีประโยชน์เลย ปกติแปลได้ประมาณว่า “shader นายใช้ไม่ได้ ไปแก้เอาเองนะ” แค่นั้นเอง ถ้าคุณเคยสงสัยว่าทำไมผมถึงชอบทำข้อความ error ของ Foony ให้ไม่ซ้ำกันเลยทุกอัน นี่แหละเหตุผล มันช่วยชี้เป้าปัญหาได้ดีกว่าประเภท “โค้ดพัง ไปแก้เอาเอง”

แต่ในเมื่อ shader ของ three.js มันถูกต้องอยู่แล้ว ทำไมถึงพังล่ะ? มันเรื่องอะไรเนี่ย?

สาเหตุจริงๆ: พร็อกซีไปทำให้ layout(location = N) เสีย

Three.js จะปล่อยโค้ด GLSL ที่มี layout qualifier ประมาณนี้:

layout(location = 0) in vec3 position;

พร็อกซีบางเจ้าพยายามจะแก้ทุกอย่างที่ หน้าตาเหมือน JavaScript API ชื่อ location ด้วยการ replace สตริงแบบเหมารวมทั่วทั้งไฟล์ ซึ่งแค่ทำใน JS ก็แย่พออยู่แล้ว แต่พวกเขาดันไปทำในสตริงของ shader ด้วย เดาว่าการพาร์สเป็น AST คงแพงเกินไปสำหรับเค้า

สุดท้าย source ของ shader เลยถูกเปลี่ยนกลายเป็นประมาณนี้:

layout(__cpLocation = 0) in vec3 position;

ชื่อ identifier ตรงนั้น ต้อง เป็น location เท่านั้น อย่างอื่นถือว่าเป็น GLSL ไม่ถูกต้อง และคอมไพเลอร์ก็จะไม่ยอมคอมไพล์ให้เลย
(Layout Qualifiers in GLSL)

จริงๆ มันเป็นปัญหาของ Three.js แค่ว่า Three.js สร้าง shader แบบไดนามิก แล้วเราเอาไปส่งให้ WebGL ตอนรันไทม์
ตัวบั๊กจริงๆ คือวิธีที่พร็อกซีเลือกจะ “แก้สตริงแบบเหมารวม” ต่างหาก

ทำไมผมถึงไม่เลือก “ไปแก้พร็อกซี”

วิธีดิบๆ ง่ายๆ คือหา string ที่ croxyproxy ใช้แทน location คือ __cpLocation แล้วก็เปลี่ยนกลับเป็น location ซะ

แต่ปัญหาคือพร็อกซีแต่ละเจ้าก็ใช้ชื่อแทนไม่เหมือนกัน บางเจ้าก็ใช้ __cpLocation บางเจ้าก็ใช้ชื่อประหลาดอย่างอื่นอีก
พอไปฮาร์ดโค้ดแบบ “เจอ __cpLocation ก็เปลี่ยนกลับเป็น location” มันเลยเปราะบางมาก

ผมเลยต้องการ:

  • วิธีแก้ที่ใช้ได้ทั่วไป (ไม่ผูกติดชื่ออะไรของพร็อกซีเจ้าไหน)
  • วิธีที่ยังใช้ได้ ถึงแม้พร็อกซีจะไปแก้คำว่า location ใน JavaScript ของผมเองด้วยก็ตาม

ทริก base64: ซ่อนคำว่า location จากพร็อกซี

ถ้าพร็อกซีจะพุ่งเข้าไปแก้ literal คำว่า location ทุกที่ที่เห็น วิธีง่ายสุดก็คือ “ไม่ใช้คำว่า location ตรงๆ” นั่นแหละ ง่ายดี ผมเคยเห็นทริกแนวนี้ใน Lua ตอนเคยนั่งแกะระบบป้องกันไกด์ของ RestedXP (ถ้าจำไม่ผิด เขาเหมือนจะ obfuscate การเรียกใช้ 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() ตรงนั้นจะรัน หลังจากที่ พร็อกซีทำการแก้ HTML/JS ไปเรียบร้อยแล้ว มันเลยไม่สามารถ “ทำให้สตริงเสียล่วงหน้า” ได้ ผมแยกสตริงออกเป็นสองส่วนให้จับได้ยากขึ้นอีกหน่อย แล้วก็ใช้ 'atob' เพราะใช้ได้สะดวก แต่จริงๆ String.fromCharCode หรือการเขียนแบบ hex เช่น window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] ก็พอใช้ได้เหมือนกัน

แพตเทิร์นของ shader ที่เสียมันจะมีโครงประมาณนี้เสมอ:

layout(<something> = <number>)

ผมก็เลยจับแพตเทิร์นนี้แบบกว้างๆ แล้วแทน <something> ด้วย identifier ที่ถูกต้อง:

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

Hook ของ WebGL: แปะ 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,
});

แล้วก็ใช้แพตช์เดียวกันนี้กับ WebGL2RenderingContext ถ้ามีอยู่

พอทำแบบนี้เสร็จ Error ตอนคอมไพล์ shader ก็หายไปเลย ตรงนี้ croxyproxy ใช้งานได้แล้ว แต่ proxyorb ยังไม่รอดอยู่ดี ทำไมกัน?! มันไม่น่าจะต่างกันขนาดนั้นนี่นา


ด่านที่ 2: ปัญหาด้านโดเมน และทำไมพอลบ foony.io แล้วทุกอย่างง่ายขึ้นเยอะ

แต่ก่อน Foony ใช้สองโดเมน อย่างน้อยก็ช่วงเดือนที่ผ่านมานี้:

  • foony.com สำหรับหน้าเว็บหลัก
  • foony.io สำหรับ static asset

เหตุผลดั้งเดิมก็เรียบง่ายดี: ถ้าเสิร์ฟ asset จากโดเมนที่ไม่มีคุกกี้ติดไปด้วย ก็จะไม่ต้องส่ง cookie ไปกับทุก request ของไฟล์ static ทำให้ header ไม่บวมเกินไป การทำแบบนี้ก็ดีแหละ
แค่ในยุค HTTP/2 ที่มี HPACK สำหรับย่อ header ให้เล็กลง มันอาจจะไม่จำเป็น “ขนาดนั้น” อย่างที่เคยคิด

สำหรับการใช้งานปกติบนเบราว์เซอร์ทั่วไป นี่ถือเป็น optimization ที่โอเคมาก

แต่พอไปอยู่หลังพร็อกซี มันกลายเป็นแหล่งปัญหาชั้นดีเลย แล้วฐานผู้เล่นของ Foony นี่ชอบใช้พร็อกซีกันหนักมากอีกด้วย ถอนหายใจเบาๆ

พร็อกซีมอง “เว็บหลัก” ไม่เหมือน “เว็บอื่น”

พร็อกซีหลายเจ้าถูกออกแบบมาให้ทำงานแนว “โปรกซีแค่โดเมน / หน้านี้หน้าเดียว”
พวกนี้จะโหลด HTML หลักได้สำเร็จ, inject script ของตัวเอง, ลง ServiceWorker ของตัวเอง อะไรพวกนี้

แต่พอแอปเริ่มไปดึง asset จาก origin อีกอัน (อย่าง foony.io) ปุ๊บ ก็จะเริ่มมีความสนุกสไตล์พังๆ ตามมา:

  • ServiceWorker intercept แล้วล้มเหลว ประมาณ:
    • “ServiceWorker intercepted the request and encountered an unexpected error”
    • “Loading failed for the module with source”
  • พร็อกซีบางเจ้าต้องใช้ query param สำหรับโครงสร้างภายในของตัวเอง (แล้วมันก็หลุดหายง่ายมาก)
  • Request ของ asset ดันทำ metadata route ภายในของพร็อกซีหายไป
  • พร็อกซีสุดประหลาดบางตัวแทนที่ทั้ง request ด้วยอะไรแนวๆ generic-php-slug.php?someQueryParam=hugeEncodedString (อันนี้ผมไม่เหนื่อยพอจะรองรับแล้ว)

กลไกภายในของพร็อกซีเหล่านี้พึ่ง query param / การ encode URL หนักมาก แล้วก็เปราะสุดๆ

ตัวอย่างที่เห็นได้ชัดอันหนึ่งคือ asset URL ที่หน้าตาประมาณนี้:

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

?__pot=... ตรงนั้นคือข้อมูล routing/state ภายในของพร็อกซีที่จะบอกว่าควร proxy ไปโดเมนไหน ถ้าเราไปลบทิ้ง พร็อกซีก็จะ resolve request ไม่ได้ แล้วเราจะจบลงที่ error ฝั่ง ServiceWorker แทน

“Resource swapping” มาช่วย (แล้วก็ทำให้ทุกอย่างยุ่งวุ่นวายเร็วมาก)

ช่วงหนึ่งผมลองใช้วิธี workaround: ตรวจให้ได้ก่อนว่า “ตอนนี้เราอยู่หลังพร็อกซี” แล้วค่อยเปลี่ยน URL ของ resource ที่เป็น foony.io ให้กลายเป็น origin ปัจจุบัน ทั้งหมด เพื่อให้พร็อกซีมองว่าทุกอย่างมาจาก origin เดียวกัน

ฟังดูเข้าท่าเนอะ แล้วกับ croxyproxy ก็ได้ผลด้วย
แต่พอใช้จริง มันเพิ่มความซับซ้อนแบบเยอะมาก:

  • ต้องไปเปลี่ยน link กับ script tag ที่มีอยู่ใน HTML ตั้งแต่โหลดหน้า
  • ต้องใช้ MutationObserver ดัก tag ที่ถูก inject ทีหลัง (พวก modulepreload, stylesheet ฯลฯ)
  • ต้องระวังไม่ทำ query param ที่พร็อกซีใส่มาหายไป ไม่งั้น routing พัง แถมแต่ละพร็อกซีก็ใส่ไม่เหมือนกันอีก จะไม่ให้เหนื่อยได้ไง
  • ทั้งหมดนี้ยังต้องเขียนให้ generic ด้วย (ห้ามเช็คตัวแปร global เฉพาะของพร็อกซีเจ้าไหน) ไม่งั้นโค้ดจะกลายเป็นกองไฟไหม้ขยะทันที

แล้วนี่แหละที่ทำให้ “ทริก base64” โผล่มาอีกครั้ง: แค่ใน JavaScript ของผมเอง ก็ต้องระวังไม่ให้มีสตริง location โผล่ เพราะพร็อกซีอาจจะไปเปลี่ยนมัน

แกะสคริปต์ที่ CroxyProxy inject เข้ามา

จุดหนึ่งผมก็เริ่มสงสัยเหมือนกันว่า “แล้วพร็อกซีมันมาทำอะไรกับหน้าเราบ้างเนี่ย”
มันแค่ inject โฆษณาเฉยๆ หรือมีอะไรแปลกกว่านั้นอีก?

สคริปต์ฝั่ง client ของ CroxyProxy ถูก obfuscate หนักมาก

(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 ในการ obfuscate โค้ด ซึ่งโชคดีที่ยังพอ deobfuscate ด้วย webcrack ได้ไม่ยาก

พอ deobfuscate แล้วก็จะกลายเป็น 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)) {

โอเคเลย แบบนี้เราก็พอเห็นแล้วว่ามันทำอะไรบ้าง
และ… ส่วนใหญ่ก็โอเคดี ผมเดาว่าเค้า obfuscate เพื่อให้ตรวจจับได้ยากเฉยๆ ว่าเป็นพร็อกซี เป็นหลัก มากกว่า

มีส่วนที่ใช้ inject โฆษณา / 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;
    }

ยังมีจุดอื่นๆ อีก แต่น้ำหนักหลักคือเอาไว้แสดงโฆษณา รวมถึงแบบ pop-under ด้วย
แล้วก็ใช้ FuckAdBlock ด้วยเหมือนกัน

ส่วนที่เปลี่ยนสตริงจริงๆ ส่วนใหญ่เกิดฝั่ง server มากกว่า ซึ่งฝั่งนั้น… ไม่มีใครรู้เลยว่าทำอะไรบ้าง

ไม่ว่าจะยังไงก็ตาม ถ้าคุณแคร์เรื่องความปลอดภัยของบัญชีตัวเอง คุณควร เลิก ใช้เว็บพร็อกซีโดยด่วน ถ้าจำเป็นจริงๆ ก็หลีกเลี่ยงการกรอกข้อมูล PII / ข้อมูลบัญชี / ข้อมูลจ่ายเงิน ทุกอย่างเท่าที่เลี่ยงได้

โยน “resource swapping” ทิ้งลงถัง

สุดท้ายผมตัดสินใจว่า ความยุ่งเหยิงจากการ resource swapping บวกกับความซับซ้อนในโค้ดส่วนอื่นๆ เพื่อรองรับ foony.io มันไม่คุ้มกับประโยชน์เล็กๆ น้อยๆ จาก request ที่สวยงามแบบไม่มีคุกกี้เลย
แถมตั้งแต่เริ่มใช้ foony.io เราก็เห็น conversion ตอนเข้าเล่นเกมดรอปลงแบบไม่มีเหตุผลชัดเจนอีก ผมเลยสงสัยว่ามันอาจมีปัญหาอื่นเกี่ยวกับ foony.io ที่เรายังไม่รู้ด้วยซ้ำ

ดังนั้นผมก็เลยตัด foony.io ทิ้งไปก่อน อย่างน้อยก็ตอนนี้

พอผมลบ logic ของ CDN foony.io แล้วปรับทุกอย่างให้ใช้ foony.com เดียวไปเลย การรองรับพร็อกซีก็ง่ายขึ้นแบบเห็นได้ชัด:

  • โหลด asset แบบ same-origin ล้วนๆ
  • ลดเคสพิเศษที่ต้องอธิบายให้ ServiceWorker ของพร็อกซีเข้าใจ
  • ลดการ rewrite URL ลง
  • โค้ดเปราะน้อยลง

สรุปสั้นๆ การลบ foony.io ทิ้งไปคือการลดความซับซ้อนในสถาปัตยกรรม ที่ช่วยให้พื้นที่ซึ่งพร็อกซีสามารถทำพังได้ลดลงเยอะมาก


ด่านที่ 3: อะไรใช้ได้ อะไรใช้ไม่ได้ แล้วทำไม

พร็อกซีที่ยืนยันแล้วว่าใช้ได้

ตอนนี้ Foony ใช้ได้หลัง:

  • croxyproxy
  • proxyorb

พร็อกซีเจ้าอื่นๆ บางเจ้าก็น่าจะใช้ได้เหมือนกัน ผมเดาว่าส่วนใหญ่ก็ยังใช้ไม่ได้อยู่ดี
แต่ก็อย่างน้อย พร็อกซีที่คนใช้เล่นเกมกันจริงๆ ตอนนี้ใช้งานได้แล้ว

ทำไมไม่รองรับ “ทุกพร็อกซีไปเลย”

เพราะพร็อกซีบางเจ้าก็รองรับเว็บแอปแบบมัลติเพลเยอร์ยุคใหม่ไม่ได้จริงๆ ตัวอย่างเช่น:

  • พร็อกซีที่รองรับ HTTPS ไม่ถูกต้อง
  • พร็อกซีที่ทำ WebSockets พังหรือบล็อกไปเลย (Foony ใช้ real-time networking) ทางเทคนิค เราพอจะหลบได้ แต่จะเพิ่มความซับซ้อนเยอะมาก
  • พร็อกซีที่บังคับข้อจำกัดเยอะเกินกับ cross-origin request, header หรือ ServiceWorker

สิ่งสำคัญที่ได้จากเรื่องนี้

เว็บพร็อกซี ไม่ปลอดภัยเอามากๆ

มันคือ middleware ที่:

  • แก้ HTML
  • แก้ JavaScript
  • บางทีก็ inject ServiceWorker ของตัวเอง
  • แถมยังพึ่ง query param / การ encode URL เพื่อ route request
  • แล้วก็อาจไปยุ่งกับหน้าเว็บคุณได้สารพัดวิธี

ผมเองก็ยังแปลกใจว่า พร็อกซีบางเจ้าลงลึกมาจนถึงขนาดไหน: ถึงกับไปแก้ สตริงของ shader, คอมเมนต์, และไม่รู้ว่ายุ่งกับอะไรอีกบ้าง

บางทีวิธีแก้ที่ดีที่สุดก็คือการเปลี่ยนสถาปัตยกรรม

แพตช์ฝั่ง WebGL ทำให้เกมกลับมา render ได้ก็จริง แต่การเลิกใช้ CDN หลายโดเมนแล้วกลับมาหาโดเมนเดียวช่วยให้การรองรับพร็อกซี นิ่ง ขึ้นมาก

มันเตือนดีเหมือนกันว่า: optimization เจ๋งๆ หลายอย่างก็สมเหตุสมผลดีแหละ จนกว่ามันจะชนเข้ากับ middleware ที่เป็นศัตรูต่อเรา หรือส่วนขยายเบราว์เซอร์ของผู้ใช้ หรือ Safari หรือการตั้งค่าภาษา หรือฟีเจอร์ช่วยการเข้าถึง หรือแม้แต่พายุสุริยะ หรืออะไรก็ได้ทั้งนั้นจริงๆ


สรุป

ตอนนี้ Foony ใช้งานหลังพร็อกซีที่สำคัญๆ (croxyproxy และ proxyorb) ได้แล้ว โดยที่ไม่ต้องเปลี่ยน codebase ให้กลายเป็นกองโค้ดเฉพาะทางสำหรับพร็อกซี:

  • แพตช์ shader ของ Three.js แบบ generic (ไม่ผูกกับ identifier ของพร็อกซีเจ้าไหน)
  • กลยุทธ์โดเมนที่เรียบง่ายขึ้น (ใช้ foony.com ที่เดียวทั่วทั้งระบบ)
8 Ball Pool online multiplayer billiards icon