こんにちは、愛甲です。今回は、リバースエンジニアリングを行う際に有用な ptrace と udis86 を用いたデバッギングについて解説させていただこうと思います。なお、本記事は Ubuntu Linux (x86) で動作確認を行っています。
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 10.04.1 LTS
Release: 10.04
Codename: lucid
-----
■ ptrace とは
ptrace は、他プロセスの制御を行う手段として提供されているシステムコールです。Manpage of PTRACE に詳しい解説が載っており、主にデバッガを作成する際に利用されます。しかし ptrace だけでは逆アセンブルができないため、今回は逆アセンブルライブラリである udis86 も使います。udis86 は x86( x86_64 含む) 用の逆アセンブルライブラリで、ファイル、もしくはメモリ上からデータ列を読み込み、逆アセンブルした結果を文字列として返します。
これらを利用することで、デバッガに似たプログラムを作成できます。今回、本記事用のサンプルプログラムとして ptracer.c を用意しました。以降はこのソースコードを元に解説を進めますので、適宜コードを参照しながら読み進めてください。
■トレースログの出力
ptracer は、他プロセスを実行した際に「実際に処理されたアセンブラコード」を逐次出力するプログラムです。OllyDbg のような一般的なデバッガにも同じような(トレースログを出力する)機能を持つものがあり、それらとほぼ同等の機能と考えてください。
仕組みとしては、まず fork で子プロセスを作成し、その後に execve によりターゲットプログラム(プロセス)に成り替わるのですが、その前に PTRACE_TRACEME を実行し、他プロセスからの ptrace アクセスの許可をしておきます。これで、親プロセスから子プロセスへ、任意のタイミングでアタッチ可能となります。
子プロセスがターゲットプログラムに成り替わったら、親プロセスから実行を制御します。PTRACE_SINGLESTEP で 1 命令ずつ進めながら、PTRACE_GETREGS でレジストリを確認します。現在実行中のアドレスは regs.eip で取得できるため、これを元に逆アセンブルし、実行されたマシン語をアセンブラコードとして出力します。アタッチしている他プロセスのアドレス空間のデータを「読む」ためには PTRACE_PEEKDATA を使います。
以下のサンプルプログラムを作成し、そのトレースログを出力します。
$ cat target1.c
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
if(argc != 2){
printf("%s <PASSWORD>\n", argv[0]);
return 1;
}
if(strcmp("HELLO", argv[1]) == 0)
printf("OK!\n");
else
printf("ERR\n");
return 0;
}
$ gcc -Wall target1.c -o target1
$ ./target
./target <PASSWORD>
$ ./target ABCD
ERR
target1 は、引数に HELLO を渡さなければエラーを返すプログラムです。これを ptracer を使って逆アセンブルします。なお、他のライブラリ内での処理は無視したいので、target1 のテキストセクションの領域をあらかじめ調べておき、その領域内においてのみの逆アセンブル結果を表示します。逆アセンブルの領域を指定するには -a オプションを使います。
$ readelf -S target1 | grep text
[14] .text PROGBITS 080483a0 0003a0 0001bc 00 AX 0 0 16
$ ./ptracer -a 080483a0 0001bc target1 ABCD
080483a0: 31ed xor ebp, ebp
080483a2: 5e pop esi
(省略)
08048498: 85c0 test eax, eax
0804849a: 750e jnz 0x10
080484aa: c7042499850408 mov dword [esp], 0x8048599
080484b1: e8befeffff call 0xfffffffffffffec3
ERR
080484b6: b800000000 mov eax, 0x0
080484bb: c9 leave
080484bc: c3 ret
ERR が表示される前に、0804849a で jnz 命令が処理されています。おそらくこの部分が、ERR と OK! のどちらを表示するかを判断している箇所だと考えられます。
■任意のアドレスの値を書き換え
実際に処理されたアセンブラコードを逐次出力するだけならばデバッガのトレースログでもよいのですが、解析をやっていると、実行されるマシン語をリアルタイムに改ざんし、挙動を変更させたい場合があります。アタッチしているプロセスのアドレス空間へデータを「書く」ためには PTRACE_POKEDATA を使います。
実行されるマシン語のリアルタイムの改ざんは、-w オプションを使うことで実現できます。
$ ./ptracer -a 080483a0 0001bc -w 0804849a 75 74 target1 ABCD
080483a0: 31ed xor ebp, ebp
080483a2: 5e pop esi
(省略)
08048498: 85c0 test eax, eax
0804849a: 750e jnz 0x10
0804849c: c7042495850408 mov dword [esp], 0x8048595
080484a3: e8ccfeffff call 0xfffffffffffffed1
OK!
080484a8: eb0c jmp 0xe
080484b6: b800000000 mov eax, 0x0
080484bb: c9 leave
080484bc: c3 ret
今度は処理が変わり OK! が表示されました。
リアルタイム改ざん機能は、その命令が実行される直前に書き換えられ、命令の実行が終わった直後に元の値に戻されます。よって逆アセンブル結果は 750e のままですが、実際にCPUが処理を行ったのは 740e というマシン語命令です。
このように実行の直前と直後に変更を加えることで、コードの改ざん検知(あらかじめテキストセクションのコードが変更されていないことを確認して処理を開始するアンチデバッギングテクニック)を回避できます。
■forkされた子プロセスを追う
サーバ系のプログラムだと、重要な通信はすべて fork された後の子プロセスに任せられ、親プロセスは accept で待つだけの役割を担う場合が多いです(とは言っても、最近のサーバ系プログラムはほぼすべてスレッド実装なので子プロセスを追う必要もありませんが...)。このようなプログラムの場合、重要なのは親プロセスよりもむしろ子プロセスなので、fork されたら、デバッグする対象を子プロセスに切り替えなければいけません。
fork や write といった「システムコール呼び出しをフックする」には PTRACE_SYSCALL を使います。ptracer では、システムコール呼び出しである int 80h を発見するまでステップ実行を行い、発見したら PTRACE_SYSCALL を呼び出して、システムコール呼び出し前と呼び出し後に処理を行っています(環境によっては int 80h が sysenter だったり、syscall であったりします)。
では、以下のサンプルプログラムを作成します。
$ cat target2.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
int st;
if(argc != 2){
printf("%s <PASSWORD>\n", argv[0]);
return 1;
}
if(!fork()){
if(strcmp("HELLO", argv[1]) == 0)
printf("OK!\n");
else
printf("ERR\n");
exit(0);
}
wait(&st);
return 0;
}
$ gcc -Wall target2.c -o target2
$ ./target2 ABCD
ERR
fork している部分以外は target1 と変わりません。ただ、target2 では、子プロセスを追わなければ出力結果を変えられません。fork 後の子プロセスを追いかけるためには -f オプションを使います。-f の後に DETACH と入力すれば「親プロセスをデタッチして」子プロセスを追いかけ、KILL と入力すれば「親プロセスを強制終了して」子プロセスを追いかけます。
$ ./ptracer -a 08048430 0001ec -f DETACH target2 ABCD
08048430: 31ed xor ebp, ebp
08048432: 5e pop esi
(省略)
08048531: 85c0 test eax, eax
08048533: 750e jnz 0x10
08048543: c7042459860408 mov dword [esp], 0x8048659
0804854a: e89dfeffff call 0xfffffffffffffea2
ERR
0804854f: c7042400000000 mov dword [esp], 0x0
08048556: e8c1feffff call 0xfffffffffffffec6
子プロセスを追いかけて ERR が表示されました。では、さらに子プロセスの 08048533 の値 750e を 740e に変更します。これで OK! が表示されます。
$ ./ptracer -a 08048430 0001ec -w 08048533 75 74 -f DETACH target2 ABCD
08048430: 31ed xor ebp, ebp
08048432: 5e pop esi
(省略)
08048531: 85c0 test eax, eax
08048533: 750e jnz 0x10
08048535: c7042455860408 mov dword [esp], 0x8048655
0804853c: e8abfeffff call 0xfffffffffffffeb0
OK!
08048541: eb0c jmp 0xe
0804854f: c7042400000000 mov dword [esp], 0x0
無事、子プロセスの処理を変更できました。
このように ptrace を使うことで、かゆいところに手が届く感じで解析を進められます。もちろん、デバッガや逆アセンブラをメインで使っていくのはこれまでと変わりませんが、ptrace の使い方を知っておくだけで、思わぬところで楽ができます。
では最後に ptrace を用いて、他プログラムの関数を呼び出すサンプルを解説して終わろうと思います。
■他プログラムのコードを利用する
ptrace をうまく使うと、他プロセスのコードを横取りして利用できます。以下のサンプルコードを見てください。
$ cat target3.c
#include <stdio.h>
#include <string.h>
unsigned long f(char *x, int y)
{
unsigned long a;
char b;
int i;
a = 0xFFFFFFFF;
if(y == 0)
return ~a;
while(1){
b = *x;
a = a ^ b;
x++;
for(i=0; i < 8; i++){
if(a & 1){
a = a >> 1;
}else{
a = a >> 1;
a ^= 0xEDB88320;
}
}
y--;
if(y == 0)
break;
}
return ~a;
}
int main(int argc, char *argv[])
{
if(argc != 2){
printf("%s <PASSWORD>\n", argv[0]);
return 1;
}
if(f(argv[1], strlen(argv[1])) == 0xd59b8359)
printf("OK!\n");
else
printf("ERR\n");
return 0;
}
$ gcc -Wall target3.c -o target3
$ ./target3 ABCD
ERR
target3 は argv[1] を引数として関数 f を呼び出し、その戻り値を d59b8359 と評価しています。見ての通り、関数 f は不可逆なので、出力が d59b8359 となる入力データを復号できません。よって、もし入力データを知りたい場合は総当たりによるパスワードクラック等を行う必要がありますが、そのような場合に ptrace は有効です。ちなみに入力データは同じく HELLO です。
まずは関数 f を呼び出したいので、f のアドレスを調べます。また一時的に処理を止めておきたいアドレスも決めます。これはどこでも良いので、適当に main 関数の先頭にしておきます。
$ objdump -d target3 | grep \<f\>:
08048454 <f>:
$ objdump -d target3 | grep \<main\>:
080484c0 <main>:
関数 f は引数を 2 つとります。第 1 引数に入力データ、第 2 引数に入力データのサイズが入りますので、第 1 引数を HELLO、第 2 引数を 5 として、スタックにこれらを積み、f を呼び出します。
以上の条件を設定したプログラムが pcaller.c です。target3 を渡して実行すると、target3 プロセス内にある関数 f を実行した結果が出力されます。
$ ./pcaller target3
retn: d59b8359
このように ptrace は使い方によっていろいろと面白いことが可能です。Manpage of PTRACE を眺めると、今回紹介した機能以外にも様々なオプションがあるので、興味があればぜひ試してみてください。