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

【ROP入門編】ガジェット連結でNXを越える|CTF思考フレームワーク #60

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

こんにちは、アンペンです!

今回はROP(Return-Oriented Programming)入門。前回のBoFはshellcodeをスタックに置いて実行する方式でしたが、現代バイナリはNX(No-Execute)でスタック実行を禁じています。NXがONでも『プログラム自身がもともと持っている命令の断片(ガジェット)』を継ぎ接ぎして任意計算を組み立てる──それがROPです。

CTFのPwnでBoFの次に必ず出てくる定番テーマで、ret2libc・ret2csu・SROPなど派生が豊富ですが、芯はすべて『retでガジェットを連結する』1点に尽きます。

ROPって、聞いた瞬間は身構えますが、発想は意外と人間くさいんです。『新しい道具を持ち込めないなら、現場にあるものを寄せ集めて間に合わせよう』——いわばDIYの精神。プログラムやライブラリの中には、もともと無数の“命令の切れ端”が転がっています。それを上手につなぎ合わせるだけで、攻撃者は新しいコードを1行も書かずに、好きな処理を実現してしまう。今日はその“寄せ集めの技”を、順を追って見ていきましょう。

NXがあればshellcodeが置けないから安全じゃないの?

新しいコードは置けないけど、libcやプログラム自身の中に『retで終わる短い命令列』が山ほどある。それを戻りアドレス連結で呼んでいけば、結果的に好きな関数を好きな引数で呼べてしまう。これがROPだよ。

まず結論

ROPは『retで終わる短い命令片(ガジェット)を戻りアドレスの羅列でチェーン実行』する技法。BoFで奪った1回のretを起点に、次々と別ガジェットへ飛ばせます。x64では引数はRDI/RSI/…に乗るため、pop rdi; retでRDIに「/bin/sh」を入れてsystemを呼ぶret2libcが王道。守る側はASLR+PIE+Full RELRO+Intel CET(Shadow Stack)の多層でROPを実質的に封じます。

この記事で分かること

  • ROPがNXを越える原理(既存命令の再利用)
  • ガジェット列挙の道具(ROPgadget / one_gadget / ropper)
  • ret2libcの組み立て5ステップ
  • x64アライメント問題(movapsクラッシュ)の回避
  • 守り側の新世代保護(CET-IBT / Shadow Stack)
難易度:中級 所要時間:13分 体験:ret2libcでシェル奪取 おすすめ:#59の後

📖 はじめてのWebセキュリティ #60|ROP入門編
ガジェット連結でNXを越える手筋を扱います。 シリーズ一覧を見る →

⚠️ 大事なお約束
他者のサービスや本番バイナリへのexploitは違法。CTF・自作バイナリのみで確認してください。

なぜretだけで任意計算が成立するのか

x64のret命令はたった1バイト(0xC3)で、『RSPの指す先(=スタック頂)から8バイト読んでRIPに入れ、RSPを+8する』だけの動きです。つまりスタックに並べた8バイトのアドレスへ次々ジャンプする装置と見ることもできます。

BoFでretアドレスを書き換える時、そこに『単発の関数アドレス』ではなく、『ガジェットA→値→ガジェットB→値→…』という8バイト単位の連続列を流し込めば、retの度に次々消費されて任意の処理が走ります。これがROPの本体です。

もう少しイメージを足すと、retは“スタックに置かれた住所を、上から順に読み上げては飛んでいく”装置です。普段は1個だけ読んで関数に戻りますが、攻撃者がそこに住所を10個ずらりと並べておけば、retは律儀に1つずつ消化して、順番にジャンプし続ける。まるで、めくるたびに次の指示が書かれた“めくり台本”。攻撃者は台本を書く人、retはそれを忠実に読み上げる役者、というわけですね。

図解:ROPチェーンの実行フロー

スタックには『ガジェット1のアドレス → ガジェット1がpopする値 → ガジェット2のアドレス → …』の順で並びます。retの度に1要素消費。下図のように、攻撃者は「次に何をしたいか」を一筆書きでスタックに書き、retは台本通りに進むだけです。

ROPガジェットを連結して任意関数を呼び出すROPチェーンの実行フロー図解
図1:ガジェットを並べると任意計算が組める
🧱 たとえるなら、レゴで作る臨時マシン

新しいパーツ(shellcode)を買いたいが店は閉まっている(NX有効)。それでも家にある『すでに作られた小さな部品』(libcの命令断片)を引っ張り出して、テープで繋げば、新しい乗り物が組み立てられます。ROPはまさに『家にあるパーツの組み合わせで新作品を作る』発想。プログラムが入っているメモリ全体が『パーツ倉庫』で、ガジェットが『部品』、スタックが『組立図』にあたります。

ここで覚える用語:ret2libc / one_gadget
ret2libcは『libc内のsystem("/bin/sh")に飛ばす』形のROPで、ガジェット数が少なくて済むため最頻出。x64ではpop rdi; retでRDIに"/bin/sh"のアドレスを乗せてからsystemを呼びます。one_gadgetはlibc内に存在する『1命令(=1ジャンプ)でシェルが起動するアドレス』を探すツール。条件([rsp+0x30] == 0等)が合えばret先を1つ書くだけで終わるため、exploitが極端に短くなります。

ガジェットを探す道具と選び方

ガジェットは『retで終わる短い命令列』なら何でも候補です。実戦では以下の3つから探します。

  • ROPgadget:ROPgadget --binary ./vuln で全候補列挙。--only "pop|ret" で絞り込み
  • ropper:同上+対話モード。ropper -f ./vuln --search "pop rdi"
  • one_gadget:one_gadget ./libc.so.6 で1発キル候補と条件を表示

x64でよく使う鉄板ガジェットは pop rdi; ret / pop rsi; pop r15; ret / pop rdx; ret / ret(アライメント用)の4つ。とりあえずこの4本があればret2libcは組めます。

最初は『そんな都合のいい命令片、本当に見つかるの?』と思うかもしれません。でも実は、ある程度の大きさのプログラムやlibcには、pop rdi; ret のような“使える破片”が、驚くほどたくさん埋まっています。だから道具(ROPgadget等)に探させれば、ほぼ確実に必要な部品はそろう。挙げた4本さえ手に入れば、まずは王道のret2libcが組める——そう思って、気軽に探し始めて大丈夫です。

ret2libc 組み立ての5ステップ

ASLR下でも通る標準手順

  • ①libcベースアドレスをリーク:1回目のBoFで puts(puts@got) を呼び、戻り先を main に。putsの実アドレスが出力される
  • ②libc base 計算:libc_base = leak − libc.symbols['puts'] でlibc先頭アドレスが確定。これで system"/bin/sh" の場所も計算可能
  • ③ガジェット解決:pop rdi; retのアドレスをROPgadgetで取得
  • ④2回目のBoFでペイロード送信:padding + p64(pop_rdi) + p64(binsh_addr) + p64(ret_gadget) + p64(system_addr)
  • ⑤シェル奪取:p.interactive()で対話化、id等で権限確認

④のret_gadgetを1つ挟むのは、x64のスタックアライメント問題を回避するため。system内部で使われるmovaps命令はRSPが16バイト境界に揃っていないとSIGSEGVするため、ret 1回でアライメントを矯正します。

既存のレゴパーツだけで新しい乗り物を組み立てるROPのたとえイラスト
図2:既存パーツの組み合わせで新作品。ROPも同じ

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

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

NX有効バイナリで初ret2libcを決める

目的は『リーク→計算→再送信』というROP exploitの定型ルーチンを体に染み込ませることです。

  1. gcc vuln.c -o vuln -fno-stack-protector -no-pie でcanary無し・PIE無しのBoFバイナリを作る(NXはGCCデフォルトでON)
  2. checksec ./vulnで「NX enabled / Canary No / PIE No / RELRO Partial」を確認
  3. BoFのoffsetをcyclicで特定(前回#59と同じ)
  4. ROPgadget --binary ./vuln --only "pop|ret" | grep "pop rdi"pop rdi; retのアドレス取得
  5. pwntoolsで1回目のpayloadを組む:pop_rdi + puts_got + puts_plt + main_addr(リーク後にmainへ戻る)
  6. 受信したpaddingからu64()でputs実アドレスを抜き、libc.address = leak - libc.symbols['puts']
  7. 2回目のpayload: pop_rdi + binsh + ret_gadget + system を送信
  8. p.interactive() でシェルを掴み、id / ls で動作確認
本番サーバや他者バイナリには絶対適用しないこと。検証は自作バイナリ・CTF配布物・許可済み環境のみで。

ROPを封じる現代の保護機構

ROPはNXを越える発想として2007年頃から普及しましたが、現代CPUと現代OSは『そもそもretの戻り先を制限する』ハードウェア機構を持ち始めています。

ASLR/PIE・Full RELRO・Intel CETという3つの現代ROP保護機構の図解
図3:ASLR+RELRO+CETでROPは大幅に困難化
現代のROP対策(多層防御)
  • ASLR/PIE:libcもバイナリ本体もアドレスがランダム化される。リーク経路を必ず1回挟む必要があり、攻撃難易度が大幅に上がる
  • Full RELRO:(-Wl,-z,relro -Wl,-z,now) GOTを起動時に解決し読取専用化。GOT Overwrite経路が消える
  • Intel CET(IBT):間接call/jmpの先頭にendbr64命令を要求。攻撃者ガジェットの多くはendbr64を持たず弾かれる
  • Intel CET(Shadow Stack):HW専用の影スタックがretの戻り先を保存。通常スタックの戻りアドレスと不一致ならCPUが例外を投げる
  • ARM PAC:関数ポインタにPMACを付与し改ざんを検出
  • 言語レベルの根本解決はRust/Go等のメモリ安全言語。BoFそのものが起きない
  • 古いCコードは段階的に置き換え、当面はASan/Fuzzで未知BoFを早期発見

『家にあるパーツで何でも作れる』って、攻撃者すごいな…

だから防御側もCET等の新機構で対抗してる。次回はFormat String・GOT Overwrite。任意書込プリミティブで実行を奪う技法を扱うよ。

ここまでの流れをひと言で。ROPは『新しいコードを書けないなら、ありもので組む』技法でした。要は“リークして、計算して、チェーンを送る”という定型作業です。最初は手数が多くて面くらいますが、一度ret2libcを通すと、その型がそのまま次の問題にも効きます。Pwnが一気に楽しくなる山場なので、ぜひ自分の手で一度通してみてください。

まとめ:『リーク→計算→チェーン送信』が定型

今回のポイント
  • ROPはretで終わる命令片を連結する技法。NXがあっても任意計算が組める
  • ret2libcは pop rdi → “/bin/sh” → ret(align) → system の定型
  • ASLR下では1回リーク→base計算→2回目で本体送信の2段階が標準
  • x64はmovapsアライメントに注意(ret 1つ挟む)
  • 守りはASLR+Full RELRO+CET(IBT/Shadow Stack)+メモリ安全言語

今日の持ち帰りは『NXは“終わり”ではなく“次の一手”を呼んだだけ』。スタックでコードを動かせなくしても、攻撃者は既存の部品を組み替えて迂回してくる——だから防御も、アドレスを隠すASLRや、戻り先そのものを検証するCETへと進化しました。攻防はこうして“一手ずつ”進んでいくんです。

次回はFormat String・GOT Overwriteprintf(user_input)から任意書込プリミティブを作り、GOTを書き換えて実行を奪う技法を扱います。

次に読みたい記事

参考資料

記事URLをコピーしました