background blurbackground mobile blur

1/1/1970

プロキシ越しでも Foony が動くようにした話

やあ!Web プロキシって、昔からサイトとの相性問題をよく起こすんですよね。ただ、Foony のプロキシ対応はそれにしてもひどくて、その互換性問題を解くのはなかなか骨が折れました。

これは「Foony が変わった API を使ってるせいだ」という話でもありません(実際そういうのもやってはいるんですが)。原因はいくつかの要素が合わさった結果でした。

  • 本来いじっちゃいけない場所まで、プロキシが文字列を書き換えまくる。
  • プロキシが、メイン のサイトドメインと、CDN やアセット配信用みたいな「別ドメイン」を違う扱いにしている。
  • そして、「そもそも最新の Web アプリをちゃんと支えられないプロキシもある」というつらい現実(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 は、こんな感じのレイアウト修飾子つき GLSL を吐きます。

layout(location = 0) in vec3 position;

いくつかのプロキシは、JavaScript の location API っぽいものを見つけると、なんでもかんでも雑なグローバル置換で書き換えようとします。JavaScript のコードにそれをやるだけでもだいぶヤバいのに、シェーダーのソース文字列の中まで同じノリで書き換えていたんです。AST をちゃんとパースするのはコストがかかりすぎる、ってことなんでしょうね。

そのせいで、シェーダーのソースはこんなふうに壊されていました。

layout(__cpLocation = 0) in vec3 position;

ここは識別子が location じゃないといけません。それ以外は全部無効な GLSL なので、コンパイラに弾かれます。(GLSL のレイアウト修飾子)

これは Three.js の問題といえば問題なんですが、せいぜい「Three.js がシェーダーを動的に生成して、それを実行時に WebGL に渡している」という意味での話です。本当のバグは、プロキシの書き換え戦略のほうにあります。

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

素朴なアプローチとしては、croxyproxy が location__cpLocation に置き換えているので、その文字列を探して location に戻してしまう、という方法があります。

でも、プロキシごとに置き換え後の名前がバラバラなんですよね。__cpLocation を使うところもあれば、もっと変な識別子を使うところもある。なので「__cpLocationlocation に戻す」みたいなハードコードは、とても壊れやすい対処になります。

ぼくに必要だったのは、こんな条件を満たす解決策でした。

  • 汎用的な修正(特定のプロキシ専用の識別子をハードコードしない)。
  • 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 の書き換えを終えたあとなので、この文字列を事前に壊すことはできません。検出されにくくするために文字列を 2 つに分けておいていますし、ここでは 'atob' を使っていますが、String.fromCharCode を使ったり、window['\x6c\x6f\x63\x61\x74\x69\x6f\x6e'] みたいに 16 進エスケープしてもたぶん動きます。

壊されてしまったシェーダーのパターンは、構造的にはいつも同じです。

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 をやめたら一気に楽になった理由

少なくともここ1か月くらい、Foony は歴史的に 2 つのドメインを使っていました。

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

最初の理由はけっこう実務的で、「クッキーが付かないドメインからアセットを配信すれば、静的ファイルを読むたびにクッキーヘッダーを送らなくて済むから、無駄な通信量を減らせるよね」という発想でした。これはこれで良い最適化なんですが、HTTP/2 がヘッダー圧縮に HPACK を使っていることを考えると、みんなが思っているほど必須というわけでもありません。

ふつうのブラウジング環境なら、まあアリな最適化です。

ところがプロキシの裏側になると、これが一気に「壊れポイント」の大本営になってしまいました。しかも Foony のユーザーはプロキシが大好きなんですよね。はぁ……。

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

多くのプロキシは「この 1 ページ / この 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 エンコードにべったり依存していて、しかもとても壊れやすいです。

象徴的な例が、こんなアセット URL でした。

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

?__pot=... の部分は、プロキシが「このリクエストはどのドメイン向けなのか」を判断するためのルーティング / 状態情報になっています。ここを削ってしまうと、プロキシが正しくルーティングできなくなって、さっきのような ServiceWorker 絡みのエラーに突入します。

「リソースの差し替え」で乗り切ろうとして、すぐに大ごとになった話

途中までは、こんな回避策を試していました。「いまプロキシ越しで動いているな」と検出できたら、foony.io 向けのリソース URL を全部、いまの オリジンに差し替えて、プロキシから見ると全部同一オリジンに見えるようにしてしまう、という方法です。

聞こえはそれなりにもっともらしいし、実際 croxyproxy ではうまく動いていました。でも、その代わりにコードの複雑さが一気に跳ね上がりました。

  • まず、HTML に最初から書かれている linkscript タグの URL を差し替えないといけない。
  • さらに、あとから動的に追加されるタグ(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 も使っています。

ただし、本気の文字列書き換えはサーバー側で行われています。そっちで実際になにをしているのかは、正直なところ誰にもわかりません

いずれにしても、アカウントの安全性を大事にするなら、Web プロキシの利用はほんとうにおすすめしません。どうしても使うなら、個人情報やアカウント情報、決済情報なんかは絶対に入力しないようにしてください。

「リソース差し替え」作戦はゴミ箱行きに

最終的には、「リソース差し替え」で増えた複雑さと、foony.io をサポートするためにコードのあちこちに増えていた複雑さを合わせて考えると、「クッキーなしでちょっとだけ通信量を節約できる」というメリットにぜんぜん見合っていない、という結論になりました。foony.io を導入してから、なぜかプレイ開始までたどり着くコンバージョン率が目に見えて落ちていたこともあって、ぼくらが気付いていない foony.io 絡みの問題がほかにもあるんじゃないか、と感じていました。

foony.io は、いったん全部やめることにしました。少なくとも今のところは。

foony.io 向けの CDN ロジックを削って、すべてを foony.com に統一したところ、プロキシ対応はびっくりするくらいシンプルになりました。

  • アセットはすべて同一オリジンから読み込まれる。
  • プロキシ側の ServiceWorker に説明しないといけない「特殊ケース」が激減。
  • URL の書き換えも少なくてすむ。
  • コード全体の「壊れやすさ」も下がる。

要するに、foony.io をやめたのは、変なプロキシ挙動に巻き込まれる面積を減らす、アーキテクチャ面でのシンプル化だったわけです。


パス 3:動くもの・動かないもの、その理由

動作確認が取れたプロキシ

今のところ、Foony は次のプロキシ越しでちゃんと動いています。

  • croxyproxy
  • proxyorb

ほかにも動くプロキシはいくつかあると思いますが、たぶん大半はまだダメでしょう。それでも、ゲームを遊ぶのに実際によく使われている重要どころについては、ひとまず動くようになりました。

なぜ「全部のプロキシ対応」までは目指さないのか

そもそも、最新のマルチプレイヤー Web アプリをまともに支えられないプロキシというものも存在します。たとえばこんな感じです。

  • HTTPS をちゃんとサポートしていないプロキシ。
  • WebSocket を壊したりブロックしたりするプロキシ(Foony はリアルタイム通信を使っています)。技術的には 回避策を作ることもできますが、そのぶんさらに複雑さが増えます。
  • クロスオリジンリクエストやヘッダー、ServiceWorker まわりに制限が多すぎるプロキシ。

まとめポイント

Web プロキシは本気で危ない

Web プロキシというのは、ざっくり言うとこんなことをしてくるミドルウェアです。

  • HTML を書き換える
  • JavaScript を書き換える
  • ときには独自の ServiceWorker を差し込む
  • クエリパラメータや URL エンコードに依存してリクエストの行き先を決めていることが多い
  • つまり、ページに対していろんなことを好き勝手にできてしまう

いくつかのプロキシがどれだけ深いところまで手を突っ込んでくるのかは、正直ちょっとショックでした。シェーダーのソース文字列コメントまで平気で書き換えますし、そのほかに何をいじっているのかは想像もつきません。

いちばん効く解決策が「設計を変えること」の場合もある

WebGL のパッチでゲームはひとまず描画できるようになりましたが、プロキシ越しでも安定して動くようになった決定打は、「マルチドメインの CDN 戦略をやめたこと」でした。

これは良い教訓になりました。ちょっと賢い最適化は、単体で見ればぜんぜん合理的でも、敵対的なミドルウェアとぶつかったり、ユーザーのブラウザ拡張や Safari、言語設定、アクセシビリティ機能、はたまた太陽フレアみたいな何かと噛み合った瞬間に、一気に足を引っぱる存在になってしまいます。ほんとうに、なんでもアリです。


おわりに

いまの Foony は、少なくとも大事なプロキシ(croxyproxy と proxyorb)の裏側ではちゃんと動きますし、そのためにコードベース全体をプロキシ専用の泥沼にすることも避けられました。

  • Three.js のシェーダーに対する汎用的な修正(特定プロキシ専用の識別子には依存しない)。
  • シンプルなドメイン戦略(どこからでも foony.com を使う)。
8 Ball Pool online multiplayer billiards icon