

1/1/1970
ผมทำให้ Foony ใช้งานได้ผ่านพร็อกซีได้ยังไง
สวัสดีครับ! ผมรู้มานานแล้วว่าเว็บพร็อกซีมักทำให้เว็บไซต์มีปัญหาความเข้ากันได้ แต่ Foony นี่คือขึ้นชื่อเรื่องเข้ากับพร็อกซีไม่ได้เลย และการแก้ปัญหาความเข้ากันได้ของพร็อกซีกับ Foony ก็ค่อนข้างยุ่งยากทีเดียว
ปัญหานี้ก็ไม่ใช่เพราะ "Foony ใช้ API แปลกๆ" หรอกนะ (ถึงแม้ เราจะใช้จริงๆ ก็ตาม) แต่เป็นเพราะหลายอย่างรวมกัน:
- พร็อกซีพยายามเขียนทับสตริงแบบโหดๆ ในที่ที่ไม่ควรทำ
- พร็อกซีจัดการกับโดเมน หลัก ของไซต์ต่างจากโดเมน "อื่น" (เช่น CDN, โฮสต์ assets เป็นต้น)
- และความจริงอันโหดร้ายว่าพร็อกซีบางตัวก็แค่รองรับเว็บแอปสมัยใหม่ไม่ได้ (ทั้งความถูกต้องของ HTTPS, WebSockets ฯลฯ)
เราไม่ได้ใช้งานได้กับทุกพร็อกซีนะ แต่อย่างน้อยตอนนี้ก็ใช้ได้กับ croxyproxy และ proxyorb ซึ่งเป็นเป้าหมายที่ตั้งไว้
ด้านล่างนี้ผมจะอธิบายว่าอะไรพัง ทำไมถึงพัง และวิธีแก้ที่ได้ผลจริงๆ
รอบที่ 1: เชเดอร์ Three.js ที่ถูกต้อง แต่กลับพัง
อาการ
ตอนผมลอง croxyproxy ผมเล่น 8 Ball Pool หรือเกม three.js อื่นๆ ของ Foony ไม่ได้เลย ผมเจอปัญหาคอมไพล์เชเดอร์ล้มเหลวใน Three.js โดยมีข้อผิดพลาดประมาณว่า:
- "Shader Error 1282 - VALIDATE_STATUS false"
ข้อความนั้นแทบไม่มีประโยชน์อะไรเลย ปกติมันแปลว่า "เชเดอร์ของคุณไม่ถูกต้อง โชคดีนะ" เยี่ยม ถ้าใครเคยสงสัยว่าทำไมผมถึงใช้ข้อความ error ที่ไม่ซ้ำกันกับทุกๆ error ใน Foony นี่แหละครับเหตุผล มันช่วยให้ระบุปัญหาได้ ไม่ใช่แค่บอกว่า "โค้ดพัง ไปแก้เอง"
แต่ทำไมเชเดอร์ของ three.js ที่ถูกต้องสมบูรณ์ถึงพังล่ะ? เกิดอะไรขึ้น?
ต้นตอที่แท้จริง: พร็อกซีทำให้ layout(location = N) เสียหาย
Three.js สร้าง GLSL ที่มี layout qualifiers แบบนี้:
layout(location = 0) in vec3 position;
พร็อกซีบางตัวพยายามเขียนทับทุกอย่างที่ดูเหมือน API location ของ JavaScript ด้วยการแทนที่สตริงแบบทื่อๆ ทั่วทั้งไฟล์ ซึ่งใน JS ก็แย่อยู่แล้ว แต่พวกเขาดันทำกับซอร์สโค้ดของเชเดอร์ด้วย คงเป็นเพราะการ parse AST แพงเกินไปสำหรับเขา
ดังนั้นซอร์สเชเดอร์เลยถูกแก้ให้กลายเป็นแบบนี้:
layout(__cpLocation = 0) in vec3 position;
ตัวระบุตรงนั้น ต้อง เป็น location เท่านั้น อย่างอื่นถือว่าเป็น GLSL ที่ไม่ถูกต้อง และคอมไพเลอร์จะปฏิเสธทันที (Layout Qualifiers in GLSL)
ปัญหานี้เป็นปัญหาของ Three.js ก็ตรงที่ Three.js สร้างเชเดอร์แบบไดนามิก แล้วเราส่งให้ WebGL ตอนรันไทม์เท่านั้น ส่วนบั๊กตัวจริงคือกลยุทธ์การเขียนทับของพร็อกซีต่างหาก
ทำไมผมไม่ "แก้ที่พร็อกซี"
วิธีง่ายๆ คือค้นหาสตริงแทนที่ของ croxyproxy คือ __cpLocation แล้วเปลี่ยนกลับเป็น location แต่ปัญหาคือพร็อกซีแต่ละตัวใช้ชื่อแทนที่ต่างกัน บางตัวใช้ __cpLocation บางตัวใช้ตัวระบุแปลกๆ อื่นๆ ดังนั้นการ hardcode แก้แบบ "เปลี่ยน __cpLocation กลับเป็น location" มันเปราะบางมาก
ผมต้องการ:
- วิธีแก้ทั่วไป (ไม่ hardcode ตัวระบุของพร็อกซี)
- วิธีแก้ที่ใช้ได้แม้พร็อกซีจะเขียนทับ คำว่า
locationใน JavaScript ของผมด้วย
เคล็ดลับ base64: ซ่อนคำว่า location จากพร็อกซี
ถ้าพร็อกซีเขียนทับทุกๆ คำว่า location ที่เห็น วิธีง่ายที่สุดก็คือไม่ใช้คำว่า location ซะเลย ง่ายๆ ผมเคยเห็นเคล็ดลับแบบนี้มาก่อนใน Lua ตอนที่ผมรีเวิร์สเอนจิเนียร์ระบบป้องกันไกด์ของ RestedXP (ถ้าจำไม่ผิด เขาทำ obfuscate การใช้ BNGetInfo เช่น _G("\x42\x4E\x47\x65\x74\x49\x6E\x66\x6F"))
เคล็ดลับนี้ก็ใช้ใน JavaScript ได้แน่นอน ใน client/index.html ผมถอดรหัสตอนรันไทม์แบบนี้:
// เนื่องจากพร็อกซีพวกนี้พยายามแทนที่ทุกคำว่า `location` เราจึงใช้สตริง base64
const suffix = 'pb24=';
const locStr = atob('bG9jYXR' + suffix); // "location"
const loc = window[locStr]; // window.location
atob() นั้นทำงาน หลังจาก ที่พร็อกซีเขียนทับ HTML/JS ไปแล้ว ดังนั้นมันเลย "ทำให้สตริงเสียก่อนล่วงหน้า" ไม่ได้ ผมแยกสตริงออกเป็นสองส่วนเพื่อให้ตรวจจับได้ยากขึ้น และผมเลือกใช้ 'atob' เพราะทำได้ แต่ String.fromCharCode หรือการ escape เป็น hex อย่าง window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] ก็น่าจะใช้ได้
รูปแบบของเชเดอร์ที่เสียจะมีโครงสร้างเหมือนกันเสมอ:
layout(<something> = <number>)
ผมเลย match แบบทั่วไป แล้วเปลี่ยน <something> เป็นตัวระบุที่ถูกต้อง:
source.replace(/layout\s*\(\s*[^=)]+\s*=\s*(\d+)\s*\)/g, 'layout(' + locStr + ' = $1)');
Hook ของ WebGL: แพตช์ shaderSource (ทั้ง WebGL1 + WebGL2)
เนื่องจาก Three.js เรียก gl.shaderSource(shader, source) ผมจึงแพตช์ 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 ถ้ามี
พอใส่ตรงนั้นเข้าไปแล้ว ปัญหาคอมไพล์เชเดอร์ก็หายไปเลย ตอนนี้ croxyproxy ใช้งานได้ แต่ proxyorb ยังพังอยู่ อ้าว! ทำไมไม่ใช้แบบเดียวกันได้ล่ะ?
รอบที่ 2: ปัญหาที่สอง (โดเมน) และทำไมการเอา foony.io ออกถึงทำให้ทุกอย่างง่ายขึ้น
ในอดีต อย่างน้อยช่วงเดือนที่ผ่านมา Foony ใช้สองโดเมน:
foony.comสำหรับเว็บหลักfoony.ioสำหรับ static assets
เหตุผลเดิมคือเรื่องการใช้งานจริง: การเสิร์ฟ assets จากโดเมนที่ไม่มีคุกกี้ช่วยลดขนาด header ที่ต้องอัปโหลดในทุกๆ คำขอไฟล์ static นี่เป็นเรื่องดี แต่ก็ไม่ได้ จำเป็น อย่างที่คิด เพราะ HTTP/2 ใช้ HPACK เพื่อลดจำนวนไบต์ที่ส่งใน headers อยู่แล้ว
มันเป็นการ optimize ที่สมเหตุสมผลในการใช้งานปกติ
แต่เบื้องหลังพร็อกซี มันกลายเป็นแหล่งทำให้พังหลักเลย และฐานผู้ใช้ของ Foony ก็รักการใช้พร็อกซีมาก เฮ้อ
พร็อกซีจัดการกับ "เว็บหลัก" ต่างจาก "เว็บอื่น"
พร็อกซีหลายตัวถูกออปติไมซ์มาเพื่อ "พร็อกซีหน้านี้/โดเมนนี้" พวกมันโหลด HTML หลักได้ ฉีดสคริปต์ได้ ลงทะเบียน ServiceWorker ของตัวเองได้
แต่พอแอปเริ่มดึง assets จาก origin อื่น (เช่น foony.io) คุณก็จะเจอปัญหาพังต่างๆ มากมาย:
- ServiceWorker ดักจับคำขอล้มเหลวแบบ:
- "ServiceWorker intercepted the request and encountered an unexpected error"
- "Loading failed for the module with source"
- โครงสร้างของพร็อกซีต้องการ query params (ซึ่งหายได้ง่ายโดยไม่ตั้งใจ)
- คำขอ asset เสีย metadata ในการกำหนดเส้นทางภายในของพร็อกซี
- พร็อกซีแปลกๆ ที่แทนที่คำขอทั้งหมดด้วย
generic-php-slug.php?someQueryParam=hugeEncodedString(ใช่ ผมไม่อยากซัพพอร์ตอันนั้นแล้ว)
กลไกภายในของพร็อกซีพวกนี้ขึ้นกับ query params / URL encoding และมันก็เปราะบางมาก
ตัวอย่างที่ชัดเจนตัวหนึ่งคือ URL ของ asset แบบนี้:
https://<proxy-ip>/assets/firebase-<hash>.js?__pot=aHR0cHM6Ly9mb29ueS5jb20
เจ้า ?__pot=... คือ routing/state ของพร็อกซีเอง ที่บอกพร็อกซีว่าคำขอนี้ไปยังโดเมนไหน ถ้าตัดออก พร็อกซีจะ resolve คำขอไม่ถูก และจบลงด้วย error path ของ ServiceWorker
"Resource swapping" มาช่วย (และทำไมมันยุ่งยากเร็วมาก)
ในจุดหนึ่ง ผมลองวิธีแก้: ตรวจจับว่า "เราอยู่ในพร็อกซี" แล้วสลับ URL ของทรัพยากร foony.io เป็น origin ปัจจุบัน เพื่อให้พร็อกซีมองทุกอย่างเป็น same-origin
ฟังดูสมเหตุสมผล และมันก็ใช้กับ croxyproxy ได้ แต่มันเพิ่มความซับซ้อนเยอะเลย:
- ต้องแทนที่แท็ก
linkและscriptที่มีอยู่แล้วใน HTML - ต้องใช้
MutationObserverเพื่อจัดการแท็กที่ถูกฉีดเข้ามาแบบไดนามิก (modulepreload, stylesheet ฯลฯ) - ต้องรักษา query parameters ของพร็อกซีไว้ ไม่งั้นการ routing ของเขาพัง และพร็อกซีแต่ละตัวก็ทำคนละแบบ แน่นอน
- และยังต้องเก็บ logic ให้เป็นแบบทั่วไป (ห้ามใช้ globals ของพร็อกซีแบบเฉพาะเจาะจง) เพื่อไม่ให้โค้ดกลายเป็นถังขยะอลเวง
ตรงนี้เองที่ "เคล็ดลับ base64" ก็ผุดขึ้นมาอีก: แม้แต่ใน JavaScript ของผมเอง ผมก็ต้องระวังสตริง location ตามตัวอักษร เพราะพร็อกซีอาจเขียนทับได้
รีเวิร์สเอนจิเนียร์สคริปต์ที่ CroxyProxy ฉีดเข้ามา
ตอนนี้ผมอยากรู้แล้วล่ะ: พร็อกซีกำลังทำอะไรกับหน้าเว็บของผมจริงๆ? มันกำลังฉีดโฆษณาของตัวเองอยู่หรือเปล่า? หรือแย่กว่านั้น?
สคริปต์ฝั่งไคลเอนต์ของ 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
ผลลัพธ์คือ 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 ส่วนใหญ่น่าจะช่วยป้องกันการตรวจจับพร็อกซี ส่วนใหญ่
มีการฉีดโฆษณา / 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 ด้วย
แต่การแทนที่สตริงตัวจริงๆ เกิดขึ้นที่ฝั่งเซิร์ฟเวอร์ และใคร จะรู้ ล่ะว่ามันทำอะไรอีกบ้าง
ยังไงก็ตาม คุณ ไม่ควร ใช้เว็บพร็อกซีเด็ดขาดถ้าใส่ใจเรื่องความปลอดภัยของบัญชี ถ้าจำเป็นต้องใช้ ก็เลี่ยงการกรอกข้อมูล PII / บัญชี / การซื้อใดๆ ทั้งสิ้น
"Resource swapping" ลงถังขยะ
ผมตัดสินใจว่าความซับซ้อนจากการ swap ทรัพยากร ผนวกกับความซับซ้อนในส่วนอื่นๆ ของโค้ดที่รองรับ foony.io ไม่คุ้มค่ากับการประหยัดเครือข่ายเล็กๆ น้อยๆ จากคำขอที่ไม่มีคุกกี้สวยๆ พวกนั้น เรายังเห็นการลดลงของ conversion ในการเล่นเกมแบบไม่มีคำอธิบายตั้งแต่เริ่มใช้ foony.io ผมเลยสงสัยว่าคงมีปัญหาอื่นๆ กับ foony.io ที่เราไม่รู้ตัวด้วย
ผมเลยลบ foony.io ออก อย่างน้อยก็ในตอนนี้
พอผมลบ logic ของ CDN foony.io ออก แล้วทำให้ทุกอย่างเป็นมาตรฐานบน foony.com การรองรับพร็อกซีก็ง่ายขึ้นมาก:
- โหลด assets แบบ same-origin
- มี "กรณีพิเศษ" น้อยลงที่ต้องอธิบายให้ ServiceWorker ของพร็อกซี
- มีการเขียนทับน้อยลง
- โค้ดเปราะบางน้อยลง
สรุปคือ การลบ foony.io เป็นการทำให้สถาปัตยกรรมง่ายขึ้น ซึ่งช่วยลดพื้นที่ผิวสำหรับพฤติกรรมแปลกๆ ของพร็อกซี
รอบที่ 3: อะไรใช้งานได้ อะไรใช้ไม่ได้ และทำไม
พร็อกซีที่ยืนยันแล้วว่าใช้งานได้
ตอนนี้ Foony ใช้งานได้ผ่าน:
- croxyproxy
- proxyorb
พร็อกซีอื่นๆ บางตัวก็น่าจะใช้ได้ ส่วนใหญ่ผมเดาว่ายังใช้ไม่ได้ แต่อย่างน้อยตัวสำคัญๆ ที่คนใช้เล่นเกมก็ดูจะใช้งานได้แล้ว
ทำไมไม่ "ทุกพร็อกซี"?
พร็อกซีบางตัวก็แค่ไม่สามารถรองรับเว็บแอปแบบ multiplayer สมัยใหม่ได้ ตัวอย่าง:
- พร็อกซีที่รองรับ HTTPS ไม่ดี
- พร็อกซีที่ทำให้ WebSockets เสียหรือบล็อก (Foony ใช้เครือข่ายแบบเรียลไทม์) ในทางเทคนิค คุณอาจหาทางเลี่ยงได้ แต่จะเพิ่มความซับซ้อน
- พร็อกซีที่มีข้อจำกัดเยอะเกินไปเรื่องคำขอ cross-origin, headers หรือ ServiceWorkers
บทสรุปสำคัญ
เว็บพร็อกซีไม่ปลอดภัย อย่างมาก
มันเป็น middleware ที่:
- เขียนทับ HTML
- เขียนทับ JavaScript
- บางครั้งฉีด ServiceWorker
- และมักขึ้นอยู่กับ query params / URL encoding ในการ route คำขอ
- สามารถจัดการกับหน้าเว็บของคุณในหลากหลายรูปแบบ
ผมแปลกใจที่พร็อกซีบางตัวลึก ขนาดไหน: พวกมันจะเขียนทับ ซอร์สสตริงของเชเดอร์, คอมเมนต์ และพระเจ้ารู้อะไรอีกบ้าง
บางครั้งวิธีแก้ที่ดีที่สุดคือเชิงสถาปัตยกรรม
แพตช์ WebGL ทำให้เกมเรนเดอร์ได้อีกครั้ง แต่การเลิกใช้กลยุทธ์ CDN หลายโดเมนต่างหากที่ทำให้การรองรับพร็อกซี เสถียร
เป็นการเตือนใจที่ดี: การ optimize อย่างฉลาดอาจสมเหตุสมผลจนกว่าจะเจอ middleware ที่ไม่เป็นมิตร หรือส่วนขยายเบราว์เซอร์ของผู้ใช้ หรือ Safari หรือการตั้งค่าภาษา หรือฟีเจอร์ accessibility หรือพายุสุริยะ หรืออะไรก็ได้ จริงๆ
บทสรุป
ตอนนี้ Foony ใช้งานได้ผ่านพร็อกซีที่สำคัญแล้ว (croxyproxy และ proxyorb) โดยไม่ทำให้ codebase กลายเป็นกองขยะที่เฉพาะเจาะจงกับพร็อกซี:
- การแก้เชเดอร์ Three.js แบบทั่วไป (ไม่มีตัวระบุเฉพาะของพร็อกซี)
- กลยุทธ์โดเมนที่เรียบง่ายขึ้น (foony.com ทุกที่)