スプラトゥーンの暗号化について
スプラトゥーン 3 に関連するサービスでは様々な暗号化、ハッシュ化が利用されています。
これらの知識はスプラトゥーンのセーブデータを復号して、カスタムシナリオ(カスタムシナリオコードではない)を実現するにあたっての背景などを紹介したいと思います。
今回は初めてスプラトゥーン 3 の解析にチャレンジしようという方がいることに期待して、可能な限り丁寧に解説していこうと思います。
スプラトゥーン 3、発売
2022 年 9 月 1 日、スプラトゥーン 3 が発売されました。
スプラトゥーン 3 は全てのニンテンドースイッチで遊ぶことができたので、発売初日のうちにゲームデータをダンプすることができました。
よって、解析自体はすぐにできたのですが実行バイナリからデバッグシンボルが消されていたため解析は前作よりも大幅に難化していました。とはいえ、シード値を計算するアルゴリズム自体は予想ができたので擬似乱数生成器(PRNG)についてはすぐに発見できました。
効かないパッチ、動かないエミュレータ
ここまでできれば解析自体は簡単なはずでした。
PRNG が生成する値(シード値)をパッチで上書きして、実際にサーモンランのゲーム内容が変更することを確かめて、シード値からどんな WAVE が発生するかがわかればシード値から WAVE 内容を決定するアルゴリズムの解析が進みます。
ところが、パッチを当ててもゲーム内容は変更されないだけでなく、そもそもゲーム開始時に PRNG が動作していないことがわかりました。
これ以上の解析をするには実機では大変なので、エミュレータでテストをしようとしたのですが macOS 向けのニンテンドースイッチのエミュレータは当時ありませんでした。これ以上することもないので、ここで解析は一旦打ち止めになりました。
シナリオコード実装
ここから三ヶ月くらい停滞していたのですが、2022 年 11 月 30 日の 2.0.0 へのアップデートでサーモンランにシナリオコードが実装されました。これにより一度遊んだバイトであればシナリオコードを保存しておくことで前作でのシード固定とほとんど同じように遊ぶことができるようになりました。
シナリオコードの問題点
とはいえ、シナリオコードはシード固定と比較して以下の点で劣りました。
- シナリオコードからシード値がわからない
- 好きなシナリオコードを設定することができない
- 誰かが公開していなければいけない
- シナリオコード取得にはいちいちクマサン商会の端末を使わないといけない
- イカリング 3 からコードを発行できれば楽だったはず
- ステージ、キケン度が選択できない
- プライベートバイトのものであればブキだけは変更できる
なので、例えばプライベートバイトで低キケン度で遊んでいてめちゃくちゃいいシードだったとしても、発行したシナリオコードを使ってステージやキケン度を変えて遊ぶことができないというわけです。
しかもプライベートバイトでは 200%以上のキケン度が指定できないので、実質的にプライベートバイトでは使い物になりませんでした。また、この頃はアプリ開発が忙しかったため、解析に充てる時間はありませんでした。
エミュレータリリース、再度解析
アプリもリリースして一息ついていた頃、macOS 向けのエミュレータがリリースされていることに気付きました。また、スプラトゥーン 3 も一応動くことがわかったので、これを解析に利用することにしました。
実機を使うと常に BAN の可能性がつきまといますが、エミュレータであれば%ニンテンドーネットワークに接続することがないので(元々 BAN されているような状態)、気軽に解析ができます。
そして、再度バイナリを眺めているときにシナリオコードに関するコードを見つけました。そしてコードを読んでいると、どうやらシナリオコードはセーブデータに保存されているらしいことがわかりました。
セーブデータの復号
とはいえ、セーブデータは暗号化されているのでそのまま直接中身を見ることはできません。ただ、シナリオコードが本当にセーブデータに保存されているかもわからなかったのでスプラトゥーン界隈エンジニアで頂点に君臨するメンバーの一人である shadowninja108 氏にコンタクトをとってみることにしました。
やり取りの結果「セーブデータにシナリオコードがあること」というだけでなく「セーブデータをくれたら復号して渡すよ」とまで言ってくれました。で、試しに送ってみたら本当に復号された JSON が返ってきました。
「セーブデータを復号するツールがあるのか」ときいてみたのですがどうやらプライベート用らしく、一般リリースはしていないのことでした。ここでクレクレくんをしても良かったのですが、未リリースのものでも shadowninja108 氏は言わなくても勝手にくれることが多かったので「何も言わずにツールを送ってこないということは自分でやれということだな」と謎の解釈をしてセーブデータの復号にチャレンジすることにしました。
ただ、ツール自体は頂けなかったものの(そもそもくれと言ってはいないのだが)、SHA256 と MurmurHash が使われていること、セーブデータのヘッダーにキーがあることなどは教えてくれたのでそれを使って自力でセーブデータを復号することにしました。
以下、回想を中止してちょっとだけ技術的な内容。
暗号化とハッシュ化
まず最初に暗号化とハッシュ化について解説します。これらの技術はどちらもスプラトゥーン 3 で使われています。
暗号化
暗号化に必要なのは読めないようにしたい「平文」と「鍵」と「暗号化方式」の三つです。
それぞれについて大雑把に解説すると以下のようになります。
- 平文
- 暗号化される前の文章
- 鍵
- 平文を暗号化する、暗号文を復号する場合に使われる
- 公開鍵暗号では暗号化と復号で異なる鍵を使うが、今回は共通鍵の場合のみを考える
- 暗号化方式(アルゴリズム)
- 鍵と平文から暗号文を作る方法
- 同じ鍵と平文を使えば毎回同じ暗号文が出力される
- 暗号文
- 暗号化された文章
アルゴリズムとは何だと思われるかもしれませんが、簡単に言えば何かの入力から何かの出力を得るための「手続き」と言い換えることができます。
古典暗号は平文以外の全てを秘密にする必要がありましたが、現代暗号は鍵さえ秘密にしていれば安全なように設計されています。本当に安全かどうかは未証明な P!=NP の真偽などに依存している部分もあるのですが、恐らく正しいと思われているので多分大丈夫です。
暗号がどのくらい安全なのかは「情報理論的安全性」と「計算量的安全性」の二つにわけて考えられます。
情報理論的安全性
鍵が漏洩しない限り理論上安全な暗号。実装するのにコストが掛かりすぎるので、ホワイトハウスなどのホットライン以外では殆ど使われていない。
計算量的安全性
鍵がなくても理論上解読ではあるものの、解読にかかるコストがあまりの大きすぎるため事実上ほとんど安全と思われる暗号。例えば、クレジットカードの情報は解読可能であるが、コストが個人のクレジットカードの利用上限を遥かに上回るため実行する意味がない。
ハッシュ化
暗号化と混同されがちですが、ハッシュ化と暗号化は大きく異なります。
暗号化は暗号文と鍵があれば必ず正しい平文が得られますが、ハッシュはハッシュとハッシュ化アルゴリズムがわかっても正しい平文が得られません。
というのも、暗号化は平文が長くなればなるほど長くなりますが、ハッシュは平文がどんなに長くなっても常に一定の長さになります。
一定の長さの文字列で表現可能な組み合わせは有限なので、計算すると同じハッシュになってしまう異なる平文が存在するわけです。といっても、これだけではわからないかもしれないので簡単な例で説明します。
- 暗号化
- 鍵(5)
- アルゴリズム(それぞれの桁にその数を足す)
- ハッシュ化
- 鍵(なし)
- アルゴリズム(全ての桁を足し合わせ、一桁の数になるまで繰り返す)
上の条件で計算したものが以下の値になります。暗号文は平文が長くなればどんどん長くなりますが、ハッシュ化はどんなに平文が長くても 1 桁の数になります。
鍵 | 平文 | 暗号文/ハッシュ | |
---|---|---|---|
暗号化 | 5 | 12345 | 67890 |
ハッシュ化 | - | 12345 | 6 |
今回の暗号も非常に簡単ではあるものの、暗号文とアルゴリズムがわかっても鍵がわからない限り平文はバレることがない。
暗号化アルゴリズムとハッシュ化アルゴリズム
名称 | 方式 | 利用目的 |
---|---|---|
SHA256 | 暗号 | データの暗号化に利用される |
MurmurHash | ハッシュ | JSON のキー計算に利用される |
今回紹介するのは上の二つのアルゴリズムです。何故なら、スプラトゥーンのデータ解析に必要なのがこれら二つのアルゴリズムだからです。逆に言えば、これ以外のアルゴリズムは不要です。
とはいえ、実際には SHA256 で利用されるキーを計算するアルゴリズムなどは必要になる
先程、ハッシュは(アルゴリズムが同じなら)常に長さが一緒になると説明しましたが、このハッシュは例えば JSON のキーなどを生成するときなどに役立ちます。JSON のキー名はそれぞれ長さが違うので、長いものも短いものもありますがハッシュ化することで常に同じ長さの値が得られるわけです。
もちろん、違うキーから同じ値が得られてしまう可能性もありますがハッシュの長さが長ければその可能性は極めて低くなります。例えば、今回紹介する MurmurHash3 では 32 ビットのハッシュが得られるので、二つの平文が同じハッシュを返す可能性は 4294967296 分の 1 の確率になります。
ただし、ハッシュを大量に生成すると 2^32 程度のハッシュだと誕生日のパラドックスによりハッシュが衝突する可能性は高くなるので注意が必要
それに対して暗号化に利用される SHA256 は極めて強力で 1.157*10^77 くらいの組み合わせがあるので、現時点で地球に存在する全ての計算資源を投入して解読しようとしても途方もない時間がかかります。つまり、事実上解読は不可能というわけです。
では、どうやって解読不可能なはずの SHA256 を解読したのかについて解説します。
実際には SHA256 CTR なのだが、書くのがめんどくさいので SHA256 と省略して書きます
SHA256 を解読する
スプラトゥーン 3 のセーブデータは SHA256 で暗号化されており、SHA256 は事実上解読が不可能です。では、何故セーブデータは復号できるのでしょうか?
ここで「本当に解読が不可能であれば何故スプラトゥーン 3 は暗号化されたセーブデータを読み込めているのか」ということに気付ければ話は早いです。先程までの「解読ができない」というのは鍵がわからない場合の話であり、スプラトゥーン 3 自身がセーブデータを読み込めているということはスプラトゥーンのゲーム内のどこかに「鍵」あるいは「鍵を生成するアルゴリズム」がコーディングされていることになります。
今回の場合は後者でした。流石に現代のゲームで共通鍵自体がハードコードされていることは少ない
鍵を生成するアルゴリズムやその仕組について解説していると記事がいくらでも長くなってしまうので割愛しますが、スプラトゥーン 3 ではハードコードされた乱数表と PRNG によってセーブデータごとに異なる鍵を使って暗号化されています。
PRNG は初期化時に初期シードが必要ですが、初期シードが暗号化されたセーブデータのヘッダー部分に保存されています。なので、以下の手続きでセーブデータを復号し、JSON に変換することができます。
- 暗号化されたセーブデータのヘッダーからシード値を読み込む
- 読み込んだシード値で PRNG を初期化する
- PRNG が生成する数と乱数表から SHA256 の鍵を生成する
- 生成された鍵でセーブデータを復号する
- 復号されたセーブデータを JSON に変換する
こう書くとなんだか簡単そうですね。実際、鍵さえわかれば解読は一瞬でできてしまいます。
どうやって鍵を計算するかも書いても良いのだけれど、多分興味がないと思うので割愛します
MessagePackを解析する
復号したセーブデータはclient
とserver
という二つの部分に分けられます。シナリオコードが書き込まれているのはclient
なので、弄るのはこちらだけで大丈夫です。
セーブデータはMessagePackという形式で保存されているので、これを JSON と相互変換できればそれで終わりのはずでした。まさか、ここからが本当の地獄だったとは。
MessagePack と JSON の相互変換は様々なライブラリで実装されており、ブラウザでもmsgpack-liteのようなサイトで簡単に実行できます。「え、じゃあもう終わりじゃん」って思うんですが、ここからが長かったです。
復号したセーブデータを上記のサイトに突っ込むと確かに JSON に正しく変換されました。
{
"Common": {
"BootCount": 30,
"ControlOptionHandheld": {
"CameraSpeedGyroDigital": 8,
"CameraSpeedStickDigital": 8,
"IsEnableGyro": true,
"IsReverseLR": false,
"IsReverseUD": false
},
...
}
}
キーとハッシュのマップとかはいろいろあるが、やっぱり長いので割愛します
じゃああとはこの JSON の中身を書き換えて MessagePack に戻して終わりだったはずなのですが、ここで問題が発生します。
型、失われたあと
MessagePack はバイナリなので厳格なフォーマットが存在します。たった 1 つでも値が違うと異なる意味を持つので(そして多くの場合、意味を持たないデータになってしまう)スプラトゥーン 3 が読み込めるようなフォーマットにする必要があります。その際に大事になるのが型です。
型 | JSON | MessagePack |
---|---|---|
文字列型 | string | string |
数値型 | number | uint8/16/32, int8/16/32 |
真理値型 | boolean | boolean |
配列型 | array | array |
辞書型 | dictionary | map |
上は正確性に欠けますが、わかりやすいと思うのでそのまま載せておきます。JSON には数値といえばnumber
型しかありません。桁がいくらだろうが、負の値だろうが、小数だろうが、全てnumber
型です。それに対して MessagePack には数値に対して複数の型があります。
つまり、MessagePack から JSON に変換する際には数値は全てnumber
型にすればよかったのですが、逆はそうはいかないわけです。スプラトゥーン 3 がその値を何の型だと認識するかを把握する必要があります。
一度 JSON に変換すると型情報が失われるので、直接 MessagePack を読む必要があります。幸い、MessagePack をそのまま読めるライブラリがあるので中身を見てみることにしました。
array[int(25030), int(25030), int(25030), int(-1)]
すると、例えば支給されるブキは数値型で与えられるのですがニンテンドースイッチではint16
として認識していることがわかりました。ランダムブキは-1
なためunsigned
は使えず、ブキの ID の最大値はたかだか 30000 程度なのでint16
で足りるわけです。
このデータを JSON で表現すると以下のようになります。
[25030, 25030, 25030, -1]
ではこれを MessagePack に直すと得られるデータはというと…
array[uint(25030), uint(25030), uint(25030), int(-1)]
なんと本来int(25030)
と返らなければいけないところがuint(25030)
となってしまいました。これは MessagePack の定義なのか、使ったライブラリが悪いのかはわからないのですが、正の整数値であれば常にuint()
を利用するというようになっているようです。ところがスプラトゥーン 3 はブキの ID はint()
が与えられるという前提で実装されているのでuint
の値を渡すと読み込めずに0
に置き換えられてしまいます。なので、何も考えずにセーブデータを復号してから再暗号化すると支給されたブキが緑ランダムと金ランダム以外は全てボールドになってしまいます。
-1 と-2 は負の数なので変換時に正しく
int(-1)
とint(-2)
にそれぞれ変換されます
ライブラリには.int()
で初期化するメソッドもあったのですが、それを利用したとしても最終的にバイナリになる際に正の値は.uint()
に変換されてしまいました。直接バイナリを指定して書き込むようにすればいいのですが、そのようなイニシャライザがないので詰んでしまいました。
じゃあ、ライブラリ自分で改修すればいいじゃんということで改修したライブラリがNSMessagePackです。まだバグがあるのですが、直接バイナリを突っ込めるようにしています。MessagePack の仕様は公開されているので、これを読んでスプラトゥーン 3 が正しくデータを認識できるように与えられた数値をバイナリに変換するようなコードを書きます。