C++ プログラムで攻撃者がメモリ破壊プリミティブ — バッファオーバーフロー、use-after-free、型混乱 — を手に入れたとき、最も強力にできることの 1 つは関数ポインタか vtable ポインタを上書きすることです。プログラムは律儀に攻撃者が選んだアドレスを呼び、攻撃者のコードが走ります。Return-oriented programming (ROP)、jump-oriented programming (JOP)、そして多くのモダンな攻撃連鎖はまさにこのステップから始まります。
Control Flow Integrity (CFI) はこの防御で、全ての間接呼び出しで問います: 「このポインタはこの呼び出しサイトの正当なターゲットか?」。ポインタがこの呼び出しサイトの型を通じて正当に呼べる関数のホワイトリストにいなければ、プログラムはハイジャックされた呼び出しを実行する前にトラップします。攻撃者のプリミティブはそのままでも、利得が消えます。
Clang の CFI 実装(-fsanitize=cfi の裏にあるもの)は勉強するのに良い題材です、フロントエンド、ミドルエンド、リンカの交差点に位置するので: Clang が intrinsic を出し、LLVM がそれを高速なビットセットチェックに下ろし、リンカはビットセットが全ての有効ターゲットを反映することに責任を持ちます。この記事は各ピースを追います。
行番号は全て /data/dev/llvm-project/ 下のファイルを参照します。主なファイルは:
clang/lib/CodeGen/CGExpr.cppとItaniumCXXABI.cpp— Clang が CFI intrinsic を出す場所。clang/lib/CodeGen/CodeGenModule.cpp— 型識別子が作られる場所。llvm/lib/Transforms/IPO/LowerTypeTests.cpp— ミドルエンドが intrinsic をランタイムチェックに変える場所。llvm/lib/Transforms/IPO/WholeProgramDevirt.cpp— 同じ型メタデータを devirtualization に使う関連パス。compiler-rt/lib/cfi/cfi.cppとcompiler-rt/lib/ubsan/ubsan_handlers.cpp— ランタイム。
脅威モデル
CFI が止めようとする攻撃はこんな感じです。C++ プログラムがあります:
struct Shape {
virtual void draw() = 0;
};
struct Circle : Shape {
void draw() override { ... }
};
void render(Shape *s) {
s->draw(); // vtable を通じた間接呼び出し
}
プログラムのどこかで、メモリ安全性バグが攻撃者に *(void**)s(vtable ポインタ)のバイトを上書きさせます。彼らはそれを自分が制御するメモリ — 最初のスロットがプログラム内の有用なガジェットのアドレス、あるいは注入したシェルコードを指す偽 vtable — に向けます。render が s->draw() を実行すると、コンパイラはオフセット 0 の vtable スロットをロードしそれを通じて間接呼び出しをし、攻撃者のコードが走ります。
CFI はこの列にチェックを挿入して断ち切ります: 「このポインタを通じて呼び出す前に、ポインタが Shape サブクラスの有効な vtable であることを検証する」。偽 vtable への偽造ポインタはマッチせず、プログラムはトラップします。
同じアイデア が普通の関数ポインタにも適用されます:
void (*fp)() = get_callback();
fp(); // fp が上書きされていたら、攻撃者が言うところへ飛ぶ
ここで CFI(具体的には -fsanitize=cfi-icall)は「fp は正しいシグネチャの関数ポインタか?」をチェックします。マッチするシグネチャで宣言された関数だけがチェックを満たせ、それ以外はトラップします。
CFI フラグファミリ
Clang は CFI をいくつかの重なるサニタイザモードとして公開します:
cfi-vcall— 仮想呼び出しをチェック(vtable を読む)。cfi-nvcall— メンバー関数ポインタ経由の非仮想呼び出しをチェック。cfi-icall— 普通の関数ポインタ経由の間接呼び出しをチェック。cfi-mfcall— メンバー関数ポインタ起動をチェック。cfi-derived-cast—dynamic_cast<Derived*>(base_ptr)が有効なDerivedvtable を返すことをチェック。cfi-unrelated-cast— 無関係なクラスポインタ間のreinterpret_castが呼び出しターゲットの偽造に使われないことをチェック。cfi-cast-strict— より厳格なキャストポリシー(cfi-derived-castが許すいくつかのケースを捕まえる)。
-fsanitize=cfi はほとんどを一度にオンにします。根底のメカニズム — llvm.type.test intrinsic を出し、LowerTypeTests にそれを下ろさせる — は全てで同じです。違いは使われる型識別子と intrinsic が挿入される場所です。
中心的な intrinsic: llvm.type.test
全ては 1 つの LLVM intrinsic を通じて流れます。llvm/include/llvm/IR/Intrinsics.td:2645 より:
def int_type_test : DefaultAttrsIntrinsic<[llvm_i1_ty],
[llvm_ptr_ty, llvm_metadata_ty],
[IntrNoMem, IntrSpeculatable]>;
そのシグネチャ:
declare i1 @llvm.type.test(ptr %ptr, metadata %type_id)
intrinsic は「%ptr は %type_id で識別される有効ターゲット集合に含まれるか?」に答えます。LowerTypeTests が走る前は実装はありません、フロントエンドが後でバックエンドが下ろすために置いたマーカーに過ぎません。LowerTypeTests が走った後、intrinsic はビットセットチェックを行う実際の命令列で置き換えられます。
2 番目の引数は値ではなく メタデータ — 集合を名付ける MDString です。典型的な識別子:
!"_ZTSFvE"—void()の Itanium マングル型。そのシグネチャを持つ全関数はvoid (*)()ポインタを通じた呼び出しの有効ターゲット。!"_ZTS6Circle"— クラスCircleのマングル型。Circleまたはそのサブクラスの全 vtable がそれに対する有効な vtable。!"all-vtables"— cross-DSO CFI モードで「これは一体プログラム内の vtable か?」と問うための catch-all。
識別子で面白いのは、それが 文字列 であること。リンク時にリンカが特定の文字列でタグ付けされたメタデータを持つ全関数と vtable を集め、その文字列のビットセットを構築し、LowerTypeTests の生成コードがそこから読むように配線します。これがシステムの「全プログラム」な部分です: 識別子は全翻訳単位が同意するグローバル名。
少し手の込んだ兄弟もあります(Intrinsics.td:2649):
declare { ptr, i1 } @llvm.type.checked.load(ptr %ptr, i32 %offset,
metadata %type_id)
これは ptr + offset のポインタをロード し 検証する、両方を同じアトミックステップで。ロードとチェックが密結合している仮想呼び出しに使われます。戻り値はペア: ロードされた関数ポインタと、ロードが有効だったかのブール。
Clang はどうやって vtable にラベル付けするか
intrinsic が意味を持つには、各有効ターゲットがマッチする識別子でラベル付けされる必要があります。vtable では、そのラベル付けは CodeGenModule::AddVTableTypeMetadata(clang/lib/CodeGen/CodeGenModule.cpp:8398-8414)で起きます:
void CodeGenModule::AddVTableTypeMetadata(llvm::GlobalVariable *VTable,
CharUnits Offset,
const CXXRecordDecl *RD) {
CanQualType T = getContext().getCanonicalTagType(RD);
llvm::Metadata *MD = CreateMetadataIdentifierForType(T);
VTable->addTypeMetadata(Offset.getQuantity(), MD);
if (CodeGenOpts.SanitizeCfiCrossDso)
if (auto CrossDsoTypeId = CreateCrossDsoCfiTypeId(MD))
VTable->addTypeMetadata(Offset.getQuantity(),
llvm::ConstantAsMetadata::get(CrossDsoTypeId));
if (NeedAllVtablesTypeId()) {
llvm::Metadata *MD = llvm::MDString::get(getLLVMContext(), "all-vtables");
VTable->addTypeMetadata(Offset.getQuantity(), MD);
}
}
呼び出し VTable->addTypeMetadata(offset, MD) は vtable グローバルに !{offset, MD} 形のメタデータノードを付加します。struct Circle : Shape {} のようなクラスに対して、Clang が出す vtable はだいたいこんな感じ:
@_ZTV6Circle = constant { ... } {
ptr null, ; RTTI slot
ptr @_ZN6Circle4drawEv, ; Circle::draw
...
}, !type !{i64 16, !"_ZTS6Circle"},
!type !{i64 16, !"_ZTS5Shape"},
!type !{i64 16, !"all-vtables"}
オフセット 16(vtable の「アドレスポイント」がある場所 — RTTI スロットの先)に 3 つのメタデータエントリ。エントリは言います:
- オフセット 16 に、型
_ZTS6Circleと互換な vtable がある。 - オフセット 16 に、型
_ZTS5Shapeと互換な vtable がある(Circle : Shapeなので、Shapeの vtable を要求するどの使用も満たされる)。 - オフセット 16 に、vtable がある(cross-DSO モード用の汎用タグ)。
各クラスの vtable が階層内の全ての型でラベル付けされているため、リンカは _ZTS5Shape のビットセットを、あらゆる派生についてプログラム内の全派生クラス vtable を含めて組み立てられます。
関数については、ラベル付けは関数レベルの !type メタデータを通じて起きます。CreateMetadataIdentifierForFnType(CodeGenModule.cpp:8361-8368)で生成されます:
llvm::Metadata *CodeGenModule::CreateMetadataIdentifierForFnType(QualType T) {
assert(isa<FunctionType>(T));
T = GeneralizeFunctionType(
getContext(), T, getCodeGenOpts().SanitizeCfiICallGeneralizePointers);
if (getCodeGenOpts().SanitizeCfiICallGeneralizePointers)
return CreateMetadataIdentifierGeneralized(T);
return CreateMetadataIdentifierForType(T);
}
プログラム内の全ての void(int) 関数が同じ識別子を得る。全ての int(char*, int) が別の識別子を得る。等々。文字列を決めるのはカノニカル型です(Itanium マングラ経由で)。
Clang がチェックを出す場所: 仮想呼び出し
仮想呼び出し発行のコードは clang/lib/CodeGen/ItaniumCXXABI.cpp:700-820 にあります。核のスニペット(ItaniumCXXABI.cpp:766-774):
if (ShouldEmitCFICheck || ShouldEmitWPDInfo) {
llvm::Value *VFPAddr = Builder.CreateGEP(CGF.Int8Ty, VTable, VTableOffset);
llvm::Intrinsic::ID IID = CGM.HasHiddenLTOVisibility(RD)
? llvm::Intrinsic::type_test
: llvm::Intrinsic::public_type_test;
CheckResult = Builder.CreateCall(CGM.getIntrinsic(IID), {VFPAddr, TypeId});
}
呼び出し s->draw() に対し、IR は次のようになります:
%vtable = load ptr, ptr %s
%vfn_addr = getelementptr i8, ptr %vtable, i64 16 ; draw はオフセット 16 にある
%cfi_ok = call i1 @llvm.type.test(ptr %vfn_addr, metadata !"_ZTS5Shape")
br i1 %cfi_ok, label %cont, label %trap
trap:
call void @llvm.ubsantrap(i8 0)
unreachable
cont:
%vfn = load ptr, ptr %vfn_addr
call void %vfn(ptr %s)
チェックはブール: 「%vfn_addr の vtable ポインタは Shape の正当な vtable の一部か?」。yes のときだけ実行が実際の仮想呼び出しに抜けます。
姉妹 intrinsic llvm.type.checked.load はロードとチェックを融合します。Clang が Virtual Function Elimination (VFE) 最適化も狙っているときに出てきます。ItaniumCXXABI.cpp:749-762 より:
if (ShouldEmitVFEInfo) {
llvm::Value *VFPAddr = Builder.CreateGEP(CGF.Int8Ty, VTable, VTableOffset);
llvm::Value *CheckedLoad = Builder.CreateCall(
CGM.getIntrinsic(llvm::Intrinsic::type_checked_load),
{VFPAddr, llvm::ConstantInt::get(CGM.Int32Ty, 0), TypeId});
CheckResult = Builder.CreateExtractValue(CheckedLoad, 1);
VirtualFn = Builder.CreateExtractValue(CheckedLoad, 0);
}
これは %vfn_addr = gep ...、%cfi_ok = call @type.test ...、%vfn = load ... を単一の intrinsic に潰します。ミドルエンド最適化はポインタ-ロード-チェックパターンよりも 1 つの intrinsic について推論するのが容易です。
Clang がチェックを出す場所: 間接呼び出し
普通の間接呼び出しは CGExpr.cpp の CodeGenFunction::EmitCallee とその仲間を通じて行きます。CFI 挿入は clang/lib/CodeGen/CGExpr.cpp:6969-6997:
if (SanOpts.has(SanitizerKind::CFIICall) &&
(!TargetDecl || !isa<FunctionDecl>(TargetDecl)) && !CFIUnchecked) {
auto CheckOrdinal = SanitizerKind::SO_CFIICall;
auto CheckHandler = SanitizerHandler::CFICheckFail;
SanitizerDebugLocation SanScope(this, {CheckOrdinal}, CheckHandler);
EmitSanitizerStatReport(llvm::SanStat_CFI_ICall);
llvm::Metadata *MD = CGM.CreateMetadataIdentifierForFnType(QualType(FnType, 0));
llvm::Value *TypeId = llvm::MetadataAsValue::get(getLLVMContext(), MD);
llvm::Value *CalleePtr = Callee.getFunctionPointer();
llvm::Value *TypeTest = Builder.CreateCall(
CGM.getIntrinsic(llvm::Intrinsic::type_test), {CalleePtr, TypeId});
auto CrossDsoTypeId = CGM.CreateCrossDsoCfiTypeId(MD);
llvm::Constant *StaticData[] = {
llvm::ConstantInt::get(Int8Ty, CFITCK_ICall),
EmitCheckSourceLocation(E->getBeginLoc()),
EmitCheckTypeDescriptor(QualType(FnType, 0)),
};
if (CGM.getCodeGenOpts().SanitizeCfiCrossDso && CrossDsoTypeId) {
EmitCfiSlowPathCheck(CheckOrdinal, TypeTest, CrossDsoTypeId, CalleePtr,
StaticData);
} else {
EmitCheck(std::make_pair(TypeTest, CheckOrdinal), CheckHandler,
StaticData, {CalleePtr, llvm::UndefValue::get(IntPtrTy)});
}
}
型識別子は CreateMetadataIdentifierForFnType(QualType(FnType, 0)) から来ます。呼び出しサイトが void (*)(int) を見れば、型 id は void(int) のマングリングです。同じマングリングでラベル付けされた関数だけが合法ターゲットです。
テストスイートからの例(clang/test/CodeGen/cfi-icall.c):
void f() {}
void xf();
void g(int b) {
void (*fp)() = b ? f : xf;
fp();
}
Clang 後の IR:
define void @f() !type !0 !type !1 { ret void }
declare void @xf() !type !0 !type !1
define void @g(i32 %b) {
%cond = icmp ne i32 %b, 0
%fp = select i1 %cond, ptr @f, ptr @xf
%cfi_ok = call i1 @llvm.type.test(ptr %fp, metadata !"_ZTSFvE")
br i1 %cfi_ok, label %cont, label %trap
trap:
call void @llvm.ubsantrap(i8 2)
unreachable
cont:
call void %fp()
ret void
}
!0 = !{i64 0, !"_ZTSFvE"}
!1 = !{i64 0, !"_ZTSFvE.generalized"}
f と xf は両方とも _ZTSFvE(マングルされた void())でラベル付けされているので、両方とも間接呼び出しの合法ターゲットです。他の関数 — 例えば void (*)(int) — はこのラベルを持たずチェックに失敗します。
LowerTypeTests が intrinsic をどう下ろすか
intrinsic は単なるプレースホルダです。パス LowerTypeTests(llvm/lib/Transforms/IPO/LowerTypeTests.cpp)が @llvm.type.test を実際の命令に変えるものです。ビットセットを構築するため全モジュールの型メタデータを見る必要があるので、遅く(通常 LTO 中に)走ります。
核アルゴリズムはビットセットベースです。型識別子 T に対して:
!type !{offset, T}でタグ付けされた全グローバル(関数または vtable)を集める — 有効ターゲット集合。- それらのグローバルのアドレスをコンパクトなレイアウトに配置する(ソース中で隣接している必要はないが、リンカ協調の結合グローバル経由で隣接させられる)。
- 各ビットが選択されたアライメントでの 1 つの可能なアドレススロットを表すビットセットを構築。
@llvm.type.test呼び出しを「結合グローバル開始からのオフセットを計算、境界チェック、そしてビットセットのビットを調べる」に置換。
ビットセットデータ構造は BitSetInfo(LowerTypeTests.cpp:136-200):
struct BitSetInfo {
uint64_t ByteOffset;
uint64_t AlignLog2;
uint64_t BitSize;
std::set<uint64_t> Bits;
bool containsGlobalOffset(uint64_t Offset) const {
if (Offset < ByteOffset) return false;
if ((Offset - ByteOffset) % (uint64_t(1) << AlignLog2) != 0) return false;
uint64_t BitOffset = (Offset - ByteOffset) >> AlignLog2;
if (BitOffset >= BitSize) return false;
return Bits.count(BitSize - 1 - BitOffset);
}
};
これを読む: 与えられたオフセットについて、まず ByteOffset(ビットセットがカバーする領域の開始)を過ぎているかチェック、次に適切にアラインされている(AlignLog2 分の末尾ゼロ)かチェック、BitSize スロット内かチェック、そしてビットを調べる。
AlignLog2 のトリックはビットセットを圧縮します。全有効ターゲットが 16 バイトアラインされているなら、アドレス空間の 16 バイトごとに 1 ビットだけ必要で、1 バイトごとには不要です。だから vtable やジャンプテーブル領域が何キロバイトに及ぶかもしれなくても、ビットセット自身は数十バイトで済みます。
回転トリック
生成されるチェックは賢い列を使います。lowerTypeTestCall(LowerTypeTests.cpp:735-820)より:
Value *PtrOffset = B.CreateSub(OffsetedGlobalAsInt, PtrAsInt);
Value *BitOffset = B.CreateIntrinsic(IntPtrTy, Intrinsic::fshr,
{PtrOffset, PtrOffset, TIL.AlignLog2});
Value *OffsetInRange = B.CreateICmpULE(BitOffset, TIL.SizeM1);
起きていること: テストされるポインタをベースから減算した後、アラインメントの倍数 であるべき オフセットを得ます。アラインメントと範囲を一度にチェックするため、コードは AlignLog2 ビットの funnel shift right(fshr)をします。これは下位ビット(ゼロであるべき)を上位ビットに回転させます。
- ポインタが アラインされていた なら、回転後の上位ビットはゼロ(値ゼロの下位ビットから来たから)。
- ポインタが ミスアライン だったなら、回転後の上位ビットは非ゼロ(オフセットの中間のどこかから来た)。
続く <= 比較は SizeM1(ビットセットサイズから 1 引いた値)に対して行われ、これが「ミスアライン」(上位ビットセットで巨大な値になる)と「範囲外」(> SizeM1)の両方を単一の符号なし比較で捕まえます。エレガント。
チェックが通過したら、生成コードは次のいずれかを行います:
- バイト配列ビットセットから実際のビットを読む:
createBitSetTest(LowerTypeTests.cpp:667-692)、または - ビットセットが小さければ(64 ビット以下)定数としてインライン化、または
- 範囲が完全に有効なら(範囲内の全アドレスが有効の
AllOnesケース)単に true を返す。
最終テストは x86 でだいたい 3〜5 個のマシン命令: sub, ror, cmp、おそらく mov と test。全間接呼び出しに挿入しても性能を破壊しない程度に安価です。
複数グローバルからのビットセット構築
ビットセットはポインタ減算で動くので、有効ターゲットはパスが知っている場所にレイアウトされる必要があります。buildBitSetsFromGlobalVariables(LowerTypeTests.cpp:824-894)がまさにそれをします:
void LowerTypeTestsModule::buildBitSetsFromGlobalVariables(
ArrayRef<Metadata *> TypeIds, ArrayRef<GlobalTypeMember *> Globals) {
std::vector<Constant *> GlobalInits;
const DataLayout &DL = M.getDataLayout();
DenseMap<GlobalTypeMember *, uint64_t> GlobalLayout;
Align MaxAlign;
uint64_t CurOffset = 0;
uint64_t DesiredPadding = 0;
for (GlobalTypeMember *G : Globals) {
auto *GV = cast<GlobalVariable>(G->getGlobal());
Align Alignment =
DL.getValueOrABITypeAlignment(GV->getAlign(), GV->getValueType());
MaxAlign = std::max(MaxAlign, Alignment);
uint64_t GVOffset = alignTo(CurOffset + DesiredPadding, Alignment);
GlobalLayout[G] = GVOffset;
// ... パディングと init 挿入 ...
}
Constant *NewInit = ConstantStruct::getAnon(M.getContext(), GlobalInits);
auto *CombinedGlobal = new GlobalVariable(M, NewInit->getType(), true,
GlobalValue::PrivateLinkage, NewInit);
lowerTypeTestCalls(TypeIds, CombinedGlobal, GlobalLayout);
}
パスは個々のグローバルを 1 つの大きな結合グローバルに 物理的にマージ し、各元グローバルが 2 のべき乗アラインされたオフセットで開始するよう注意深くパディングします。次に元のシンボルから結合グローバルへのエイリアスを作り、既存の参照が動き続けるようにします。ビットセットの ByteOffset は結合グローバルの開始、AlignLog2 は選択されたアラインメント、セットされたビットの集合は有効グローバルを含むオフセットに対応します。
関数とジャンプテーブル
関数については、LowerTypeTests は異なる戦略を使います: ジャンプテーブル。buildBitSetsFromFunctions(LowerTypeTests.cpp:1390)は有効関数ごとに 1 つの jmp 命令の小さなテーブルを発行します、全てアライン済みで。すると有効関数ポインタを通じた全呼び出しがジャンプテーブルを通り、ビットセットチェックは「このポインタはジャンプテーブルのアドレス範囲内か?」になります。
x86-64 では、各ジャンプテーブルエントリは次のようになります:
jmp func@plt
int3
int3
int3
8 バイト、アライン済み。ビットセットは密(全 8 バイトスロットが有効ターゲット)なので、関数ポインタ CFI のテストはしばしば「pointer は [jump_table_start, jump_table_end) 内か?」に退化します — 2 つの比較、一握りのサイクル。
面白い副作用: CFI 有効バイナリの逆アセンブルをダンプすると、jmp func; int3; int3; int3 列の巨大な領域があります。それがジャンプテーブルで、CFI が間接呼び出しで到達可能な関数を制約するのに使うものです。
Whole-program devirtualization は同じメタデータを使う
仲間のパス WholeProgramDevirt(llvm/lib/Transforms/IPO/WholeProgramDevirt.cpp)があり、同じ !type メタデータを別の目的に使います: 仮想呼び出しの devirtualization。
全プログラムが見える(LTO)とき、仮想呼び出しサイトがプログラム全体で 1 つの実装しか持たないなら、WPD は間接呼び出しを直接呼び出しで置き換えられます。解決種類は ModuleSummaryIndex.h:~1292:
enum Kind {
Indir, // 通常の間接呼び出し、最適化なし
SingleImpl, // 単一実装への直接呼び出しで置換
BranchFunnel, // Retpoline-safe なバリアント
};
プラス、引数ごとの最適化:
enum Kind {
UniformRetVal, // 全実装が同じ値を返す
UniqueRetVal, // vtable の同一性を使って正しい値を選ぶ
VirtualConstProp, // 計算された戻り値を vtable に埋め込む
};
SingleImpl が大きなものです。次を考えます:
struct Shape { virtual double area() const = 0; };
struct Circle : Shape { double area() const override { return 3.14 * r * r; } };
// 使用: for (auto &s : shapes) total += s.area();
Circle がプログラム内で Shape の唯一のサブクラスなら、WPD は s.area() を Circle::area への直接呼び出しで置換できます。つまり: vtable ロードなし、間接呼び出しなし、CFI チェックなし、間接呼び出しからの分岐誤予測オーバーヘッドなし。
WPD と CFI は補完的です。CFI は持っている間接呼び出しを保護。WPD は不要と証明できる間接呼び出しを除去。両方とも同じ型メタデータに頼って「この呼び出しサイトで有効な関数は?」を推論します。
Cross-DSO CFI
単一コンパイル内(または単一 LTO 単位内)の CFI は素直です、リンカが全てを見るから。しかし共有ライブラリ境界を越えた呼び出しはどうか?メイン実行可能ファイルは libfoo.so の型メタデータについて何も知りません。高速パスビットセットは見えない関数を含められません。
解決策は cross-DSO CFI。cross-DSO が有効なとき、各 DSO は自身の __cfi_check 関数 — 自身の型メタデータを知る弱リンクシンボル — を出します。ターゲットが別の DSO にある CFI チェックでは:
- 呼び出し元は自身の型メタデータに対して通常の高速パスビットセットチェックを行う。
- チェックが失敗したら、即座にトラップする代わりに呼び出し元は
__cfi_slowpathを呼ぶ。 __cfi_slowpathはターゲットポインタがどの DSO に属するかを(ランタイムのロード済みライブラリ台帳経由で)調べ、その DSO の__cfi_checkを呼ぶ。- ターゲットの
__cfi_checkがポインタを自身のメタデータに対して検証。 - 検証が通ったら実行は戻って呼び出しが行われる。失敗したら
__cfi_check_failが呼ばれ、最終的にトラップか報告。
スタブ定義は CodeGenFunction::EmitCfiCheckStub(clang/lib/CodeGen/CGExpr.cpp:4329-4361):
llvm::Function *F = llvm::Function::Create(
llvm::FunctionType::get(VoidTy, {Int64Ty, VoidPtrTy, VoidPtrTy}, false),
llvm::GlobalValue::WeakAnyLinkage, "__cfi_check", M);
llvm::BasicBlock *BB = llvm::BasicBlock::Create(Ctx, "entry", F);
SmallVector<llvm::Value*> Args{F->getArg(2), F->getArg(1)};
llvm::CallInst::Create(M->getFunction("__cfi_check_fail"), Args, "", BB);
llvm::ReturnInst::Create(Ctx, nullptr, BB);
各コンパイル単位が弱 __cfi_check を貢献。リンク時にそれらは DSO ごとに 1 つに折り畳まれます。各 DSO の __cfi_check は自身の型メタデータを知り、ランタイムの配線が cross-DSO 呼び出しを正しいものにルーティングします。
このスキームには性能コストがあります — 低速パスが遅い — が、動的リンクを越えて正しく、高速パスは同一 DSO 呼び出し(共通ケース)では影響を受けません。
失敗パス
CFI チェックが失敗する(ビットセットがポインタは無効と言う)と、設定によって 2 箇所のいずれかに着地します:
- トラップモード(
-fsanitize-trap=cfi):@llvm.ubsantrap(i8 N)が呼ばれ、即座のアボート命令(x86-64 ではud2など)にコンパイルされる。プログラムは segfault 様の挙動で死ぬ。高速、無音、ランタイム依存なし。 - 診断モード(一部設定でのデフォルト): パスは
__ubsan_handle_cfi_check_failへの呼び出しを出す(compiler-rt/lib/ubsan/ubsan_handlers.cpp:923-940で定義):
void __ubsan::__ubsan_handle_cfi_check_fail(CFICheckFailData *Data,
ValueHandle Value,
ValueHandle ValidVtable) {
handleCFICheckFail(Data, Value, ValidVtable, Opts);
}
ハンドラは Data 構造体(ソース位置と CFI チェックの種類をエンコード)を検査し、診断メッセージ(「Control flow integrity check failed: indirect call through incompatible function pointer at …」)をフォーマットし、(handle_cfi_check_fail なら)続行するか(handle_cfi_check_fail_abort なら)アボートします。
プロダクションではトラップモードが一般的な選択です: 小さなコードサイズオーバーヘッド、ランタイム依存なし、失敗時の決定論的挙動。デバッグビルドやサニタイザ実行では診断モードが破損の現場を指すスタックトレースを与えてくれます。
ピースをまとめて
CFI で保護された 1 つの間接呼び出しの完全なライフサイクルをスケッチしてみましょう:
┌─ あなたの C++ コード ─┐
│ void (*fp)(); │
│ fp(); │
└────────┬──────────────┘
│
▼ Clang フロントエンド
┌────────────────────────────┐
│ call i1 @llvm.type.test( │
│ ptr %fp, │
│ metadata !"_ZTSFvE") │
│ br i1 %res, %ok, %trap │
└────────┬───────────────────┘
│
▼ LLVM ミドルエンド
│ (場合によっては先に WPD — できる分を devirtualize)
│
▼ LowerTypeTests (LTO 中)
┌────────────────────────────────┐
│ (全 !type !"_ZTSFvE" を集める) │
│ (ジャンプテーブルかビットセット発行) │
│ (@type.test を下ろす: │
│ ptrtoint, sub, ror, cmp, br) │
└────────┬───────────────────────┘
│
▼ バックエンド + リンカ
┌────────────────────────────────┐
│ 実際のマシン命令 │
│ チェック: %fp は void(void) の │
│ ジャンプテーブル内か? │
│ Yes なら呼ぶ。 │
│ No なら ud2 (トラップ)。 │
└────────────────────────────────┘
ランタイムでは、各間接呼び出しが一握りの追加チェックサイクルを持ちます。オーバーヘッドはパーセントポイント単位、倍数単位ではありません。攻撃者がどれだけメモリを破壊しても任意のアドレスへ制御フローをリダイレクトできない、というセキュリティ保証に対して、優れたトレードです。
なぜ機能するか
CFI が頼る安全性の性質は 型メタデータが読み取り専用メモリにある ことです。!type 注釈は最終的にリンカが生成する読み取り専用データセクションになります。ヒープオーバーフローや dangling ポインタを持つ攻撃者はそれらのセクションを変更できません(書き込み保護)。だからビットセットは信頼できる: その内容はコンパイラとリンカがリンク時に協調して決めたものを反映しています。
攻撃者の選択肢は次に絞られます: 悪用に使えるポインタをビットセット内から見つける。関数ポインタ CFI では「ターゲットのシグネチャを持ち何か有用なことをする関数を見つける」 — 有用な攻撃ガジェットには典型的に不可能。vtable CFI では「正しいオフセットに目的のメソッドを持つサブクラス vtable を見つける」 — 攻撃者が必要な全連鎖にはほぼ常に不可能。
CFI に対する実際の攻撃は存在します(control-flow bending、制御フローをハイジャックしない data-only 攻撃、など)が、「どこかのガジェット」から「特定の型の関数でなければならない」へバーを上げることで、既製の悪用技法の大多数を排除します。
次に読むもの
clang/test/CodeGen/cfi-icall.c— 最もシンプルな CFI の例、intrinsic 発行を示すインラインCHECK行付き。llvm/test/Transforms/LowerTypeTests/— パスのテストディレクトリ。function-disjoint.llとfunction.llはジャンプテーブル構築を示す。simple.llとsimple2.llはグローバル用のビットセット構築を示す。- Tice ら、Enforcing Forward-Edge Control-Flow Integrity in GCC & LLVM、USENIX Security 2014 — LLVM の特定の CFI 設計を導入した論文。
- Attributor の記事 — 異なる手続き間解析だが似たテーマ: 全プログラム性質についてコンパイラが推論する。
- MemCpyOpt の記事の WPD 脚注 — まあ、脚注というわけでもないが、WPD は MemCpyOpt と同じ一般的な LTO 近辺で走り、型メタデータを同様に使う。
CFI は「コンパイラは一体どうやって知っているんだ?」が興味深い問いで、答えが「コンパイラは知らない — しかしリンカは知っていて、コンパイラはリンカにどうセットアップするかを尋ねる方法を知っていた」というタイプの機能です。ツールチェーンの 3 つのフェーズ全てにまたがるからこそ、勉強するのが楽しいメカニズムです。