PR 本記事には広告(Amazonアソシエイト・もしもアフィリエイト・A8.net等)が含まれます。掲載情報の正確性には努めていますが、商品の詳細は必ずリンク先で最新情報をご確認ください。
CTF・セキュリティ学習

【スタックBoF基礎編】ret書き換えとret2winで初Pwn成功する|CTF思考フレームワーク #59

かも次郎とアンペンが「スタックBoF」を解説するマスコットイラスト
安全に生きたい編集部

こんにちは、アンペンです!今回はスタックバッファオーバーフロー(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が何を防ぐか
難易度:初〜中級 所要時間:13分 体験:ret2winで初Pwn おすすめ:#58の後

📖 はじめての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の核心です。

BoFでbufを超えてCanary・RBP・戻りアドレスを書き換える位置を示した図解
図1:bufを超えて書き込むと戻りアドレスが書き換わる
📮 たとえるなら、郵便受けからはみ出した手紙

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

郵便受けからあふれた手紙がドアの鍵まで届くBoFのたとえイラスト
図2:郵便受けからはみ出した手紙がドアの鍵まで届く

ここで覚える用語: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特定をやるわけですね。

offset特定・win関数アドレス取得・ペイロード組立のret2win 3ステップ図解
図3:ret2win 3ステップ(offset→アドレス→送信)

ここまで読んだら、あとは手を動かすのみ。理屈を100回読むより、実際に自分のバイナリで一度シェルを奪うほうが、10倍記憶に残ります。次のラボは、その“初めての一発”のための最短手順です。安全な自分の環境で、ぜひ味わってみてください。

CTFでやってみよう:自作バイナリでret2win

やってみよう / 自分の環境・CTFのみ

故意に脆弱なC言語バイナリでret2win成功

  1. void win(){system("/bin/sh");} を含むCを-fno-stack-protector -no-pieでビルド
  2. main()char buf[64]; gets(buf); を呼ぶ
  3. cyclic patternでoffsetを特定(72くらいになるはず)
  4. objdump -dでwin()のアドレスを取得
  5. pwntoolsで payload = b"A"*72 + p64(0x4011XX) を作って送信
  6. シェルが起動すれば成功
本番サーバや他者のバイナリには絶対適用しないこと。検証は自作バイナリ・CTFのみ。

さて、ここまでは“攻める”話でした。ここからは反転して“守る”側です。うれしいことに、BoFの防御は研究され尽くしていて、正しく重ねれば現代ではほぼ完封できます。その代表が、次の4層です。

守る側:BoFを完封する4層

スタック攻撃対策の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が有効でも『既存コードの破片(ガジェット)』を繋いで実行を奪う手法を扱います。

次に読みたい記事

参考資料

記事URLをコピーしました