nuta / operating-system-in-1000-lines

Writing an OS in 1,000 lines.
https://operating-system-in-1000-lines.vercel.app
188 stars 18 forks source link

[質問] 11. プロセスの章の例外ハンドラの修正について #12

Closed speed1313 closed 1 year ago

speed1313 commented 1 year ago

例外ハンドラの修正 の部分で, なぜコンテキストスイッチ時に次に実行するプロセスのカーネルスタックの底をsscratchで持っておく必要があるかがわかりません... 例外ハンドラで以下のようにカーネルスタックの底から31個分のメモリを使ってレジスタを退避しています(と思われる)が, これだとカーネルスタックの底付近のデータを上書きしてしまっているのではないでしょうか?

// tmp = sp; sp = sscratch; sscratch = tmp;
"csrrw sp, sscratch, sp\n"
"addi sp, sp, -4 * 31\n"
        "sw ra,  4 * 0(sp)\n"
        "sw gp,  4 * 1(sp)\n"
        "sw tp,  4 * 2(sp)\n"
        "sw t0,  4 * 3(sp)\n"
        "sw t1,  4 * 4(sp)\n"

修正前のまま, プロセスごとに例外発生時のspから上の部分をレジスタの退避領域として使えばいい気がするのですが, なぜダメなのでしょうか?

nuta commented 1 year ago

例外ハンドラに入る際、大きく3つのパターンがあります。

a. カーネルモードで例外が発生した。 b. 例外処理中にカーネルモードで例外が発生した (ネストされた例外)。 c. ユーザーモードで例外が発生した。

ご指摘の「例外発生時のスタックポインタをそのまま使う」ことができるのは (a)、(b) の場合です。ただしこの実装ではネストされた例外からの復帰を想定していないため、(b) の場合はおっしゃる通り退避領域を上書きした後にカーネルパニックで停止します。

問題は (c) の場合です。このとき、spは「ユーザー (アプリケーション) のスタック領域」を指しています。spを引き継いでしまう実装の場合に次のような不正な値をセットして例外を発生させると、カーネルをクラッシュさせる脆弱性 (類似事例: CVE-2014-4699) に繋がります。

// shell.c
#include "user.h"

void main(void) {
    __asm__ __volatile__(
        "li sp, 0xdeadbeef\n"
        "unimp"
    );
}
epc:0x0100004e, tval:0x00000000, desc=illegal_instruction <- unimpで例外ハンドラに遷移
epc:0x802009dc, tval:0xdeadbe73, desc=store_page_fault <- スタック領域 (0xdeadbeef) への書き込み失敗例外
epc:0x802009dc, tval:0xdeadbdf7, desc=store_page_fault <- スタック領域 (0xdeadbeef) への書き込み失敗例外 (2)
epc:0x802009dc, tval:0xdeadbd7b, desc=store_page_fault <- スタック領域 (0xdeadbeef) への書き込み失敗例外 (3)
epc:0x802009dc, tval:0xdeadbcff, desc=store_page_fault <- スタック領域 (0xdeadbeef) への書き込み失敗例外 (4)

これを防ぐために、信頼できるスタック領域をsscratchから取り出すようにしています。

余談ですが、xv6 (有名な教育用UNIX風OS) のRISC-V版実装では、(a)と(b)の時用の例外ハンドラ (kernelvec) と、(c)の時用の例外ハンドラ (uservec) がそれぞれ別になっています。前者の場合は、例外発生時のスタックポインタを引き継ぎ、後者の場合はカーネルスタックを別途取り出す実装になっており、カーネルを出入りするときに ハンドラの設定を切り替える ようになっています。ご興味があれば、xv6の実装と解説 (日本語訳 の「4.2 カーネル空間からのトラップ」あたり) も参考になると思います。

speed1313 commented 1 year ago

ありがとうございます! ユーザモードでのspの悪意ある書き換えを防ぐ上でsscratchでスタックの底を指しておくことが重要なのですね!

ただまだ理解できていない点があります。

ユーザモードで例外が発生した時、sscratchは&next->stack[sizeof(next->stack)])、すなわちプロセスの持つスタックの一番底の部分を指しており、spはそれより上の部分、すなわちsp < sscratch という関係になっていると思います。 その状態でkernel_entryで以下のコードのようにプロセスの持つスタックの底の部分にレジスタを退避すると、退避前にあったデータが上書きされてしまうと思います。 すると例外処理の前後でスタックの状態が変わってしまい、例外処理から復帰した後の計算がおかしくなってしまいそうなのですが、なぜこの実装でよいのでしょうか?

スタックの底から31*4行分は例外処理のためのレジスタの退避以外の目的で使われないという制約があるんでしょうか? """

    "csrrw sp, sscratch, sp\n"

    "addi sp, sp, -4 * 31\n"
    "sw ra,  4 * 0(sp)\n"
    "sw gp,  4 * 1(sp)\n"
    "sw tp,  4 * 2(sp)\n"

"""

nuta commented 1 year ago

分かりづらいところが2点あるので、別々に解説します。

1. カーネルスタック外のメモリ領域を破壊していないのか

スタックポインタはトラップハンドラ内で次のように動きます。

csrrw sp, sscratch, sp

カーネルスタックの最初の部分にセット。

struct kernel_stack {
    char rest[8192 - sizeof(trap_frame)]; // 残り
    struct trap_frame { // トラップ発生時の状態
        uint32_t ra;
        uint32_t gp;
        uint32_t tp;
        uint32_t t0;
        ...
        uint32_t s11;
        uint32_t sp;
    };

    // <==== sp
};

addi sp, sp, -4 * 31

struct trap_frame の大きさ分、スタックポインタを引く。つまり、スタック領域を割り当てる。

struct kernel_stack {
    char rest[8192 - sizeof(trap_frame)];
    struct trap_frame {
        uint32_t ra;    // <==== sp
        uint32_t gp;
        uint32_t tp;
        uint32_t t0;
        ...
        uint32_t s11;
        uint32_t sp;
    };
};

これで、スタックポインタをベースアドレスとして、各レジスタの保存領域に オフセット(sp) の形でアクセスできる。

struct kernel_stack {
    char rest[8192 - sizeof(trap_frame)];
    struct trap_frame {
        uint32_t ra;    // 4 * 0(sp)
        uint32_t gp;    // 4 * 1(sp)
        uint32_t tp;    // 4 * 2(sp)
        uint32_t t0;    // 4 * 3(sp)
        ...
        uint32_t s11;
        uint32_t sp;    // 4 * 30(sp)
    };
};

あとは、restの部分が使われていくようになります。最初に 4 * -31 引いているので、トラップハンドラはカーネルスタックの後ろの領域を壊すことなく保存できているというわけです。

2. ハンドラに入るごとに同じスタックポインタを使って大丈夫か

その状態でkernel_entryで以下のコードのようにプロセスの持つスタックの底の部分にレジスタを退避すると、退避前にあったデータが上書きされてしまうと思います。 すると例外処理の前後でスタックの状態が変わってしまい、例外処理から復帰した後の計算がおかしくなってしまいそうなのですが、なぜこの実装でよいのでしょうか?

まず、アプリケーションのスタックに関しては、カーネルスタックとは別のメモリ領域にあるので問題ありません。

カーネルスタックについては、確かにプロセスからカーネルのトラップハンドラに入る際には、必ずカーネルスタックをゼロから再利用する実装になっています。そのためトラップハンドラが呼ばれるごとに、カーネルスタックにあったデータは確かに破壊されることになります。

ただし、例外処理から「カーネルモードに」復帰するケースは本実装では現れません。復元されるのは必ずユーザーモード (アプリケーション) 、つまりカーネルスタックがもう必要ない状態です。確かにシステムコール処理中にトラップが発生するようなケースで元の例外発生時 (システムコール呼び出し時) の状態を上書きしてしまいますが、すぐにカーネルパニックして処理を止めるので問題ありません。答えとしては「復元されることはないので破壊されても問題ない」です。

ちなみにですが、HinaOSの方ではこのケースに対応できるよう「カーネル (トラップハンドラ内) で例外が発生したら、スタックポインタをそのまま引き継ぐ」実装になっています(リンク)。イメージとしては、次のようなカーネルスタックの使い方をして上書きしないようになっています。

struct kernel_stack {
    char rest[...];           // スタックの残り
    char stack1[...];         // ページフォルトハンドラのローカル変数など
    struct trap_frame frame1; // システムコールハンドラでページフォルト (トラップ) が発生した時の状態    
    // <-- ページフォルト発生時のspが指す位置
    char stack0[...];         // システムコールハンドラのローカル変数など
    struct trap_frame frame0; // システムコール呼び出し時の状態
};
speed1313 commented 1 year ago

アプリケーションのスタックはuser.ldで用意されているものを使うため問題ないんですね!

疑問を解決することができました. ありがとうございます!

nuta commented 1 year ago

上記の内容を b8bc104415991c352b13491277a609af01142867 にて追記しました。こちらこそ内容の向上にご協力いただき、ありがとうございます 😄