ROP/JOP ガジェットチェーンの構築原理
コード注入を封じても乗っ取りが成立する理由が腑に落ちる。既存命令片を ret/jmp で連結するガジェットチェーンの組み立て、自動探索、stack pivot、CET が連鎖を断つ瞬間まで原理から追える。
- 1.ROP/JOP は新しいコードを注入せず、バイナリ内に既存する命令片(ガジェット)を ret や間接 jmp/call で連結して任意計算を組む。可変長命令の途中バイトから無数の意図しないガジェットが生まれる。
- 2.ROP は ret がスタックを駆動して直線的に連鎖する。JOP は ret を使わず、ディスパッチャガジェットがガジェット表を1つずつ進めて間接 jmp で連結し、制御をデータ(テーブル)側に持つ。
- 3.stack pivot で RSP を攻撃者が制御するバッファへ向け替えてチェーンを起動する。CET シャドースタックは ret の戻り先不一致で ROP を、IBT は endbr 以外への着地拒否で JOP を最初の1歩で止める。
なぜ「既存コードの断片」で任意計算が組めるのか
DEP/NX が書き込み可能ページの実行を禁じると、攻撃者は自前のコードを置けなくなります。そこで発想を反転させ、すでに実行可能な領域(プログラム本体や libc)にある命令の断片を部品として寄せ集めるのがコード再利用攻撃です。詳細はメモリ破壊攻撃の原理で扱ったとおりで、本稿はその「部品をどう連結して任意計算にするか」という構築原理に絞ります。
部品を ガジェット(gadget) と呼びます。ガジェットは「数個の有用な命令+制御を次へ渡す末尾命令」という形をしています。末尾命令の種類が攻撃手法を分けます。
- ROP(Return-Oriented Programming):末尾が
retのガジェットを使う。 - JOP(Jump-Oriented Programming):末尾が間接
jmp(例jmp rax)のガジェットを使う。 - COP(Call-Oriented Programming):末尾が間接
callのガジェットを使う。
重要なのは、これらのガジェットが開発者の意図とは無関係に存在する点です。x86-64 は可変長命令で、命令の途中バイトから逆向きに解釈すると、元のコードには無かった別の命令列が現れます。十分な大きさのバイナリには、チューリング完全な計算を組めるだけのガジェットが揃ってしまいます。
元のコード(バイト列): 48 8b 07 c3
正規の解釈: mov rax, [rdi] ; ret
1バイトずらして解釈: 8b 07 c3 → mov eax, [rdi] ; ret (別ガジェット)
解説は防御・教育目的です。攻撃手順の再現ではなく、なぜ成立し、どの緩和がどこで連鎖を断つかという原理に絞ります。検証は許可された環境に限られます。
ガジェット探索:何を集め、どう絞るか
チェーンを組む前に、対象バイナリから使えるガジェットを列挙します。ROPgadget や ropper といったツールは、実行可能セクションを走査して末尾命令(ret / jmp reg / call reg)を探し、そこから手前へ向かって短い命令列に逆アセンブルし、ガジェット候補を抽出します。
探索の核は「末尾命令のバイトを起点に、その手前の数バイトを命令として解釈する」ことです。ret のオペコードは 0xC3 なので、コード領域から 0xC3 を全部探し、各位置から手前数バイトを逆アセンブルすれば ROP ガジェット候補が網羅できます。
探索アルゴリズム(ROP の場合の概念)
for off in コード領域の各オフセット:
if バイト[off] == 0xC3: # ret を発見
for back in 1..maxlen: # 手前へ伸ばす
逆アセンブル(off-back .. off)
if 全体が有効な命令列:
ガジェット候補に登録(アドレス off-back)
集めた候補から、目的の処理に必要なものを選びます。実戦で価値が高いのは次の系統です。
- レジスタ設定:
pop rdi ; retのように、スタックの値をレジスタへ載せる。関数の引数を整える要。 - メモリ書き込み:
mov [rdi], rsi ; retのように、任意アドレスへ任意値を書く。 - 算術・論理:
add rax, rbx ; retなど。アドレス計算や値の合成に使う。 - 副作用の少なさ:末尾の手前に余計な
popや条件分岐が無いほど扱いやすい。副作用があればその分のダミー値をスタックに足して辻褄を合わせる。
利用できるガジェットの集合は対象バイナリ(とリンクされた libc)に固定されています。理想の pop rdx ; ret が無ければ、pop rdx ; pop rbx ; ret で代用し、余分な rbx 用のダミー値をスタックに積む、といった手持ちのガジェットでの組み合わせ最適化が攻撃者の作業になります。だから同じ脆弱性でもバイナリが変わればチェーンは作り直しです。
ROP チェーン:ret がスタックを駆動する
ROP の連鎖は ret の振る舞いそのものです。ret はスタックトップの値へジャンプし、スタックポインタ(RSP)を 8 バイト進めます。そこでスタック上に「ガジェットのアドレス」と「そのガジェットが pop する値」を順に並べておけば、各ガジェット末尾の ret が次のガジェットへ制御を渡し、ret 自身が連鎖を駆動するエンジンになります。
目標は多くの場合「1つの関数を正しい引数で呼ぶ」ことです。x86-64 System V 規約では第1〜第6引数が RDI, RSI, RDX, RCX, R8, R9 に載るので、pop 系ガジェットで引数レジスタを整え、最後に目的の関数へ飛ばします。例として mprotect(addr, len, RWX) を呼んでスタックを実行可能に戻す典型を擬似的に示します。
攻撃者がスタックに並べる ROP チェーン(上から消費される)
[ &(pop rdi ; ret) ]
[ addr ] ← RDI = 第1引数(保護を変える先頭アドレス)
[ &(pop rsi ; ret) ]
[ len ] ← RSI = 第2引数(長さ)
[ &(pop rdx ; ret) ]
[ 7 ] ← RDX = 第3引数(PROT_READ|WRITE|EXEC = 7)
[ &mprotect ] ← 引数が揃った状態で mprotect へ
[ &shellcode ] ← mprotect の戻り先(実行可能化した領域)
最初の ret(脆弱性で奪った戻りアドレス)が先頭ガジェットへ飛び、以降は各 ret が次の行へ進めます。制御の流れが完全にスタック上のデータ配置で決まる点が ROP の本質です。
JOP チェーン:ディスパッチャがテーブルを進める
ROP は ret に依存するため、ret を監視する後方エッジ防御に弱くなります。そこで ret を一切使わずに連鎖する手法が JOP です。JOP では連鎖の駆動を ret ではなく ディスパッチャガジェット(dispatcher gadget) が担います。
仕組みはこうです。ガジェットのアドレスを並べた ディスパッチテーブル をメモリに用意し、その読み出し位置を指すレジスタ(ディスパッチャレジスタ、例 rdx)を保持します。各「機能ガジェット」は仕事をした後、末尾の間接 jmp でディスパッチャへ戻ります。ディスパッチャは「テーブル位置を1つ進め、その指す先のガジェットへ間接 jmp する」だけの小さな部品です。
ディスパッチャガジェット(概念):
add rdx, 8 ; テーブル読み出し位置を次へ
jmp [rdx] ; テーブルが指す機能ガジェットへ間接ジャンプ
機能ガジェットの末尾は ret ではなく:
... 仕事 ... ; jmp rXX (rXX はディスパッチャに戻る間接 jmp)
制御フロー:
ディスパッチャ → 機能ガジェット1 → ディスパッチャ → 機能ガジェット2 → ...
ROP では ret と RSP が「次はどこか」を決めていましたが、JOP ではそれをディスパッチテーブルとディスパッチャレジスタが肩代わりします。ret も特定の戻りアドレス列も不要なので、ret だけを守る防御をすり抜けられます。COP は連結に間接 call を使う変種で、call がスタックに戻りアドレスを積む副作用を吸収する工夫が要る点が JOP と異なります。
| 観点 | ROP | JOP | COP |
|---|---|---|---|
| 末尾命令 | ret | 間接 jmp(jmp reg / jmp [mem]) | 間接 call |
| 連鎖の駆動 | ret + RSP がスタックを進める | ディスパッチャ + ディスパッチテーブル | call 連鎖(戻りアドレス処理が必要) |
| 制御を持つ場所 | スタック上のアドレス列 | テーブルとディスパッチャレジスタ | call 先と戻り先の管理 |
| 主に弱い防御 | 後方エッジ(シャドースタック) | 前方エッジ(IBT / 前方 CFI) | 前方エッジ(IBT / 前方 CFI) |
stack pivot:チェーンの起点をすり替える
ROP チェーンを動かすには、ret が読むスタック(RSP の指す先)が攻撃者の並べたチェーンを指していなければなりません。ところが溢れさせたバッファがヒープにある、あるいは制御を奪った時点で RSP が攻撃者の用意した領域を指していない、という状況が頻繁に起きます。
そこで stack pivot(スタックピボット) を使います。これは RSP を攻撃者が内容を制御できるメモリ(偽スタック、fake stack)へ向け替える ガジェットで、典型は次のような形です。
xchg rax, rsp ; ret—raxに偽スタックのアドレスを入れておき、RSP と交換する。mov rsp, rax ; ret—raxを RSP に直接ロードする。pop rsp ; ret— スタックトップの値を RSP に載せる。add rsp, 0x... ; ret— RSP を既知量ずらして、別領域のチェーン先頭に合わせる。
pivot 前: RSP → 元のスタック(攻撃者が十分に制御できない/短い)
pivot 後: RSP → 偽スタック(攻撃者が並べた本命の ROP チェーン)
xchg rax, rsp ; ret を踏むと
・rax(=偽スタックのアドレス)が RSP に入る
・続く ret が偽スタック先頭のガジェットアドレスへ飛ぶ
→ 以降は本命チェーンが駆動する
最初に奪える制御が「ガジェット1個ぶん」しか無くても、その1個を pivot に充てれば、以降の長いチェーンを別領域に展開できます。pivot は ROP/JOP チェーンの着火点であり、エクスプロイト構築で頻出する定石です。
CET:連鎖の最初の1歩を断つ
CFI と最新のエクスプロイト緩和で詳述したとおり、Intel CET は ROP と JOP/COP を別々の機構で止めます。ガジェットチェーンの観点で「どこで連鎖が始められなくなるか」を見ると要点が明確になります。
シャドースタック(後方エッジ) は、call 時に戻りアドレスを通常スタックと CPU 管理のシャドースタックの両方へ積み、ret 時に両者が一致するかを照合します。ROP チェーンは「偽の戻りアドレス列を ret でたどる」ものなので、最初のガジェットへ飛ぶ ret の時点でシャドースタック側の正規戻り先と一致せず、制御保護例外(#CP)で停止します。連鎖の1歩目で破綻します。
IBT(Indirect Branch Tracking、前方エッジ) は、間接 jmp/call の着地点を endbr64 命令のある位置だけに制限します。JOP/COP は「途中バイトから始まる意図しないガジェット」や「関数の途中」へ間接分岐で着地することに依存しますが、そこには endbr64 が無いため #CP 例外になります。ディスパッチャや機能ガジェットへの間接 jmp が着地できず、JOP の連鎖が組めません。
ROP → ret が偽戻り先へ飛ぶ瞬間
通常スタック=偽アドレス vs シャドースタック=正規アドレス → #CP(不一致)
JOP → 間接 jmp がガジェット(途中バイト起点)へ飛ぶ瞬間
着地先に endbr64 が無い → #CP(IBT 違反)
CET はメモリ破壊(オーバーフローや Use-After-Free)の発生そのものは止めません。あくまで「破壊後に制御を奪う最後の一歩」を妨げる障害物です。前方 CFI の粒度の粗さを突く手法、シャドースタックを見ない data-only 攻撃、CET 非対応 CPU など抜け道は残ります。根治は境界チェックと所有権を持つメモリ安全な実装で、開発段階の検出には fuzzing が効きます。
実務での向き合い方
ガジェットチェーンを「組ませない/着火させない」ためには、構築の各前提を潰すのが筋です。
- ガジェットの供給を減らす:不要なコードをリンクしない、
libcを最小化する、CET のendbr強制で途中バイト起点ガジェットを着地不能にする。完全には消せないが攻撃者の選択肢を狭める。 - 着火点を塞ぐ:stack pivot は RSP の書き換えに依存する。スタックカナリアや前方 CFI で pivot ガジェットへの間接遷移を弾き、ピボットの成立条件を厳しくする。
- 多層で前後を塞ぐ:CET シャドースタック(後方=ROP 対策)と IBT・前方 CFI(前方=JOP/COP 対策)を両方有効化する。片方では ROP か JOP のどちらかが素通りする。発想は最小権限の原則と多層防御と同じで、1層破られても次で止める。
(1) ROP/JOP は既存コード片(ガジェット)の連結で、可変長命令の途中バイトから意図しないガジェットが生じる。(2) ROP は末尾 ret を ret+RSP がスタック上のアドレス列で駆動する。(3) JOP は ret を使わず、ディスパッチャガジェットがディスパッチテーブルを進めて間接 jmp で連結する。(4) stack pivot は xchg rax, rsp ; ret 等で RSP を偽スタックへ向け替え、長いチェーンを着火する。(5) CET シャドースタックは ret 不一致で ROP を、IBT は endbr 以外への着地拒否で JOP を、いずれも最初の1歩で止める。
セキュリティ Article
ROP/JOP ガジェットチェーンの構築原理を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
ROP
比較で見る軸
難易度: advanced / カテゴリ: セキュリティ / タグ数: 6
導入後に効く点
ROP は ret がスタックを駆動して直線的に連鎖する。JOP は ret を使わず、ディスパッチャガジェットがガジェット表を1つずつ進めて間接 jmp で連結し、制御をデータ(テーブル)側に持つ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- セキュリティ
- タグ数
- 6
判断チェックリスト
- 自社の用途が「ROP / JOP」に近いか確認する。
- 強みである「ROP/JOP は新しいコードを注入せず、バイナリ内に既存する命令片(ガジェット)を ret や間接 jmp/call で連結して任意計算を組む。可変長命令の途中バイトから無数の意図しないガジェットが生まれる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。