

1/1/1970
我是如何让 Foony 在代理后正常运行的
大家好!我早就知道网页代理会给网站带来兼容性问题。然而,Foony 在代理环境下的表现一直臭名昭著地糟糕,而解决 Foony 的代理兼容性也相当棘手。
这并不是"Foony 用了什么奇葩 API"的问题(尽管我们确实用了)。它其实是几方面因素的组合:
- 代理在绝对不应该的地方做了激进的字符串重写。
- 代理把主站域名和"其他"域名(CDN、资源主机等)区别对待。
- 还有一个残酷的现实:有些代理就是没法支持现代 Web 应用(HTTPS 正确性、WebSocket 等等)。
我们没法兼容所有代理,但现在至少能在 croxyproxy 和 proxyorb 上正常运行,这就达到了目标。
下面我会讲讲哪里出了问题、为什么会出问题,以及那些真正起作用的修复方案。
第一关:有效却被破坏的 Three.js 着色器
症状
我尝试 croxyproxy 时,无法玩 8 球台球 或者 Foony 的其他 three.js 游戏。 Three.js 一直报着色器编译失败的错误,比如:
- "Shader Error 1282 - VALIDATE_STATUS false"
这条信息几乎毫无用处。它通常意思是"你的着色器无效,祝你好运"。真棒。如果你好奇为什么我在 Foony 上对每一个错误都用独一无二的错误信息,这就是原因。它能帮你定位问题,而不是只告诉你"代码挂了,自己修去"。
但是为什么完全有效的 three.js 着色器会出问题?到底怎么回事?
真正的原因:代理破坏了 layout(location = N)
Three.js 生成的 GLSL 里有这种布局限定符:
layout(location = 0) in vec3 position;
有些代理会通过简单粗暴的全局字符串替换,试图重写任何看起来像 JavaScript location API 的东西。这在 JS 里就已经够糟了,但他们居然连着色器源字符串里也这么干。我猜对他们来说做 AST 解析太昂贵了。
于是着色器源代码就被破坏成这样:
layout(__cpLocation = 0) in vec3 position;
那个标识符必须是 location。其他都是无效的 GLSL,编译器会拒绝。(GLSL 中的布局限定符)
只在"Three.js 动态生成着色器,然后我们在运行时把它们传给 WebGL"这个意义上,这才算是 Three.js 的问题。真正的 bug 在于代理的重写策略。
我为什么没去"修代理"
一种朴素的做法是搜索 croxyproxy 的 location 替换字符串 __cpLocation,然后把它替换回 location。但是不同代理用不同的替换名字。有些用 __cpLocation,有些用别的奇怪标识符。所以硬编码"把 __cpLocation 改回 location"这种修复方式很脆弱。
我需要的是:
- 一个通用的修复(不硬编码代理的标识符)。
- 一个即使代理也在重写我 JavaScript 中的单词
location时也能工作的修复。
Base64 小技巧:把 location 这个词藏起来不让代理看见
如果代理会重写它看到的每一个 location 字面量,最简单的办法就是不用 location。够简单的。我之前在用 Lua 逆向 RestedXP 攻略保护系统时见过类似的招数(如果我没记错,他们对 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 或者用十六进制转义 window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] 应该也行。
被破坏的着色器模式在结构上总是相同的:
layout(<某个东西> = <数字>)
所以我用通用方式匹配它,然后把 <某个东西> 替换成正确的标识符:
source.replace(/layout\s*\(\s*[^=)]+\s*=\s*(\d+)\s*\)/g, 'layout(' + locStr + ' = $1)');
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 还是不行。为啥?!不是应该一样的吗?
第二关:第二个问题(域名)以及为什么去掉 foony.io 让一切都简单了
至少在过去一个月里,Foony 一直用着两个域名:
foony.com用于主站foony.io用于静态资源
最初的原因很实用:从无 cookie 的域名提供资源,可以避免每次静态文件请求时上传臃肿的 cookie 头。这很棒,不过其实没有你想的那么必要,因为 HTTP/2 用 HPACK 来减少头部传输的字节数。
正常浏览场景下,这是一个合理的优化。
但是在代理后面,它就成了出问题的主要源头。而 Foony 的用户群偏偏特别喜欢用代理。叹气
代理对"主站"和"其他站"区别对待
许多代理是针对"代理这一个页面/域名"做优化的。它们能成功加载主 HTML、注入脚本、注册自己的 ServiceWorker 等等。
但当应用开始从一个不同的源(比如 foony.io)拉取资源时,各种各样有趣的崩溃就来了:
- ServiceWorker 拦截失败,比如:
- "ServiceWorker intercepted the request and encountered an unexpected error"
- "Loading failed for the module with source"
- 代理基础设施需要查询参数(很容易就被意外剥离)。
- 资源请求丢失了代理的内部路由元数据。
- 一些奇葩代理会把整个请求替换成
generic-php-slug.php?someQueryParam=hugeEncodedString(没错,我懒得去支持那种)。
这些代理的内部机制依赖查询参数 / URL 编码,而且相当脆弱。
一个典型的例子是这样的资源 URL:
https://<proxy-ip>/assets/firebase-<hash>.js?__pot=aHR0cHM6Ly9mb29ueS5jb20
那个 ?__pot=... 是代理自己的路由/状态信息,它告诉代理这个请求对应哪个域名。如果你把它剥掉,代理就没法正确解析请求,你最后就掉进了 ServiceWorker 错误那一路。
"资源换源"来救场(以及为什么很快就变得复杂)
有一阵子我尝试了一个绕过办法:检测"我们正在被代理",然后把所有 foony.io 的资源 URL 换成当前的源,这样代理就会把所有东西都当作同源。
听起来挺合理的,对 croxyproxy 也确实有用,但它带来了大量的复杂度:
- 你得替换 HTML 中已经存在的
link和script标签。 - 你得用
MutationObserver来处理动态注入的标签(modulepreload、stylesheet 等等)。 - 你得保留代理的查询参数,否则会破坏它们的路由。而不同代理做法不一样。当然咯。
- 而且你还得保持逻辑通用(不能用代理特有的全局变量),这样代码才不会变成一堆臃肿的垃圾。
这也是"base64 小技巧"再次登场的地方:即使在我自己的 JavaScript 里,我也得小心 location 这个字符串字面量,因为代理可能会重写它。
逆向 CroxyProxy 注入的脚本
到这里我有点好奇了:代理到底在我的页面上做了什么?是在注入它自己的广告吗?还是更糟的事情?
CroxyProxy 的客户端脚本被严重混淆了。
(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 来做混淆。所幸用 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)) {
不错。现在能看清它在做什么了。然后……感觉基本还好。我猜混淆主要是为了避免代理被检测到。主要是这样。
里面有些广告 / 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;
}
还有一些其他的地方,但基本上就是在显示广告,包括弹出后台式广告。它还用了 FuckAdBlock。
不过,字符串的真正替换是在服务端做的。而那里到底在做什么,天知道。
不管怎样,如果你在意账号安全,绝对不要用网页代理。如果非用不可,千万别输入任何 PII / 账号 / 购买信息。
"资源换源"被丢进垃圾桶
我决定:资源换源带来的复杂度,加上代码其他地方为支持 foony.io 而带来的复杂度,完全配不上那点优雅的无 cookie 请求所节省的网络流量。我们启用 foony.io 之后还看到游戏转化率有一个无法解释的下降,所以我怀疑 foony.io 还有别的我们没注意到的问题。
所以我把 foony.io 移除了。至少目前是这样。
一旦我删掉了 foony.io 的 CDN 逻辑,把所有东西都统一到 foony.com,代理支持就一下子变得简单多了:
- 同源资源加载。
- 给代理 ServiceWorker 解释的"特殊情况"更少了。
- 重写更少。
- 代码不那么脆弱。
简而言之,移除 foony.io 是一次架构上的简化,减少了代理诡异行为可能波及的面。
第三关:什么能用、什么不行,以及为什么
已确认可用的代理
到目前为止,Foony 在以下代理后能正常运行:
- croxyproxy
- proxyorb
也许还有一些其他代理也能用。我赌大部分还是不行。但至少人们用来玩游戏的几个重要代理看起来是可以的。
为什么不是"全部代理都能用"?
有些代理就是没法支持现代多人 Web 应用。例如:
- 不能正确支持 HTTPS 的代理。
- 会破坏或屏蔽 WebSocket 的代理(Foony 使用了实时网络)。技术上你可以绕过这个问题,但会增加复杂度。
- 在跨源请求、头部或 ServiceWorker 上限制太多的代理。
关键收获
网页代理非常不安全
它们是这样的中间件:
- 重写 HTML
- 重写 JavaScript
- 有时会注入一个 ServiceWorker
- 而且经常依赖查询参数 / URL 编码来路由请求
- 能以各种方式折腾你的页面
我惊讶于有些代理能"深入"到多深:它们会重写着色器源字符串、注释,以及天知道还有什么。
有时最佳修复方案是架构层面的
WebGL 补丁让游戏重新能渲染了,但移除多域名 CDN 策略才让代理支持持续稳定。
这是一个不错的提醒:聪明的优化在和敌对中间件碰撞之前可能完全合理。或者用户的浏览器扩展。或者 Safari。或者语言设置。或者无障碍功能。或者太阳耀斑。或者任何东西,真的。
结语
Foony 现在能在重要的代理后正常运行(croxyproxy 和 proxyorb),并且没有把代码库搞成一堆代理专用的烂摊子:
- 一个通用的 Three.js 着色器修复(没有代理特定的标识符)。
- 一个更简单的域名策略(到处都用 foony.com)。