難読化の話(超!?入門編)その3 前編|セキュリティごった煮ブログ

ネットエージェント
セキュリティごった煮ブログ

 コース:こってり

難読化の話(超!?入門編)その3 前編

bubobubo

どーも、bubobuboです。

かい○ゃから前回の続編を書けと言われたので、クライアントプログラムのセキュリティの一分野である(とされている)、「プログラムの難読化」をテーマに割と真面目に作文を行いました。

過去の記事はこちら。
難読化の話(超!?入門編)その1
難読化の話(超!?入門編)その2

プログラムに対する暗号化と難読化の違い

前回、前々回の記事を読んだ方なら、暗号化(Encrypt)と難読化(Obfuscate)の違いは自明だと思いますが、実際のクラック対策ツールでは両者の技術を併用しているケースがほとんどで、技術の成り立ちからしてその境界線も曖昧になっていると思うので、ここで改めてプログラムに対する暗号化と難読化の違いを記します。

暗号化されたプログラム

  • 暗号化されたプログラムは、人間もCPUも読めなくなる
  • 暗号化されたままでは実行できないので、一時的であっても必ず復号する必要がある
    → プログラム単位ではなく関数単位で暗号化するなどして、平文になる範囲を小分けにするなどの工夫が求められる
  • 復号するには復号処理(と鍵)が必要
    → 復号処理(と鍵)をどこに隠したらよいかという問題は解決できない
  • プログラムの暗号化ならプログラム中のどこかに隠すしかない
    → 復号処理(と鍵)を隠すために別の暗号が必要になるという入れ子構造がある

難読化されたプログラム

  • 難読化されたプログラムは、程度の差はともかくとして人間にとっては読みづらくなる
  • CPUには理解できるままなので、難読化されたままでも実行できる
    → 要素技術などによってはパフォーマンスが悪化する場合がある
  • 程度の差はともかくとして表層的にはオリジナルの処理とは違うものになる
    → 処理の内容は変わらない(変わってしまったらそれは不具合)
  • 難読化したことが原因でプログラムが正常に動作しなくなる場合がある
    → 暗号化でも誤動作することはある
    → 難読化したプログラムに対しても改めて動作テストを実施する必要がある
    → 不幸にも不具合が出た場合は、難読化のレベルを下げて再度動作テストを実施するしかない (譲歩、後退と言ってはいけない)

異論は認める。 本エントリでは上記の解釈で進めていきます。

難読化テクニック概観

難読化を実現するテクニックは複数存在しますが、これらは大雑把に2通りに分類できるはずです。

  • 情報量を削ることで難読化を試みる方法
  • 情報量を無駄に増やすことで難読化を試みる方法

この分類に従うなら広義の「暗号化」はどちらにも該当せず、難読化には含まれないとも解釈できますが、話の筋がややこしくなるので忘れてください。

ここから挙げる難読化テクニックの一例を読んでいけば、2通りの分類の意味はわかると思うので詳述しません。今回は前編なので、前者を紹介します。

難読化テクニックの一例

変数名・関数名の変更

プログラマがソースコードを記述する際に、わかりやすい変数名、関数名をつけることは、プログラムの可読性を高めるために必要です。しかし、そのシンボル情報(変数名、関数名、クラス名など)が、最終成果物である実行可能ファイルや中間コード(バイトコード)、あるいはスクリプトファイルまで残存していると、ソースコードを持たない第三者がプログラムを解析するうえで大きなヒントになります。

例えば、IsDebuggerPresent()のような名前の関数があれば、関数内部の処理を精査しなくても、その名前だけで処理の内容を類推することができます。

例えば、int magicStone;のような名前の変数があれば、言うに及ばず。

コンパイラやリンカが、実行可能ファイル生成の過程で単体では意味のない値(例:アドレス値)に置き換えたり、名前マングリングのように便宜上シンボル情報を改変することもあります。その結果、元の変数名や関数名が失われる処理系もあります。ネイティブコンパイラではよく見かけます。

一方で、プログラムが仮想マシン上で動作することを前提とするアーキテクチャの場合、最終成果物は中間言語で記されたバイトコードです。

Java、特に .NET(と互換環境であるMono)のバイトコードで顕著ですが、関数名や変数名だけでなく、本来のプログラムの構造がほとんど改変されることなく残っていたりします。特定のアーキテクチャに依存する高度な最適化もまだ行われていません。連続する分岐命令(if/else)で置き換えられそうなswitch命令も、仮想マシンの仕様書を読むと専用のニーモニックが割り当てられていたりします。この場合、高度な構文解析が必要なため難易度の高い逆コンパイルがやりやすくなるということになります。

スクリプト言語の場合はソースコード渡しが前提になるため、なんとしてでもソースコードを秘匿したいのであれば、中間コードに変換できる言語ならそうするか、exeファイルに変換するツールがあるならそれを使うか(これらでもって処理の内容を秘匿できるのかは別の機会にします)、サーバーサイドに置くか、スクリプト言語を使わないという選択肢になると思います(もちろんスクリプト言語に対応した難読化ツールも複数存在しますが、ここでは述べません)。

名称の簡略化

最もシンプルな方法は、変数名や関数名、クラス名の簡略化です。

仮想マシンなりCPUなり、アーキテクチャが識別できる範囲までなら簡略化できます。

変数名であれば、アルファベット1文字にすると読みづらくなります。

bool isRootUser;bool a; または bool iii;
int magicStone;int b; または int iiii;
String authToken;String c; または String iiiii;

a, b, c と機械的に振っていけば、同じスコープに変数がたくさんあっても対応できます。逆の発想で、シルエットが似ている1とiとlの組み合わせを選択的に使うなどすると、人間視点では一層読みづらくなる...ような気がします

先ほどスコープという言葉を使いましたが、スコープが異なれば同じ名前の変数を使っても良いので、用途の異なる変数を一つの変数名にまとめることができそうです。これで、より一層読みづらくなる...ような気がします

ただし、言語仕様を読み込むほど、スコープの影響範囲がとても広く、容易に手を出せるものでないと思うはずです。一つの関数内、それも数十行以内で完結するような狭いスコープを持つ変数であれば、変数名を変えたことで不具合はまず出ないでしょう。しかし、スコープの狭い変数名を変えたとしても、難読化としての効果はほとんど期待できないはずです。

スコープがものすごく広い変数名を変換対象にしたい場合は、その変数を参照している箇所すべてを、プログラムの整合性を保ったまま修正する必要があります。これは簡単なことではありません。

関数名も同じように置換すると読みづらくなります。

public bool isRoot(); → public bool a();
public String getEmail(); → public String b();
public String generatePassword(int length, boolean upperCase, boolean lowerCase); → public String b(int c, boolean d, boolean e);

下2つは、関数のオーバーロードを使うことで、関連がないはずの処理を同じシンボル(関数名) b でまとめています。引数の数と型が違っていれば多重定義できるため、同一のシンボルであるbでどんどんまとめることができます。これで、より一層読みづらくなる...ような気がします

関数のオーバーロードによる難読化アルゴリズムは特許が主張されています。興味のある方は「オーバーロード誘導」で調べてみてください。

関数名の簡略化においても、スコープの問題は存在します。特にpublic属性の関数はプログラム中のどこからでも参照できるため、その関数を参照している箇所すべてを、プログラムの整合性を保ったまま修正する必要がありますが、技術的な限界からそれができない場合もあります(後述)。

最後はクラスです。基本的には1クラス1ファイルなので、ファイル一覧を見るだけでも見通しが良さそうです。それだと困るので、同じようにアルファベット1文字にしてしまいます。

AesEngine.class → a.class
EncryptionAlgorithm.class → b.class
CryptoUtil.class → c.class

リリースビルドされたAndroidアプリの中にあるdexファイルを展開すると、a.class, b.class, c.class といったアルファベットの連番のクラスファイルが必ずあるはずです。これは、Android Studioに最初から入っている難読化ツールであるProGuardによるものと見ていいでしょう。

デバッグビルドしたapkファイルはProGuardがかからないので、本来のクラス名がそのまま残っているはずです。

同じことの繰り返しになりますが、クラス名の簡略化においても、スコープの問題は存在します。クラス名を簡略化したい場合は、そのクラス名が記述されている箇所すべてを、プログラムの整合性を崩すことなく修正する必要があります。

関数名やクラス名の場合は、この整合性に関して非常にシビアです。例えば、関数名やクラス名は、中間コードの中だけではなく設定ファイルにもベタ書きされていることがあります。Androidであれば、主となるActivityクラスの名称は AndroidManifest.xml に記載されています。ProGuardのデフォルト設定ならば、Activityクラスの名称は変更しないはずです。難読化をやりすぎると、関数名やクラス名を見失ってアプリが動作しなくなるケースがあるからです。

他にも、プログラム自身の情報を参照するリフレクションを実現するために、プログラム本体とは別個にメタデータを持つこともあるし、実行してからでないとデータ型が確定しない実行時型情報もあります。外部の静的クラスを呼び出すために、コードではなく文字列を使って参照する場合もあります。当然ですが、外部参照されることを前提とした関数名、クラス名も安易に置き換えられません。

これらの情報も含めて、全て整合性を取りながら名前を簡略化できるかと言うと、プログラムの根幹に手を入れるという特異な技術ゆえ「難読化したら、動かなくなった」となることもあります。難読化すると動かなくなる処理があった場合、その処理は難読化しないように設定できれば回避できますが、これ単体で得られる効果は限定的である、としか言えません。

複数のクラスファイルを統合してしまうという、オブジェクト指向におけるクラス設計の思想を逆手に取った方法もありますが、副作用も大きいだろう、としか言えません。

...

ちなみに、シンボル情報を削るというアイディアは20年以上前からありました。ガラケー向けのDojaプロファイル(要するにiアプリ)ではJavaアプリのサイズ制約が非常に厳しく(10KB~100KB前後)、涙ぐましい職人芸的なサイズ縮小テクニックが生み出されました。

DoJaによるiアプリの開発入門(最終回) iアプリを10Kbytesに収めるテクニック

初期のJava難読化ツールであるRetroGuardは、関数名や変数名を1文字に削るため、難読化の一機能の副産物とはいえ、クラスファイルのサイズを小さくできるメリットがあったのです。

予約語、制御文字などの使用

ソースコード上の識別子の規約と、中間コード上での識別子の規約の相違に着目したテクニックがあります(ありました?)。

一般的に、以下のような変数名、関数名はエラーになります。

long 1111; // 変数名が数字だけ
int true; // 予約語 "true" を使った変数名
void )if((int i); // カッコを含む関数名 ")if("
String ho
ge(); // 制御文字を含む関数名 "ho[改行]ge"
String 🍣(); // 絵文字(サロゲートペア Swiftだとエラーにならない)

上記の識別子はソースコード上では許されませんが、中間コード上では許される場合があります。通常の開発プロセスではこのような変数名、関数名を埋め込むことはできないので、一度生成した中間コードに対して操作を行います。

上記の操作から期待されているのは、デコンパイルの妨害です。デコンパイラが出力するソースコードをバグだらけにしてしまおう、読みづらくしよう、という意図があります。上記の例で説明すると、数字だけで構成される変数名がある、予約語がある、カッコが合わない、不自然な改行がある、絵文字がある、という具合です。

昔のデコンパイラならこの程度で騙せましたが、いずれも手口が知れ渡ると、デコンパイラのアップデートで対策される類のもので、所詮小手先です。予約文字も制御文字も、デコンパイル時にエスケープされたら終わりです。今もメンテナンスされているデコンパイラなら、だいたいは対策済みです。さらに対抗したいのであれば、他の難読化手法を検討する必要があります。

そもそも、難読化されていなかったとしても、デコンパイラが出力するコードが文法的に間違っていたり、そのせいで再コンパイルに堪えられないケースは往々に存在します。全体を俯瞰したい場合はデコンパイラを、細部を改変したい場合は逆アセンブラをといった使い分けもあるので、この難読化手法だけでは限界があるように思います。

.NETアプリケーション用デコンパイラであるdnSpyは、まるでソースコードを持っているかのように錯覚できるほど良くできているんだよなあ...。

Unicodeの私用領域の使用

識別子をより分かりにくくするテクニックの一つに、私用領域の文字を使う方法があります。名前の通り、私的利用のために予約されている領域で、Unicode標準にはない符号を好きなように割り当ててもらうのが本来の用途です。

ここでは、私用領域 U+E000~U+F8FF (57344~63743) など には符号が割り当たっていないため、文字データとしては別物であっても、等しく文字として視認できない点に着目されています。エスケープすれば視認できるようにはなりますが、筆者の所感としてはかなりウザい改変だと思います。

その他の置換(バッドノウハウ)

今日のウェブサイトで必須のJavascriptのコードや、各種スクリプト言語で書かれたコードは基本的にはソースコード渡しですが、なかでもJavascript固有の難読化テクニックは味わい深いものがあります。

JavaScript難読化読経 (注:これはバッドノウハウではありません)

一部のサイトでは、インデントや改行を削ることが「ソースコードを読みづらくする方法」として紹介されることもありますが、各種IDEが持つフォーマットの自動整形機能で一瞬で元に戻るので全く意味はありません。jquery-3.3.1.js はインデントと改行があって読みやすいですが、jquery-3.3.1.min.js にはそれがなく、変数名も短くなっていますが、単にファイルサイズを小さくするためでしかありません。

余計なお世話ですが、人力難読化は絶対にやめましょう。 工数をかけてもそれに見合う効果が得られることはまずありません(自分自身で追試すればわかるはず)。開発効率が下がるだけでなく、バグの温床になる邪悪なノウハウです。

エントリが長くなったので、途中ですが今日はここまで。
後半は来月を予定しています。

メルマガ読者募集 採用情報 2020年卒向けインターンシップ

月別