本記事は C# その2 Advent Calender2018 12日目の記事です。
はじめに
こんにちは、@yoship1639です。
普段は、ゲームエンジンを用いない個人ゲーム開発を行っています。
皆様は、「ゲームのセーブデータを改ざん出来ない様にしろ」と言われたらどう対処しますか?一番簡単なのは、セーブデータをサーバに丸投げして暗号化してもらうのがベストですね。暗号化に用いた手法もパスワードも分からないので、安全です。
しかし、それができない状況ではどうすれば良いでしょうか。つまり、ローカル環境でセーブデータを安全に管理する方法ですね。
さらに、今回は以下の制約付きです。
- C#オンリー (c++のdllは不可)
- 難読化ツール(dotfuscator等)が利用できない
こんな状況だとどうすればいいかを考えてみたいと思います。
ローカルデータ管理方法
まず、ローカルデータの管理方法はどの様なものがあるのかを見てみます。
上から順に安全性が高くなります。
1. ファイルにテキストとして書き出し
一番簡単なのはファイルにテキストデータとして直接書き出してしまうことですね。ご想像通り、一番イケないやつです。簡単にデータを改ざん出来てしまいます。
2. ファイルにバイナリとして書き出し
次に考えるのは、テキストとしてではなく、バイナリデータとして書き出してしまうやり方です。こうすると、ファイルを開いてもバイナリが並んでるだけで何のデータか解り難くなります。しかし、改ざんは容易にできてしまいます。何のデータか解らなくても適当に値を書き換えることができてしまいます。
3. 暗号化して、パスワードを設定ファイルに記述
バイナリでも駄目だとすると、次に考えるのはデータの暗号化です。バイナリデータに暗号処理を施すことで、改ざん検知が可能となりセキュリティが高くなります。そして、暗号化にはパスワードが必要です。複合化のために同じパスワードが必要だからです。
しかし、パスワードを設定ファイルに保存すると、パスワードが設定ファイルから丸見えです。簡単に改ざんは出来なくなりましたが、仕組みが分かれば複合は簡単です。
4. 暗号化して、パスワードをコード内に記述
パスワードを設定ファイルに保存するのが駄目だとすると、今度はコード内にパスワードを埋め込んでしまうという手法を取ります。これで、簡単にはパスワードが解らなくなるので、セキュリティが更に高くなります。
しかし、逆アセンブルを知っている場合は話は別です。C#は中間言語にコンパイルされますが、中間言語を元のコードに比較的簡単に戻すことができてしまいます。コード内のパスワードが発見されたら、アウトです。
5. 暗号化して、パスワードをコード内に記述し、難読化を施す
中間言語から元のコードに戻すときに何の処理高解り難くしてしまうのが難読化です。基本はdotfuscatorという難読化ツールを利用します。こうすることで、パスワードが解り難くなります。
ですが、今回は制約により利用できません。
6. 暗号化、複合化、パスワードをネイティブDLL(c++)に記述する
ローカルデータ保存の最終形態は、ネイティブDLLに任せてしまうことです。こうすることで、機械語に変換されるので、普通の人では到底解読できなくなります。しかし、中には解読できてしまう人もいます。こうなったらもうお手上げです。
今回は、制約によりネイティブDLLも利用できません。
6手法の安全性を知る
先ほどの6手法のパスワード管理だとどのくらいの安全性があるのかを知ってもらうために、逆にどのくらいの手順を踏めばデータの取得にたどり着くことができるのかをご紹介します。
1. ファイルにテキストとして書き出し
- ファイルを開き、目的の値を見つける
2. ファイルにバイナリとして書き出し
- バイナリを解読する
3. 暗号化して、パスワードを設定ファイルに記述
- 設定ファイルからパスワードを取得
- 暗号化手法を見つけ出す
- 複合化しバイナリを解読する
4. 暗号化して、パスワードをコード内に記述
- 逆アセンブルする
- パスワードが記述されている箇所を見つけ出す
- 暗号化手法を見つけ出す
- 複合化しバイナリを解読する
5. 暗号化して、パスワードをコード内に記述し、難読化を施す
- 逆アセンブルする
- 難読化されたパスワードが記述されている箇所を見つけ出す
- 難読化された暗号化手法を見つけ出す
- 複合化しバイナリを解読する
6. 暗号化、複合化、パスワードをネイティブDLL(c++)に記述する
- 機械語を読み解き、パスワード部分を解析する
- 機械語を読み解き、暗号化手法を解析する
- 複合化しバイナリを解読する
1に関しては説明不要のセキュリティのガバガバさです。2はバイナリ解読に時間がかかりますが、適当に書き換えて確認するという手法がとれるのでセキュリティは低めです。3は暗号化してあるので改ざん検知が可能ですが、暗号化の手法さえ分かってしまえばパスワードが設定ファイルからする解ってしまうので2と同等のセキュリティレベルになります。4は3のパスワードを通常じゃ見られなくしてしまう手法なので更に安全性は高まりますが、逆アセンブルという手法さえとられてしまうとパスワードが見つかってしまうという危険性もあります。5以降に関してはそこら辺のエンジニアでは手を出そうとは思わないレベルの解読難易度です。絶対に安全とは言い切れませんが、相当セキュリティレベルは高いと思っていただいて大丈夫でしょう。
正直、3,4でもそこそこは安全性はあると思ってもいいと思います。しかし、そこそこなのでパスワードと暗号化手法さえ当てられてしまうと一発アウトです。なので、そのパスワードを動的に生成する様にしたらどうなるでしょう。
パスワードを動的生成する
難読化、ネイティブDLLの利用不可の場合、現段階でセキュリティが一番高いのは、「暗号化して、パスワードをコード内に記述」することです。しかし、逆アセンブルによりパスワードが見つかってしまうというリスクがあります。
そこで、そのリスクを避けるためにパスワードをプログラムで動的に作成してしまうという手法をとってみたいと思います。
「暗号化して、パスワードをコード内に記述」の場合、以下のようなコードでデータを保存します。
class Test
{
private static readonly string Password = "password";
public static void Main()
{
var saveData = new byte[32]; //保存するデータとして仮定
// 暗号化する処理
var encryptData = Encrypt(saveData, Password);
// ファイルに保存する処理
SaveFile(encryptData);
}
}
この場合、*Password = "password";*が逆アセンブルにより発見されてしまうと困ってしまいます。
なので、パスワードを動的に生成する方法で発見を回避してみようと思います。
こうです。
class Test
{
public static void Main()
{
var saveData = new byte[32]; //保存するデータとして仮定
var pass = "hoge";
for (var i = 0; i < 8; i++)
{
pass = Sha256(pass) + "poyo";
}
// 暗号化する処理
var encryptData = Encrypt(saveData, pass);
// ファイルに保存する処理
SaveFile(encryptData);
}
}
意味のない処理をかませて、パスワードをコードを見ただけではわからなくしてしまいます。実際にコードを動かさないと何がパスワードの文字列かわかりません。Sha256は引数からSHA-256ハッシュ文字列を生成する関数であると仮定してください。
しかし、まだです。passという変数はすぐにパスワードだと分かってしまいますし、そのあとに続く処理がパスワード生成であるという事も解ってしまいます。
なので、passを別の文字に変えてみます。
class Test
{
public static void Main()
{
var saveData = new byte[32]; //保存するデータとして仮定
var buf = "hoge";
for (var i = 0; i < 8; i++)
{
buf = Sha256(buf) + "poyo";
}
// 暗号化する処理
var encryptData = Encrypt(saveData, buf);
// ファイルに保存する処理
SaveFile(encryptData);
}
}
passをbufというパスワードとは一見わからない変数名にしました。
bufという名前が良いかどうかはこの程度のプログラムではわかりません。しかし、規模がもっと大きく具体的なプログラムだと更に効果を発揮します。パスワードの文字列を、周りにある変数名と似た名前にし紛れ込ませることが出来るのです。こうすると、改ざんを試みる人は以下の手順を踏まなくてはいけません。
- 逆アセンブルする
- パスワード生成処理と思わしき箇所を特定する
- パスワード生成処理を抽出し、自前で処理を回しパスワードを入手する
- 暗号化手法を見つけ出す
- 複合化しバイナリを解読する
これだけ面倒になれば、そうそう手を出そうとは思わなくなります。
注意点
ここで注意点です。この手法を用いる場合は以下の事をしてはいけません。
パスワード生成部分のみを関数化してしまう
これでは実は意味がありません。なぜなら、その関数さえ掘り当て再現できればパスワードが一発でわかってしまうからです。関数化せずに直接インラインでコードにしてください。インラインならば周りのコードに紛れるので隠密になりますが、関数の場合は私はここですよと言っているのと同じになってしまします。
暗号化と複合化のパスワード生成ロジックを別のものにしてしまう
当たり前と言えば当たり前ですが、暗号キーと複合キーは同一でなければ共通暗号方式である今回の手法は成り立ちませんので、パスワード生成プロセスは暗号時と複合時で同じ処理のものを用意する必要があります。
パスワードの長さを短くしてしまう
これは、今回の手法に限ったことではないのですが、パスワードの長さは十分に長くする必要があります。そうでないとブルートフォースアタック(総当たり攻撃)でパスワードが当てられてしまい今回の手法が意味をなさなくなります。パスワードの長さには十分気を付けてください。
自分書いたパスワードのロジックを忘れる
1か月前に書いた自分のコードが読み返せない方は注意が必要です。ロジックを忘れる、見失うと自分で探し当てるしかなくなります。
以上、上記4つを気を付ければ、セキュリティの上がったファイルの保存が可能となるでしょう。
まとめ
パスワードを動的に生成することで、ローカルデータのセキュリティを高めてみました。一見意味のない処理をもっと冗長化したり変数名をうまい事プログラムになじませる事が出来れば、そうそうパスワードは見つかるものではなくなります。
当然、難読化ツールを使ったり、ネイティブなDLLを利用した方がセキュリティは上がりますが、それらが扱えない状況下では、今回のやり方は有効になるのではないかと思います。
ありがとうございました。