プログラミング
Fil-Cにおけるメモリ安全なコンテキスト切り替え(longjmp、setjmp)
Memory Safe Context Switching (longjmp, setjmp) in Fil-C (fil-c.org)
要約
この記事では、Fil-Cがいかにしてlongjmp、setjmp、ucontext APIを完全にメモリ安全な方法でサポートしているかを説明しています。これらのAPIの誤用が、スタック破損やFil-Cの能力モデルのその他の違反を引き起こすことはありません。Fil-Cは、これらの危険なAPIがもたらす一般的な問題を解決し、信頼性の高い実行を保証します。
全文翻訳
ucontext APIのメモリ安全なコンテキスト切り替えサポートは、リリース0.680以降の新しい機能です。setcontext、getcontext、makecontext、swapcontextを試したい場合は、ソースからビルドする必要があります。このドキュメントでは、Fil-Cがlongjmp、setjmp、setcontext、getcontext、makecontext、およびswapcontextを完全にメモリ安全な方法でサポートする方法について説明します。特に、Fil-CでのこれらのAPIの誤用は、スタック破損やFil-Cの能力モデルのその他の違反を引き起こすことはありません。
これらのAPIは広く使用されています。longjmpとsetjmpは、Cプログラムで例外処理を実装するために使用されます。特に、シグナルハンドラから「スローされる」例外を実装するためによく使用されます。getcontext、setcontext、makecontext、およびswapcontext(ucontext APIとして知られています)は、コルーチンとファイバーを実装するために使用されます。たとえば、Boostはそのファイバー実装の一部としてucontextを使用しています。ucontext APIはlongjmp/setjmpほど一般的ではなく、一部のOS(Darwinなど)では非推奨とされています。しかし、glibcでは引き続き十分にサポートされています。
メモリ安全性を維持しながらこれらのAPIを実装することは困難です。その誤用は、ダングリングスタックの復元につながる可能性があるためです。たとえば、特定の関数内でsetjmpまたはgetcontextを呼び出し、その後以下のいずれかの操作を行うことができます。
* その関数から戻る。この時点で、保存されたコンテキストは、もはや存在しないスタックフレームを復元しようとします。
* スレッドから終了する。この時点で、保存されたコンテキストは、解放されたスタック上で実行を復元しようとします。
makecontextやswapcontextのような、より親しみやすいAPIでさえ、簡単に誤用される可能性があります。
* makecontextを使用して、特定のスタックを指すコンテキストを作成し、そのスタックを解放し、その後swapcontextまたはsetcontextを使用してそのコンテキストに切り替えることができます。Yolo-Cでは、これはダングリングスタック上での実行につながります。Fil-Cでは、これはエラーではありません。
* swapcontextを呼び出す際に、現在実行中のコンテキストを2番目の引数として渡すことができます。これは、最初の引数と2番目の引数を混同した場合に発生する可能性があります。Yolo-Cでは、最良の場合、これはlongjmpのように動作します。最悪の場合、これはダングリングスタック上での実行につながります。Fil-Cでは、これはプログラムをパニックさせる安全エラーです。
Yolo-Cでは、ダングリングスタック上での実行は、デバッガがスタックトレースを印刷することさえできないため、最も混乱する種類のクラッシュを引き起こします!さらに悪いことに、プログラムのコンテキスト処理に微妙なバグがある場合、攻撃者はそれらのバグを悪用して、攻撃者が好きなようにプログラムを動作させることができます。Fil-Cでは、ダングリングスタック上での実行は不可能です。そのようなすべてのケースは、longjmpまたはucontext APIのいずれかを誤用した時点でのパニック、またはFil-Cがスタックを管理する方法のために確実に合法的な実行のいずれかです。
Fil-Cは、setjmp/longjmpとucontext APIをかなり異なる方法で実装しています。
**setjmp/longjmpをメモリ安全にする**
setjmpの堕落には驚くべき深さがあります。Fil-Cがsetjmp/longjmpをどのように実装しているかの詳細に入る前に、この関数をこれほどまでに驚くほど悪質にしているものが正確に何であるかについて議論する必要があります。setjmpは、呼び出された瞬間のコンテキストを保存し、後でlongjmpが呼び出されたときに、setjmpが2回目にリターンするようにします。2回リターンするという事実が、これを非常に忌まわしいものにしており、その影響を正確に理解する必要があります。
**例**
このシンプルなプログラムを考えてみましょう。
```c
#include <setjmp.h>
#include <stdio.h>
int main(int argc, char** argv) {
volatile int x = 42;
jmp_buf jb;
if (setjmp(jb)) {
printf("x = %d\n", x);
return 0;
}
x = 666;
longjmp(jb, 1);
printf("Should not get here.\n");
return 1;
}
```
このプログラムは次のように出力します。
```
x = 666
```
そして終了します。フローは次のとおりです。
* setjmpの最初の呼び出しでは、0を返し、呼び出し元のコンテキストをjbに保存します。
* 次に、xを666に設定し、1の値を付けてjbにlongjmpします。
* setjmpは1を返すので、printfして終了します。
プログラムが確実に666を印刷するためには、xをvolatileとしてマークする必要があることに注意してください。そうしないと、コンパイラはxへのアクセスを最適化し、代わりに42を返す可能性があります。これは次のような方法で発生する可能性があります。
* コンパイラはxを42に定数畳み込みすることができます。これは、volatileを削除し、-O0以上の最適化レベルを使用すると、この例で発生します。すると、x = 42が印刷されます。
* 定数畳み込みが起こらないと仮定します。おそらく、xの定義直後にasm("" : "+r"(x))を挿入したためです。その場合、コンパイラはxを呼び出し先保存レジスタにレジスタ割り当てすることができ、その場合、レジスタはsetjmpによって保存されます。これもx = 42が印刷されることにつながります。
* 何らかの理由でレジスタの圧迫があり、xが呼び出し先保存レジスタに入らず、代わりにスピルされると仮定します。-O0以上の最適化レベルでは、コンパイラはxを2つの変数に分割します。1つはx = 42用、もう1つはx = 666用で、printfは最初の変数を参照します(x = 42がprintfを支配するため)。これら2つの変数は、ほとんどの場合、別々のスピルスロットを取得します。したがって、setjmpから2回目に戻ったときにxを読み取ると、依然として42が得られます。
反省すべき3つの点があります。
* printfでxの値が666として観察されるという特性を得るには、コンパイラがxを変数としてではなく、スタック割り当てとして扱うようにする必要があります。volatileを使用するとこれが実現されます。また、xへのポインタをどこかに渡すことも、これを達成する可能性が高いです。
* スピルスロットはスタック割り当てと同じではありません。変数がスタック割り当てされている場合、1つのスタック割り当てを取得します。変数がスピルされている場合、複数のスピルを取得する可能性があります(多くの場合、割り当てごとに別々のスピル)。
* コンパイラは、スピルスロットとスタック割り当てのライフタイムを分析することを許可されています。スピルスロットの再利用も許可されています。コンパイラは、x = 42のスピルスロットがlongjmpが発生するまで生き続けることをどのように知るのでしょうか?なぜ再利用されず、setjmpから2回目に戻ったときにxが666またはランダムなゴミの値になるのでしょうか?
ここに、gcc、clang、およびfilccでxを2つの異なるスピルスロット(42用と666用)にスピルさせる、より悪魔的なバージョンの例があります。
```c
#include <setjmp.h>
#include <stdio.h>
int main(int argc, char** argv) {
int x = 42;
asm volatile("" : "+r"(x));
jmp_buf jb;
int a = 1, b = 2, c = 3, d = 4, e = 5, f = 6, g = 7, h = 9, i = 10; /* Force some spilling */
asm volatile("" : "+r"(a), "+r"(b), "+r"(d), "+r"(e), "+r"(f), "+r"(g), "+r"(h), "+r"(i));
if (setjmp(jb)) {
asm volatile("" : "+r"(a), "+r"(b), "+r"(d), "+r"(e), "+r"(f), "+r"(g), "+r"(h), "+r"(i));
printf("x = %d\n", x);
return 0;
}
x = 666;
void (*jump)(jmp_buf, int) = longjmp;
asm volatile("" : "+r"(x));
asm volatile("" : "+r"(jump), "+r"(a), "+r"(b), "+r"(d), "+r"(e), "+r"(f), "+r"(g), "+r"(h), "+r"(i));
jump(jb, 1);
asm volatile("" : "+r"(jump), "+r"(a), "+r"(b), "+r"(d), "+r"(e), "+r"(f), "+r"(g), "+r"(h), "+r"(i));
asm volatile("" : "+r"(x));
printf("Should not get here.\n");
return 1;
}
```
このプログラムは、xが定数畳み込みされたり、レジスタ割り当てされたりしていないにもかかわらず、x = 42と出力します。これまでのすべての例はFil-Cで動作することに注意してください。変数の値を難読化するために使用しているインラインアセンブリでさえ、Fil-Cで動作し、望ましい効果を発揮します。
**一体何が起こっているのか**
x86_64上のmusl実装を見て、setjmpがいかにシンプルであるかを見てみましょう。
```assembly
__setjmp:
_setjmp:
setjmp:
mov %rbx,(%rdi) /* rdi is jmp_buf, move registers onto it */
mov %rbp,8(%rdi)
mov %r12,16(%rdi)
mov %r13,24(%rdi)
mov %r14,32(%rdi)
mov %r15,40(%rdi)
lea 8(%rsp),%rdx /* this is our rsp WITHOUT current ret addr */
mov %rdx,48(%rdi)
mov (%rsp),%rdx /* save return addr ptr for new rip */
mov %rdx,56(%rdi)
xor %eax,%eax /* always return 0 */
ret
```
これは、呼び出し時点の呼び出し先保存レジスタと、スタックポインタおよび命令ポインタのみを保存しています。スタック自体は保存していません。後でlongjmpが呼び出されると、レジスタの状態が復元されますが、1つだけ違いがあります。%eax(戻り値レジスタ)には、longjmpに渡された引数が設定されます。したがって、setjmpの最も基本的な安全上の問題は、それを呼び出して関数から戻った場合です。