昨日、このような素晴らしい記事を見かけました
こちらの記事ではPLDという命令を使用してアンチディスアセンブルを施していましたが、こちらでも言及されている通り、Arm64では存在しない命令です。
そこで、Arm64ではアンチディスアセンブルを施すことはできないのかと気になったので自分なりに調べてまとめてみました。
初めに
ArmにはDCBディレクティブという1バイト以上のメモリを割り当て、メモリの実行時の初期内容を定義するものがあります。
The DCB directive allocates one or more bytes of memory, and defines the initial runtime contents of the memory.
そのほかにも種類があり、
DCB | DCW | DCD | DCQ |
---|---|---|---|
BYTE (1 Bytes) | WORD (2 Bytes) | DWORD (4 Bytes) | QWORD (8 bytes) |
というものがあります。
(以後これらを総じてDCXと呼びます)
DCX命令の使用例としては次の通りです
C_string DCB "C_string",0
off_XXXXX DCQ fprintf
`vtable for'Sample
DCQ `typeinfo for'
off_XXXX DCQ sub_XXXXX
(あまり意識してなかったけどそこら中にいるな.....)
DCXは一般的にReadOnlyなデータ領域に置かれるようですが、どうやらtext領域にも設置できるようです。
本来置かれるはずのないtext領域に設置することによりIDAのディスアセンブルを妨害するということですね。
データ埋め込み (DCQ)
インラインアセンブラを使用してDCQを設置することが出来るようです。
.longに任意の4バイトを続けて直接書きます
extern "C" JNIEXPORT jstring JNICALL
Java_rec_enuwbt_arm64_1studying_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */thiz) {
__asm__(R"(
.long 0x12345678
.long 12345678
)");
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
無事にデータ扱いされてますね。F5もCreate Functionも効きません。
Ghidraにもかけてみましょう
Ghidraは頑張ってデコンパイルしてくれますが、何だかダメそうです。
しかしながら、これではとある問題が発生します。
関数内に埋め込んだデータは他の命令と同じように実行されます。
が、埋め込んだデータは有効な命令ではありません。
なので実行するとクラッシュします。
Fatal signal 4 (SIGILL), code 1 (ILL_ILLOPC), fault addr 0x7c70bd523c in tid 8419 (.arm64_studying), pid 8419 (.arm64_studying)
B命令 + DCQ
そこで、無条件分岐命令であるB命令を使用して不正な命令を飛ばしてみます。
extern "C" JNIEXPORT jstring JNICALL
Java_rec_enuwbt_arm64_1studying_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */thiz) {
__asm__(R"(
b 0xc
.long 0x12345678
.long 12345678
)");
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
早速IDAにかけてみましょう
なんと、関数として解釈されてしまっています。
デコンパイルも出来てしまいました。
どうやら、B命令がIDAに解析のヒントを与えてしまったみたいです。(Ghidraも同じ結果でした)
単純なデータ埋め込みはIDAの解析を妨害することが出来ますが、実行するとクラッシュしてしまいます。
その問題を回避するためにB命令を使ってみましたが、ジャンプ先が分かってしまうためIDAに解析されてしまいました。
もう他に方法ないのでしょうか?
偽の制御フロー + DCQ
もちろんあります。DCQを決して実行されない分岐に配置すれば良いのです。
extern "C" JNIEXPORT jstring JNICALL
Java_rec_enuwbt_arm64_1studying_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */thiz) {
constexpr volatile bool flag = false;
// __asm__(R"(
// .long 0x12345678
// .long 12345678
// )");
if (flag){
__asm__(R"(
.long 0x12345678
.long 12345678
)");
}
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
最適化されてDCQを消されないようにflag変数に最適化抑止をつけます
IDAにかけてみると....
きちんと妨害できてますね。
もちろん、ちゃんと実行も出来ます。
さて、これでアンチディスアセンブルを施すことが出来ましたが、
これらを組み合わせることで更に強固なディスアセンブルを施すことが出来ます。
偽の制御フロー + B + DCQ
extern "C" JNIEXPORT jstring JNICALL
Java_rec_enuwbt_arm64_1studying_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */thiz) {
constexpr volatile bool flag = false;
if (flag){
__asm__(R"(
B 0x4
.long 123456789
)");
}
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
なんとも歪な形になりましたね
さて、今までの手法は関数の範囲内にデータを埋め込んでそもそもデコンパイルさせないというものでしたが、実は少し工夫すると簡単に突破されてしまいます。
そこで、次はデコンパイルにより解析されることを前提として対策を講じていきたいと思います。
BR命令による間接ジャンプ
さっきB命令でジャンプしてたやん、と思われる方もいらっしゃるかと思いますが、先程はジャンプ先がIDAにばれてしまっていたので解析されてしまいました。
なので、今回はBR命令(Branch to Register)を使用してみます。
静的解析はレジスタの実行時の値を取得できないため、ジャンプ先のアドレスをレジスタに格納することでIDAに解析されないようにするといったところです。
extern "C" JNIEXPORT jstring JNICALL
Java_rec_enuwbt_arm64_1studying_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */thiz) {
__asm__(R"(
mov x8, #0x1
adr x9, #0x10
mul x8, x9, x8
.long 0x12345678
br x8
)");
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
アドレス構造としてはこんな感じ
アドレス | 命令 | |
---|---|---|
~000 | adr x9, 0x10 | x9に現在のアドレス + 0x10した値を格納 |
~004 | mul x8, x9, x8 | x8にx9とx8の乗算結果を格納 |
~008 | .long 0x12345678 | フェイクの命令(DCQ) |
~00C | br x8 | x8に格納されているアドレス(下の命令)にジャンプ |
~010 | (続きの命令) | ジャンプしてきた😊 |
IDAのディスアセンブル結果はこんな感じ
うまく騙されてくれていますね
BR命令のところでstringFromJNI関数の終わりと判断され、次の命令からは別の関数として扱われています。
デコンパイル結果はこんな感じ
BR先の関数の呼び出しは表示されているものの、インラインアセンブリ埋め込み箇所より上の部分のデコンパイルは失敗したようです。
このケースはうまくIDAを騙せたものの、関数の参照を切ることは出来ませんでした。
ここまで来たらデコンパイル結果を空にしたい、ということで次はret命令を使用してみましょう。
ret命令
今回はx30、関数呼び出し命令時にリターンアドレスを保持するリンクレジスタを使用します。
extern "C" JNIEXPORT jstring JNICALL
Java_rec_enuwbt_arm64_1studying_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */thiz) {
__asm__(R"(
adr x8, #0xc
mov x30, x8
ret
)");
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
アドレス構造としてはこんな感じ
アドレス | 命令 | |
---|---|---|
~000 | adr x8, #0xc | x8に現在のアドレス + 0x10した値を格納 |
~004 | mov x30, x8 | リンクレジスタにx8に格納されているアドレスを格納 |
~008 | ret | リンクレジスタが指すアドレスにリターン |
~010 | (続きの命令) | リターンしてきた😊 |
勝手にリンクレジスタを書き換えていいの? と考える方もいらっしゃると思いますが、
大丈夫です。コンパイラが破壊される前の値をスタックに保存、関数の終了時にリンクレジスタに戻す処理を挿入してくれます。
IDAのディスアセンブル結果はこんな感じ
素晴らしい、傍から見たら完全に別々の関数です
デコンパイル結果はこんな感じ
x8レジスタにアドレスを代入しているのにIDAがデコンパイル結果に関数の呼び出しを表示しないのは、ret命令だからでしょう。
ただレジスタにアドレスを代入していると判断されたみたいですね。
さて、これで遂に関数を空っぽにすることに成功しました。
終わり
いかがだったでしょうか。
何通りかの方法をお教えしましたが、私のお気に入りはret命令を使用したアンチディスアセンブルです。
他の命令を使用したり、組み合わせによっては更に強固なディスアセンブルを施すことが出来そうですね。
皆様も是非お試しください!!
参考文献
本記事はこれで終わりとなります。ご覧いただきありがとうございました!
おまけ
ret命令を使用した場合のアンチディスアセンブルでは、ret命令の前後で分断され別々の関数として認識されていました。
それが原因でデコンパイル結果が空っぽになったのですが、関数を元に繋ぎ直したらデコンパイル結果が正常に戻るのかと気になったのでやってみたところ....
なんと、変わりませんでした。 (やり方が悪かっただけ...?)
ふむ、Ghidraもほとんど空っぽの関数を吐き出していますね。
retの下を見てみると...
どうやらretの下の命令達はただのデータとして扱われているようです。
まだデコンパイル結果は変わりませんが...
キタ―――(゚∀゚)―――― !!
(ん、待てよ? IDAでも出来るんじゃ..?)
結論
もう何も意味をなさない。こいつらに取り込まれた時点で我々は既に負けているのである。