📌

セキュリティ・キャンプ2024ハイパーバイザゼミ応募課題さらし

2024/09/13に公開

2024セキュリティ・キャンプ全国大会、S12『ハイパーバイザを自作して仮想化技術やセキュリティについて学ぶゼミ』に合格したので、応募課題を公開します!!!

この記事は、セキュリティ・キャンプでハイパーバイザを作りました!!!の一部です。

問題はこちらにまとまっています。

第一問

本ゼミで取り組んでみたいことを、講義概要を読んだ上で回答してください。 あなたの知識や経験、及びそれらから得た学びなどを記述すると良いでしょう。 もしこの分野について初心者であるという場合は、なぜあなたがハイパーバイザに興味を持ったかを記述してください。

また、取り組む上で必要となる知識やそれを得るための手法、前提となるコンピュータの機能などを調査し記述してください。

参考: https://syuu1228.github.io/howto_implement_hypervisor/
中学のころから、type1,2どちらのハイパーバイザーにも興味を持っており、自宅にあるmacminiでweb上に落ちている情報が少ない中どうにかESXiを動かしたりvmware workstation、fusionやvirtualbox、qemuを動かして見たりしていた。
ただ、内部の仕組みは何度か調べたことがあったが、いずれもごく曖昧な理解のまま終わってしまっていた。
最近自作OSに興味をもち調べていたら、セキュリティ・キャンプがあることを知り実際にどのようなことがやりたいか考えていたら、自作OS上でtype2ハイパーバイザーを動かしてみたいとなったが、自作OSはあまり機能を持たないため、type1ハイパーバイザーを作るほうが性能の低下もそれほどなく良さげだと思い応募した。
この講義でやってみたいこととして、キーボードショートカットで簡単に画面に出ているOSを切り替えられ、ファイルの共有も簡単にできるようなハイパーバイザーを作ってみたいということがある。
これを実現するためには、一般的なハイパーバイザーの機能に加えて画面の表示やPCに接続されているキーボードを瞬時に切り替えられ、かつファイルサーバーの機能を追加したハイパーバイザーを実装することが必要である。
実装するには、仮想化支援があるcpuが必要であり、arm64のメモリマップIOをMMUのStage2の仮想化支援機能によって実装したり、トラップされハイパーバイザーに渡されたセンシティブ命令を適切に処理するように作り、その後、キーボード入力による外部割り込みがあったとき今画面に出ているOSにわたすようにすればよい。
ファイルの共有については、linux,windowsで安全に使えるexfatでフォーマットされたドライブを外部ストレージとして双方に認識させることで共有が可能にできると考えられるが、同時アクセスの制限をできるかはわかっていないため、PC内部で完結するファイルサーバーを実装するほうが良いのかもしれない。

第二問

ハイパーバイザによる仮想化とエミュレータによるエミュレーションの違いについて述べてください。 ポイントとして以下の点を調べて挙げてください。

  • ハイパーバイザよる仮想化のメリット、デメリット(制限)
  • エミュレータによるエミュレーションのメリット、デメリット
  • ハイパーバイザで仮想マシンを実行する際は、エミュレーションは必要か、不要か
    • ヒント: 講義概要では"QEMU-KVM"と書きましたが、"QEMU"と"KVM"の役割について調べてみてください
  • ハイパーバイザによる仮想化では、異なるCPUアーキテクチャ命令の実行は可能か

エミュレータ型とハイパーバイザ型の仮想化の模式図(hypervisor_emulator.jpg)を添付しました。
図の作成にはdraw.io(https://app.diagrams.net/)を使用しました。

参考資料:
https://www.infraexpert.com/study/virtual1.html
https://tech-blog.rakus.co.jp/entry/20221114/vmware
https://gihyo.jp/dev/serial/01/vm_work/0002
http://hp.vector.co.jp/authors/VA022911/tec/suse/kvm_virtmanager.htm
https://logmi.jp/tech/articles/323956

ハイパーバイザー型のエミュレータ型に対する優位な点としては、図のようにエミュレータ型に比べてホストOSを挟まずにゲストOSを起動していることからオーバーヘッドを少なくすることができる点が挙げられます。そのため、動作の応答性が早く性能がエミュレータ型に比べると高くできます。
その一方でやはり直にOSを動作させるよりは性能が落ちてしまう点と、ハイパーバイザを利用するサーバーやPCを一台必ず用意しなくてはならない点、ホストPCから構成の違う他のPCに仮想OSを移行するのが容易でない点が挙げられます。

エミュレータ型のメリットとしては専用のPCが不要である上他のPCに移行するのも容易である。さらにはソフトを選べばarmのPCでx86対応のOSを動かすなどアーキテクチャが異なるOSを動かすことができる。そのため、あるアーキテクチャが載っているPC等を持っていなくともそのソフトウェアの開発が行える等の利点がある。
欠点としては、ホストOS上のソフトウェアが仮想ハードウェアを動かしているためオーバーヘッドがかなり大きく、性能を上げづらい面などが挙げられる。

ハイパーバイザーで仮想化を実行するとき、IOを仮想化しないとIOがゲストOSに専有されてしまい一つのOSのみしか同時に実行できなくなってしまうため、仮想化は必須である。
また、単一OSのみを実行することを目的とした軽量ハイパーバイザの場合でも主目的としてIO入出力の監視が挙げられるため、結局仮想化が必須であろうと考えられる。
KVMはCPUの演算を制御するカーネルであり、QEMUはその他IOの仮想化等を担当するという違いがある。

ハイパーバイザーによる仮想化では異なるcpuアーキテクチャの命令は実行できない。これはハイパーバイザーでの仮想化はcpuの仮想化支援(VT-x,Virtualization Extensions)等を利用して仮想OSにおけるほとんどのcpu命令をそのまま実行しているからである。

第三問

問題文が驚きの長さです。テンプレートがあったのは、低レイヤー初心者にはありがたかった。

Booting AArch64 Linux を読んでAArch64用のLinuxをブートする際にブートローダがすべきことを述べてください。

またハイパーバイザで必要となるブートローダの処理について、特にメモリ配置に注意して、以下のC言語のテンプレートを元に記述してください。

実装条件は以下のとおりです。

  • POSIX準拠の関数が使用可能
  • インラインアセンブリ使用可能
  • 特権命令が必要なレジスタの操作も可能
  • 指定された情報だけで処理できない場合は、必要な情報を取得・設定する関数を宣言し、その詳細をlaunch_vmの例に従って記載し使用
  • MMUの設定(仮想アドレスと物理アドレスの対応付け)処理は不要
  • launch_vm内でmem_startmem_sizeを元にマッピング処理を行うものとする
  • (U)EFIは不使用
  • プロセッサは1つのみ

この問題では、仕様を自分なりに調べて理解しコード設計に落とし込めるかどうかを見ています。
指定したドキュメントには今回の処理に必要ない規定や説明があります。この中から必要な情報を探し出してコードに書き出してください。

すべての処理を完璧に記載する必要はありません。システムプログラムを書く際は、まず最低限動くようにする事を目標にすることが多いです。
このため、実装の優先度を把握し、必要な部分を実装するテクニックが必要です。
「本来はこのような処理が必要だが、今回はこうなっているであろうと仮定して記述した」というのがあればそれがわかるようにコメントで記載してください。

まずは、メモリに正しく"Image"と"DTB"が正しく配置され、entry_pointに適切にジャンプできることを目指してください。
その上で、追加の"Initramfs"のロードと、そのアドレスの通知、周辺デバイスの初期化などをわかる>範囲で記述してください。

わからない部分があった場合は、その事を明記して自分の思う実装を記述してください。
間違った処理となっていても減点はしません。その処理がどのくらいの優先度で必要かを把握できていれば加点します。

何をしたら良いか分からない方へのヒント

この問題はLinuxという「ソフトウェア」を如何にメモリ上にロードして実行開始場所を特定するかが理解できるかを見ています。

ソフトウェアは通常メモリにロードされ、CPUのインストラクションポインタをロードしたソフトウェアのエントリポイントに設定することで実行されます。
これは、OSであるLinuxでも変わりません。

仮想マシンでゲストOSを起動する際はデバイスの設定などが必要ですが、今回は難しいことを抜きにしてソフトウェアを読み込む作業に集中してください。

指定したドキュメントには、"must be placed"というワードが複数出てきます。"placed"はすなわちメモリに配置される、ロードされるという意味です。
各プログラムやファイルを読み込む際には読み込む場所(アドレス)に制約があります。一番良く出てくるのがアライメントです。
アライメントについて知らない場合はまずこれについて調べて記述してみてください。その後アドレスの計算を行ってみてください。

今回メモリはmemで確保してあります。仮想マシンで使用するメモリの0番地がmemの指す先だと考えると仮想マシン上でのx番地はmem + xということになります。

これらを元にプログラムを書くと、実は数回、fread(mem + offset, 1, read_size, fp) を実行し読み込んだ値をif文で少し検証し、値を取り出してlaunch_vmに渡すだけで最低限の実装は完了します。
書いた後に、「こんな少ない行数でいいんだろうか...」と不安になるくらいかもしれません。
しかし実際のところ、これにデバイスの設定などを追加するだけでLinuxは動作してしまいます。
実装が出来たら、どうしてそのような実装にしたかをドキュメントを元に記述してみてください。

ここまで出来て余裕があったら、ドキュメントに書いてあるデバイスの設定などにトライしてみてください。
おすすめは、"initramfs"のロードです。これは単純にfreadするだけでは不十分です。
何が必要かを考え、適宜関数宣言を追加してください。

テンプレート

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

#define MEM_SIZE (512 * 1024 * 1024)
#define MEM_ALIGN 1 /* 調整してください */

/*
処理内容: VMを指定された条件で実行を開始します。
引数:
- mem_start: 仮想環境と共有するメモリ領域の開始アドレス
- mem_size: 仮想環境と共有するメモリのサイズ
  - 仮想環境は mem_start ~ (mem_start + mem_size)
の間のメモリにアクセス可能です。
- entry_point: 仮想環境で実行を始めるアドレス
- x0: 実行を開始する際のx0 registerの値
- x1: 実行を開始する際のx1 registerの値
- x2: 実行を開始する際のx2 registerの値
- x3: 実行を開始する際のx3 registerの値

結果:
成功した場合は、VMに制御が移り、この関数は戻ってきません。失敗した場合はエラーコードが返却されます。
*/
int launch_vm(uintptr_t mem_start, size_t mem_size, uintptr_t entry_point,
             uint64_t x0, uint64_t x1, uint64_t x2, uint64_t x3);

/*
main関数内では表現できない処理がある場合は、上の"launch_vm"のように処理内容と引数、結果を明記して関数宣言の形で記載してください。
*/

int main(void) {
 void *mem =
     aligned_alloc(MEM_ALIGN, MEM_SIZE); /* VMに割り当てるメモリです */
 FILE *image = fopen("./Image", "rb"); /* Linux Kernel Image */
 FILE *initrd =
     fopen("./initramfs", "rb"); /* Initramfs 使用しなくても構いません */
 FILE *dtb = fopen("./dtb", "rb"); /* The device tree blob (dtb) */
 int err = 0;

 /* ここから処理を記述してください */

 err = launch_vm(mem_start /*変更してください*/, mem_size /*変更してください*/,
                 entry_point /*変更してください*/, x0 /*変更してください*/,
                 x1 /*変更してください*/, x2 /*変更してください*/,
                 x3 /*変更してください*/);
 return err;
}

markdown形式で書いた。

AArch64でLinuxをブートするためにブートローダがすべきこと

※ここでは最新バージョンのLinuxを使うと考え、古いバージョンでの動作は考えないこととする

1 --> 4の順に行う

1. RAMのセットアップと初期化

文字通り
すべてのRAMを初期化することが期待されるらしい
今回の実装ではメモリは事前に与えられているためとくに処理は必要はないと判断した。

2. DTBの配置

DTBを配置するとき、キャッシュされることを避けるために2MB以内に収める必要がある。
また、アライメントによる制約として8byteの境界上に置く必要がある。

※ アライメントとはメモリにデータを配置するときに特定のbyte数の境界上に置くことである。これにより計算時の最適化ができる。
参考

3. カーネルイメージの解凍 ※任意

カーネルイメージがgzip等で圧縮されているなら、それを解凍する。
実装では未圧縮なイメージを読み込んでいたため省略できた

4. カーネルイメージの呼び出し

カーネルイメージには64バイトのヘッダーが含まれている。 このなかで必要なもののみを記述する。

  • u64 text_offset
    カーネルイメージのオフセットが入っている。
  • u64 image_size
    カーネルイメージのサイズが入っている。もし0ならカーネルになるべく多くのメモリを与える処理をする必要がある

カーネルイメージはRAM上の任意の2MBのアライメントからtext_offset byte離れたところにある必要がある。 そこからimage_size byte離れたところまでは最低でもカーネルが自由に使える必要がある。
Initramfsがカーネルに渡される場合、カーネルのあとに配置され1GBのアライメントの中にある必要がある。
また、CPUのx0 registerにdtbのアドレスが入っていて、x1,x2,x3registerは0である必要がある。
CPUモードがnon_secure_stateのEL2、MMUがoffである必要があるが、今回は実装しない。


実装したコード

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

#define MEM_SIZE (512 * 1024 * 1024)  // 512MB
#define MEM_ALIGN (1024 * 1024 * 1024)   // initramfsによる制限から もしなければアライメントは2MBなはず

/*
 処理内容: VMを指定された条件で実行を開始します。
 引数:
 - mem_start: 仮想環境と共有するメモリ領域の開始アドレス
 - mem_size: 仮想環境と共有するメモリのサイズ
   - 仮想環境は mem_start ~ (mem_start + mem_size)
 の間のメモリにアクセス可能です。
 - entry_point: 仮想環境で実行を始めるアドレス
 - x0: 実行を開始する際のx0 registerの値
 - x1: 実行を開始する際のx1 registerの値
 - x2: 実行を開始する際のx2 registerの値
 - x3: 実行を開始する際のx3 registerの値

 結果:
 成功した場合は、VMに制御が移り、この関数は戻ってきません。失敗した場合はエラーコードが返却されます。
 */
int launch_vm(uintptr_t mem_start, size_t mem_size, uintptr_t entry_point,
              uint64_t x0, uint64_t x1, uint64_t x2, uint64_t x3);

// ここから書き足した
//この関数は詳しい処理方法がわからなかったためごまかしました
/*
  処理内容: VMが起動したあとにkernelにinitrdの場所をわたすための関数
  引数: 
  - mem_address
  結果:
  kernelが起動したあとにinitrdが渡されるように設定されます。 正常終了時は0が、エラー発生時はエラーコードが返却されます。
*/
int initrd_vm(size_t initrd_mem);

/*
  以下標準ライブラリの関数の処理は
  https://ja.wikibooks.org/wiki/C%E8%A8%80%E8%AA%9E/%E6%A8%99%E6%BA%96%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA
  を参考にしています
  initramfsは
  https://qiita.com/akachochin/items/d38b538fcabf9ff80531
  https://www.kernel.org/doc/html/latest/admin-guide/initrd.html?highlight=initrd
  https://github.com/qemu/qemu/blob/master/hw/arm/boot.c の1017行目
  を参考にしました
  また、コードはchatgptにレビューしてもらい、エラー時にexit(1)のみでfclose等をしていなかったこと、printfで書いていたエラー文をfprintf(stderr, ...)としたほうがよいことを指摘され、
  linuxのソースコードのエラー処理(https://www.kernel.org/doc/html/v4.19/process/coding-style.html)に習いgoto文に書き換えることにしました
*/

int main(void) {
  void *mem = aligned_alloc(MEM_ALIGN, MEM_SIZE); /* VMに割り当てるメモリです */
  // fopenは指定されたファイルを指すポインタを返す
  // rbはreadbinaryってことwrite不可
  FILE *image = fopen("./Image", "rb");
      /* Linux Kernel Image */  // 非圧縮だから1 -> 4の操作のうち3は必要ない!
  FILE *initrd =
      fopen("./initramfs", "rb"); /* Initramfs 使用しなくても構いません */
  FILE *dtb = fopen("./dtb", "rb"); /* The device tree blob (dtb) */
  int err = 0;
  // ここから記述
  // メモリ確保のチェック
  if (!mem) {
    fprintf(stderr,"Error: Failed to get mem\n");  // メモリが正しく確保できてなかったら終了する
    goto exit_failure;
  }
  // fopenでのエラーのチェック
  if (!image || !initrd || !dtb) {
    fprintf(stderr,"Error: Failed to open file\n");  // もしfopenでエラー吐いたら終了する
    goto exit_failure;
  }
  // dtbの配置
  /*
   最初はsizeはdtbの大きさをもとめて投げようと思ってたけども
   カーネルイメージのアライメントとかの兼ね合いも考えると結局2MBでいいと感じた
  */
  if (!fread(mem, 1, (2 * 1024 * 1024), dtb)) {
    fprintf(stderr,"Error: Failed to load dtb\n");
    goto exit_failure;
  }

  // カーネルイメージのfopenを読み込む
  // u64 text_offsetとimage_sizeを読み込む
  uintptr_t text_offset;
  uintptr_t image_size;
  // カーネルイメージの先頭から64bit=8byteの位置にあるtext_offsetの頭まで移動
  // エラーのときはfseekが0以外の値を返す
  if (fseek(image, 8L, SEEK_SET)) {
    fprintf(stderr,"Error: Failed to seek text_offset\n");
    goto exit_failure;
  }
  // text_offsetを読み取る エラーのときは終了する little endian
  if (!fread(&text_offset, 1, 8, image)) {
    fprintf(stderr,"Error: Failed to get text_offset\n");
    goto exit_failure;
  }
  // text_offset先頭から64bit=8byteの位置にあるimage_sizeの頭まで移動
  // エラー処理はtext_offsetと同様
  if (fseek(image, 8L, SEEK_CUR)) {
    fprintf(stderr,"Error: Failed to seek image_size\n");
    goto exit_failure;
  }
  // image_sizeを読み取る エラーのときは終了する little endian
  if (!fread(&image_size, 1, 8, image)) {
    fprintf(stderr,"Error: Failed to get the size of kernel image\n");
    goto exit_failure;
  }
  // ファイルのはじめに戻る
  if (fseek(image, 0L, SEEK_SET)) {
    fprintf(stderr,"Error: Failed to seek the head of kernel image\n");
    goto exit_failure;
  }
  // image_sizeが0のときになるべくカーネルにメモリを与える処理をする
  if (!image_size) {
    image_size = MEM_SIZE - text_offset - (2 * 1024 * 1024) - (128 * 1024 * 1024);
  }
  // カーネルイメージがメモリをオーバーしてないか一応検証
  if (MEM_SIZE - (128 * 1024 * 1024) < (2 * 1024 * 1024) + text_offset + image_size) {
    fprintf(stderr,"Error: kernel image too large\n");
    goto exit_failure;
  }
  // カーネルイメージを4の条件に適するように置く エラー検証も
  if (!fread(mem + text_offset + (2 * 1024 * 1024), 1, image_size, image)) {
    fprintf(stderr,"Error: Failed to load kernel image\n");
    goto exit_failure;
  }
 /*
    最初gzipで圧縮されているinitrdを解凍してkernelにわたすのかと思ってたけど
    どうやら圧縮されたままramdiskにマウントして渡すっぽい 解凍処理はkernelがやってくれる
    initrdの大きさは環境によって様々なので、freadができない
    1GBのアライメントが厄介で、dtbとkernelimageのあとに置かないといけないのに、メモリが512MBなせいでかなりむずかしい
    と考えていたがどうやら1GBアライメントの中にあればいいらしい
    qemuによると512MBなら最初から128MBのところに置けばいいらしい??
  */
  //まずinitrdの大きさ検証 SEEK_ENDは定義されているとは限らない!!!ただ、他の方法がわからなかった 完全な理解をせずにやっているので間違っているかもしれないが非常に重要な処理だと考える
  int error_initrd_seek = fseek(initrd,0L,SEEK_END);
  long initrd_size = ftell(initrd);
  int error_initrd_seek_back = fseek(initrd,0L,SEEK_SET);
  if(error_initrd_seek || -1L == initrd_size || error_initrd_seek_back){//initrdの大きさが得られたかのエラー検証
    fprintf(stderr,"Error: Failed to get Initrd size\n");
    goto exit_failure;
  }
  if(MEM_SIZE - (128 * 1024 * 1024) < initrd_size){//開始アドレスから128MB進んだところにinitrdを置くからMEM_SIZE-128MBよりinitrdが大きいときエラー出す
    fprintf(stderr,"Error: Initrd too large\n");
    goto exit_failure;
  }
  if(!fread(mem + (128 * 1024 * 1024), 1, initrd_size, initrd)){
    fprintf(stderr,"Error: Failed to load initrd\n");
    goto exit_failure;
  }
  if(initrd_vm(mem + (128 * 1024 * 1024))){//initrdをカーネルに渡す方法がわからなかったため、曖昧な関数を定義してごまかしてる
    fprintf(stderr,"Error: Failed to set initrd for VM\n");
    goto exit_failure;
  }
  //fopenしたやつをfcloseする
  fclose(image);
  fclose(initrd);
  fclose(dtb);
  /*
   最後!
   mem_startにmem、mem_sizeにMEM_SIZEをいれて
   カーネルイメージをロードした位置をentry_pointに
   x0にdtbを入れた位置を、x1-3に0を入れる
  */
  err = launch_vm(mem, MEM_SIZE, mem + text_offset + (2 * 1024 * 1024), mem, 0,
                  0, 0);
  free(mem);
  return err;

exit_failure:
  if(image) fclose(image);
  if(initrd) fclose(initrd);
  if(dtb) fclose(dtb);
  free(mem);
  return EXIT_FAILURE;
}

第四問

これまでにあなたが行ってきたプログラミングに関することについて記述してください。 自作ハイパーバイザや、自作OS、その他システムプログラミング、それ以外のプログラミングについても可能な限り記述してください。 リポジトリへのリンクや、活動記録サイトへのリンク、プログラミングでのこだわりなども歓迎します。 また、この応募課題を解くにあたって、苦労した点や分からなかった点があれば記載してください。

私はいままで低レイヤではcpuをcpuの創り方という本にならって自作したり、LinuxKernelの解説書を読んだり、ESP32でWebサーバーを立て、mbedマイコンを用いてライトをPWMで制御し、Web上で時刻を設定し、その時刻の1時間前から少しずつライトを明るくしていく目覚ましを作ったりしていました。
また今は、みかん本でOSを自作してそれをrustで作り直そうと試みています。また、Unityで軽いシューティングゲームを作りそれを3Dに拡張したり、最近ではDirectX12を学んでいます。基本的にプログラミング記法のこだわりはないのですが、以前よく間違えていたので等価演算子を使うときは基本定数の比較対象を先に持ってくることでミスをしたときに代入になってしまうことを防ぐようにはしています。

応募課題の感想

この応募課題は自分の実力ではとてもむずかしく、問題にも書いてある通り満点は難しいと感じました。
後に講師にも聞いてみたのですが、この応募課題は難しい問題にどの程度手を動かして取り組むことができるかを評価しているとのことでした。

Discussion