Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

攻撃者の視点: 安全性を破る(1/2)

第一原理から始めましょう。データはコードです1。 多くの抽象化は、次のような論理的な分離を強制しようとします。

  • データ - 読み書きできる情報。
  • コード - 実行できる情報。

しかし、この分離の抽象化は、次のうち1つ以上に該当する可能性があります。

  1. 設計上脆弱である(高レベルのエラー)。

  2. 実装上脆弱である(低レベルのエラー)。

  3. 環境との相互作用によって脆弱である(フォールトインジェクション、サイドチャネルなど)。

動機を持った敵対者が、3つの脆弱性クラスのいずれかに対するエクスプロイトを構築するか、複数のクラスを連鎖させると、彼らはおそらく自分が制御するデータを何らかの形の実行可能な論理コードとして扱う方法を見つけたことになります。

より一般的には、Sergey Bratus はエクスプロイトを次のように定義しています2

(複雑な)コンピューターまたは人間とコンピューターのシステムを、その設計者や運用者の信頼仮定および/または期待に反する動作をさせること。

エクスプロイトの文脈における「信頼仮定」とは、システムが特定のセキュリティ要件または機能要件を満たすという、私たちが持つ信念です。 前のセクションでは、スタック安全性を破ることが可用性をどのように損なうかを見ました。

あるシステムが機密性を保護していると信じているとしましょう。 この信頼仮定の下では、認証されていない、または権限を持たないエンティティが機密データ(シークレット、知的財産、ユーザーの PII など)を読み取ることができるなら、それは有効な攻撃です。 それは依然としてデータをデータとして扱っています。 ただし、アクセス制御が誤っているだけです。 しかし、最も壊滅的な攻撃は、何らかのアクションを実行し、データをコードとして扱う傾向があります。

攻撃者が任意であれ制約付きであれコード実行を獲得すると、ほぼあらゆる信頼仮定を侵害できる可能性があります。 機密性、完全性、可用性など、何であれです。 攻撃者の能力は一般化されます。

これが今後のセクションのトピックです。メモリ破壊脆弱性(実装上の問題、上記の脆弱性クラス #2)に対する入門的なエクスプロイト開発です。

覚えておいてください。データ。は。コード。です。

高レベルでは、コードとデータの分離はどのように破られるのか?

ネイティブ実行ファイルを作成するために、コンパイラーは、CPU だけが理解できるエンコーディングでデータを出力します。CPU はそれをコードとして盲目的に実行してしまいます。 一度に任意のチャンクを1つずつ。 これは、先ほど議論したフェッチ/デコード/実行サイクルの[ドラマ化された表現]です。

危険が抑えられているのは、命令ポインター(IP)の値という単一の数値が期待される範囲内に留まり、期待される論理シーケンスを実行するという事実によります。 少なくとも、ほとんどの場合は。 創造的な攻撃者は、IP レジスターの内容を制御する方法を見つけることができますし、実際に見つけています。 技術用語では制御フローハイジャックと呼ばれ、これはデータとコードの分離を破った結果です。

エクスプロイトを詳細に掘り下げる前に、根本原因について議論しましょう。 「値」という用語は、プログラム内の型付きデータの具体的なインスタンス、つまり整数、文字列、構造体、継承されたメソッドを持つクラスなどを指すことを思い出してください。 バイナリエクスプロイトでは、多くの場合、値の安全性仮定を破る必要があります。 つまり、次のいずれか、または両方に違反することを意味します。

  1. メモリ安全性 - 空間的安全性と時間的安全性の両方を必要とし、次のように定義されます。

    • 空間的メモリ安全性 - 値へのすべてのアクセスが正しい境界内に留まること。

    • 時間的メモリ安全性 - 値はアクセス時点で有効でなければならない(例: 初期化済みだが、まだ解放されていない)。

  2. 型安全性 - 値は正しい型(セマンティクス)としてのみアクセスされ、またそうする権限を与えられたコード(可視性)によってのみアクセスされる必要があります。

ここで「バイナリエクスプロイト」とは、特にネイティブコンパイルされたプログラムを攻撃することを指します。元のソースコードにアクセスできない場合もあります。 C、C++、または unsafe Rust のいずれであってもです。

メモリ安全性と型安全性についてより具体的に議論するために、3つの C スニペットを調べ、その中の違反を可視化します。注意:

  • 3つのスニペットはいずれも、私たちの知る限り、エクスプロイト可能ではありません。これらの小さなプログラムは安全性を破っていますが、データとコードの分離を破ることを可能にするほど不運な形ではありません。

この区別は意図的です。 まずは、たとえ無害であっても、バグを識別することを学ぶところから始めます。 そこから脆弱性のエクスプロイトへと発展させていきます。

空間的メモリ安全性(値の境界)を破る

この短い C プログラムを考えてみましょう。

#include <assert.h>     // assert
#include <stdio.h>      // puts
#include <string.h>     // strncpy
#include <stdlib.h>     // malloc

char* get_greeting() {
    char* greeting = (char*)malloc(6);
    if (greeting == NULL) {
        return NULL;
    } else {
        strncpy(greeting, "Hello", 6);
        assert(greeting[5] == '\0');
        return greeting;
    }
}

int main() {
    char* greeting = get_greeting();
    if (greeting != NULL) {
        // バッファー上書き、空間的安全性違反!
        greeting[12] = '!';

        puts(greeting);
        free(greeting);
    }
    return 0;
}

この小さなスニペットには、実際には C 固有の細かな事項がかなり詰め込まれています。 分解してみましょう。

  • get_greeting() は、ヒープ割り当てされた文字列を参照で返します(生ポインターを使用)。C の文字列は null 終端されます。つまり、バイトリテラル /0、すなわち「null バイト」で終わらなければなりません。そして、C の文字列処理はエラーが非常に起きやすいことで悪名高いです。この関数は、注意深い一連の手順を正しく実行しています。

    • 標準ライブラリのメモリアロケーターである malloc() の呼び出しを介して、ヒープ上に 6 バイトを割り当てます(Rust の std も、あなたに代わって同じ API を使用しています!)。6 は、ASCII 文字列 Hello とその null 終端子を格納するために必要な最小の長さです。

    • ヒープメモリがすでに満杯の場合(枯渇)、または要求された割り当てが残りメモリに収まらないほど大きい場合(断片化)、malloc()NULL ポインターを返すことがあります。この重要なエッジケースを処理することを忘れず、同様に NULL を返すことで、エラーを呼び出し元へ暗黙的に伝播しています。

    • malloc() が成功した場合、要求されたサイズのヒープ割り当てバッファーを返します。そこで文字列 Hello をコピーします。strncpy()(コピーする最大バイト数を受け取り、ここでは 6)は、安全でない strcpy() 関数(NULL バイトに到達するまでデータのコピーを続けるため、空間的違反につながることがよくあります)の「ベストプラクティス」な安全版です。

    • strncpy() は、指定された入力最大長を超えない場合、null バイトを含めます。ただし、念のため、assert() を使用して文字列が null 終端されていることを確認します。終端されていない文字列を出力しようとすると、機密データが漏洩する可能性があります。なぜなら、/0 に到達するまで隣接するバイトを出力し続けてしまうからです!

ライブラリ抽象化設計の影響

Safe Rust では、文字列を扱う際に、メモリ表現に関する認知負荷をそこまで負う必要がありません。 std::string::String3 を使えば、手動割り当て、特定の長さのコピー、null 終端、エンコーディングについて心配する必要はありません。

ある程度、これはモダン C++ の std::string クラス4 にも当てはまります!

  • 残念ながら、get_greeting() の呼び出し元である main() はそこまで注意深くありません。ここでは、挨拶文字列が Hello World、つまり 11 文字長であると誤って仮定しています。そして、puts() で挨拶を出力する前に、12 文字目として感嘆符を追加しようとします。

    • 長さの不一致により、文 greeting[12] = '!'; はヒープ割り当てされたバッファーの境界を越えて書き込んでおり、その時点で隣接するヒープ位置にたまたま格納されていた可能性のある任意の値のメモリを破損しています。これは境界外書き込みです。この場合、書き込まれるデータは攻撃者が制御できるものではないため、脆弱性ではなくバグです。

      • 注: もし greeting が実際に 11 文字長で null 終端されていた場合、greeting[12] = '!';NULL バイトを上書きし、前述の puts() でのデータ漏洩を引き起こしていたでしょう。したがって、どちらにせよここにはエラーがあります。次の例では、正しい ! 追加ロジックを書き出します。
    • 最後に、C では、文字列のために割り当てられたメモリを手動で free() することを覚えておく必要があります。たとえ元の割り当てを行ったのが get_greeting() 関数であってもです。所有権がなければ、誰がいつメモリを解放するかについて強制される契約はありません。忘れるとメモリリーク(データ漏洩ではなく、可用性の問題であり、機密性の問題ではありません)につながり、早すぎる解放はuse-after-free (UAF) バグ(悪用可能な場合があります)を生み、誤って複数回解放すると二重解放になります(後で見ることになります)。

上記のプログラムはエラーなくコンパイルされ、空間的安全性違反を含んでいるにもかかわらず、最後まで実行されます。 出力は次のとおりです。

Hello

少なくとも、最後に確認した時点では私たちの個人用マシンではそうでした。これは UB の「時限爆弾」であり、プラットフォームやツールチェーンが変わると爆発する可能性があります。 この上書きによって空間的メモリ安全性が破壊されたため、もはやこのプログラムの機能性を信頼することはできません。

視覚的には、文 greeting[12] = '!';(境界外書き込み)は次のようなメモリ破損を引き起こします。

バッファー上書き: 空間的メモリ安全性違反。

同様のバグが悪用可能になるための前提条件は何か?

古典的な「バッファーオーバーフロー」は、悪用可能な空間的脆弱性の一例です。 これは、バッファーの容量を超えて、複数の連続したバイトを上書きするものです。 私たちのバッファー上書きの例のように、固定オフセット(12)にある単一の固定バイト(!)だけではありません。

なぜこの違いが重要なのでしょうか? スタックの制御フロー実装における重要な詳細があるためです。 関数呼び出しが戻るために、コンパイル済みコードは各スタックフレームの先頭にリターンアドレスを配置します(IP レジスターをスタックにプッシュします)。 関数の終了時に、この格納された値が Instruction Pointer にポップされます。 これにより、CPU は完了した関数呼び出しに続く次の文へ到達できます。

コンパイラーが挿入する「スタックカナリア」(リターンアドレスの前にランダムな値を挿入し、関数終了時にそれを検証するエクスプロイト緩和策)が使われる前は、バッファーオーバーフロー脆弱性は簡単に悪用できました。 攻撃者は、リターンアドレス(CPU が使用する制御フローメタデータ)を上書きするのに十分な長さの文字列を書き込むだけで、データをコードに変えることができました。 つまり、次のようになります。

古典的なスタックバッファーオーバーフローエクスプロイト。

時間的メモリ安全性の破壊(値の妥当性)

get_greeting() は変更せず、以前の空間的バグを修正するために main() を更新したとしましょう。 しかし、そのリファクタリングによって新しい時間的バグが入り込みました!

リファクタリングには注意が必要

プロフェッショナルなソフトウェアエンジニアリングにおいて、既存コードのリファクタリングには重要な利点があることがよくあります。 しかし、新しいバグを持ち込むリスクもあります。 テストスイートやアーキテクチャパターンはそのリスクを低減できますが、自明でないコードベースではそれを完全に排除することはできません。

リファクタリングの多い Pull Request (PR) は、セキュリティ固有のコードレビューを行う絶好のタイミングです。

int main() {
    char* greeting = get_greeting();
    if (greeting != NULL) {
        // "!" を正しく追加
        size_t greeting_len = strlen(greeting); // null バイトを含まない
        greeting = (char*)realloc(greeting, greeting_len + 2);
        if (greeting != NULL) {
            // 下の2行の代わりに、ここで strcat を使用することもできる
            greeting[greeting_len] = '!';
            greeting[greeting_len + 1] = '\0';
        }
        puts(greeting);
        free(greeting);
    }

    // 二重解放、時間的安全性違反!
    free(greeting);
    return 0;
}
  • 今回は、C で文字列に追加するには、(realloc() を介して)背後のメモリを手動で再割り当てする必要があることを覚えていました。もはや 11 文字の Hello World が返るとは仮定せず、代わりに strlen で文字列の長さを動的に計算します。

    • greeting が非 null であることを確認した後に strlen を呼び出すようにしています。そうしないと null ポインタをデリファレンスし、UB が発生します。

    • 少し癖のある点として、報告される長さには null 終端文字が含まれません(ただし、strlen が正しい結果を報告するには null 終端文字が存在していなければなりません)。そのため、greeting_len + 2 を指定して realloc を呼び出しています。これは、! を追加するための領域と、新しい null バイトを付加するための領域の両方を考慮しているためです。

    • この詳細を忘れて greeting_len + 1 を使っていた場合、コードは off-by-one エラーによって再びバッファ上書きを引き起こします! C における文字列処理は危険に満ちていることを忘れないでください。

  • 再割り当ては失敗する可能性があるため、ここでも NULL が返っていないか確認します。realloc() が成功した場合、! と必要な null 終端文字の両方を正しく追加します。その後、変更された文字列を出力して解放します。

  • すべてを正しく行うところまでかなり近づいていたにもかかわらず、最後に重大な間違いを犯しています。greeting をもう一度解放しているのです。ダブルフリーバグです。同じメモリチャンクを二度解放してはなりません。この微妙なエラーは深刻な副作用をもたらす可能性があります。

しかし今回も危機を免れました。 このプログラムは警告なしでコンパイルされますが、ダブルフリーエラーは実行時に捕捉されます(Ubuntu 20.04、glibc 2.31 の場合):

Hello!
free(): double-free detected in tcache 2
Aborted (core dumped)

視覚的には、2 回の free は時間的に連続したイベントです。 2 回目の free は致命的なエラーとして正常に検出されました。

ダブルフリー: 時間的メモリ安全性違反。

早期の abort はエンドユーザーにとって唐突に感じられるでしょう。 また、バックエンドサービスではダウンタイムにつながる可能性もあります。 しかし、実行時検出によってエクスプロイトを防げる可能性があります! ここでは運が良かったのです。この特定のダブルフリーは、最近のバージョンの glibc に同梱されている、私たちの特定のメモリアロケータ5によって捕捉されました。 glibc の Wiki6 によると:

malloc サブシステムは、コード全体を通じてヒープ破損を検出するための合理的な試みを行います。 一部のチェックは一貫してエラーを検出します(たとえば、free に十分にアラインされていないポインタを渡す場合)。 しかし、ほとんどのチェックはヒューリスティックであり、欺くことができます…ヒープ破損はしばらく気付かれないこともあり、まったく報告されないこともあります。

言い換えると、私たちのサンプルスニペットにおけるダブルフリーは、たまたま glibc のアロケータ実装内のヒューリスティックによって検出されたということです。 古いアロケータや単純なアロケータを使用する別のプラットフォームでは捕捉されなかったかもしれません。 さらに、より複雑なシーケンスで大きな割り当て要求を伴うダブルフリーバグは、まったく捕捉されない可能性もあります。

なぜでしょうか? Rust の静的な所有権検証とは異なり、動的な不変条件チェックには実行時コストがあります。 アロケータは性能とセキュリティのバランスを取る必要がありますが、多くの場合、前者を優先せざるを得ません。 現実的には、不変条件チェックは次の任意の組み合わせになり得ます。

  • 性質として「ベストエフォート」(保証なし、信頼性が低い)である(型システムとは異なります!)
  • デフォルトで無効化されている。
  • 可用性の保証とトレードオフになる(assert 失敗による panic)。
  • 確率的防御のためにランダム化に依存している。
  • [多くの場合より低速な]「強化された」アロケータにのみ用意されている。
  • 実装上または設計上の欠陥によってバイパス可能である。

この章の後半で、ヒープの仕組みをさらに詳しく取り上げます。 そして、より単純なアロケータに支えられたダブルフリー脆弱性をエクスプロイトします。 最新の glibc とその同時代の実装を攻撃する現代的なヒープエクスプロイトは、この本の範囲を超えた技術であり学問でもあります。 その深みに潜りたい人向けには、他にも無料のリソースがあります7

可用性の堅牢性: Rust への移植版が厳密に優れているわけではない

Rust で ! を追加するバージョンは、比較すると完全に安全で、読みやすく快適に見えるかもしれません。

fn get_greeting() -> String {
    String::from("Hello")
}

fn main() {
    let mut greeting = get_greeting();
    greeting.push('!');
    println!("{}", greeting);
}

所有権がメモリの割り当てと解放を処理し、String 型が再割り当てを抽象化します。 また、null 終端について心配する必要もありません。Rust の文字列ではそうではないためです(C の外部コードとの相互運用のために std::ffi::CString8 を明示的に使用する場合を除きます)。

しかし、すべてのエルゴノミクスが無償で得られるわけではありません。 String をヒープ割り当てする際には、依然として libcmalloc が呼び出されます。 生のバッファポインタは依然として返されます。 それでも、私たち自身で NULL チェックする機会はありませんでした。 Rust への移植版は、C 版より堅牢性が低いとも言えます。

  • Rust の fn get_greeting() は、システムメモリが枯渇すると panic! します。失敗し得る操作に対する、失敗しないインターフェイスなのです!

  • C の char* get_greeting() は、枯渇時に終了しません。そのエラーを NULL 戻り値を介して [暗黙的に] 伝播します。これにより、呼び出し元は失敗し得る操作をビジネスに適したロジックで処理する機会を得られます。

現代のシステムで 5〜6 文字の文字列を割り当てるときにメモリ不足になることは、どれほど現実的でしょうか? おそらく、あなたが予想するよりも現実的です。

Web サーバーのような長時間実行されるプロセスが、ホットパス上のたった 1 つの割り当てでさえ解放し忘れた場合、メモリ使用量は時間とともに、潜在的には線形に増加します。 その結果、枯渇は避けられません。 それらのリークした割り当てが外部リクエストの処理によって引き起こされる場合、攻撃者は早期に枯渇を強制してサービスを拒否できます。

現在、メモリリークは安全な Rust では概ね防止されます。 しかし、循環参照を誤用したり、unsafe コードを呼び出したりすれば、依然として発生し得ます。

型安全性を破る(低レベルの値セマンティクス)

空間的および時間的メモリ安全性に関する以前の問題を踏まえ、実行時に ! を追加するのは諦め、単純に Hello! 文字列をハードコードすることにしました。 文字列処理の危険から解放されたので、刺激的な新機能に集中できます。ユーザーレコードの追加です9

この機能には 2 つの機能要件があります。

  1. 新規ユーザーには Hello! で挨拶すること
  2. 既存ユーザーの訪問回数を追跡すること。

これらの要件をサポートするために、ユーザーレコード構造体を追加します。

#define TYPE_NEW_USR 1 // 新規ユーザー、挨拶される
#define TYPE_CUR_USR 2 // 現在のユーザー、訪問回数をインクリメントする

struct user_record_t {
    int type;
    union {
        char *greeting;
        unsigned visit_count;
    };
};
  • #define 行は C プリプロセッサディレクティブ、別名「マクロ」です。これは、ソース内に現れる大文字の定数名のすべてのインスタンスを、対応する定数値に置き換えるようコンパイラに指示します。たとえば、TYPE_NEW_USR はソース内で現れる10場所すべてで 1 に置き換えられます。C マクロには「衛生性」に関する落とし穴11がありますが、ここでは関係ありません。型付けは、2 つのバリアントを持つ Rust の enum よりも弱くなります(それはなぜだと思いますか?答えは複数あるかもしれません)。

  • user_record_t は C の構造体です。これは、新規ユーザーまたは既存ユーザーのいずれかを表します(ただし、同時に両方を表すことはありません)。union フィールドにより、同じメモリ位置に異なるデータ型を格納できます。ここでは、新規ユーザーの挨拶用の文字列ポインタ(char*)と、既存ユーザーの訪問回数用の符号なし整数(unsigned)です。

struct user_record_t を活用した更新版の main 関数:

int main() {
    struct user_record_t rec;

    rec.type = TYPE_NEW_USR;
    rec.greeting = "Hello!";

    // ロジックエラー: `TYPE_CUR_USR` であるべき
    if (rec.type == TYPE_NEW_USR) {
        rec.visit_count += 1; // 型混同、型安全性違反!
    }

    if (rec.type == TYPE_NEW_USR) {
        printf("%s\n", rec.greeting);
    }

    return 0;
}

上記の main 関数を実行すると、何か奇妙なことが起こります。 プログラムは挨拶を出力しますが、先頭の H が欠けています。まるでくだけた省略表現のようです。

ello!

実行時に実際には何が起きたのでしょうか? 型安全性のバグにより、(カウント用整数ではなく)文字列ポインタを誤って 1 増やしてしまい、その結果、次のバイト(H ではなく e)を指すようになりました。 これが可能なのは、次の理由によります。

  1. バグが存在していたこと。
  2. 意図的な演算子オーバーロードのサポート(ポインタ演算は C の重要な機能です)。
  3. union が同じメモリ位置で異なるデータ型を裏付けていること。

これは合法であるべきではないように思えます。 しかし、これらの仕組みは C の「生の」力を支えています。union とポインタ演算により、巧妙な最適化が可能になります。 実際、unsafe Rust の主要なユースケースは、同様の最適化を可能にすることです。

視覚的には、型混同は次のように展開されます。

型混同: 型安全性違反。

上の図には(簡潔にするため)反映されていない詳細があります。 メモリ安全性の例では、動的文字列 Hello! は実行時にヒープ割り当てされていました。 この型安全性の例では、たまたまハードコードされているため、静的メモリに格納されています。

バイナリについて話しているわけではない場合は?

この章ではバイナリエクスプロイトに焦点を当てていますが、データはコードであるという概念は一般に適用されます。 少し Java を例に取り上げてみましょう。

Java 言語はエンタープライズで広く使われており、Kotlin、Clojure、Scala などの言語とランタイムを共有しています。 Java ファミリーのプログラムは、Java Virtual Machine(JVM)によって実行されるバイトコードにコンパイルされます。JVM 自体は、CPU 上で動作するネイティブコンパイルされたプログラムです。

この間接化は、ガベージコレクションと相まって、パフォーマンスコストを伴います。 しかし、その代わりに、Java とその同類はメモリ安全かつ型安全です。 だからといって、それらがエクスプロイト不可能という意味ではありません。 次を考えてみてください。

  • シリアライゼーション攻撃 - 攻撃者が制御するデータを、特定のメモリ内構造へデシリアライズすること(場合によっては、さらに型混同を可能にすることもあります)。

    • Apache Kafka における RCE である CVE-2023-2519412 を参照してください。
  • コマンドインジェクション攻撃 - 攻撃者が指定した任意のコマンドを、実行のためにホストシステムのシェルへ直接渡すこと。

    • Apache Log4j における非常に広範囲に及んだ RCE である CVE-2021-4422813、別名「Log4J」または「Log4Shell」を参照してください。

バイナリエクスプロイトは、システムメモリがどのように機能するかを理解するための優れたレンズです。 しかし、多くのソフトウェア開発者やセキュリティエンジニアは、ネイティブアプリケーションを書いているわけではありません。

非バイナリ攻撃については、自主的に探究することをお勧めします。 念のため、次のセクションで Log4J の詳細についても簡単に扱います。

攻撃者はなぜコードとデータの分離を破るのか?

ソース言語に関係なく、バイナリエクスプロイトはメモリ安全性、型安全性、またはその両方の組み合わせを破ります。 安全性が破られると、未定義動作(UB)が引き起こされます。 言語の操作的意味論は通用しなくなります。 プログラムは、欠陥のあるソースコードには反映されておらず、言語仕様上も有効ではない任意の操作を実行できます。 これにより、エクスプロイト開発者には次の余地が生まれます。

  • 制御フローのハイジャック - 命令ポインタ(IP)の値を設定することで、プログラムの実行をリダイレクトすること。

    • これは強力なエクスプロイトにおける重要な「構成要素」です(ニッチなデータ指向攻撃14は除きます)。限られた場合には、制御フローのハイジャックだけで有用な悪意ある操作が可能になります(関数に望ましい副作用がある場合、引数なしで既存の関数を呼び出すなど)。
  • コードの注入 - 実行可能メモリ位置に書き込み、新しいコードを追加したり既存のコードを変更したりすること。新しく追加または変更されたコードを実行するように制御フローをハイジャックできれば、攻撃者は被害者プロセスを任意に再プログラムできます。

    • 現代のハードウェア/OS の組み合わせでは、コードインジェクションを防ぐため、実行可能コードを読み取り専用メモリに格納します。しかし、この保護を持たないリソース制約のある組み込みシステム15にとっては、依然として主要な攻撃ベクトルです。また、主要な Web ブラウザで使用される JavaScript エンジン16のように、Just-In-Time コンパイルをサポートするアプリケーションにとっても同様です。
  • コードの再利用 - 「ガジェット」と呼ばれる既存コードの小さな断片を多数つなぎ合わせ、半任意の操作列を生成すること17。コードインジェクションよりも、計算の柔軟性とエクスプロイトの安定性の両面で汎用性は低いですが、多くの場合に有効です。

    • コード再利用攻撃は、一般的な緩和策を迂回するため、ほとんどの現代的ソフトウェアに対して実行可能です。ただし、防御策も存在します。この章の後半で防御策について議論し、将来の付録セクションで最も一般的なコード再利用攻撃のクラスである Return-Oriented Programming(ROP)を扱います。

要点

メモリ安全性(空間的または時間的)および/または型安全性を侵害すると、セキュリティと機能性の保証が破壊されます。 これは、攻撃者が制御するデータとホストが実行するコードの間の分離を破るためです。 安全性の脆弱性は、攻撃者に被害者のソフトウェアに対する制御を与える可能性があります。

攻撃者がプロセスの制御を得ると、その足場を活用して、さらに悪意ある目的を進めます。 サービスの妨害、機密データの窃取、マルウェアのインストールなどです。 一見些細な脆弱性が、事業やミッションに重大な影響をもたらす可能性があります。

攻撃者の視点へのこの導入では、コードスニペットとメモリ/型安全性違反のメカニズムを用いて、低レベルに焦点を当てています。 長期的には、エクスプロイトをより抽象的に理解し、この種の脅威について推論するための枠組みを構築することが役立つ場合があります。 そのような概念化はより一般的であり、バイナリ攻撃を超えて応用できます。 それが次のトピックです!


  1. 「コードはデータである」または「データはコードである」は、Lispプログラマーの間でよく知られた格言です。技術的には、どのプログラミング言語のコードもデータです。すべてのインタープリターとコンパイラーは、何らかのデータをエンコードしたファイルを解析します。しかしLisp系言語では、整形式のプログラムのソースコードは、同時に「S式」と呼ばれる有効なデータ構造でもあります。S式は言語そのものの中核をなすため、Lispプログラムにはホモイコニシティ18と呼ばれる性質があります。つまり、プログラムをデータとして操作できます。これにより豊かなメタプログラミング能力が可能になり、それはRustのマクロシステムに影響を与えた要素の1つです。 エクスプロイトに話を戻すと、メモリ破壊エクスプロイトは、奇妙な種類のメタプログラミングだと考えることができます。

  2. What Hacker Research Taught Me. Sergey Bratus (2011).

  3. Struct std::string::String. The Rust Team (2023年アクセス).

  4. Class std::string. The C++ Team (2023年アクセス).

  5. tchacheは「thread local cache」の略で、glibcバージョン2.266malloc実装に追加された内部最適化です。非常に小さなメモリチャンクが要求された場合、特にマルチスレッドプログラムにおいて、より高速な割り当てを可能にします。このエラーメッセージは、実装に極めて固有です。

  6. Overview of Malloc. glibc Wiki (2022年アクセス). ↩2

  7. how2heap. Shellphish CTF team (2022年アクセス).

  8. Struct std::ffi::CString. The Rust Team (2023年アクセス).

  9. CWE-843: Access of Resource Using Incompatible Type (‘Type Confusion’). MITRE Corporation (2022年アクセス). 私たちの型[非]安全性に関するコードスニペットは、そこで提供されている例に基づいています。

  10. 3.13 Options Controlling the Preprocessor. The GNU Project (2021年アクセス). マクロを多用したCコードをデバッグするとき、マクロが何に展開されるかを確認するためにプリプロセッサを実行すると役立つ場合があります。gcc-Eフラグは、この展開を行います。

  11. 衛生的マクロ. Wikipedia (2022年アクセス).

  12. CVE-2023-25194. MITRE (2023年アクセス).

  13. Apache Log4j Vulnerability Guidance. CISA (2021).

  14. ここでほぼという但し書きを付けているのは、「データ指向攻撃」を考慮するためです。その名前が示すように、これらの攻撃は非制御データ(変数値や非関数ポインターなど)を操作して、情報を漏えいさせたり、性能を低下させたり、さらには権限昇格を行ったりします。言語非依存の緩和策の文脈でデータ指向攻撃について簡単に説明しますが、本書ではコード例は扱いません。これらの攻撃は、制御フローハイジャック攻撃に比べて、はるかに汎用性が低く、一般的でもない傾向があります。

  15. Challenges in Designing Exploit Mitigations for Deeply Embedded Systems. Ali Abbasi, Jos Wetzels, Thorsten Holz, Sandro Etalle (2019).

  16. SoK: Make JIT-Spray Great Again. Robert Gawlik, Thorsten Holz (2018).

  17. xgadget. Tiemoko Ballo and contributors (2022年アクセス). Return-Oriented Programming (ROP)およびJump-Oriented Programming (JOP)のエクスプロイト開発のための、このオープンソースツールに取り組む自由時間がもっとあればよかったのですが。

  18. ホモイコニシティ. Wikipedia (2022年アクセス).