Tuesday, December 27, 2022

速習CFI(Control Flow Integrity) 〜回避方法を添えて〜

 著者:hugeh0ge, ptr-yudai

はじめに

弊社では、直近でFuzzing Farmシリーズの記事を投稿しています。シリーズ最後のPart4では、「複雑なexploitの開発と安定化」についてご紹介します。

しかし、その記事を書くなかで0-dayを発見しており、ベンダによる修正が未完了のため、残念ながら年内には投稿できなくなってしまいました。

そこで、今回は「速習CFI(Control Flow Integrity) 〜回避方法を添えて〜」と題して、著者hugeh0geが業務中に発見した、ClangのCFIに潜むバグについて紹介します。

CFIとは

攻撃者が制御を奪うために使えるような未定義動作をプログラム実行中に検知できる機能として、サニタイザ(Sanitizer)と呼ばれるものがあります。サニタイザにはいくつかの種類があり、代表的なものとしてUse-after-Freeやメモリリークなどヒープ関連の異常を検知できるアドレスサニタイザなどが挙げられます。

今回題材とするCFI(Control Flow Integrity)もClangのサニタイザとして実装されています。サニタイザはデバッグ時にバグを検知する目的で使われることが多いですが、CFIはリリースビルドでも使われることが多く、緩和策(mitigation)としての役割が強いです。

CFIを有効にすると、関数ポインタの書き換えや誤ったクラスのインスタンスなどを検知できます。より正確には、CFIでは次の2つの点を実行時に確認します。

  • 呼び出そうとしている関数が、プログラマの意図した引数・戻り値の型を持っているか。
  • 扱っているポインタが、プログラマの意図したクラスのインスタンスであるか。

CFIについて詳しく説明する前に、まずはC/C++プログラムのコンパイルについて少し説明しましょう。

ClangやGCCなどのコンパイラは、C/C++のプログラムをビルドするとき、ファイルごとにオブジェクトファイルを作ります。作られたファイルを1つの実行可能ファイルにまとめる処理をリンクと呼び、リンカーと呼ばれるプログラムがこれを行います。

 

図1. コンパイルとリンク


ビルドにおいては、ファイル単位で別々のプロセスがソースコードをコンパイルするため、通常は複数のファイルにまたがって最適化をかけることはできません。そこで、リンク時に最適化をかけるためにLTO(Link Time Optimization)と呼ばれる技術があります。

ClangではオブジェクトファイルをLLVMのIR形式とすることで、最適化に必要な情報を残します。また、LTOが有効なときはソースコード中の関数や変数の型情報をオブジェクトファイルに残すことができます。ClangのCFIは、このLTOが残してくれる情報を型検査に活用します。したがって、ClangではLTOを有効にしないとCFIを使えないという点を念頭に置きましょう。

それでは、CFIによってどのような恩恵があるのかを、具体的なコードを例に見てみましょう。

#include <stdio.h>

int f1(int x) {
  return x + 1;
}

int f2(int x) {
  return x + 2;
}

int f3(short x) {
  return x + 3;
}

int main() {
  int (*func_ptr)(int) = NULL;
  int option = 0;

  printf("option = ");
  scanf("%d", &option);

  switch (option) {
    case 1: func_ptr = f1; break;
    case 2: func_ptr = f2; break;
    case 3: func_ptr = f3; break;
    default:
      printf("func_ptr = ");
      scanf("%p", &func_ptr);
      break;
  }

  printf("result = %d\\n", func_ptr(123));
  return 0;
}

 

このプログラムは、入力した数字によって呼び出す関数を切り替えています。関数ポインタを使っており、ユーザー入力のアドレスを関数ポインタとして呼び出すことも可能な(脆弱な)プログラムです。

CFIを有効にして、このコードをコンパイル・実行してみましょう。

$ clang -O0 -flto -fvisibility=default -fsanitize=cfi \
-fno-sanitize-trap=cfi test.c

コンパイルオプションのうち、 -flto-fvisibility はCFIを有効化するために必要なオプションです。また、 -fno-sanitize-trap を設定すると、該当するCFIの違反検知時、Trapを発生させる代わりに、検知した違反の詳細情報を出力してAbortします。

まず、”1”と”2”を入力すると、次のように f1 , f2 がそれぞれ呼ばれてプログラムは正常に終了します。

$ ./a.out 
option = 1
result = 124
$ ./a.out 
option = 2
result = 125

しかし、”3”を入力すると、次のようにCFI違反が発生しました。

$ ./a.out 
option = 3
a.c:32:27: runtime error: control flow integrity check for type 'int (int)' failed during indirect function call
(/home/ricsec/cfi/a.out+0x423700): note: f3 defined here
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior a.c:32:27 in

エラー内容を読むと、型 int (int) に対するCFIチェックが失敗していることが分かります。”3”を入力したときに呼ばれる f3 は次のように、 int (short) 型として定義されています。

int f3(short x) {
  return x + 3;
}

同様に、”4”を入力して適当なアドレスに実行を移そうとすると検知されます。

$ ./a.out 
option = 4
func_ptr = 12345678
a.c:32:27: runtime error: control flow integrity check for type 'int (int)' failed during indirect function call
0x000012345678: note: (unknown) defined here
a.c:32:27: note: check failed in /home/ricsec/cfi/a.out, destination function located in (unknown)
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior a.c:32:27 in

このように、CFIでは関数ポインタを使った関数呼び出し(Indirect Call)すべてに対して、プログラマの意図した引数・戻り値の型を持っているかの型検査を挿入します。

C++の場合、CFIはこれ以外にも、ポインタが意図したクラスのインスタンスであるかをチェックする検査を挿入しますが、今回は取り扱いません。ご興味のある方はCFIのドキュメントをご覧ください。

CFIによる型検査の実装

それでは、CFIではどのように関数呼び出し時の型を検査しているのでしょうか。

先ほどのプログラムをコンパイルして生成された実行可能ファイルを解析してみましょう。

 

図2. 関数ポインタ利用箇所の逆アセンブル結果

 

図2を見ると分かるように、関数ポインタ利用前にCFIによるチェックと思われる分岐が存在します。この分岐は次のように表せます。

if ((u64)(func_ptr - f1) / 8 > 1
    || (u64)(func_ptr - f1) % 8 != 0)
  __ubsan_handle_cfi_check_fail_abort();

f1 はCのコード中で定義した関数名ですが、解析すると図3のように、関数の実体ではなく関数本体へ実行を移すジャンプ命令(jmp)のみになっています。

 

図3. f1f2 は関数本体へ飛ぶジャンプ命令になっている。

 

また、各関数が8バイト単位でアラインされていることも分かります。このように、関数本体へ飛ぶ仲介役のような役割をする関数をスタブ(stub)と呼びます。ジャンプ命令は必ず8バイト以下に収まるので、メモリ上でスタブも8バイトごとに設置できます。

つまり、先ほどのCFIによるチェックでは、呼び出そうとしている関数ポインタが、何番目のスタブに該当するかをチェックしています。今回 int (int) 型の関数は f1f2 の2つなので、関数ポインタがスタブの0番目か1番目に該当しない限り、誤った関数呼び出しであると判断できます。

このようにCFIは、同じ引数・戻り値の型を持つ関数のスタブを同じ領域にまとめることで、高速かつ簡潔な型検査を実現しています。

共有ライブラリとCFI

冒頭にも説明したように、LTOにより関数や変数の型情報がリンク時まで保持されるため、先述のような「型ごとにまとめる」方法で関数ポインタの検査が実装できています。したがって、たとえソースコードを分割して関数を別々のファイルに記述したとしても、リンク時にスタブが適切に生成されます。

では、共有ライブラリの場合はどうでしょうか。ほとんどの大きなプログラムでは、何かしら外部のライブラリ関数を呼び出します。共有ライブラリにある関数を関数ポインタに入れて利用する場合、CFIは正しく型検査を実現できるのでしょうか。

残念ながら、特段設定をせずに共有ライブラリをビルドした場合、外部関数呼び出しはすべて「誤った型」としてCFIの検査に引っかかってしまいます。(注釈:関数呼び出しの場合はGOTを経由するため、関数定義を正しく書いていれば「GOTを経由した外部関数の呼び出し」に対してスタブが生成されるため、問題なく動作します。しかし、dlopenを利用した場合や、C++でvtableを利用した場合などは上手く動作しなくなります。)これまで見てきた型検査の仕組みを考えると当然ですが、共有ライブラリはプログラム本体と独立してビルドされており、プログラムビルド時に共有ライブラリ側の型情報が得られないからです。

例として、次のコードを共有ライブラリとしてビルドします。

int external_f1(int x) {
  return x + 1;
}

short external_f2(short x) {
  return x + 2;
}

そして、これらの関数を別のコードから参照します。

#include <stdio.h>
#include <dlfcn.h>

int main() {
  void *handle;
  int (*func_ptr)(int) = NULL;
  int option = 0;

  printf("option = ");
  scanf("%d", &option);

  if (!(handle = dlopen("./libtest.so", RTLD_LAZY)))
    return 1;

  if (option == 1) {
    func_ptr = dlsym(handle, "external_f1");
  } else {
    func_ptr = dlsym(handle, "external_f2");
  }

  printf("result = %d\\n", func_ptr(123));

  dlclose(handle);
  return 0;
}

ここでは問題が発生するコードを簡単にするためdlopenを使っていますが、例えばvtableを使うような複雑なコードになると、単純に共有ライブラリを動的リンクした場合でも同様の問題が再現できます。

さて、初めに共有ライブラリを通常ビルドし、本体をCFI有効でビルドしてみましょう。

$ clang -shared -fPIC libtest.c -o libtest.so
$ clang -flto -fvisibility=default -fsanitize=cfi -fno-sanitize-trap=cfi -ldl main.c

本体から共有ライブラリlibtest.soの型情報は分からないため、共有ライブラリ中の関数を呼び出そうとすると、型の整合性に関わらず異常として検知してしまいます。

$ ./a.out 
option = 1
main.c:21:27: runtime error: control flow integrity check for type 'int (int)' failed during indirect function call
(libtest.so+0x1100): note: external_f1 defined here
main.c:21:27: note: check failed in /home/ricsec/cfi/a.out, destination function located in ./libtest.so
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior main.c:21:27 in

$ ./a.out 
option = 2
main.c:21:27: runtime error: control flow integrity check for type 'int (int)' failed during indirect function call
(libtest.so+0x1110): note: external_f2 defined here
main.c:21:27: note: check failed in /home/ricsec/cfi/a.out, destination function located in ./libtest.so
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior main.c:21:27 in

この問題を解決するオプションとして、 -fsanitize-cfi-cross-dso があります。このオプションは名前の通り、DSO(Dynamic Shared Object)をまたいだCFIを実現してくれます。

$ clang -flto -fvisibility=default -fsanitize=cfi -fno-sanitize-trap=cfi -fsanitize-cfi-cross-dso -shared -fPIC lib.c -o libtest.so
$ clang -flto -fvisibility=default -fsanitize=cfi -fno-sanitize-trap=cfi -fsanitize-cfi-cross-dso -ldl main.c

実行してみましょう。

$ ./a.out 
option = 1
result = 124

$ ./a.out 
option = 2
main.c:21:27: runtime error: control flow integrity check for type 'int (int)' failed during indirect function call
(libtest.so+0x3050): note: external_f2 defined here
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior main.c:21:27 in

誤った型の関数呼び出しを検知できています。

では、この機能はどのように実現されているのでしょうか。プログラムを逆アセンブルして解析してみましょう。図4に示すように、関数ポインタを利用する直前で __cfi_slowpath_diag という関数が呼ばれています。

 

図4. Cross DSOを有効にした際のCFI

 

詳しい実装方法については今回省略しますが、この関数はCFI Shadowと呼ばれる仕組みを使って次のような手順で整合性を確認しています。

  1. 検査するアドレス(今回は関数ポインタ)が、どの共有ライブラリのものか調べる。
  2. 該当する共有ライブラリでCFIが有効かを確認する。
  3. 有効な場合、該当する共有ライブラリに実装されている __cfi_check 関数を呼ぶ。
  4. 共有ライブラリ側の __cfi_check がアドレスを検査する。

__cfi_check によるアドレスチェックはこれまで説明したのと同じ手法で実現されます。例えば関数の型チェックの場合、共有ライブラリにあるスタブの領域から型が一致しているかを判断します。

実際に共有ライブラリ側を解析すると、図5のように __cfi_check 関数が存在します。

 

図5. 共有ライブラリ側に実装されている __cfi_check 関数

 

型検査に失敗した場合は __cfi_check_fail 関数が呼ばれてAbortすることが分かります。

このように、Cross DSOでは、呼び出す関数ポインタがどの共有ライブラリに属するかを調べ、共有ライブラリ側で実際の検査を実施しています。

CFIの回避

さて、これまで見てきたように、CFIが有効になると関数ポインタ呼び出しにチェックが入ります。つまり、攻撃者としては関数ポインタを書き換えて制御を奪う攻撃ができなくなってしまいます。

なんとかしてCFIを回避する方法はないでしょうか?

一般的にClangのCFIを回避できる方法は多数存在します。例えば、CFIが有効になっていないライブラリ(glibcなど)が使われている場合、そこで使われる関数ポインタを書き換えれば制御を奪えます。また、GOT(Global Offset Table)はIndirect Callですが、ClangのCFIでは保護検対象外です。

このように、CFIを回避できてしまう状況は多数挙げられます。

では、すべてのライブラリでCFIが有効で、Full RELROのためGOTも書き換えられないといった「理想的な」状況ではどうでしょうか。すべての関数ポインタ呼び出しがCFIにチェックされる場合、攻撃者としては何かしらCFIの検査自体を回避する方法が必要となります。

この記事では、ClangのCFIに存在する抜け道として、既知の手法と我々が新たに発見した手法の2つを紹介します。

UserDieCallbackを利用する

図2で示したように、CFIは異常を検知すると __ubsan_handle_cfi_check_fail_abort という関数を呼び出します。この関数は最終的に __sanitizer::Die という関数を呼びます。図6は、 __sanitizer::Die 関数をデコンパイルした結果の先頭です。

 

図6. __sanitizer::Die 関数のデコンパイル結果

 

このコードを読むと分かるように、 __sanitizer::UserDieCallback が非NULLのとき、それを関数ポインタとして呼び出しています。この関数ポインタは.bssセクションに存在し、書き換え可能です。

つまり、任意アドレス書き込みや範囲外書き込みなどでこの関数ポインタを書き換えられる場合、せっかくCFIで攻撃を検知しても、Abortの過程で任意アドレスにジャンプできてしまいます。

この問題は一般的にも知られており、CTFで出題されたこともあります。

__cfi_check_failのバグを利用する

弊社では業務の一環でCFIについて調査する機会があったのですが、その際に新しい抜け道を見つけたので紹介します。

図5に示したように、Cross DSOで共有ライブラリ側の型検査が異常を検知した場合、 __cfi_check を経由して __cfi_check_fail 関数が呼ばれます。この関数は図7のような内容になっています。

 

図7. _cfi_check_fail 関数のコード

 

DiagData という診断用の情報が存在する場合、何の検査で失敗したか( CheckKind )によってswitch文で分岐し、Abort時に詳細なエラーメッセージを出力できるような仕組みになっています。

しかし、 CheckKind が異常な(5以上の)値を取る場合、関数がreturnしています。本来 __cfi_check_fail は必ずAbortする(returnしない)関数ですので、これはCFI実装上のバグと言えます。 DiagData 自体は.dataセクションに存在し、中身を書き換えることが可能です。

したがって、任意アドレス書き込みなどで CheckKind を1バイト書き換えられる場合、共有ライブラリの関数ポインタが書き換えられている際に __cfi_check_fail がreturnします。

図8の実行フローに示したように__cfi_check_fail の呼び出し元である _cfi_check もreturnするため、結局関数ポインタが使われる箇所まで戻ってきてしまいます。

 

図8. バグにより __cfi_check_fail がreturnするとCFIを回避できる。

 

今回紹介した回避方法は、いずれもCFIが異常を検知してから検知内容を出力するまでのパスに存在する抜け道を使います。したがって、Trapせずに検知内容が出力される -fno-sanitize-trap オプションがないと使えないという点には注意してください。しかしながら、出力ログやユーザーフィードバックからバグの原因を特定するために、リリースビルドでもこのオプションが付いている例は十分に考えられます。

おわりに

今回はCFIを少し解説し、CFI回避に使えるちょっとした手法を紹介しました。

CFIやサニタイザに限らず、世の中には攻撃を検知して利用者を守るための多種多様な緩和策(mitigation)が存在します。しかし、緩和策はあくまでも手法の一種で、攻撃を完全に防げるわけではなく、ほとんどの場合抜け道が存在します。緩和策の改善を進めるほど攻撃は困難になりますが、技術力の高い攻撃者は次々と新しい回避手法を生み出していきます。

防御機構に頼るだけでなく、問題の根本的な原因となる脆弱性を取り除くことで、安全なアプリケーションの設計・開発を心がけるようにしましょう。