取締役CTOの小竹(aka tkmru)です。 この記事は、大好評企画(?)「ARM環境でディスアセンブルを妨害するテクニック」の第2弾です。 前回は、32ビットのARM環境のみで有効なアンチディスアセンブルテクニックを紹介しました。
本記事では、32ビット/64ビットのARM環境(以下、ARM32/ARM64)両方で有効なアンチディスアセンブルのテクニックを紹介します。
ARM64でもアンチディスアセンブルはx86/x64に比べ困難
前回の記事では、ARM32を例に命令の長さが固定長のARM環境のアンチディスアセンブルはx86/x64に比べ困難であることを説明しました。 x86/x64の場合、命令が可変長であることを利用し、命令の途中のバイトを分岐先として解釈させるなどして、 命令間の境界を誤認させることで、比較的容易にアンチディスアセンブルを実現できます。 ARM64でも、ARM32と同様に命令の長さは固定長です。 そのため、ARM64でも命令間の境界を誤認させることが難しく、x86/x64と比較してディスアセンブルを妨害するためのテクニックは限られています。
命令として実行できないバイト列を埋め込む
命令として実行できないバイト列をデッドコードとして埋め込むことで、ディスアセンブルを妨害できます。 デッドコードとは、実行されることがないコードのことです。
次のC言語のコードでは、デッドコードとして.long
ディレクティブを使用して無意味なデータを埋め込んでいます。
b skip_deadcode
により、実行フローはskip_deadcode
ラベルにジャンプし、デッドコード部分を飛ばします。
このようにすることで、ディスアセンブラはデッドコード部分を命令として誤認識し、解析できなくなります。
#include <stdio.h> int main() { __asm__ volatile( "b skip_deadcode\n" ".long 0x01020304\n" ".long 0x05060708\n" "skip_deadcode:" ); printf("Hello, world!\n"); return 0; }
コンパイルと実行
前回と同様、x64のUbuntu 22.04上にクロスコンパイル環境を構築し、QEMUというエミュレータを使ってコンパイルしたプログラムを実行します。 次のコマンドでARM32向けにプログラムをコンパイルできます。
$ arm-linux-gnueabihf-gcc -marm -o skip-deadcode32 skip-deadcode.c
コンパイルが完了したら、QEMUを使って実行します。
環境変数QEMU_LD_PREFIX
を使って、プログラムが使う共有ライブラリのパスを指定する必要があります。
次のように正常に動作することを確認できます。
$ QEMU_LD_PREFIX='/usr/arm-linux-gnueabihf/' qemu-arm-static skip-deadcode32 Hello, world!
ARM64でも動作を確認してみます。 次のコマンドでARM64向けにプログラムをコンパイルできます。
$ aarch64-linux-gnu-gcc -o skip-deadcode64 skip-deadcode.c
次のコマンドでビルドしたプログラムを実行できます。 環境変数QEMU_LD_PREFIXに指定するパスやコマンドが変わることに注意してください。
$ QEMU_LD_PREFIX='/usr/aarch64-linux-gnu/' qemu-aarch64-static skip-deadcode64 Hello, world!
ディスアセンブル結果の確認
ARM64向けにビルドしたプログラムのディスアセンブル結果を確認してみましょう。 アンチディスアセンブルが有効かどうかを見る上ではARM32向けのものとディスアセンブル結果は本質的には変わらないため、ARM32向けのディスアセンブル結果は割愛します。
IDA Pro
IDA Proでディスアセンブルした結果を確認してみましょう。 アセンブリの中に16進数のバイト列が出現しており、アンチディスアセンブルとして機能していることが確認できます。 デッドコード部分は、DCQ(Define Constant Quadword)という命令として認識されています。
DCQは、メモリの領域を確保し、文字列や数値などの定数を定義するための命令です。 指定された定数のサイズに応じて命令の名前は変わります。 このような命令はDCQの他に、DCB(Define Constant Byte)、DCD(Define Constant Doubleword)、DCW(Define Constant Word)があります。 作成したバイナリ内では、Hello Worldの文字列が定義されている部分で、DCBが使われています。
Ghidra
Ghidraでディスアセンブル結果を確認してみましょう。 Ghidraでは、未定義のバイトとして認識されており、アンチディスアセンブルとして機能していることが確認できます。
デコンパイル結果は次のようになりました。 デッドコード部分はデコンパイル結果には含まれておらず、うまくデコンパイルできています。 Ghidraに対してはアンチディスアセンブルとしては有効であるものの、デコンパイルを妨害する効果はありません。
undefined8 main(void) { puts("Hello, world!"); return 0; }
分岐先が一意に定まる条件分岐を挿入する
分岐先が一意に定まる、最適化では消えない条件分岐を挿入する方法を組み合わせられます。
次のコードでは、c < sqrt(a*a+b*b)
という条件分岐を挿入しています。
a
、b
は定数であるため、sqrt(a*a+b*b)
の計算結果は一意に定まります。
この条件式は常に偽となるため、if文内のコードは実行されません。
ここに命令として実行できないバイト列を埋め込むことで、ディスアセンブルを妨害します。
コンパイラは、最適化を行う際にsqrt()
の結果が5
以下であることを計算しないため、この条件分岐は最適化では消えることはありません。
単に意味のない条件分岐を挿入するだけでは最適化で消されますが、実行しなければ結果が分からない関数(ここではsqrt()
)を組み合わせることで、最適化を回避しています。
意味のない条件分岐を挿入し、無意味なコードを混入させるのは、Bogus Control Flowと呼ばれる制御フローに対する難読化技術です。
ここでは、アンチディスアセンブルのために命令として実行できないバイト列を埋め込んでいますが、処理のロジックの文脈を無視した意味不明な命令を挿入することで制御フローの難読化を強化することもできます。
#include <math.h> #include <stdio.h> int main() { int a = 3; int b = 2; int c = 5; if (c < sqrt(a*a+b*b)) { __asm__ volatile( ".long 0x01020304\n" ".long 0x05060708\n" ); } printf("Hello, world!\n"); return 0; }
コンパイルと実行
ARM32向けにプログラムをコンパイルし、実行します。
先ほどと同様のコマンドを用いてコンパイルできますが、math.h
を使っているため、リンクのために-lm
オプションを指定する必要があります。
ビルドしたプログラムを実行すると正常に動作します。
$ arm-linux-gnueabihf-gcc -marm -o bogus-control-flow32 bogus-control-flow.c -lm $ QEMU_LD_PREFIX='/usr/arm-linux-gnueabihf/' qemu-arm-static bogus-control-flow32 Hello, world!
ARM64向けの場合でも、先ほどと同様のコマンドでコンパイルできます。
ここでも、-lm
オプションを指定する必要があります。
$ aarch64-linux-gnu-gcc -o bogus-control-flow64 bogus-control-flow.c -lm $ QEMU_LD_PREFIX='/usr/aarch64-linux-gnu/' qemu-aarch64-static bogus-control-flow64 Hello, world!
ディスアセンブル結果の確認
ARM64向けにビルドしたプログラムのディスアセンブル結果を確認してみましょう。 ここでもARM32向けのディスアセンブル結果は割愛します。
IDA Pro
IDA Proでディスアセンブルした結果を確認してみましょう。 アセンブリの中に16進数のバイト列が出現しており、アンチディスアセンブルとして機能していることが確認できます。 ここでもデッドコード部分は、DCQ(Define Constant Quadword)という命令として認識されています。
先ほどのプログラムではGraph viewで表示することができましたが、今回のプログラムではGraph viewに表示を切り替えようとすると次のエラーが発生しました。 Bogus Control Flowを組み合わせることで、Graph modeでの表示を妨害することができました。
Ghidra
Ghidraでもディスアセンブル結果を確認してみましょう。 Ghidraでは、未定義のバイトとして認識されており、アンチディスアセンブルとして機能していることが確認できます。
Ghidraでのデコンパイル結果は次のようになりました。
先ほどと同じくデッドコード部分は消されています。
また、驚くことに、条件式が、c < sqrt(a*a+b*b)
からc >= sqrt(a*a+b*b)
に相当するものに変更されています。
ディスアセンブルできない部分を消去し、ディスアセンブルに成功した部分だけで成り立つように、コードを再構成しているようです。
/* WARNING: Control flow encountered bad instruction data */ undefined8 main(void) { double dVar1; dVar1 = sqrt(13.0); if (dVar1 <= 5.0) { puts("Hello, world!"); return 0; } /* WARNING: Bad instruction - Truncating control flow here */ halt_baddata(); }
WARNING: Control flow encountered bad instruction data
とWARNING: Bad instruction - Truncating control flow here
という警告がコード中に表示されています。
これはディスアセンブルできなかったデータが含まれており、ディスアセンブルできなかった部分を無視してデコンパイルを続行していることを示しています。
コードの一部がディスアセンブルできないため、その部分を切り捨ててデコンパイルを行っていることが、コメントからも読み取れます。
GhidraのデコンパイラはBogus Control Flowと命令として実行できないバイト列の挿入には強いようです。
まとめ
ドキュメントが少ないARM32/ARM64環境でのアンチディスアセンブル技術を紹介しました。 今回紹介した命令として実行できないバイト列をデッドコードとして埋め込む手法は、アンチディスアセンブルのテクニックとしてはIDA ProとGhidraに対して有効であることが確認できました。 また、Bogus Control Flowを組み合わせることで、IDA ProのGraph viewの表示を妨害することもできました。 しかし、Ghidraに対しては、デコンパイルを妨害する効果はなく、デコンパイル結果は正常に表示されました。 意外なことにGhidraのデコンパイラは、今回紹介した手法に対しては強いようです。
今回のPoCでは8バイトのバイト列を埋め込んだだけですが、大量のバイト列を複数箇所に埋め込むことで解析者を混乱させることができるでしょう。 また、勘のいい読者の方はお気づきかもしれませんが、今回紹介したテクニックはジャンプ命令を書き換える必要があるもののx86/x64でも有効です。
弊社のセキュリティ技術顧問サービスでは、 今回紹介したようなリバースエンジニアリングに関する技術を調査したり、耐タンパ性の向上に活用したりするコンサルティングも行っています。 ご興味のある方は、お気軽にお問い合わせください。