シノプシス ソフトウェア・インテグリティ・グループはブラック・ダックになりました 詳細はこちら

close search bar

申し訳ありませんが、この言語ではまだご利用いただけません

close language selection

[CyRC Case Study] CVE-2020-25669 および Linux カーネルを使用したエクスプロイト可能なメモリ破壊

Black Duck Editorial Staff

Aug 25, 2022 / 3 min read

メモリ破壊の研究目的と課題

シノプシス Cyber Security Research Center(CyRC)の主な目標の 1 つは、脆弱性が悪用される範囲を特定することです。 公開されているアドバイザリでは、脆弱性の影響が一般的な用語で説明されていることが多く、ボイラープレートスコアの種類に基づいて脆弱性が区分されます。ただし、メモリ破壊のバグは、ASLR(アドレス空間配置のランダム化)やポインター認証など、悪用を緩和する仕組みがあることで、最新のシステムでは悪用が困難です。メモリ破壊の脆弱性に関するアドバイザリに含まれている概念実証の「エクスプロイト」は、通常、影響を受けるアプリケーションをクラッシュさせ、可用性への影響を示します。 任意のコードの実行など、より深刻な影響を示す概念実証のエクスプロイトはまれです。

現代の世界では、高度な脅威アクターは日常茶飯事です。これは、多くのお客様がよく知っている事実です。 多くのソフトウェアのバグ、特にメモリ破壊に関連するバグは、巧妙な方法で利用して、標的のアプリケーションを完全に破壊することができます。 特定の脆弱性の悪用可能性のレベルを判断することは、訓練されていない目には謎に包まれた技術です。 私たちの仕事の過程で、私たちのチームの知識と経験が報告された影響に疑問を投げかける脆弱性を見つけることがよくあります.

このブログ投稿では、興味深い脆弱性といくつかの解析方法を紹介しています。 脆弱性 CVE-2020-25669 には、Linux カーネル内のメモリ破壊の問題が含まれます。 カーネルの安定性とセキュリティに関する最も重要な要素の 1 つは、動的に割り当てられたメモリリソースを安全に管理することです。メモリの動的に割り当てられた部分に存在するオブジェクトは、他のカーネルコンポーネントで使用するためにメモリのその部分を解放した後は、カーネルコンポーネントによってアクセスされないことが非常に重要です。 そうすることは、いわゆる「解放済みメモリの使用」イベントを構成します。 解放済みメモリの使用の脆弱性は、攻撃者が強力なエクスプロイトプリミティブに利用できるため、セキュリティに非常に深刻な影響を与える可能性があります。

CVE-2020-25669 の概要

2020 年 11 月 5 日、oss-security メーリングリストで、Linux カーネルの Sun Microsystems キーボードドライバー(drivers/input/keyboard/sunkbd.c にあります)内の脆弱性を詳述するアドバイザリが公開されました。このアドバイザリには、概念実証のエクスプロイトが含まれており、解放後の使用イベントが sunkbd_reinit() 関数でどのように発生する可能性があるかが説明されています。 概念実証のエクスプロイトは、use-after-free イベントを確実にトリガーしますが、潜在的なサービス拒否以外の影響は示しません。CVE-2020-25669 について説明している一般公開されているアドバイザリの中には、この脆弱性を悪用して権限昇格を行う可能性があると述べているものもありますが、このガイダンスは一般的な条件で発行されたようです。 (前述のように、これはメモリ破損の脆弱性について議論するアドバイザリでは一般的です。) 最近まで、このような攻撃を実行するために脆弱性を悪用する方法について公開されているリソースはありませんでした。しかし、Black Duck® Security Research (CyRC の一部)は、適切な再割り当てガジェットを所有する攻撃者がこの脆弱性を利用して命令ポインタを制御できることを実証しました。 ローカルの攻撃者は、この実行リダイレクトプリミティブをチェーンの一部として使用して、本格的な権限昇格のエクスプロイトを構築する可能性があります。

脆弱なコードの解析

Sun Microsystems のキーボードドライバは、構造体 (struct sunkbd) を使用してキーボードデバイスを記述します。 この構造体のインスタンスは、各デバイスの状態に関する情報を格納するために使用されます。 構造は次のように定義されます。

struct sunkbd { 
    unsigned char keycode[ARRAY_SIZE(sunkbd_keycode)];
    struct input_dev *dev;
    struct serio *serio;
    struct work_struct tq;
    wait_queue_head_t wait;
    char name[64];
    char phys[32];
    char type;
    bool enabled;
    volatile s8 reset;
    volatile s8 layout;
};

sunkbd_connect() 関数は、kzalloc() を呼び出して、カーネルヒープ内にこの構造体のインスタンスを割り当てます。

sunkbd = kzalloc(sizeof(struct sunkbd), GFP_KERNEL);

ご覧のとおり、sunkbd->tq メンバーは構造体 work_struct 型です。 この構造体は、カーネル内で遅延処理を実行するために使用されます。 「延期された作業」とは、将来のある時点で実行されるコードを指します。 遅延作業を実行するために、カーネル内でさまざまなメカニズムを使用できます。 これらの遅延作業メカニズムは、カーネルのさまざまなコンポーネントで必要とされますが、特に重要なコンポーネントの 1つは割り込みハンドラーです。据え置き作業は、これらのハンドラーが受ける制約により、割り込みハンドラーの実装に不可欠です。 そのような制約の 1 つは、割り込みハンドラーはプロセスコンテキストではなく割り込みコンテキストで実行されるため、スリープする可能性のあるコードを割り込みハンドラーが実行できないことです。 割り込みハンドラがスリープする可能性のあるコードを実行する必要がある場合は、ワークキューを使用できます。 これらは、遅延コードをプロセスコンテキストで実行させる遅延作業メカニズムです。 プロセスコンテキストで実行される関数は、初期化されるたびに work_struct インスタンスに関連付けることができます。 この場合、そのタスクは sunkbd_connect() で実行されます。

INIT_WORK(&sunkbd->tq, sunkbd_reinit);

この作業の実際のスケジューリングは、データ引数が SUNKBD_RET_RESET と一致し、キーボードデバイスが有効になっている場合は常に、sunkbd_interrupt() で実行されます。

case SUNKBD_RET_RESET:
    if (sunkbd->enabled)
        schedule_work(&sunkbd->tq);
        sunkbd->reset = -1;
    break;

これにより作業がグローバル・ワーク・キューに追加され、最終的に sunkbd_reinit() がカーネル・ワーカー・スレッドを介して実行されます。

use-after-free イベントが発生するのは sunkbd_reinit() 関数内です。 関数が呼び出されるたびに、スケジュールに使用された work_struct インスタンスへのポインタが渡されます。 sunkbd_reinit() によって実行される最初のアクションは、この struct work_struct インスタンスを含む struct sunkbd インスタンスへのポインタを取得することです。

static void sunkbd_reinit(struct work_struct *work)
{
    struct sunkbd *sunkbd = container_of(work, struct sunkbd, tq);

次に、関数は wait_event_interruptible_timeout() を呼び出します。

wait_event_interruptible_timeout(sunkbd->wait,
    sunkbd->reset >= 0 || !sunkbd->enabled,
    HZ);

これにより、条件が sunkbd->reset >= 0 || の場合、ワーカースレッドがスリープ状態になります。 !sunkbd->enabled は true です。 3 番目の引数は、ワーカースレッドがスリープする時間を指定します。 この場合、1 秒のハードコーディングされたスリープ期間が指定されています。 sunkbd_reinit() 関数は 1 秒後に再び起動し、wait_event_interruptible_timeout() の呼び出しに続くコードを実行し続けます。 このコードの最初の行は serio_write() の呼び出しです。

serio_write(sunkbd->serio, SUNKBD_CMD_SETLED);

どのように「解放済みメモリの使用」が起こるのか

serio_write() をしばらく調べます。 まず、use-after-free イベントがどのように発生するかを調べてみましょう。 sunkbd_reinit() によって実行される最初のアクションは、sunkbd 変数に格納される struct sunkbd インスタンスへのポインタを取得することであることを思い出してください。 このインスタンスがヒープメモリ内にあることがわかりました。 sunkbd_reinit() がスリープ状態になるたびにそのメモリが解放されると、sunkbd_reinit() が起動し、sunkbd に保存されているポインタを逆参照しようとするたびに解放後使用イベントが発生します。 sunkbd が指すメモリは、sunkbd_disconnect() 関数を介して解放できます。 この関数は、特定のキーボードデバイスの登録を解除するために使用されます。 これには、そのデバイスに割り当てられたリソースの解放が含まれます。 この関数の定義は次のとおりです。

static void sunkbd_disconnect(struct serio *serio)
{
    struct sunkbd *sunkbd = serio_get_drvdata(serio);
 

    sunkbd_enable(sunkbd, false);
    input_unregister_device(sunkbd->dev);
    serio_close(serio);
    serio_set_drvdata(serio, NULL);
    kfree(sunkbd);
}

ご覧のとおり、この関数によって実行される最後のアクションは、sunkbd オブジェクトを解放することです。 sunkbd_reinit() がスリープ状態になるたびに sunkbd_disconnect() が呼び出され、sunkbd 変数が参照するデバイスの登録が解除されると、sunkbd_reinit() が起動するたびに解放済みメモリの使用イベントが発生します。 これは、公開されている概念実証のエクスプロイトがどのように機能するかです。 sunkbd_reinit() 内で指定されたハードコーディングされた 1 秒のスリープ期間は、大きくて信頼できるレースウィンドウを提供します。 これにより、概念実証が信頼できるものになります。

任意のコード実行を実現する方法

では、この脆弱性を利用して任意のコードを実行するにはどうすればよいでしょうか? 一度 sunkbd_reinit() が目覚めたときに実行されるコード行に戻りましょう。

serio_write(sunkbd->serio, SUNKBD_CMD_SETLED);

この関数呼び出しには、sunkbd->serio の値を取得するために、sunkbd の逆参照が含まれます。 sunkbd が解放されたメモリを参照すると、障害が発生します。 ただし、通常の状況では、sunkbd->serio は struct serio インスタンスへの有効なポインタを示します。 この構造体の定義を調べると、多数の関数ポインタが含まれていることがわかります。

struct serio {
    void *port_data;
    …
    int (*write)(struct serio *, unsigned char);
    int (*open)(struct serio *);
    void (*close)(struct serio *);
    int (*start)(struct serio *);
    void (*stop)(struct serio *);
    …

serio_write() の定義は次のとおりです。

static inline int serio_write(struct serio *serio, unsigned char data)
{
    if (serio->write)
        return serio->write(serio, data);
    else
        return -1;
}

この関数は、 serio->write 関数ポインタが設定されているかどうかをテストします。 持っている場合は、それが指す関数が呼び出されます。

命令レベルでの解析

それでは、デバッガ内で sunkbd_reinit() を調べて、 serio_write() への呼び出しが命令レベルでどのように実装されているかを見てみましょう。

関数 sunkbd_reinit のアセンブラコードのダンプは次のようになります。

push   r12
push   rbp
push   rbx
mov    rbx,rdi                     [1]
...
mov    rdi,QWORD PTR [rbx-0x8]     [2]

関数のプロローグ中に、rdi レジスタに含まれる値が [1] の rbx レジスタに移動されます。 x86_64 Linux システムでは、関数の最初の引数を格納するために rdi レジスタが使用されます。 sunkbd_reinit() の最初で唯一の引数は、その呼び出しをスケジュールするために使用された work_struct インスタンスへのポインタであることがわかっています。 [2] で、そのポインターから 8 が減算されます。 結果のアドレスは逆参照され、その場所の値が rdi に読み込まれます。 この命令の目的は、sunkbd 構造体の定義を調べることで判断できます。

struct sunkbd {
    unsigned char keycode[ARRAY_SIZE(sunkbd_keycode)];
    struct input_dev *dev;
    struct serio *serio;   <- rbx – 0x8
    struct work_struct tq; <- rbx
    wait_queue_head_t wait;
    char name[64];
    char phys[32];
    char type;
    bool enabled;
    volatile s8 reset;
    volatile s8 layout;
};

関数のプロローグ中に rbx に格納されるアドレスは sunkbd->tq へのポインタです。 tq メンバーが serio メンバーの直後にあることがわかります。これは、struct serio インスタンスへのポインターです。 x86_64 Linux システムではポインタの長さは 8 バイトであるため、rbx から 8 を引いた結果は sunkbd->serio へのポインタになります。 命令 [2] は、このポインタを逆参照して、sunkbd->serio の値を rdi にロードします。 これは serio_write() への最初の引数を取得する方法です。

rdi↓

serio_write(sunkbd->serio, SUNKBD_CMD_SETLED);

serio_write() はインライン関数として宣言されているため、その命令はコンパイラによって sunkbd_reinite() の命令に簡単に組み込むことができます。 serio_write() が serio->write が指す関数を実行しようとしていることがわかりました。

if (serio->write)
    return serio->write(serio, data);

命令レベルでは、これは、rdi に格納されている値に対してポインター演算を実行することによって実現されます。

mov  rax,QWORD PTR [rdi+0xd8]
test   rax,rax

rdi レジスタが struct serio インスタンスへのポインタを格納していることはわかっています。 16 進数の 0xd8(10 進数の 216)は、そのインスタンス内の書き込み関数ポインタのオフセットであるため、rdi に 0xd8 を追加してから結果を逆参照すると、書き込み関数ポインタ自体の値が得られます。 これは rax にロードされ、その後のテスト rax、rax 命令を使用して、関数ポインタがゼロ以外の値に設定されているかどうかが判断されます。 したがって、これら 2 つの命令は if (serio->write) 条件に対応します。 次のいくつかの指示は次のとおりです。

je        0xffffffff817c15a2 <sunkbd_reinit+176>
mov    esi,0xe
call     0xffffffff81e00ee0 <__x86_indirect_thunk_rax>

serio->write の値がゼロの場合、関数エピローグへのジャンプが発生します。 ゼロ以外の場合、__x86_indirect_thunk_rax() が呼び出されます。 これは関数トランポリンで、次の 2 つの指示で終了します。

mov    QWORD PTR [rsp],rax
ret

これには、rax の値(つまり、serio->write)を rip にロードする効果があり、serio->write 関数ポインタで指定された場所に実行をリダイレクトします。

sunkbd_reinit() の評価が、serio->write で指定された関数の実行にどのようにつながるかを見てきました。 任意のコードが実行される可能性は、use-after-free バグにより、sunkbd_reinit() で使用される struct sunkbd インスタンスの内容を攻撃者が制御できるという事実に起因しています。 このインスタンスは、sunkbd_reinit() がスリープ状態のときはいつでも sunkbd_disconnect() の実行をトリガーすることで解放できることを思い出してください。 適切な再割り当てガジェットを所有する攻撃者は、解放された sunkbd バッファを再割り当てし、制御するデータで満たす可能性があります。 sunkbd_reinit() が起動すると実行されるコードの最初の行をもう一度調べてみましょう。

serio_write(sunkbd->serio, SUNKBD_CMD_SETLED)

インライン serio_write() 関数が serio->write 関数ポインタを取得するためにポインタ演算を実行し、__x86_indirect_thunk_rax() を介して命令ポインタにロードされることを確認しました。 この一連のイベントから、次の興味深い命令を選択できます。

mov    rbx,rdi     [3]

mov    rdi,QWORD PTR [rbx-0x8] [4]

mov    rax,QWORD PTR [rdi+0xd8] [5]

mov    QWORD PTR [rsp],rax [6]
ret   [7]

再割り当てガジェットを介して、攻撃者は、sunkbd 変数が参照するヒープ・メモリ・バッファを制御し、その rdi が命令で指し示します [3]。 したがって、sunkbd->serio の値を制御できます。これは、命令 [4] で取得されます。 命令 [5] は、この値に 0xd8 (216) を追加し、結果のアドレスを逆参照します。 このアドレスの値は、命令 [6] および [7] によって命令ポインタにロードされます。

したがって、攻撃者は次の手順を実行して、任意のコードを実行する必要があります。

  1. rip にロードするインメモリ値のアドレスを確立します。
  2. このアドレスから 0xd8 (216) を引きます。
  3. sunkbd_reinit() の実行をトリガーします。これにより、sunkbd 変数の値が設定され、スリープ状態になります。
  4. sunkbd_reinit() がスリープ状態のときはいつでも、sunkbd_disconnect() の実行をトリガーして、sunkbd 変数が参照するヒープ メモリを解放します。
  5. 再割り当てガジェットを使用して、手順 2 で計算したアドレスで sunkbd->serio の値を上書きします。

エクスプロイトのデモ

以下は、そのような攻撃を示す段階的な例です。 公開されている概念実証のエクスプロイトは、この例を実行するために悪意のある再割り当てイベントと組み合わせて使用されました。その目的は、ユーザーが制御する値を命令ポインターにロードすることでした。 システム・コール・テーブルは、このようなデモンストレーションの関数ポインタの便利なソースです。したがって、この例では、__x64_sys_read() ルーチン(このインスタンスのシステム・コール・テーブルの最初のメンバー)にリダイレクトされるコード実行が含まれます。 この例は、メモリの内容を調べることができるように、GDB が接続された QEMU を使用して実行されました。

まず、sunkbd_disconnect() 内で kfree() が呼び出された時点での rdi レジスタの内容を調べてみましょう。 これにより、sunkbd がメモリ内のどこにあるかがわかります。

(gdb) print/x $rdi
0xffff888006bd9600

したがって、sunkbd バッファはアドレス 0xffff888006bd9600 にあります。 sunkbd の serio メンバーは、 sunkbd 構造への 136(0x88)バイトのオフセット(つまり、アドレス 0xffff888006bd9688)に存在します。 これは、コード実行をリダイレクトするために上書きする必要がある sunkbd のメンバーです。

serio メンバーを上書きする値を決定するには、いくつかの手順を実行する必要があります。 まず、攻撃者は命令ポインターにロードする関数ポインターを決定する必要があります。 前述のように、この例ではシステム・コール・テーブル (__x64_sys_read) の最初のエントリが選択されています。

(gdb) print/x sys_call_table
$14 = 0xffffffff82000280 <sys_call_table>

システム・コール・テーブルは、アドレス 0xffffffff82000280 から始まります。 sunkbd->serio を上書きするために使用される値に到達するには、このアドレスに対して算術を実行する必要があります。 具体的には、216 を減算する必要があります。serio->write を特定するために、serio ポインタに 216(0xd8)が加算されることを思い出してください。 したがって、悪意のあるアドレスは 0xffffffff820001a8 です。 次に、攻撃者は適切な再割り当てガジェットを特定し、それを使用して、sunkbd_disconnect() によって解放された sunkbd オブジェクトに関連付けられたメモリを再割り当てする必要があります。 攻撃者は、悪意を持って作成されたアドレスが、再割り当てされたバッファーの先頭から 136 バイトのオフセットに配置されていることを確認する必要があります。 攻撃者がこれらの手順を正しく実行した場合、sunkbd_reinit() が目覚めるまでに、sunkbd->serio の上書きに成功します。

再割り当て前:

(gdb) print/x ({struct sunkbd}$sunkbd)->serio
 $3 = 0xffff888006bce000

再割り当て後:

(gdb) print/x ({struct sunkbd}$sunkbd)->serio
 $10 = 0xffffffff820001a8

次の一連の命令は、sunkbd_reinit() が目覚めた後に実行されます。これにより、__x64_sys_read 関数ポインタが rip にロードされます。

mov    rdi,QWORD PTR [rbx-0x8]

  • rbx は、sunkbd->work を指します。
  • rbx-0x8 は、sunkbd->serio へのポインタを生成します(つまり、再割り当てバッファ内に配置された、悪意を持って作成されたアドレスへのポインタ)。
  • このポインタは逆参照され、細工されたアドレスが rdi に読み込まれます。

この命令が実行された後、rdi はシステム・コール・テーブルの前の 216 バイトを指します。

print/x $rdi
$12 = 0xffffffff820001a8

そして、次に実行される命令は次のとおりです。

mov    rax,QWORD PTR [rdi+0xd8]

  • rdi+0xd8 は、システム・コール・テーブル(__x64_sys_read)の最初のエントリへのポインタを生成します。

この命令の実行後、rax には __x64_sys_read() ルーチンへのポインタが含まれます。 続いて、次の一連の命令が実行されます。

call     0xffffffff81e00ee0 <__x86_indirect_thunk_rax>
jmp    0xffffffff81e00ee5 <__x86_retpoline_rax>
call     0xffffffff81e00ef1 <__x86_retpoline_rax+12>
mov    QWORD PTR [rsp],rax
ret

このシーケンスの最後の 2 つの命令により、rax の内容が命令ポインタにロードされます。 したがって、実行は __x64_sys_read にリダイレクトされます。

(gdb) si
__do_sys_read (count=<optimised out>, buf=<optimised out>, fd=<optimised out>) at fs/read_write.c:625

したがって、実行はユーザーが制御する場所にリダイレクトされました。 これは強力なエクスプロイトプリミティブであり、攻撃者が本格的なチェーンに組み込み、標的のシステムを完全に侵害する可能性があります。

Continue Reading

トピックを探索する