ORK(オーク)-コード難読化コンパイラツール vol.1

こんにちは。LINEでクライアント保護ソリューションであるAIR ARMORの開発を担当しているCHUNG SANG MINです。以前、「iOSのコード署名について」という記事では、SIM MINYOUNGさんがiOSアプリの完全性や署名者を検証できるiOSのコード署名について説明しました。今回の記事では、アプリの改ざんや盗用を防ぐために独自で開発している難読化ツールを紹介したいと思います。サンプルソースコードを利用してコンパイラ動作の各段階を確認し、難読化がどのように行われるかを見てみます。

ORK(オーク)とは?

ソフトウェアを保護するための法律や制度はすでに存在し、整備されていますが、技術やサービスの発展スピードには追いつくことができていない状況です。そのため、アプリの改ざんや盗用問題を技術的に解決するために様々な試みを行っています。ORKプロジェクトもそのような試みの一つです。

ORKは、LLVMコンパイラインフラをベースにLINEが独自で開発している難読化ツールで、「Obfuscated Representation Kaleidoscope」の略語です。C/C++のようにLLVMベースで処理できるプログラミング言語を、コンパイル段階で難読化してくれるコンパイラツールです。

実行ファイル保護技術であるパッカーの限界

すべての実行コードはメモリーにロードされ、中央処理装置(CPU)で実行されるため、リバースエンジニアリング(reversing、reverse engineering)によっていつかは解析されてしまいます。ただし幸いなことに、リバースエンジニアリングの過程には、CPUが実行コード(instruction)を直接処理する時間とは比べものにならないほど長い時間がかかります。そのため、実行コード保護技術は、この「時間」というコストを上げる方向で開発されています。様々な周辺技術が存在し、相互補完的に使用されているため、その中で1つだけを取り上げて説明するのは難しいのですが、一般的に実行ファイル圧縮(executable compression) と呼ばれる技術を多く使っています。 

実行ファイル圧縮技術とは、元の実行コードを圧縮された形で保存し、実行時点でメモリーに復元して実行フローを変える技術です。名前からわかるように、もともとは実行ファイルのサイズを小さくするための圧縮機能として開発されましたが、元の実行コードを隠蔽できるという利点から実行ファイル保護技術として注目されるようになりました。実行ファイル圧縮技術は、具体的な動作方法によってさまざまに区別されることもあります。殆どは実行可能なパッカー(executable packer)または、略してパッカー(packer)と呼ばれています。下図は、実行ファイル圧縮の前後を示しています。

実行ファイル圧縮には、アセンブリ言語や実行ファイルのフォーマット、リンカーなど、システム全体についての理解が必要です。また安定性を確保するためには多くの検証過程が必要であり、実現は容易ではありません。それにも関わらず、コンパイルされた実行ファイルにすぐ適用できるという便利さや、他の保護技術を簡単に追加できるという利点から、実行コードを保護するための技術として多く使用されています。そのため、非常に多様な無償/有償の実行パッカーが存在しています。LINEでも、一部のサービスの実行コードを保護するために、独自の実行パッカー技術を開発して使用しています。

パッカーは、非常に効果的な保護技術です。しかし、欠点もあります。CPUで処理される実行コードは必ずメモリーにロードされますが、そのとき、メモリーにロードされた実行コードが露出してしまう可能性があります。また、実行パッカーの動作原理を一度把握されてしまうと、以降は保護の効果を得ることは難しいという限界があります。これを補うため、コード仮想化やアンチ‐メモリダンプ(anti-MemoryDump)などの他の保護技術を一緒に使用することもありますが、このような保護技術にもやはり同様の問題があります。 

私たちは、このような限界を効率よく乗り越えるために、ソフトウェアのアップデートサイクルに着目しました。特にゲームの場合、新規コンテンツを供給するための定期的なアップデートは必須です。アップデートするたびに、毎回異なる形に難読化した実行コードをデプロイすることで、それまでの解析内容を無駄にするという方法で実行ファイルを保護します。言わば、「難読化ライフルサイクル(obfuscation life cycle)」を管理する方法です。コンパイル時点で難読化技術を適用することで、これが可能となります。

実は、コンパイラを利用した難読化はだいぶ前から試していた発想です。しかし、実現するのは簡単ではありませんでした。既存のコンパイラはあまりにも膨大で複雑だったので実現が難しく、この概念の証明に適していなかったためです。ところが、コンパイラの構造が改善され、発展するにつれて実現の可能性が高まりました。また、LLVMコンパイラインフラ(infrastructure)が登場することで、概念を証明するだけでなく、商用レベルのコンパイラも簡単に実現できるようになりました。

LLVMコンパイラインフラ

LLVMは、コンパイルに必要なさまざまな技術やツールを集めておいたコンパイラフレームワークプロジェクトです。LLVMコンパイラインフラは、3段階の構造になっていますが、それぞれ解析と最適化、機械語(machine code)の生成という役割を果たしています。このようにきちんと構造化されているため、必要な機能をコンパイラに簡単に追加できます。

LLVMコンパイラインフラの中核は、構造的にソース(source)と対象(target)から独立した最適化(optimizer)機能を提供することです。このような構造は、プログラミング言語と機械語の間でLLVM IR(Intermediate Representation)という中間言語を使用しているから可能なことです。最適化段階では、ただLLVM IRだけを処理するため、プログラミング言語と機械語から完全に独立して動作できます。LLVMコンパイラインフラは、GNUコンパイラであるGCCだけでなく、MicrosoftコンパイラであるMSVCとも互換されるため、LLVMベースでさまざまな開発環境をサポートできます。主なモバイルプラットフォームの開発ツールであるXcodeでは4.2バージョンから、Android NDKではr13bバージョンから、既存のGCCの代わりにLLVMが基本コンパイラとして選択されています。今やLLVMコンパイラインフラに代わるものはなかなか見つからず、多くのプロジェクトがLLVMをベースに進められています。 

LLVMコンパイラインフラは、GCCフロントエンドに代わってC/C++形式のプログラミング言語をサポートするために、Clangという配下プロジェクトを含んでいます。Clangは、他のツールやコンパイラに互換性を提供するコンパイラドライバー(driver)としての機能と、C/C++形式のプログラミング言語を解析するフロントエンドとしての機能を果たしています。

参照:Clangは、LLVMと一緒にバージョン管理されている中核プロジェクトですので、LLVMコンパイラインフラを使用するには、http://clang.llvm.orgで関連内容も確認することをお勧めします。

LLVMコンパイラインフラベースの難読化ツール、ORK

ORKプロジェクトは、LLVMコンパイラインフラをベースに実現した共有ライブラリであり、ソースコードはLLVMプロジェクトとは分離して別のプロジェクトで管理しています。

ORKは、以下の2つに重点をおいてプロジェクトを設計および実装しました。

  • 毎回新しい難読化の結果物を生成
  • 既存の開発環境と簡単に統合可能

難読化のライフサイクルで最も重要なのは、毎回違う形で実行コードを難読化することです。そのために、すべての難読化機能は原本のソースコードの内容やコンパイル時点によって任意の分岐や変数、条件でLLVM IRを変更または追加するように設計しました。さらに、既存のプロジェクトのコンパイル設定を変更しなくてもいいように、独立した難読化設定機能を提供しています。なお、XcodeやAndroid NDKのような開発環境に統合するために必要な、多くの修正作業を自動化した統合機能を提供しています。

ORKは、共有ライブラリの形でビルドしており、 clang または opt コマンドツールの実行時点でロードされて動作します。

以下の opt コマンドで、ORKが提供する難読化機能を確認できます。

ORKは、LLVMコンパイラインフラをベースに動作するため、ORKを理解するにはコンパイル過程を理解する必要があります。そこで、ORKについて説明する前に、まずコンパイル過程を見てみましょう。

参照:難読化コンパイラの概念は、一部エラーが存在しますがオープンソースで公開されているObfuscator-LLVMで確認できます。

Clangのコンパイル過程

通常のプロジェクトでは、統合開発環境(Integrated Development Environment、IDE)で自動的にコンパイルを実行・管理するため、各コンパイルの段階を確認することは難しいです。そこで、直接Clangを使用し、各コンパイルの段階を見ることで、ORKがどの時点で難読化を実行しているか確認してみましょう。 

以下のサンプルコードを利用し、Clangのコンパイル過程を見てみます。以下のサンプルはフィボナッチ数列を確認してくれるプログラムで、確認したい項とフィボナッチ数を入力すると、正解を知らせるように実装されています(一部例外処理が足りない場合があります)。

以下のコマンドで、サンプルソースコードをコンパイルできます。 -v 設定を追加すると、コンパイル情報の詳細が出力されます。

コンパイルされたサンプル実行ファイルの制御フローグラフ(control flow graph)は以下のとおりです。

以下のとおり、サンプル実行ファイルが正常に実行されることを確認できます。

Clangは、コンパイラドライバーで先に実行され、他のコンパイラと互換可能な設定でフロントエンドとリンカーを実行して一連のコンパイル過程を処理します。この過程で、ClangはC/C++形式の言語をサポートするフロントエンド機能としても実行されます。 clang コマンドツールに -cc1 設定を追加すると、すぐフロントエンドとして実行できます。 -### オプションを使用すると、フロントエンドを実行するために必要な設定をあらかじめ確認できます。

次は、Clangフロントエンドでスタートする各コンパイルの段階を説明します。コンパイルは、前処理 → 構文解析 → LLVM IR → 最適化 → コンパイラバックエンド → アセンブラ → リンカーの順で行われます。

前処理(preprocessor)

Clangのフロントエンドが真っ先に行う作業は、ソースコードに使用された #include 、 #define などを拡張する前処理過程です。以下のコマンドを実行してソースコードの前処理結果を出力できます。

構文解析(parsing)

構文解析段階は、入力されたソースコードの構文と語彙を解析し、解析した内容を「抽象構文木(AST、Abstract Syntax Tree)」というデータ構造の形で保存する段階です。また、コンパイルのスピードを上げるために、重複参照されるヘッダーファイルやモジュールに対する「プリコンパイル済みヘッダー(PCH、Precompiled headers)」または「プリコンパイル済みモジュールファイル(PCM、Precompiled module files)」を生成します。入力がヘッダーファイルの場合は、この段階まで実行されます。

以下のコマンドで、拡張子が「.ast」のASTファイルを生成できます。


以下のコマンドを実行して生成されるASTファイルの情報を出力できます。

以下のコマンドで、この段階の結果を出力できます。

LLVM IR(Intermediate Representation)

LLVM IR段階は、ASTタイプで保存されているソースコードを最適化(optimizer)するためにLLVM IR形式に変換する段階です。LLVM IRはすべての高級言語を示すことのできる表現方式やタイプ拡張、タイプ情報を提供する中間言語です。

LLVMの最適化とORKの難読化は、すべてLLVM IRの解析や生成、修正、削除の過程によって行われます。この過程を理解するには、LLVM IRを構成するモジュール(module)や関数(function)、基本ブロック(basic block)、コマンド(Instruction)などの構造的な面と共に、タイプ(Type)要素やSSA(Static Single Assignment)のような機能的な面を理解する必要があります。これに関連してはLLVM IRの中間言語である仕様ページを参照してください。

LLVM IRは、2つの形式で表現できます。1つは、人が読める文字列で表現されるLLVMアセンブリ言語(LLVM assembly language)形式で、もう1つはバイナリーで表現されるLLVMビットコード(bitcode)形式です。LLVM IRをファイルで保存すると、LLVMアセンブリ言語形式は「.ll」拡張子のテキストファイルで保存され、LLVMビットコード形式は「.bc」拡張子のバイナリファイルで保存されます。

以下のコマンドを使用してLLVMアセンブリ言語をコンパイル段階で保存できます。


「.ll」ファイルを開いてみると、サンプルソースコードで生成したLLVMアセンブリ言語の特徴を確認できます。


以下のコマンドで、LLVMビットコードをアセンブル(assemble)段階で保存できます。

LLVMアセンブリ言語とLLVMビットコードは表現の形式だけが異なっており、お互い自由に切り替えることができます。LLVMコマンドツールのllvm-disを使用してLLVMビットコードをLLVMアセンブリ言語に切り替えることができ、逆にllvm-asを使用してLLVMアセンブリ言語をLLVMビットコードに切り替えることができます。

LLVM IRは、optコマンドで最適化するかllcコマンドで特定のCPUアーキテクチャに該当する機械語を生成できます。

最適化(optimizer)

最適化に関連した作業は、LLVM IRと共に最適化作業の中核構造であるLLVMパス(pass)という単位で管理します。LLVMパスは登録された優先順位によって順次LLVM IRを受け取ります。それぞれの最適化パスは、受け取ったLLVM IRを最適化した後、最適化したLLVM IRを次のパスに送信します。 

ORKのすべての難読化機能もLLVMパスで実現しました。最適化パスと一緒に登録され、LLVM IRを難読化する構造です。ORKの難読化パスは、最適化パスの順番に影響されず、重複で適用できるように実現しました。

難読化パスが最適化パスに影響されないようにするには、考慮すべき要素が多くあります。例えば、難読化のために追加したガーベッジ(garbage)コードやジャンク(junk)コードまたは一部の難読化ロジックは、他の最適化パスでいつでも除去される可能性があります。そのため、当該の難読化コードが外部から参照されるよう、リンケージ型(linkage type)を指定する方法で最適化を迂回する考慮をすべきです。また難読化には、ソースコードの範囲を超える機能や特定のCPUアーキテクチャに密接した機能を実現できないという限界もあります。それでも、LLVMの最適化と同様に、プログラミング言語や機械語に独立した難読化機能を提供できるというメリットだけでも実現する価値は十分です。 

登録されているすべてのパスが実行されると、LLVM IRの最適化が完了します。以下のoptコマンドを使用して、各パスで修正されたLLVM IRを出力できます。

コンパイラバックエンド(compiler backend)

この段階では、最適化されたLLVM IRを特定のCPUアーキテクチャに依存するアセンブリ(assembly)言語に変換します。以下のコマンドを使用して拡張子が「.s」のアセンブリの言語ファイルを保存できます。

保存したファイルは以下のとおりです。

アセンブラ(assembler)

アセンブラは、アセンブリ言語を機械語に変更し、拡張子が「.o」のオブジェクト(object)ファイルで保存します。Clangは、基本的に内部に統合されているアセンブラを使用しますが、設定によりGNUアセンブラのような外部ツールも使用できます。

リンカー(linker)

リンカーは、コンパイルの最後の段階で、参照関係を確認して保存されているオブジェクトファイルを1つにまとめて実行(executable)ファイルまたは共有ライブラリ(shared object)ファイルを作成します。この段階でリンク時最適化(LTO、Link Time Optimizer)も実行できます。ホストシステムのリンカー以外に他のリンカーもありますが、LLVMには独自のリンカープロジェクトであるLLDがあります。

参照:LLVMコマンドガイド文書でLLVMのさまざまなコマンドツールを確認できます。また、Clangで提供する設定はclang -helpまたはclang -cc1 -helpコマンドで確認できます。

おわりに

今回の記事ではサンプルソースコードのコンパイル過程を見ながら、難読化が行われる段階を確認しました。次回は、ORKの難読化がどのように動作するか、サンプル実行ファイルで見てみます。お楽しみに!

[追記]続編を公開しました。
ORK(オーク)-コード難読化コンパイラツール vol.2

Related Post