メモリ安全なC言語実装「Fil-C」について紹介

今回はメモリ安全なC言語実装を提供できる「Fil-C」について紹介します。
既存のC言語プログラムに対しても互換性を持ち、再コンパイルすることでメモリの安全性を保証することも可能です。

はじめに

C言語のメモリ管理は、パフォーマンスとハードウェアへの直接的なアクセスを最優先し、プログラマにメモリ管理の全責任を委ねる設計となっています。
この設計思想は「プログラマは常に正しい」という前提に立ちますが、バッファオーバーフローやUse-After-Freeといったメモリ安全性に起因したセキュリティの脆弱性が止まらないのも現状です。

Rustのようなメモリ安全性を保証する言語へ移行するのが最も根本的な解決策ではありますが、既存のC言語コードを全て書き直すことは現実的では無いでしょう。

「Fil-C」は既存のCコードを書き換えることなく、コンパイラとランタイムのレベルで完全なメモリ安全性を付与するプロジェクトです。ベースとしてはClang/LLVMのフォークであり、独自のランタイムシステム、ガベージコレクタ、そして InvisiCaps と呼ばれるポインタ表現を利用します。

Fanatical Compatibility(狂気的な互換性)と自称しておりRustのようなUnsafeという逃げ道すら一切用意せず、あらゆるポインタ操作に対して厳密なチェックを強制します(少し思想強め)

この記事では実際に Fil-C を使ってみて、使い方と実際の挙動を確認します。

Fil-Cの設計

C言語のポインタ(特にポインタと整数の相互変換や、任意のメモリアドレスへのアクセス)を維持しつつ、バッファオーバーフローやUse-After-Freeを防ぐために導入されたのが「InvisiCaps」です。

InvisiCaps

標準的なx86_64アーキテクチャにおいて、ポインタは64ビットの仮想メモリアドレスそのものです。
「どのメモリ領域を指しているか」という情報は含まれますが「その領域のサイズはいくつか」や「その領域はまだ有効か」といったメタデータは含まれません。

Fil-C環境下ではメモリ割り当てが行われる際、システムは実際のデータ領域とは別にメタデータ領域も確保します。

例えば、ポインタを含む構造体が割り当てられた場合、その構造体内のポインタに対応する位置のメタデータ領域を確保し、そのポインタが指す先の「範囲」と「権限」が記録されます。これにより、ポインタ自体は64ビットのままでありながら、そのポインタを経由したアクセス時にはメタデータの情報を参照して安全性を検証できます。

Strict Provenance

C言語の危険な機能の一つに、整数からポインタへのキャストがありますが、Fil-Cではこれを厳格に防止しています。(Rustの「Strict Provenance」に近い)

  • ポインタ → 整数
    • ポインタを uintptr_t などにキャストすると、値は保持されるがメタデータは失われる
  • 整数 → ポインタ
    • 整数をポインタにキャストバックすると、Fil-Cはそのポインタに対して「Null Capability」を割り当てる

「Null Capability」を持つポインタを参照しようとすると、即座にトラップが発生し、プログラムは停止します。つまりmallocなど正しい手順で生成され、正当な演算によって派生したポインタ以外は、一切メモリにアクセスができません。

ガベージコレクタ

C言語のfree()はメモリを即座に解放しますが、その領域を指すポインタが残っていた場合、再割り当てされた別のオブジェクトを誤って操作してしまう危険があります。

Fil-Cは独自のガベージコレクタ「FUGC(Fil’s Unbelievable Garbage Collector)」を採用して、これを防いでいます。FUGCの特徴は以下のとおりです。

  • 非移動型
    • C言語のポインタは絶対アドレスに依存することが多いため、オブジェクトは移動させない
  • 並行性
    • アプリケーションと並行して動作し、停止時間を最小限に抑える
  • 遅延解放
    • プログラムがfree()を呼び出すと、そのオブジェクトを「解放済み」としてマークするが、即座にメモリをOSに返却したり再利用したりはしない
  • アクセス検知
    • 解放済みマークがついたオブジェクトに対して、残存するポインタを通じてアクセスしようとすると InvisiCapsのチェック機構が検知し、安全にクラッシュさせる

FUGCが実際にメモリを回収するのは、そのオブジェクトを指すポインタがシステム内に一つも存在しないことが証明された後になります。

Fil-C環境の構築と使用法

環境構築

座学はこれぐらいにして実際に触ってみましょう。Fil-CをGitHubからインストールします。
https://github.com/pizlonator/fil-c

$ wget https://github.com/pizlonator/fil-c/releases/download/v0.675/filc-0.675-linux-x86_64.tar.xz
$ tar -xvf filc-0.675-linux-x86_64.tar.xz
$ cd filc-0.675-linux-x86_64
$ ./setup.sh
$ ./build/bin/clang --version
Fil-C 0.675 clang version 20.1.8 (git@github.com:pizlonator/llvm-project-deluge.git e16f10094cc38a6444e3d5d9cc4914120b380e5b)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /home/mix64/filc-0.675-linux-x86_64/build/bin
Build config: +assertions

Fil-Cでコンパイルされたバイナリは通常のLinuxバイナリとは異なるABIを持つため、システム標準のライブラリとリンクすることはできません。

Fil-Cを使用する場合、アプリケーションが依存する全てのライブラリもFil-Cで再コンパイルする必要があります。PizfixにはFil-Cでビルドされたmusl-libc、openssl、zlib、ncursesなどが含まれていて、./build_all.sh を使用することで、これらを含む全ソフトウェアをビルドできます。

脆弱性検出の実験

標準的なCコンパイラでは見逃されるバグを、Fil-Cがどう検出するか検証します。
いくつかのメモリ破壊バグを含むコードをビルドします。

スタックバッファオーバーフロー

最も古典的かつ危険な脆弱性です。
確保されたバッファのサイズを超えて書き込みを行うことで、リターンアドレスを含む隣接メモリを破壊し、任意のコード実行につながる可能性があります。

#include <stdio.h>
#include <string.h>

void vulnerable_function() {
    char buffer[10];
    // 10バイトのバッファに、32バイトを書き込む
    // 標準Cではスタック破壊が発生する
    strncpy(buffer, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32);

    printf("Buffer: %s\n", buffer);
}

int main() {
    printf("Starting overflow test...\n");
    vulnerable_function();
    printf("Finished normally (This should not happen).\n");
    return 0;
}

実際ここまで明示的に書くとコンパイラはビルド時に警告を出してくれますが、それは一旦無視して実行時の挙動を見てみましょう。

$ gcc -O0 overflow_test.c -o overflow_test
overflow_test.c: In function ‘vulnerable_function’:
overflow_test.c:8:5: warning: ‘__builtin_memcpy’ writing 32 bytes into a region of size 10 overflows the destination [-Wstringop-overflow=]
    8 |     strncpy(buffer, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
overflow_test.c:5:10: note: destination object ‘buffer’ of size 10
    5 |     char buffer[10];
      |          ^~~~~~

$ ./overflow_test
Starting overflow test...
Buffer: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Segmentation fault

gccではSegmentation faultが発生しました。これはスタックが破壊されてリターンアドレスが上書きされたことに由来しています。Fil-Cではどうなるでしょうか?

$ ./build/bin/clang -O0 overflow_test.c -o overflow_test
overflow_test.c:8:5: warning: 'strncpy' size argument is too large; destination buffer has size 10, but size argument is 32 [-Wfortify-source]
    8 |     strncpy(buffer, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 32);
      |     ^
1 warning generated.

$ ./overflow_test
Starting overflow test...
filc safety error: cannot write pointer with ptr >= upper.
    pointer: 0x7fe3b3f84340,0x7fe3b3f84330,0x7fe3b3f84340
    expected 1 writable bytes.
semantic origin:
    src/string/stpncpy.c:12:17: __stpncpy
check scheduled at:
    src/string/stpncpy.c:12:18: __stpncpy
    src/string/strncpy.c:5:2: strncpy
    <somewhere>: vulnerable_function
    <somewhere>: main
    src/env/__libc_start_main.c:79:7: __libc_start_main
    <runtime>: start_program
[2908] filc panic: thwarted a futile attempt to violate memory safety.
Trace/breakpoint trap

詳細に色々出してくれていますね、意味は以下のとおりです。

  • filc safety error
    • 安全性侵害が検出されたことを示します
  • cannot write pointer with ptr >= upper
    • ポインタが上限を超えて書き込もうとしたことを明示しています
  • semantic origin, check scheduled at
    • エラーの発生した場所と、そこまでのスタックトレースを示してくれています。

今回はどちらもクラッシュしたので結果としては同じですが、そこに至るまでの仮定が違います。

gccはスタックオーバーフローが発生してスタックが破壊された結果、リターンアドレスを失いクラッシュしています。Fil-Cはスタックオーバーフローが発生しそうになったので、強制的にトラップを発行してプログラムを終了させています。

実際に脆弱性として攻撃された場合、gccの場合は任意のコードが実行される可能性があるのに対して、Fil-Cはbufferから外れた場所に書き込もうとした時点で落ちるので、その心配はありません。

Use-After-Free

こちらもスタックオーバーフローに並ぶ古典的かつ危険な脆弱性です。
動的に確保したメモリを解放した後に、そのポインタを使用してアクセスするバグで、攻撃者はこれを利用してヒープの構造を操作し、権限昇格などを狙うことがあります。

#include <stdlib.h>
#include <stdio.h>

int main() {
    int *data = (int*)malloc(sizeof(int));
    *data = 42;
    printf("Allocated: %d\n", *data);

    free(data);
    printf("Freed data.\n");

    // 解放済みのメモリにアクセス
    // 標準Cでは「42」が表示されたり、クラッシュしたり、不定となる
    printf("Accessing after free: %d\n", *data);
    
    return 0;
}

gccでビルドしてみます。Use-After-Freeはコンパイラが見つけにくいので警告も出てないですね。

$ gcc -O0 uaf_test.c -o uaf_test
$ ./uaf_test
Allocated: 42
Freed data.
Accessing after free: 1519600330
$ ./uaf_test
Allocated: 42
Freed data.
Accessing after free: 1509833197

解放後のアドレスにも問題なくアクセスができていて、値が不定なのがわかります。
42のまま残っている場合もあれば、クラッシュすることもあります。

次にFil-Cでビルドしてみます。

$ ./build/bin/clang -O0 uaf_test.c -o uaf_test
$ ./uaf_test
Allocated: 42
Freed data.
filc safety error: cannot read pointer to free object.
    pointer: 0x7f51bad84270,0x7f51bad84270,0x7f51bad84270,free
    expected 4 bytes.
semantic origin:
    <somewhere>: main
check scheduled at:
    <somewhere>: main
    src/env/__libc_start_main.c:79:7: __libc_start_main
    <runtime>: start_program
[4739] filc panic: thwarted a futile attempt to violate memory safety.
Trace/breakpoint trap

「解放済みオブジェクトへのアクセス」であると判断されて、プログラムが終了されています。
またエラーの発生した場所と、そこまでのスタックトレースを示してくれています。

FUGCの働きにより、free(data)が実行された時点でこのメモリブロックには「解放済み」のビットが立ちます。物理メモリはまだ回収されていなくても、InvisiCapsのチェック機構がメタデータを参照し、アクセスを拒否しています。

Type Confusion / Forging

整数型にキャストしたポインタを操作し、再度ポインタに戻すことで、任意のメモリアドレスを指すポインタを作成する処理を指します。OSやデバイスドライバーなど低レイヤーではたまに使われる手法です。

#include <stdio.h>
#include <stdint.h>

int main() {
    int secret = 12345;
    int *ptr = &secret;
    
    // ポインタを整数に変換
    uintptr_t addr = (uintptr_t)ptr;
    printf("Address: 0x%lx\n", addr);
    
    // 整数からポインタへキャスト
    int *forged_ptr = (int*)addr;
    
    // 標準Cではアクセス可能
    // Fil-Cではここでトラップするはず
    printf("Forged access: %d\n", *forged_ptr);
    
    return 0;
}

gccでビルドした場合、正常に動作します。

$ gcc -O0 forge_test.c -o forge_test
$ ./forge_test
Address: 0x7ffdeaa5be34
Forged access: 12345

Fil-Cでビルドした場合、Trapが発生しプログラムが終了します。

$ ./build/bin/clang -O0 forge_test.c -o forge_test
$ ./forge_test
Address: 0x7f77aac84270
filc safety error: cannot read pointer with null object.
    pointer: 0x7f77aac84270,<null>
    expected 4 bytes.
semantic origin:
    <somewhere>: main
check scheduled at:
    <somewhere>: main
    src/env/__libc_start_main.c:79:7: __libc_start_main
    <runtime>: start_program
[6805] filc panic: thwarted a futile attempt to violate memory safety.
Trace/breakpoint trap

整数からキャストされたポインタは、アドレス値自体は正しいものの、有効なメタデータを持っていません。Fil-Cは「ポインタがどこから来たか」という来歴を失ったポインタに対しては、一切のアクセス権限を与えないため、この処理が発生するとプログラムを終了します。

ただ、こういった処理はデバイスドライバーやOSに関連する部分でたまに使われるので unsafe も無しで全部許されないのは少し厳しいな…という印象を受けます。

パフォーマンスへの影響

Fil-CはC言語に強力なメモリ安全性を提供しますが、その代償としてパフォーマンスへの影響は避けられません。Fil-Cでコンパイルされたプログラムは、最適化された標準的なバイナリと比較して1倍〜4倍程度遅くなる傾向にあるようです。

各メモリアクセスごとに発生するInvisiCapsのメタデータルックアップ、境界チェック、そして並行GCのオーバーヘッドが主な要因で、特にポインタを多用するデータ構造(リンクリストやツリー)の操作においてオーバーヘッドが顕著になる傾向があります。

また、メモリオーバーヘッドも大きく、メモリ使用量は約2倍に増加するとのことです。これは全てのオブジェクトに対するメタデータ領域の確保と、FUGCの遅延解放によるものだと言われています。

ベンチマークテスト

オーバーヘッドを確認するために、ポインタ操作とメモリ割り当てを多用する簡単なベンチマークプログラムを作成し、Fil-Cと標準的なClangでの実行時間を比較します。

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define ITERATIONS 100000000

struct Node {
    int value;
    struct Node *next;
};

int main() {
    printf("Starting benchmark with %d iterations...\n", ITERATIONS);
    clock_t start = clock();

    struct Node *head = NULL;

    // 1. 大量の割り当てとリンク (Allocation & Linking)
    for (int i = 0; i < ITERATIONS; i++) {
        struct Node *new_node = (struct Node*)malloc(sizeof(struct Node));
        if (!new_node) {
            perror("malloc");
            return 1;
        }
        new_node->value = i;
        new_node->next = head;
        head = new_node;
    }

    // 2. トラバース (Traversal - Read access check)
    long long sum = 0;
    struct Node *current = head;
    while (current!= NULL) {
        sum += current->value;
        current = current->next;
    }

    // 3. 解放 (Free - GC logic)
    current = head;
    while (current!= NULL) {
        struct Node *temp = current;
        current = current->next;
        free(temp);
    }

    clock_t end = clock();
    double time_spent = (double)(end - start) / CLOCKS_PER_SEC;

    printf("Sum: %lld\n", sum);
    printf("Time: %f seconds\n", time_spent);

    return 0;
}

大量のノードを持つリンクリストを作成し、全要素をトラバースしてから解放するプログラムです。これによりmalloc/freeの速度とポインタ参照時のInvisiCapsチェックのコストを計測できます。

$ gcc -O0 perf_test.c -o perf_test
$ ./perf_test
Starting benchmark with 100000000 iterations...
Sum: 4999999950000000
Time: 2.820630 seconds

$ gcc -O2 perf_test.c -o perf_test
$ ./perf_test
Starting benchmark with 100000000 iterations...
Sum: 4999999950000000
Time: 2.690448 seconds

gccだと約3秒弱でした。

$ ./build/bin/clang -O0 perf_test.c -o perf_test
$ ./perf_test
Starting benchmark with 100000000 iterations...
Sum: 4999999950000000
Time: 8.555717 seconds

$ ./build/bin/clang -O2 perf_test.c -o perf_test
$ ./perf_test
Starting benchmark with 100000000 iterations...
Sum: 4999999950000000
Time: 6.629729 seconds

一方でperfだと6.6~8.5秒ほどかかったので約2~3倍ほどになっています。

mallocごとにメタデータ領域の確保が発生すること、new_node->nextやcurrent->valueへのアクセスごとに境界チェックが行われることが要因だと考えられます。CPUだけを使う数値計算よりも、こうしたポインタ処理において、Fil-Cのオーバーヘッドは顕著になります。

そのため、I/Oバウンドな処理やCPUバウンドな処理をメインとするプログラムの方がFil-C向けと言えるでしょう。もっとも、ポインタ処理を多用するプログラムの方がFil-Cの恩恵は大きいので難しいところですが…

まとめ

Fil-CはInvisiCapsとFUGCを用いて「C言語の完全な後付けメモリ安全性」を実装しています。

もちろん、速度低下とメモリ消費の増加は無視できないコストなので、速度を重視したりリソースに制限がある環境で利用するのは難しいと思います。
ただ、インターネットとの境界を担うサーバプロセスや、信頼できないデータをパースするようなツールの場合、このコストは保険料として受け入れてメモリ安全を確保するのもアリかもしれません。

もっとも、Rustで書き直したほうが良いのは言うまでもないですが…

シェアする

  • このエントリーをはてなブックマークに追加

フォローする