こんにちは、愛甲です。今日は64ビット環境におけるリバースエンジニアリングについて書かせていただこうと思います。
-----
ここ数年のコンピュータ業界の流れのひとつとして、オペレーティングシステムの64ビット化があります。リバースエンジニアリングの視点から考えると、32ビットから64ビットへ移行することでレジスタのサイズが倍になり、命令セットがx86とは異なれば、それらを新たに知識として学習しなければ64ビット用のソフトウェアを解析できません。今回はUbuntu LinuxのAMD64版(x86_64版)を利用して、実行ファイルが扱う64ビットのマシン語(アセンブラ)を見ていきたいと思います。
まず、64ビット環境(UbuntuLinux x86_64環境)のgccでは、特に指定しない限り、引数の受け渡しにスタックではなくレジスタが使われます。レジスタの数にも限界があるので、どこまでレジスタが利用されるのかを調べるために、次のプログラムを作成します。
// test01.c
#include <stdio.h>
int func(int a, int b, int c, int d, int e, int f, int g)
{
return (a + b + c + d + e + f + g);
}
int main(void)
{
printf("%d\n", func(1, 2, 3, 4, 5, 6, 7));
return 0;
}
7つの引数を持つ関数を自作しました。これをコンパイルして逆アセンブルします。これでfunc関数が呼び出される際、どれだけの引数がレジスタを経由して関数へ渡されるかを確認できます。
$ gcc -Wall test01.c -o test01
$ ./test01
28
$ gdb test01
GNU gdb (GDB) 7.1-ubuntu
Copyright (C) 2010 Free Software Foundation, Inc.
(gdb) disas main
Dump of assembler code for function main:
0x0000000000400556 <+0>: push %rbp
0x0000000000400557 <+1>: mov %rsp,%rbp
0x000000000040055a <+4>: sub $0x10,%rsp
0x000000000040055e <+8>: movl $0x7,(%rsp)
0x0000000000400565 <+15>: mov $0x6,%r9d
0x000000000040056b <+21>: mov $0x5,%r8d
0x0000000000400571 <+27>: mov $0x4,%ecx
0x0000000000400576 <+32>: mov $0x3,%edx
0x000000000040057b <+37>: mov $0x2,%esi
0x0000000000400580 <+42>: mov $0x1,%edi
0x0000000000400585 <+47>: callq 0x400524 <func>
0x000000000040058a <+52>: mov %eax,%edx
0x000000000040058c <+54>: mov $0x40069c,%eax
0x0000000000400591 <+59>: mov %edx,%esi
0x0000000000400593 <+61>: mov %rax,%rdi
0x0000000000400596 <+64>: mov $0x0,%eax
0x000000000040059b <+69>: callq 0x400418 <printf@plt>
0x00000000004005a0 <+74>: mov $0x0,%eax
0x00000000004005a5 <+79>: leaveq
0x00000000004005a6 <+80>: retq
End of assembler dump.
0x400585にてfunc関数が呼び出されていますが、その際の引数はedi、esi、edx、ecx、r8d、r9d、(rsp)となっており、最後の引数(0x7)だけがスタックに積まれています。以上から引数は6つまでレジスタを利用し、7つ目からスタックに渡されると分かりました。
また関数からのリターンにleaveq、retqを用いていることから、引数はレジスタでも、戻り先アドレスはスタックに積まれるようです。よってスタックがオーバーフローすればripが任意の値に変更できます。
// test02.c
#include <stdio.h>
int cpy(char *s)
{
char buff[16];
strcpy(buff, s);
return 0;
}
int main(int argc, char *argv[])
{
if(argc < 2)
return 1;
cpy(argv[1]);
return 0;
}
単純なオーバーフロー脆弱性を持つプログラム(test02)を作成します。
$ gcc -fno-stack-protector test02.c -o test02
$ gdb test02
GNU gdb (GDB) 7.1-ubuntu
Copyright (C) 2010 Free Software Foundation, Inc.
(gdb) r AAAABBBBCCCCDDDDEEEEFFFFGGGG
Starting program: /home/kenji/tmp/test02
AAAABBBBCCCCDDDDEEEEFFFFGGGG
Program received signal SIGSEGV, Segmentation fault.
0x0000000047474747 in ?? ()
gccに-fno-stack-protectorを指定し、スタックプロテクション(VC++における/GSオプションと同等)をOFFにしてコンパイルし、gdb上で実行します。するとripが0x47474747に変わりました。
リターン値は変更できましたが、引数がスタックではなくレジスタに格納されるというのは、return-to-libcによるAPI呼び出しに制限がかかることになります。NX(DEP)は近年ではスタンダードな仕様になりつつあるので、これまで通りのreturn-to-libcが64ビット化に起因したレジスタ経由の引数渡しによって実現できなくなるというのは面白い話です。
実際は実現できなくなるというよりも、実現が難しくなる(満たさなければならない条件がひとつ増える)という意味であり、例えばpop %rdi; retという連続した命令がどこかにあれば、引数として1つ値を渡せますし、pop %rdi; pop %rsi; retがあれば2つの引数を渡せますので、この時点でsystem関数やexec系関数を呼び出せます。
ではpop %rdi; ret(5f c3)は現実的によく存在する命令列でしょうか? 試しにlibc.so.6から調べてみます。
# hexdump -C /lib/libc.so.6 | grep "5f c3"
000201d0 c4 38 5b 5d 41 5c 41 5d 41 5e 41[5f c3]0f 1f 00
00022980 00 00 00 89 e8 5b 5d 41 5c 41 5d 41 5e 41[5f c3]
00023340 00 5b 5d 41 5c 41 5d 41 5e 41[5f c3]48 89 d5 48
00025400 5b 5d 41 5c 41 5d 41 5e 41[5f c3]bd 07 00 00 00
00025ca0 5d 41 5e 41[5f c3]48 89 c3 c7 44 24 2c 05 00 00
00026140 41[5f c3]4c 89 e0 31 d2 4c 89 eb 8b 08 85 c9 78
000266a0 00 00 00 89 e8 5b 5d 41 5c 41 5d 41 5e 41[5f c3]
00027730 5d 41 5e 41[5f c3]4c 89 eb 4c 89 e2 31 c9 66 90
00027ac0 41[5f c3]0f b6 41 07 88 45 00 0f b6 41 06 88 45
00028040 5d 41 5c 41 5d 41 5e 41 [5f c3]48 83 7c 24 48 00
000286b0 41[5f c3]0f 1f 44 00 00 8d 4c 0d 00 85 ed 4d 8d
00029010 48 83 c4 78 5b 5d 41 5c 41 5d 41 5e 41[5f c3]90
00029050 00 00 5b 5d 41 5c 41 5d 41 5e 41[5f c3]0f 1f 00
000293e0 41 5e 41[5f c3]0f 1f 00 0f b7 49 02 48 8b 7c 24
000294c0 5b 5d 41 5c 41 5d 41 5e 41[5f c3]90 90 90 90 90
00029890 c4 28 48 89 d8 5b 5d 41 5c 41 5d 41 5e 41[5f c3]
000311a0 5b 5d 41 5c 41 5d 41 5e 41[5f c3]0f 1f 44 00 00
00031a60 5b 5d 41 5c 41 5d 41 5e 41[5f c3]48 8b 55 f0 48
000355d0 [5f c3]48 89 c8 4c 29 c8 4c 8d 2c c2 49 8d 41 ff
00036460 c4 38 5b 5d 41 5c 41 5d 41 5e 41[5f c3]0f 1f 00
00036720 41 5d 41 5e 41[5f c3]66 0f 1f 84 00 00 00 00 00
00036960 5d 41 5c 41 5d 41 5e 41 [5f c3]66 0f 1f 44 00 00
(省略)
かなりの数が見つかりましたが、これらは本当はpop %r15; ret(41 5f c3)という命令列であり、pop %rdi; ret(5f c3)ではありません。しかし、x86やx86_64は可変長命令であるためpop %r15; ret(41 5f c3)の途中からマシン語を解釈するとpop %rdi; ret(5f c3)となるため、命令コードの途中へジャンプさせることで、事実上pop %rdi; ret(5f c3)として命令コードを使うことが可能です。そして、同じ方法でpop %rsi; ret(5e c3)という命令列が見つからなくとも、pop %r14; pop %r15; ret(41 5e 41 5f c3)の途中へジャンプすることで、pop %rsi; %pop %r15; ret(5e 41 5f c3)という命令を実行できます。つまりpop %r14; pop %r15; ret(41 5e 41 5f c3)があれば、2つの引数を渡して関数呼び出しを実現できます。
ちなみに、3つの引数を渡したい場合はrdxへ格納する必要があるため、pop %rdx; ret(5a c3)を探さなければなりませんが、pop %r10; ret(41 5a c3)でも同様の理由でOKです。
# hexdump -C /lib/libc.so.6 | grep "5a c3"
00001b80 be 1e 9a 17 c3 6a 30 3f f8 38 5a c3 f8 38 5a c3
00001b90 f9 38 5a c3 b6 1d cd 66 2b 41 bd 7e 54 06 6a ec
000a6090 2d 00 64 c7 00 02 00 00 00 31 c0[5a c3]90 90 90
000a9e10 00 e8[5a c3]04 00 48 81 c4 80 00 00 00 e9 73 ff
000f6160 87 07 85 c0 75 f1 5a 41 [5a c3]66 0f 1f 44 00 00
000fea10 84 e5 27 00 64 c7 00 16 00 00 00 83 c8 ff[5a c3]
以上から、64ビット環境により引数がレジスタ渡しになったとしても、glibcがASLRされていないならばreturn-to-libcは実現可能であると分かります(まぁASLRされていればどちらにせよreturn-to-libcはできないですが...)。ちなみに、このように「可変長の命令であること」と「retを終端とした実行領域にあるコード」を何度も利用して欲しいプログラム結果を得るテクニックをReturn-Oriented Programmingと呼びます(参考:When Good Instructions Go Bad:Generalizing Return-Oriented Programming to RISC)。
本来はglibcがASLRされており、return-to-libcで任意のAPIを呼び出せない場合でも、ランダマイズされていないアドレスに配置された命令群を利用して新たなコードを生成するといったテクニックとして利用されるようです。
続いてスタックプロテクションを見てみます。関数呼び出し時に引数の格納先としてrdi、rsi、rdx、rcx、r8d、r9dのレジスタが使われること、そしてレジスタのサイズが64ビットであるため、スタックが8バイトずつ消費されることが32ビットとの大きな違いですが、カナリア値も4バイトではなく、8バイトの値がスタックに積まれます。
$ cp test02.c test03.c
$ gcc test03.c -o test03
$ gdb test03
GNU gdb (GDB) 7.1-ubuntu
Copyright (C) 2010 Free Software Foundation, Inc.
(gdb) disas cpy
Dump of assembler code for function cpy:
0x0000000000400594 <+0>: push %rbp
0x0000000000400595 <+1>: mov %rsp,%rbp
0x0000000000400598 <+4>: sub $0x30,%rsp
0x000000000040059c <+8>: mov %rdi,-0x28(%rbp)
0x00000000004005a0 <+12>: mov %fs:0x28,%rax
0x00000000004005a9 <+21>: mov %rax,-0x8(%rbp)
0x00000000004005ad <+25>: xor %eax,%eax
0x00000000004005af <+27>: mov -0x28(%rbp),%rdx
0x00000000004005b3 <+31>: lea -0x20(%rbp),%rax
0x00000000004005b7 <+35>: mov %rdx,%rsi
0x00000000004005ba <+38>: mov %rax,%rdi
0x00000000004005bd <+41>: callq 0x400498 <strcpy@plt>
0x00000000004005c2 <+46>: mov $0x0,%eax
0x00000000004005c7 <+51>: mov -0x8(%rbp),%rdx
0x00000000004005cb <+55>: xor %fs:0x28,%rdx
0x00000000004005d4 <+64>: je 0x4005db <cpy+71>
0x00000000004005d6 <+66>: callq 0x400488 <__stack_chk_fail>
0x00000000004005db <+71>: leaveq
0x00000000004005dc <+72>: retq
End of assembler dump.
(gdb) b *0x00000000004005cb
Breakpoint 1 at 0x4005cb
(gdb) r AAAA
Starting program: /home/kenji/tmp/test03 AAAA
Breakpoint 1, 0x00000000004005cb in cpy ()
(gdb) i r rdx
rdx 0x1dd80a9c73dfcd00 2150480489144634624
(gdb) r AAAA
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/kenji/tmp/test03 AAAA
Breakpoint 1, 0x00000000004005cb in cpy ()
(gdb) i r rdx
rdx 0xc431a2165d1c6200 -4309485151481732608
(gdb)
カナリア値は領域としては8バイトを使いますが、実際は最下位バイトが00hで固定されているようで、事実上、上位7バイトが有効値となります。このカナリア値はどこから来ているのかというと、環境変数領域辺りに「x86_64」で終わるデータ列があり、それの先頭7バイトを取得しています。
$ cp test03.c test04.c
$ gcc -static test04.c -o test04
$ gdb test04
GNU gdb (GDB) 7.1-ubuntu
Copyright (C) 2010 Free Software Foundation, Inc.
(gdb) disas _start
Dump of assembler code for function _start:
0x0000000000400320 <+0>: xor %ebp,%ebp
0x0000000000400322 <+2>: mov %rdx,%r9
0x0000000000400325 <+5>: pop %rsi
0x0000000000400326 <+6>: mov %rsp,%rdx
0x0000000000400329 <+9>: and $0xfffffffffffffff0,%rsp
0x000000000040032d <+13>: push %rax
0x000000000040032e <+14>: push %rsp
0x000000000040032f <+15>: mov $0x400e00,%r8
0x0000000000400336 <+22>: mov $0x400e40,%rcx
0x000000000040033d <+29>: mov $0x40047d,%rdi
0x0000000000400344 <+36>: callq 0x4004c0
<__libc_start_main>
0x0000000000400349 <+41>: hlt
0x000000000040034a <+42>: nop
0x000000000040034b <+43>: nop
End of assembler dump.
(gdb) b *0x400322
Breakpoint 1 at 0x400322
(gdb) disas cpy
Dump of assembler code for function cpy:
0x0000000000400434 <+0>: push %rbp
0x0000000000400435 <+1>: mov %rsp,%rbp
0x0000000000400438 <+4>: sub $0x30,%rsp
0x000000000040043c <+8>: mov %rdi,-0x28(%rbp)
0x0000000000400440 <+12>: mov %fs:0x28,%rax
0x0000000000400449 <+21>: mov %rax,-0x8(%rbp)
0x000000000040044d <+25>: xor %eax,%eax
0x000000000040044f <+27>: mov -0x28(%rbp),%rdx
0x0000000000400453 <+31>: lea -0x20(%rbp),%rax
0x0000000000400457 <+35>: mov %rdx,%rsi
0x000000000040045a <+38>: mov %rax,%rdi
0x000000000040045d <+41>: callq 0x4002a8
0x0000000000400462 <+46>: mov $0x0,%eax
0x0000000000400467 <+51>: mov -0x8(%rbp),%rdx
0x000000000040046b <+55>: xor %fs:0x28,%rdx
0x0000000000400474 <+64>: je 0x40047b <cpy+71>
0x0000000000400476 <+66>: callq 0x40f8f0 <__stack_chk_fail>
0x000000000040047b <+71>: leaveq
0x000000000040047c <+72>: retq
End of assembler dump.
(gdb) b *0x400449
Breakpoint 2 at 0x400449
(gdb) r AAAA
Starting program: /home/kenji/tmp/test04 AAAA
Breakpoint 1, 0x0000000000400322 in _start ()
(gdb) x/15s 0x7fffffffe900
0x7fffffffe900: ""
0x7fffffffe901: ""
0x7fffffffe902: ""
0x7fffffffe903: ""
0x7fffffffe904: ""
0x7fffffffe905: ""
0x7fffffffe906: ""
0x7fffffffe907: ""
0x7fffffffe908: ""
0x7fffffffe909: "\242\314?B\351v\016\204S\355]r,}\016x86_64"
0x7fffffffe920: ""
0x7fffffffe921: ""
0x7fffffffe922: "/home/kenji/tmp/test04"
0x7fffffffe939: "AAAA"
0x7fffffffe93e: "SHELL=/bin/bash"
(gdb) x/32b 0x7fffffffe909
0x7fffffffe909: 0xa2 0xcc 0xca 0x9d 0x42 0xe9 0x76 0x0e
0x7fffffffe911: 0x84 0x53 0xed 0x5d 0x72 0x2c 0x7d 0x0e
0x7fffffffe919: 0x78 0x38 0x36 0x5f 0x36 0x34 0x00 0x00
0x7fffffffe921: 0x00 0x2f 0x68 0x6f 0x6d 0x65 0x2f 0x6b
(gdb) c
Continuing.
Breakpoint 2, 0x0000000000400449 in cpy ()
(gdb) i r $rax
rax 0x76e9429dcacca200 8568453011528786432
(gdb)
0x7fffffffe909以降の7バイト(a2 cc ca 9d 42 e9 76)がその値です。cpy関数内で用いられるカナリア値(0x76e9429dcacca200)と同じ値になっています。ちなみにBlackHat EU 2009のStack Smashing as of Todayでは、カナリア値の設定に以下のアルゴリズムが用いられていると書かれてあります。
def canary():
__WORDSIZE = 64
ret = 0xff0a000000000000
ret ^= (rdtsc() & 0xffff) << 8
ret ^= (%rsp & 0x7ffff0) << (__WORDSIZE 23)
ret ^= (&errno & 0x7fff00) << (__WORDSIZE 29)
return ret
これは0x7fffffffe909以降の7バイトがない、かつ、/dev/urandomからの読み込みに失敗した際に使われるアルゴリズムとなっています。__libc_start_main関数を読むことでそれを確認できます。
(gdb) disas __libc_start_main
Dump of assembler code for function __libc_start_main:
0x00000000004004c0 <+0>: push %r15
0x00000000004004c2 <+2>: mov $0x0,%eax
0x00000000004004c7 <+7>: push %r14
0x00000000004004c9 <+9>: push %r13
(省略)
0x0000000000400583 <+195>: mov 0x2a452e(%rip),%rax
# 0x6a4ab8 <_dl_random>
0x000000000040058a <+202>: movq $0x0,0x88(%rsp)
0x0000000000400596 <+214>: lea 0x89(%rsp),%r13
0x000000000040059e <+222>: test %rax,%rax
0x00000000004005a1 <+225>: je 0x400692
<__libc_start_main+466>
(省略)
0x00000000004005c1 <+257>: mov 0x88(%rsp),%r13
0x00000000004005c9 <+265>: mov %r13,%fs:0x28
(省略)
0x0000000000400692 <+466>: xor %esi,%esi
0x0000000000400694 <+468>: mov $0x479a3b,%edi
# /dev/urandom
0x0000000000400699 <+473>: callq 0x40e080 <open64>
0x000000000040069e <+478>: test %eax,%eax
0x00000000004006a0 <+480>: mov %eax,%r14d
0x00000000004006a3 <+483>: js 0x4006c9
<__libc_start_main+521>
0x00000000004006a5 <+485>: mov $0x7,%edx
0x00000000004006aa <+490>: mov %r13,%rsi
0x00000000004006ad <+493>: mov %eax,%edi
0x00000000004006af <+495>: callq 0x40e140 <read>
0x00000000004006b4 <+500>: mov %r14d,%edi
0x00000000004006b7 <+503>: mov %rax,%r15
0x00000000004006ba <+506>: callq 0x40e0e0 <close>
0x00000000004006bf <+511>: cmp $0x7,%r15
0x00000000004006c3 <+515>: je 0x4005c1
<__libc_start_main+257>
0x00000000004006c9 <+521>: movb $0xff,0x6(%r13)
0x00000000004006ce <+526>: movb $0xa,0x5(%r13)
0x00000000004006d3 <+531>: rdtsc
0x00000000004006d5 <+533>: mov $0xffffffffffffffd0,%rdx
0x00000000004006dc <+540>: add %fs:0x0,%rdx
0x00000000004006e5 <+549>: movzwl %ax,%eax
0x00000000004006e8 <+552>: and $0x7ffff0,%r13d
0x00000000004006ef <+559>: and $0x7fff00,%edx
0x00000000004006f5 <+565>: shl $0x8,%rax
0x00000000004006f9 <+569>: shl $0x29,%r13
0x00000000004006fd <+573>: shl $0x23,%rdx
0x0000000000400701 <+577>: xor %rdx,%r13
0x0000000000400704 <+580>: xor 0x88(%rsp),%r13
0x000000000040070c <+588>: xor %rax,%r13
0x000000000040070f <+591>: mov %r13,0x88(%rsp)
0x0000000000400717 <+599>: jmpq 0x4005c9
<__libc_start_main+265>
(省略)
rdtsc、rsp、errnoアドレスの3つが分かれば特定できるため、この場合はかなり脆弱なカナリア値になってしまいます。とはいえ、7バイトのランダム値が得られなかった場合の最終手段なので基本的に問題なさそうです。
-----
64ビット化の流れは個人的にはとても楽しみにしています。まだまだ一般のPCは32ビット環境であり、しかもそれで十分事足りるのですが、やはり4GBを超えるリニアなメモリ空間だったり、マルチコア以外の処理性能の向上(それがほんの小さな差だったとしても)など、いろいろと調べてみたいことが多々あります。今後も少しずつですが、64ビット環境について調査していきたいと思います。