【スタックBoF基礎編】ret書き換えとret2winで初Pwn成功する|CTF思考フレームワーク #59
こんにちは、アンペンです!今回はスタックバッファオーバーフロー(BoF)基礎編。CTF Pwnの最初の関門であり、最高に気持ちいい初成功体験『ret2win』を扱います。
Pwnを学ぶ人が最初に味わう“感動の瞬間”が、このret2winです。自分で組み立てた入力をプログラムに送り込んだら、本来絶対に呼ばれないはずの関数が動き出し、シェルがポンと立ち上がる——『うわ、本当に乗っ取れた!』というあの体験。理屈は意外とシンプルなので、今日はその仕組みを一歩ずつ、最後の成功体験まで一緒にたどっていきましょう。

BoFで関数の戻り先を書き換えられる、ってどういう仕組み?

C言語のgets()みたいな『境界チェックなし入力』にバッファ容量より長いデータを流すと、スタック上の戻りアドレスまで上書きできる。retがその偽住所を読んで、攻撃者が指定した関数に飛ぶ。
バッファオーバーフロー(BoF)は、ひとことで言えば『容器からあふれた水が、隣のものを濡らしてしまう』現象です。プログラムが用意した“入力を受ける箱”に、その容量を超えるデータを流し込むと、あふれた分が箱のすぐ後ろにある大事なデータ——とりわけ『関数の戻り先メモ』——を上書きしてしまう。古典中の古典でありながら、いまもCTFの王道です。まずは“なぜあふれると乗っ取れるのか”を押さえましょう。
スタックBoFは『境界チェックなしの入力でスタック上の戻りアドレスを書き換え、retで任意関数に飛ばす』攻撃です。最初の練習はret2win(プログラム内に既に存在する勝利関数 win() へ飛ばす)。手順は(1)offset特定(cyclic pattern)、(2)ペイロード組立(padding + win()アドレス)、(3)送信の3ステップ。守りは境界チェック+Canary+NX+ASLRを組み合わせます。
この記事で分かること
- スタックBoFの仕組み
- offsetをcyclic patternで特定する手順
- ret2winのペイロード組立
- Canary/NX/ASLRが何を防ぐか
📖 はじめてのWebセキュリティ #59|スタックBoF基礎編
ret書き換えとret2winを扱います。 シリーズ一覧を見る →
⚠️ 大事なお約束
他者のサービスや本番バイナリへのexploitは違法。CTF・自作バイナリのみで確認してください。
とはいえ、どんなプログラムでもあふれさせられるわけではありません。BoFが成立するには、いくつかの“条件”がそろう必要があります。まずはその条件を確認して、『どういうプログラムが狙われやすいか』の勘どころをつかみましょう。
BoFが成立する条件
- 境界チェックしない入力関数(
gets / strcpy / sprintf / scanf "%s")を使用 - Canary(スタックカナリア)がオフ or 漏洩している
- 戻り先候補(win関数 / system call gadget)が既にある
- NX(実行不可)+ASLRのON/OFFで難易度が変わる
ざっくり言うと、『入力の長さをチェックしていない』うえに『見張り役(Canary)が居ない、あるいは居場所がバレている』とき、BoFは通ります。逆に言えば、このどれか一つでもしっかりしていれば、攻撃はぐっと難しくなる。だから守る側は“どれか一つ”に頼るのではなく、複数を重ねて防ぐわけです(これは後半でくわしく見ます)。
図解:スタック上の書き換え位置
buf[64]→Canary→保存RBP→戻りアドレス。bufを超えて書き込むと、後ろにある戻りアドレスを書き換えられます。
スタックの並びをイメージしておくと、ぐっと分かりやすくなります。入力を受ける箱(buf)があって、その“後ろ”に見張り役のCanary、保存されたRBP、そして『戻りアドレス』が順番に並んでいます。箱に収まる量なら平和ですが、箱を超えて書き込むと、あふれた文字が後ろのものを順に塗りつぶしていく。十分な長さを流せば、いちばん奥の戻りアドレスまで自分の好きな値で書き換えられる——これがBoFの核心です。

郵便受け(buf)の容量を超える手紙(入力)を入れると、後ろのドア(戻りアドレス)まで押し出してしまう状態。『どのくらい後ろにドアがあるか(=offset)』さえ分かれば、ドアの先(任意関数のアドレス)を狙って撃ち込めます。

ここで覚える用語:cyclic pattern / De Bruijnシーケンス
『AAAABAAACAAA…』のような重複しない文字列を入力すると、クラッシュ時のRIPに残った4バイトから『何バイト目で戻りアドレスを上書きしたか』が分かる魔法のパターン。pwntoolsならcyclic(200)+cyclic_find()で一発。
offsetさえ分かってしまえば、もう勝ったも同然です。あとは『何バイト埋めて、その後ろにどのアドレスを置くか』という“穴埋め問題”を解くだけ。次の3ステップは、その穴埋めの手順を分解したものです。
ret2win 3ステップ
- ①Offset特定:cyclic patternを流す → SEGVのRIPからoffsetを逆引き
- ②勝利関数のアドレス特定:
objdump -d | grep winまたは Ghidra で確認 - ③ペイロード組立:
b"A"*offset + p64(win_addr)をstdinへ送信 - 注意:x64ではスタックアライメントで
ret-gadgetを1つ挟むことが多い(movapsクラッシュ回避)
ここで効くのが、用語ボックスでも触れた『cyclic pattern』です。ただの『AAAA…』を流すと、何文字目で戻りアドレスに届いたのか分かりません。でも“重複しない目盛り付きの定規”のような特殊文字列を流せば、クラッシュした瞬間にレジスタへ残った数文字から『ちょうど72バイト目だ』と逆算できる。手探りの総当たりが、一発の計測に変わるんです。だからこそ、まず最初にこのoffset特定をやるわけですね。

ここまで読んだら、あとは手を動かすのみ。理屈を100回読むより、実際に自分のバイナリで一度シェルを奪うほうが、10倍記憶に残ります。次のラボは、その“初めての一発”のための最短手順です。安全な自分の環境で、ぜひ味わってみてください。
CTFでやってみよう:自作バイナリでret2win
故意に脆弱なC言語バイナリでret2win成功
void win(){system("/bin/sh");}を含むCを-fno-stack-protector -no-pieでビルドmain()でchar buf[64]; gets(buf);を呼ぶ- cyclic patternでoffsetを特定(72くらいになるはず)
objdump -dでwin()のアドレスを取得- pwntoolsで
payload = b"A"*72 + p64(0x4011XX)を作って送信 - シェルが起動すれば成功
さて、ここまでは“攻める”話でした。ここからは反転して“守る”側です。うれしいことに、BoFの防御は研究され尽くしていて、正しく重ねれば現代ではほぼ完封できます。その代表が、次の4層です。
守る側:BoFを完封する4層
- 境界チェック関数を使う(
fgets/strncpy/snprintf)、ベストはRust/Go等の安全言語 - Canary(スタックカナリア)でret改ざんを検知(
-fstack-protector-strong) - NX/DEPでスタック上のshellcode実行を防止
- ASLR/PIEでアドレスをランダム化、推測困難に
- CIでchecksecを回し、4層全てONであることを検証
この4層は、1枚ずつは破られても、重ねるほど突破が難しくなる“多層防御”の考え方です。境界チェックで『あふれさせない』、Canaryで『あふれを検知する』、NXで『注入したコードを実行させない』、ASLRで『飛び先を分からなくする』。攻撃者はこの全部を同時に突破しないといけません。だから1つでも多く積むほど、攻撃のコストは跳ね上がっていくんです。

『境界チェックなし関数を使わない』だけで7割守れる感じだね。

その通り。次回はROP入門。NX有効で『コード注入できない』状況をどう突破するかを扱うよ。
ここまでをひと言でまとめると、ret2winは『あふれさせて、戻り先を“勝利関数”に差し替える』。やることは、offsetを測って、飛ばしたいアドレスを調べて、その2つを正しい順で並べて送るだけです。最初は呪文に見えても、一度通れば“型”として体に入りますよ。
まとめ:『offset→アドレス→送信』
- BoFは境界チェックなし入力でret書き換え
- ret2winはoffset+win_addr+paddingの3要素
- 守りはCanary+NX+ASLR+境界チェック関数
今日の持ち帰りは『あふれは、ただのバグではなく“乗っ取りの入口”』。たった数十バイトのはみ出しが、プログラムの制御を丸ごと奪ってしまう。だからこそC/C++では境界チェックが命綱で、可能なら最初から安全な言語を選ぶのが王道です。攻めて仕組みを知れば、守りの勘どころも自然と見えてきます。
次回はROP入門編。NXが有効でも『既存コードの破片(ガジェット)』を繋いで実行を奪う手法を扱います。
