CFI と最新のエクスプロイト緩和(ASLR/CFG/CET)
ROP や JOP はなぜ最新環境で止まるのか。前方/後方エッジの制御フロー整合性、Intel CET のシャドースタックと IBT、ASLR/DEP との多層防御を、どこで攻撃が破綻するかの原理から押さえられる。
- 1.制御フロー整合性(CFI)は、間接呼び出し(前方エッジ)と関数リターン(後方エッジ)の飛び先を、コンパイル時に確定した正規の集合だけに制限する防御。ROP/JOP が前提とする「任意の場所への遷移」を成立させない。
- 2.Intel CET は CFI をハードで実装する。シャドースタック(後方エッジ)は ret 用の戻りアドレスを CPU 管理の別領域に二重保存して改竄を検出し、IBT(前方エッジ)は間接分岐の着地を endbr 命令の位置だけに強制する。
- 3.ASLR は配置の秘匿、DEP/NX は注入コード実行の禁止、CFI は再利用先の制限という別レイヤを担う多層防御。ROP はシャドースタックで、JOP/COP は IBT で着地点を奪われ、root cause を残しても悪用が成立しにくくなる。
なぜ「緩和の積み重ね」が必要なのか
メモリ破壊の脆弱性(バッファオーバーフローや Use-After-Free)は、最終的に制御フロー(次にどの命令へ進むか)を攻撃者が奪うことで悪用されます。各緩和は、この奪取に至る経路の別々の段階を塞ぎます。どれか一つでは破られるため、独立した層を重ねて「全部を同時に突破する」コストを跳ね上げるのが現代のエクスプロイト緩和の設計思想です。
時系列で攻撃の段階と、それを止める緩和を並べると関係が見えます。
| 攻撃の段階 | 攻撃者がやること | 塞ぐ緩和 | 塞ぎ方 |
|---|---|---|---|
| 飛び先の特定 | コードやガジェットの実アドレスを知る | ASLR | 配置を起動ごとにランダム化し、アドレスを秘匿 |
| 注入コードの実行 | スタック/ヒープに置いた機械語を走らせる | DEP / NX(W^X) | 書き込めるページを実行不可にする |
| 既存コードの再利用 | gadget を ret/jmp/call で連鎖(ROP/JOP/COP) | CFI(CET 含む) | 間接分岐・リターンの飛び先を正規集合に制限 |
メモリ破壊攻撃の原理で見たとおり、DEP/NX が注入コードを封じると攻撃者は **ROP(Return-Oriented Programming)**へ移ります。ROP は末尾が ret のガジェットを連鎖させる手法でした。CFI は、この「既存コードの断片を意図しない順序でつなぐ」段階そのものを不正と判定します。
CFI の核心:前方エッジと後方エッジ
制御フローグラフ(CFG)を考えると、関数間・関数内の遷移は2種類のエッジに分かれます。CFI はこの両方を保護対象とします。
- 前方エッジ(forward edge):これから進む方向の間接遷移。間接呼び出し(関数ポインタ経由の
call)と間接ジャンプ(switch のジャンプテーブル等)。飛び先は呼び出し時点で初めて決まる。 - 後方エッジ(backward edge):呼び出し元へ帰る方向の遷移。関数リターン(
ret)。飛び先は「誰が呼んだか」で決まり、スタック上の戻りアドレスが頼り。
直接 call func のように飛び先が命令に埋め込まれた遷移は書き換えようがないので保護対象外です。攻撃者が奪えるのは、飛び先がデータ(関数ポインタや戻りアドレス)として可変な間接遷移だけであり、CFI はそこに「許される飛び先の集合」という制約を課します。
┌─ 前方エッジ:間接 call / jmp(関数ポインタ・vtable)
制御フローの間接遷移 ──┤
└─ 後方エッジ:ret(戻りアドレス)
CFI: それぞれの飛び先を、CFG から導いた「正規の飛び先集合」に限定する
前方エッジ CFI:飛び先を型で絞る
ソフトウェア CFI(代表は LLVM の -fsanitize=cfi や Clang CFI)は、間接呼び出しの飛び先が「呼び出し側の関数ポインタ型と一致する関数群」に属するかを、呼び出し直前に実行時チェックします。コンパイラは同じシグネチャの関数をグループ化し、その集合に対する高速な所属判定(ビットセットやレンジチェック)を間接 call の前に挿入します。void(*fp)(int) を int(*)(char*) の関数や、関数の途中バイトへ飛ばそうとすると、集合外なのでプログラムを止めます。
これにより、UAF で偽の vtable ポインタを仕込んでも、呼び出される仮想関数が「その型の正規メソッド集合」の外なら弾かれます。攻撃者は「任意のアドレスへ飛ばす」自由を失い、「同じ型の正規関数のどれか」しか選べなくなります。
CFI の強さは飛び先集合の細かさで決まります。集合が「型ごと」なら攻撃者は同型関数の中でしか選べず強い。一方、互換性のため集合を粗く(例:全関数を1集合)すると、チェックは通っても自由度が残り弱くなります。粗い CFI を「coarse-grained CFI」と呼び、同じ集合内の正規関数列だけでガジェット連鎖を組む Counterfeit Object-Oriented Programming (COOP) などの回避研究があります。CFI を導入する際は集合の粒度を意識する必要があります。
後方エッジ CFI:戻りアドレスを守る
前方エッジを締めても、ret が信じるスタック上の戻りアドレスを書き換えられれば ROP は成立します。後方エッジ CFI は、この戻りアドレスの正しさを保証する仕組みで、決定版が後述のシャドースタックです。ソフトウェア実装としては、戻りアドレスを別領域へ退避して照合する手法や、Windows の **RFG(Return Flow Guard)**の試みがありましたが、性能と頑健性の課題からハード支援の CET へ収束しました。
Intel CET:CFI をハードウェアで
**Intel CET(Control-flow Enforcement Technology)**は、前方・後方の両エッジを CPU 機能として実装します。Windows では HSP(Hardware-enforced Stack Protection)、Linux でも対応が進み、AMD も Shadow Stack 互換を提供します。CET は2つの独立した機構からなります。
シャドースタック(後方エッジ)
シャドースタックは、通常のスタックとは別に CPU が管理する戻りアドレス専用の影のスタックです。call は戻りアドレスを通常スタックとシャドースタックの両方に積み、ret は両者の戻りアドレスが一致するかを CPU が照合します。一致しなければ制御保護例外(#CP)を出して停止します。
シャドースタックのページはハードが特別扱いし、通常のストア命令では書き込めません。だからバッファオーバーフローで通常スタックの戻りアドレスを書き換えても、シャドースタック側は無傷で残り、ret の瞬間に不一致が露見します。
call 時: 戻りアドレス R を 通常スタック と シャドースタック の両方へ積む
攻撃者が通常スタックの戻りアドレスを R → R'(gadget)へ書き換え
ret 時: 通常スタック = R' シャドースタック = R → 不一致 → #CP 例外で停止
(シャドースタックは通常の store では書けないので R のまま)
これが ROP の致命傷です。ROP チェーンは「スタックに並べた偽の戻りアドレス列を ret でたどる」ものでしたが、シャドースタックはどの ret でも正規の戻り先と照合するため、最初のガジェットへ飛んだ時点で不一致になり連鎖が始まりません。
IBT(前方エッジ)
IBT(Indirect Branch Tracking)は、間接 call/jmp の着地点を制限します。CET 下では、間接分岐は直後に endbr64(または endbr32)命令がある場所にしか着地できません。endbr 以外の命令へ間接分岐すると #CP 例外になります。
コンパイラは「正規に間接呼び出しされ得る関数の入口」にだけ endbr を置きます。したがって攻撃者は、関数の入口(endbr のある所)にしか飛べず、ROP/JOP が頼る「関数の途中バイトから始まる意図しないガジェット」へは着地できません。x86-64 の可変長命令ゆえに大量に存在したアンインテンデッドなガジェット群が、IBT によって着地不能になります。
間接 jmp/call の着地先:
正規入口 → [ endbr64 ][ push ... ][ ... ] OK(endbr がある)
途中バイト→ [ pop rdi ][ ret ] #CP 例外(endbr がない)
→ JOP/COP が使う「途中バイト起点のガジェット」へは飛べない
前方と後方は別の攻撃を塞ぎます。IBT は間接 jmp/call を使う **JOP(Jump-Oriented Programming)・COP(Call-Oriented Programming)**の着地を制限しますが、ret は分岐ターゲットの種類が違うため IBT の対象外で、ROP は止まりません。逆にシャドースタックは ret を守りますが間接 jmp/call は見ません。両方を有効化して初めて前方・後方の双方が塞がり、ROP と JOP/COP の主要経路が同時に封じられます。CET の2機構が独立しているのはこのためです。
Windows CFG とその位置づけ
**CFG(Control Flow Guard)**は Windows のソフトウェア前方エッジ CFI です。コンパイラとローダーが協調し、間接呼び出しの直前に「飛び先が正規の関数開始アドレスか」をビットマップで照合します。プロセス空間の8バイトごとに1ビットを割り当てた表(CFG ビットマップ)で、16バイト粒度で「正規の呼び出し先か」を表し、間接 call のたびに __guard_check_icall がその位置を引いて検証します。正規でなければプロセスを終了します。
CFG は前方エッジのみで、しかも粒度が「関数の先頭」までなので、後方エッジ(ret)は守りません。そこを補うのがハード後方エッジである CET シャドースタック(Windows の Hardware-enforced Stack Protection)であり、**CFG(前方・ソフト)+ CET シャドースタック(後方・ハード)**の組み合わせが現在の Windows の標準的な構成です。CET の IBT 相当はより新しい CPU で効きます。
| 機構 | 守るエッジ | 実装 | 塞ぐ攻撃 | 限界 |
|---|---|---|---|---|
| LLVM/Clang CFI | 前方 | ソフト(型ごとの集合チェック) | vtable/関数ポインタ書き換えによる任意呼び出し | 集合が粗いと COOP 等で回避 |
| Windows CFG | 前方 | ソフト(関数先頭ビットマップ) | 間接 call の不正な飛び先 | 粒度が粗く後方エッジは無防備 |
| CET IBT | 前方 | ハード(endbr 強制) | JOP/COP の途中バイト着地 | endbr のある正規入口は依然到達可 |
| CET シャドースタック | 後方 | ハード(戻りアドレス二重化) | ROP(戻りアドレス書き換え) | 未対応 CPU・一部の合法な ret 改変 API |
ROP/JOP がどこで止まるか
緩和を全部有効にした環境で、典型的な攻撃がどこで破綻するかを段階で整理します。
- アドレス特定で止まる(ASLR):ガジェットや関数の実アドレスが分からなければ ROP チェーンを組めません。ただし情報リーク脆弱性で1つでもアドレスが漏れると、相対オフセットから他も逆算され得るため ASLR 単独では崩れます。
- 注入実行で止まる(DEP/NX):スタック/ヒープに置いた機械語は実行不可ページなので走りません。だから攻撃者はコード再利用(ROP/JOP)へ向かいます。
- 再利用先で止まる(CFI/CET):
- ROP は
ret連鎖。シャドースタックが最初の不正retで戻りアドレス不一致を検出し停止。 - JOP/COP は間接
jmp/call連鎖。IBT がendbr以外への着地を拒否し、途中バイト起点のガジェットへ飛べない。前方 CFI/CFG も飛び先集合の外を弾く。
- ROP は
つまり ASLR・DEP・CFI が揃うと、攻撃者は「アドレスを漏らし、注入は諦め、正規入口の正規関数だけを正規の順序に近い形で使う」という極めて狭い行動しか取れません。残る現実的な経路は、data-only 攻撃(制御フローを曲げず、設定値や権限フラグなどデータだけを書き換える)や、前方エッジの粒度の粗さを突く COOP、シャドースタック非対応環境の悪用などで、いずれも成立条件が厳しくなります。
実務での向き合い方
緩和は「全部入れて初めて層になる」ため、ビルドと実行環境で漏れなく有効化するのが要点です。
- コンパイル時に全部立てる:PIE+フル ASLR、DEP/NX、スタックカナリア(
-fstack-protector-strong)、RELRO(GOT 保護)、前方 CFI(Clang CFI/Windows は/guard:cf)、CET(-fcf-protection=full、Windows は/CETCOMPAT)を既定にする。CET はバイナリのフラグと実行 CPU の両方が対応して初めて効く。 - 多層の前提を崩さない:ASLR は情報リークで、CFI は粒度の粗さで、それぞれ単体では迂回され得る。どれか一つに頼らず重ねることが設計の前提。発想は最小権限の原則と多層防御と同じで、1枚破られても次の層で止める。
- 被害の封じ込めを併設:万一制御を奪われても、プロセスを最小権限で動かし、ブラウザサンドボックスのような分離で影響範囲を区切る。緩和(悪用を難しくする)と封じ込め(成立後の被害を狭める)は別の仕事で、両方が要る。
(1) CFI は間接遷移を保護し、前方エッジ=間接 call/jmp、後方エッジ=ret の2種を区別する。(2) CET シャドースタックは戻りアドレスを二重保存して照合し ROP を止める(後方)。IBT は間接分岐を endbr の位置に限定し JOP/COP を止める(前方)。両方で前後を塞ぐ。(3) Windows CFG は前方エッジのソフト CFI(関数先頭粒度)で、後方は CET シャドースタックが担う。(4) ASLR=配置秘匿、DEP/NX=注入実行禁止、CFI/CET=再利用先制限の多層防御。(5) 緩和は root cause を消さず、情報リークや data-only 攻撃で迂回され得る。根治はメモリ安全な実装。
セキュリティ Article
CFI と最新のエクスプロイト緩和(ASLR/CFG/CET)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
CFI
比較で見る軸
難易度: advanced / カテゴリ: セキュリティ / タグ数: 6
導入後に効く点
Intel CET は CFI をハードで実装する。シャドースタック(後方エッジ)は ret 用の戻りアドレスを CPU 管理の別領域に二重保存して改竄を検出し、IBT(前方エッジ)は間接分岐の着地を endbr 命令の位置だけに強制する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- セキュリティ
- タグ数
- 6
判断チェックリスト
- 自社の用途が「CFI / Intel CET」に近いか確認する。
- 強みである「制御フロー整合性(CFI)は、間接呼び出し(前方エッジ)と関数リターン(後方エッジ)の飛び先を、コンパイル時に確定した正規の集合だけに制限する防御。ROP/JOP が前提とする「任意の場所への遷移」を成立させない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。