コラム概要
■こんな方におすすめ:
- 難読化ツールを自作してみたい
- 手動で行っている難読化を自動化したい
■難易度:★☆☆
■ポイント:
- ソースコード難読化ツールは簡単に作れる。
- パーサ・難読化コード作成モジュール・ソースコード変更モジュールという観点で見ると、
ソースコード難読化ツールは理解しやすい。 - おそらくパーサが最も難しいので、最初は非対応の範囲を広くとる形で作ればよい。
この記事は「Javaソースコード難読化ツール自作入門」シリーズの第1回です。
他の回はこちらからご覧ください。
Javaソースコード難読化ツール自作入門 – 第2回
Javaソースコード難読化ツール自作入門 – 第3回
難読化については、マーケティング担当者コラムにも記事がございます。
難読化をアプリのセキュリティ対策ツールとして検討中の方や
既に対策として実施されている方向けのコラムとなっています。
ご興味のある方は、ぜひご覧ください。
難読化だけで大丈夫?!アプリに施すセキュリティ対策の落とし穴
1. はじめに
難読化は、対象プログラムについて、逆コンパイル結果の可読性が下がるようにする処理で、
静的解析を妨害し、ソースコードに含まれる情報を守るためのものです。
難読化は、ソフトウェアの保護手段の中ではやや効果が分かりづらいものです。
そのため、必要性に懐疑的な読者もおられるかもしれません。
そちらに関しては、既に以前の記事で議論しておりますし、本筋からやや外れるのでここでは掘り下げません。
興味がある方は、是非下記記事をご覧ください。
「アプリに施せる耐タンパ―Tips -難読化はなぜ必要か?-」
さて、本題に入る前に、難読化ツールを自作するメリットについて説明しようと思います。
最大のメリットは関連技術を学べることですが、実用上のメリットはないのでしょうか。
素晴らしい難読化ツールが世に出回っているのに、なぜそれらを使わない選択肢があり得るのでしょうか。
メリットの一つは、ある種の逆難読化ツールに強い点です。
有名な難読化ツールのパターンを認識して逆難読化を施す類のツールが存在するのですが、
自作ツールによる難読化はパターンに合致しにくく、対策もされにくいという訳です。
また、自前実装なので当然ですが、自由度が高いという点もメリットです。
具体的には、欲しい機能の追加や各機能の細かい調節などが自由にできます。
これらのメリットは手作業による難読化でも享受できますが、以下の理由からおすすめしません。
・ソースコードを手作業で難読化するのは、手間がかかる上に保守性が下がる
・コンパイル結果を自力で解析・編集して難読化するのは、要求される知識が特殊でハードルが高い
代わりに、本コラムでは下記の方針をおすすめします。
・ソースコードを難読化対象にする(特殊な技術・知識が要求されない)
・自動で難読化してくれるプログラムを作成する
(一度作成すれば、手間がかからなくなる。また、オリジナルのソースコードが残せるので、開発・保守しやすい。)
今回、自作方法を紹介する難読化ツールの概要は以下です。
難読化対象(ファイル) | Javaソースコード |
非対応範囲 | 変数名・メソッド名などにマルチバイトを使用しているソースコード |
難読化機能 | 定数難読化(int型整数のみ) |
動作 | コマンドライン引数で1つのファイルを受け取り、 難読化結果を標準出力に出力する |
具体的な難読化機能は定数難読化に絞りました。
難読化ツール自作の肝である、ソースコードから難読化対象を抽出する部分にフォーカスして
説明をするべきだと考えたからです。
ソースコード中に現れる12、345などの定数を検出して関数呼び出しに置換します。
置換のイメージ :
前) int a = 12;
func(345);
後) int a = func0004(930);
func(func0005(927854));
2. 難読化ツールの構成とパーサ(1)
2-1. 難読化ツールの構成:
詳しい議論に入る前に、ソースコードの難読化ツールを以下のモジュールに分解して説明します。
・パーサ
・難読化コード作成モジュール
・ソースコード変更モジュール
パーサは、ソースコードを解析して、難読化対象の検出などを行います。
難読化コード作成モジュールは、パーサが検出した難読化対象に対応する関数呼び出しや関数定義を作成します。
ソースコード変更モジュールは、難読化コード作成モジュールが作成した関数呼び出しで
難読化対象を置換するなどして、ソースコードを変更します。
下記の順序で難読化を行います。
①難読化対象の数値を見つけて、関数呼び出しコードに変更する。
②クラスの終わりを見つけて、関数定義コードを追加する。
①において、各モジュールが果たしている役割は下記です。
・パーサ(1)が、難読化対象の数値を見つける。 (※)
・難読化コード作成モジュールが、数値に対応する関数呼び出しコードと関数定義を作成する。
・コード変更モジュール(1)が、数値を関数呼び出しコードに置き換える。
②において、各モジュールが果たしている役割は下記です。
・パーサ(2)が、クラスの終わりを見つける。
・コード変更モジュール(2)が、関数定義をまとめて挿入する。
※以降の説明の都合上、パーサとコード変更モジュールに番号を振っています。
さて、各論に入ります。時系列で説明します。
2-2. パーサ(1):基本的な方針
ソースコードの各行を1文字ずつ解析していきます。
(順番)
コメントなどのスキップ
↓
整数らしき要素の抽出と整数判定
以下の順番でスキップします。
(順番)
コメントのスキップ
↓
「'」で囲まれた文字のスキップ
↓
文字列リテラルのスキップ
↓
switch case文の一部スキップ
2-3. パーサ(1):コメントのスキップ
以下を避けるために実装します。
・コメント内の数字が置換される
・コメント内の「'」・「"」の影響で以降のスキップ対象の検出ロジックが正常に動作しない
①「//」によるコメントをスキップ
「//」が出現した場合、行末までスキップします。
②「/**/」によるコメントをスキップ
「/*」が出現した場合、以下の要領で「*/」が出現するまでの範囲をスキップします。
・フラグを立てる(改行に対応するため)
・フラグが立っている場合、行内で「*/」を探索する
(見つけた場合、フラグを落として、次の文字からパースを再開する)
実装例:
bool bInComment = false;
char *pcSkipIfComment(char *pcPlaceInSzLine) {
//find comment
if (!bInComment) {
if (*pcPlaceInSzLine == '/' && strlen(pcPlaceInSzLine) > 1) {
if (pcPlaceInSzLine[1] == '*') {
bInComment = true;
}
if (pcPlaceInSzLine[1] == '/') {
return &pcPlaceInSzLine[strlen(pcPlaceInSzLine) - 1];
}
}
}
//skip comment
if (bInComment) {
char *szEndMarker = strstr(pcPlaceInSzLine, "*/");
if (szEndMarker) {
bInComment = false;
return szEndMarker + 2;
} else {
return &pcPlaceInSzLine[strlen(pcPlaceInSzLine) - 1];
}
}
return pcPlaceInSzLine;
}
2-4. パーサ(1):「'」で囲まれた文字のスキップ
以下を避けるために実装します。
・'\"'の影響で、文字列リテラルを検出するロジックが正常に動作しない
「'」が出現した場合、次の「'」までの範囲をスキップします。
※厳密には、「次のエスケープされていない「'」までの範囲」とするべきですが、今回は割愛します。
実装例:
char *pcSkipIfSingleQuotation(char *pcPlaceInSzLine) {
//find single quotation
if (!bInComment) {
if (*pcPlaceInSzLine == '\'') {
//skip single quotation
return strchr(pcPlaceInSzLine + 1, '\'');
}
}
return pcPlaceInSzLine;
}
2-5. パーサ(1):文字列リテラルのスキップ
以下を避けるために実装します。
・文字列リテラル内の数字が置換される
・文字列リテラル内の「'」の影響で、「'」で囲まれた部分を検出するロジックが正常に動作しない
・文字列リテラル内の「//」・「/*」をコメントの開始とみなしてしまう
「"」が出現した場合、次の「"」までの範囲をスキップします。
※厳密には、「次のエスケープされていない「"」までの範囲」とするべきですが、今回は割愛します。
実装例:
char *pcSkipIfStringLiteral(char *pcPlaceInSzLine) {
//find string literal
if (!bInComment) {
if (*pcPlaceInSzLine == '\"') {
//skip string literal
return strchr(pcPlaceInSzLine + 1, '\"');
}
}
return pcPlaceInSzLine;
}
2-6. パーサ(1):switch case文の一部スキップ
以下を避けるために実装します。
・caseに続く定数が難読化される(コンパイルエラーになる)
例:
switch (条件式) {
case 5: //定数難読化の対象から外さないとコンパイルエラーになる。
...
}
セパレータ(区切り文字)に挟まれた「case」が出現した場合、その行のパースを終了します。
※以下の文字に半角スペース、Tab文字、改行文字を加えたものをセパレータとして使用します。
,;(){}[]+-*/=<>&|^~"!?:
case文検出関数・セパレータ判定関数の実装例:
bool bIsCase(char *pcPlaceInSzLine) {
if (*pcPlaceInSzLine == 'c' && strlen(pcPlaceInSzLine) >= 7) {
if (pcPlaceInSzLine == strstr(pcPlaceInSzLine, "case")) {
if (bIsSeparator(pcPlaceInSzLine[-1]) && bIsSeparator(pcPlaceInSzLine[4])) {
return true;
}
}
}
return false;
}
bool bIsSeparator(char c) {
char szSepChar[] = " ,\t;(){}[]\n+-*/=<>&|^~!?\":";
char *pcSepChar = szSepChar;
while (*pcSepChar) {
if (c == * pcSepChar) {
return true;
}
pcSepChar++;
}
return false;
}
2-7. パーサ(1):その他特殊な扱いが必要なもの
厳密にはその他にも定数難読化の対象外とすべきもの、特殊な扱いが要求されるものがありますが、
ここでは割愛します。
定数難読化の対象外とすべき例:
・@IntDef, @IntRangeの引数に使用されている変数
・case文の値に使用されている変数
特殊な扱いが要求される例:
・byte型変数
・short型変数
パーサ(1)の説明の途中ですが、長くなってきてしまったので、残りは次回とします。
次回は、パーサ(1)の残りとその他のモジュールを時系列で説明します。ご期待ください。