background blurbackground mobile blur

1/1/1970

Foony をプロキシ越しでも動かすまでの道のり

やあ! ウェブプロキシがウェブサイトの互換性問題を引き起こすことは、ずっと前から知っていたんだ。でも Foony のプロキシ対応はこれまで悲惨なほど悪く、その互換性を解決するのはなかなか厄介だった。

これは「Foony が珍しい API を使っているから」という話でもない (まあ 使ってはいる けど)。原因はいくつか組み合わさっていた:

  • プロキシが、絶対に手を出してはいけない場所にまで強引な文字列置換をかけていること.
  • プロキシが メイン サイトのドメインと「その他」のドメイン (CDN やアセットホストなど) を別扱いすること.
  • そして、一部のプロキシは現代的なウェブアプリ (HTTPS の正しさや WebSocket など) をそもそもサポートできないという厳しい現実.

すべてのプロキシで動くわけじゃないけど、少なくとも croxyproxyproxyorb では動くようになった。これが目標だったんだ。

以下では、何が壊れていたのか、なぜ壊れていたのか、そして本当に効いた修正について説明していくよ。


第1段階: 正しいはずなのに壊れる Three.js シェーダー

症状

croxyproxy を試してみたら、8 Ball Pool も Foony の 他の three.js ゲーム もプレイできなかった。 Three.js でシェーダーのコンパイルに失敗し続け、こんなエラーが出ていた:

  • 「Shader Error 1282 - VALIDATE_STATUS false」

このメッセージはほとんど役に立たない。だいたい「シェーダーが無効です、頑張ってね」くらいの意味だ。素晴らしい。なぜ僕が Foony のすべてのエラーに固有のメッセージを付けているのか不思議に思ったことがあるなら、これが理由だよ。問題箇所を特定できる方が「コードが壊れてるから直して」よりずっと役に立つ。

それにしても、なぜ完全に正しい three.js のシェーダーが壊れていたんだろう? 何が起きていたんだ?

真の原因: プロキシが layout(location = N) を破壊していた

Three.js は、こんな感じの layout 修飾子付きの GLSL を生成する:

layout(location = 0) in vec3 position;

一部のプロキシは、JavaScript の location API のように見えるものを片っ端から、単純なグローバル文字列置換で書き換えようとする。これは JS でも十分まずいんだけど、シェーダーのソース文字列の中まで置換していたんだ。AST 解析するのは高コストすぎたんだろうね。

その結果、シェーダーソースはこんな風に壊されていた:

layout(__cpLocation = 0) in vec3 position;

ここの識別子は 必ず location でなければならない。それ以外は無効な GLSL で、コンパイラに弾かれる。(GLSL の Layout Qualifiers)

これが Three.js の問題と言えるのは、Three.js がシェーダーを動的に生成し、実行時に WebGL に渡しているからにすぎない。本当のバグはプロキシの書き換え戦略にある。

なぜ「プロキシを直す」ことをしなかったのか

素朴な方法としては、croxyproxy の location 置換文字列である __cpLocation を探して location に戻す、というのが考えられる。でもプロキシによって置換名が違う。__cpLocation を使うものもあれば、別の奇妙な識別子を使うものもある。だから「__cpLocationlocation に戻す」みたいな決め打ちの修正は脆い。

必要だったのは:

  • 汎用的な修正 (プロキシの識別子をハードコードしない).
  • たとえプロキシが僕の JavaScript 内の 単語 location まで書き換えていても動く修正.

base64 トリック: プロキシから location という単語を隠す

プロキシが見えるすべての location リテラルを書き換えるなら、いちばん単純なのは location を使わないことだ。簡単な話。同じようなトリックは前にも見たことがある。RestedXP のガイド保護システムをリバースエンジニアリングしたとき、Lua でやられていたんだ (記憶が正しければ、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 の書き換えを 終えた後 に実行されるので、プロキシは文字列を「事前に破壊」できない。文字列を 2 つに分割して検出をさらに難しくしているし、'atob' を使っているのは単に使えるからだけど、String.fromCharCode や 16 進エスケープの window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] でも動くかもしれない。

壊されたシェーダーのパターンは構造的にいつも同じだ:

layout(<something> = <number>)

なので、それを汎用的にマッチさせて <something> を正しい識別子に置き換える:

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 はまだ失敗していた。なんで?! 同じように動くはずじゃないの?


第2段階: 2つ目の問題 (ドメイン) と、foony.io を捨てたらすべてが楽になった話

Foony は歴史的に、少なくとも直近1ヶ月の間は2つのドメインを使っていた:

  • メインサイト用の foony.com
  • 静的アセット用の foony.io

元々の理由は実用的なものだった: アセットを Cookie のないドメインから配信すれば、すべての静的ファイルリクエストで Cookie ヘッダーの肥大化を避けられる。これは素晴らしいんだけど、HTTP/2 が HPACK を使ってヘッダーの転送バイト数を削減することを考えると、思ったほど 必須 ではない。

通常のブラウジングでは妥当な最適化なんだ。

でもプロキシ越しでは、これが壊れる主因になった。しかも Foony のユーザー層はプロキシが大好きときている。はぁ

プロキシは「メインサイト」と「その他のサイト」を別扱いする

多くのプロキシは「この1ページ / ドメインをプロキシする」用に最適化されている。メインの 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 エンコードに依存していて、けっこう脆いんだ。

典型的な例の1つが、こんなアセット URL だった:

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

この ?__pot=... はプロキシ自身のルーティング / 状態で、リクエストがどのドメイン宛てなのかをプロキシに伝えている。これを剥がすと、プロキシはリクエストを正しく解決できず、ServiceWorker のエラーパスに突入してしまう。

「リソーススワッピング」で救う (そしてあっという間に複雑化した話)

ある時、こんな回避策を試した: 「自分はプロキシ越しだ」と検知したら、foony.io のリソース URL を 現在の オリジンに差し替えて、プロキシからすべて同一オリジンに見えるようにする、というもの。

理にかなっているように聞こえるし、croxyproxy では動いた。でも複雑性がぐっと増した:

  • HTML にすでに存在する linkscript タグを書き換える必要がある.
  • 動的に注入されるタグ (modulepreload や stylesheet など) を扱う MutationObserver が必要.
  • プロキシのクエリパラメータを保持しなければならない、さもないとルーティングが壊れる。しかもプロキシによってやり方が違う。まあ当然そうなるよね.
  • それでもロジックは汎用的に保たなければならない (プロキシ固有のグローバルを使わない)、でないとコードが膨れ上がったゴミ箱火災になる.

「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 を取り除くのは、プロキシの奇妙な挙動の影響範囲を減らすためのアーキテクチャ的な単純化だったんだ。


第3段階: 何が動いて、何が動かないか、そしてなぜか

動作確認済みのプロキシ

現時点で、Foony は以下のプロキシ越しで動く:

  • croxyproxy
  • proxyorb

他のプロキシでもたぶん動くものはある。ほとんどはまだ動かないだろうけどね。それでも、人々がゲームを遊ぶのに使っている重要どころは動いているようだ。

なぜ「すべてのプロキシ」じゃないのか?

一部のプロキシは、現代的なマルチプレイヤーウェブアプリをそもそもサポートできない。たとえば:

  • 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