background blurbackground mobile blur

1/1/1970

我是如何让 Foony 在代理后正常运行的

大家好!我早就知道网页代理会给网站带来兼容性问题。然而,Foony 在代理环境下的表现一直臭名昭著地糟糕,而解决 Foony 的代理兼容性也相当棘手。

这并不是"Foony 用了什么奇葩 API"的问题(尽管我们确实用了)。它其实是几方面因素的组合:

  • 代理在绝对不应该的地方做了激进的字符串重写。
  • 代理把站域名和"其他"域名(CDN、资源主机等)区别对待。
  • 还有一个残酷的现实:有些代理就是没法支持现代 Web 应用(HTTPS 正确性、WebSocket 等等)。

我们没法兼容所有代理,但现在至少能在 croxyproxyproxyorb 上正常运行,这就达到了目标。

下面我会讲讲哪里出了问题、为什么会出问题,以及那些真正起作用的修复方案。


第一关:有效却被破坏的 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 中已经存在的 linkscript 标签。
  • 你得用 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)。
8 Ball Pool online multiplayer billiards icon