【ROP入門編】ガジェット連結でNXを越える|CTF思考フレームワーク #60
こんにちは、アンペンです!
今回は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)
📖 はじめての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は台本通りに進むだけです。

新しいパーツ(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回でアライメントを矯正します。

CTFでやってみよう:自作バイナリでret2libc
NX有効バイナリで初ret2libcを決める
目的は『リーク→計算→再送信』というROP exploitの定型ルーチンを体に染み込ませることです。
gcc vuln.c -o vuln -fno-stack-protector -no-pieでcanary無し・PIE無しのBoFバイナリを作る(NXはGCCデフォルトでON)checksec ./vulnで「NX enabled / Canary No / PIE No / RELRO Partial」を確認- BoFのoffsetを
cyclicで特定(前回#59と同じ) ROPgadget --binary ./vuln --only "pop|ret" | grep "pop rdi"でpop rdi; retのアドレス取得- pwntoolsで1回目のpayloadを組む:
pop_rdi + puts_got + puts_plt + main_addr(リーク後にmainへ戻る) - 受信したpaddingから
u64()でputs実アドレスを抜き、libc.address = leak - libc.symbols['puts'] - 2回目のpayload:
pop_rdi + binsh + ret_gadget + systemを送信 p.interactive()でシェルを掴み、id/lsで動作確認
ROPを封じる現代の保護機構
ROPはNXを越える発想として2007年頃から普及しましたが、現代CPUと現代OSは『そもそもretの戻り先を制限する』ハードウェア機構を持ち始めています。

- 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 Overwrite。printf(user_input)から任意書込プリミティブを作り、GOTを書き換えて実行を奪う技法を扱います。
