localhost だから安全ではない:HttpListener の定数時間比較とサイズ上限
Stream Deck から Unity アプリを操作する localhost HTTP。トークン比較のタイミング攻撃、chunked 転送で素通りする Content-Length 上限、裏スレッドからの Resources.Load。
何に困っていたか
Stream Deck や外部スクリプト、監視ツールから、24 時間配信で動く MimiCatHub 製アプリを同じ PC 内の HTTP で操作・監視したい。「12 時間で別枠へ移す」「配信停止を検知して再起動する」といった自動化を Unity の外から駆動する入口が必要だった。
Remote パッケージは GET /health と POST /commands/<name> の 2 つだけを持つ。
curl -H "Authorization: Bearer <token>" http://127.0.0.1:17800/health
{"ok":true,"state":"Initialized","uptimeSeconds":123.4,
"modules":[{"id":"mimicathub.save","state":"Initialized","health":{"state":"Healthy"}}]}
設計
- バインド先は
127.0.0.1固定で設定変更不可。外部からの操作は SSH ポートフォワードなど PC 側の手段に委ねる。HTTPS は持たない。 - 認可はトークン 1 つ。
.envのMIMICATHUB_REMOTE_TOKENをAuthorization: BearerかX-MimiCatHub-Tokenで受け取る。値が無ければ初期化に失敗する。Develop でも必須にして「開発中だけ認可なし」を作らない。 HttpListenerの受付・本文読み取り・応答書き込みは裏スレッド、コマンド実行は必ずメインスレッド(Tick)。ConcurrentQueueに積み、RunContinuationsAsynchronouslyの完了源で待つ。コマンドはTask.WhenAnyでタイムアウトし 504 を返す。- 振り分け(トークン照合 401 → パス解決 404/405 → コマンド検索 404 → 実行)は純粋関数にして、サーバーを差し替えてテストする。
- JSON は自前の最小実装で、外部ライブラリに依存しない。
何が起きたか
トークン比較が定数時間でなかった。 文字列比較は先頭から不一致を見つけた時点で返るので、応答時間からトークンを推測できる。localhost 限定とはいえ、同じ PC 上の別プロセスが攻撃者になりうる。長さや一致位置で処理時間が変わらない比較へ変更した。
Content-Length の無い要求で上限が効かなかった。 本文の上限(既定 65536 バイト)は申告値で判定していたので、chunked 転送では本文全体をメモリへ読み切ってから判定していた。上限を超えた時点で読み取りを打ち切り 413 を返すようにした。
裏スレッドから Resources.Load を呼んでいた。 警告ログの色を裏スレッドで初めて評価すると、内部で Resources.Load が走ってメインスレッド外の例外になった。色は生成時(メインスレッド)で取得するようにした。
教訓
- 秘密情報の比較は、経路が localhost でも定数時間にする。 攻撃者は「ネットの向こう」だけではない。
- サイズ上限は申告値ではなく実読み取り量で強制する。 chunked 転送は申告値のチェックを素通りする。
- Unity では「どのスレッドで最初に評価されるか」が事故になる。 裏スレッドで触る値はメインスレッドで先に確定させる。
- HTTP の受付とゲームロジックをキューと
Tickで分けると、Unity API の安全性とタイムアウト制御が両立する。 - 「開発中だけ認可なし」を許さないと、後から締める工数と事故確率の両方が消える。
対象外
LAN やインターネットからの接続、HTTPS、サーバーからの通知(Webhook は Discord パッケージの責務)、コマンド別トークンは非対応にした。取得はポーリングだけである。
Related Articles