Intel SGX入門 - SGXプログラミング編
本記事は、前回の続きとなっておりますので、SGXの前提知識に自信の無い方に関しましては、前回記事からお読み下さるとスムーズであると思われます。
追記: Linux版SGXを前提に記事を書いておりますため、Windows版SGXを使用されている方にとっての完全な参考にはならない可能性が高いです。根本的な構成や考え方は同じですので、Windows版を使用されていらっしゃる方はあくまでも概念的な部分の参考にお役立て下さい。
サンプルコードのリポジトリ
今回の記事では、こちらに上げておりますソースコードを用いて、限りなく最低限のSGXプログラミングについての説明を行います。Cloneするなり、コードを眺めるなりしながら、本記事を読み進めることをオススメします。
SGXプログラミングにおけるファイルの構成
SGXプログラミングでは、実に様々なファイルが要求されますが、最低限Hello Worldレベルのプログラムを動かす上では、以下の図のような構成が必要となります (あくまで直感的なイメージですので、実際の実行時にはこれらをビルドした実行バイナリや共有オブジェクトが使用されます)。
それぞれに説明を加えますと、
- App.cpp: Enclave外で動作させるプログラムのソースコード。
- Enclave.cpp: Enclave内で動作させるプログラムのソースコード。
- Enclave.edl: Enclave内外を跨ぐ関数に関する専用の定義言語。
- Enclave.config.xml: コンフィグファイル。前記事で述べた通り、バージョンや最大ヒープサイズ等を設定します。
- private.pem: Enclaveイメージに署名し、署名済みEnclaveイメージを生成するための署名鍵。これがMRSIGNERに直接関係します。
のようになっています。なお、ファイル名に関しては当然ですが任意です(自分の名付けた通りにMakefileを記述してやれば良いだけです)。
ECALL/OCALL
さて、上記の図や前回記事でも一部出ましたが、SGXにはECALLとOCALLというものが存在します。これは、簡単に言えばEnclave内外の境界を跨ぐ関数呼び出しの事です。また、このようにEnclave境界を跨いだ関数呼び出しにより呼び出される関数をブリッジ関数と呼びます。
これらに関しては、通常の関数定義とは別にEDL (Enclave Definition File)と呼ばれる専用の定義ファイルを追加で設定する必要があります。詳しい書き方に関しては、後に簡単に記述します。
ECALL
ECALLは、Enclave外からEnclave内の関数を呼び出す為の関数呼び出しです。SGXのプログラムは、常にEnclave外のソースコードからEnclaveを起動する所から始まりますので、このECALLを欠かすことは出来ません。
CPUが行う具体的な命令セットとしては、EENTERという命令セットが使用されます。
OCALL
OCALLは、Enclave内からEnclave外の関数を呼び出す為の関数呼び出しです。前回記事で述べた通り、主にEnclaveでは不可能な操作(システムコールや動的ライブラリによって提供される機能)を行うために使用します。
ECALL/OCALLにおける注意点 (2019/9/18追記)
このECALL及びOCALLは、境界をまたいでデータを宛先にコピーする際に、内部で一度専用の保護されたスタックを経由します。このスタックは通常のスタックと同様に上限が8MBですので、このサイズを超える大容量のデータを渡そうとすると即座にセグフォを起こします。
ulimit及びEnclaveの設定XML双方でスタックサイズの上限を書き換える試みは、筆者の環境では失敗に終わっています。解決策としては、愚直ですがこのスタックサイズを超えない程度のブロックに分割してロードするという手です。実験してみた所、ブロックサイズを8MBにしても正常に動作しましたが、念の為5MBくらい分割するのが無難なのではないかと思います。
Edger8r tool
ところで、このブリッジ関数ですが、その実現には先程述べた通りEDLという定義言語が必要になります。EDLは、引数のポインタの渡される方向や最大バッファ数等を指定する為の言語です。
SGXは、このEDLを参照しながらECALLやOCALLを実現するのですが、当然通常のC++コンパイラではそのような芸当は出来ません。そこで、SGXSDKには、edger8rと呼ばれる専用のツールが用意されています。
edger8rは、EDLに基づきエッジ関数 (あるいはグルーコードとも呼ばれるようです[2])と呼ばれるC言語もどきの怪文書を生成します。この怪文書は、ユーザの定義に従って、例えばEnclaveの出入りを実際に特殊な命令セット (EENTER等)で行うように変換するなどして生成された、ある種のプロキシ関数です。
SGXSDKは、そのエッジ関数を参照することで、ECALLやOCALLを実現するのです。
例えば、今回用意したサンプルコードでは、ecall_test()
というEnclave内関数は単純に受け取ったデータをOCALLで標準出力する、というだけの簡素なものですが、これがedger8rに変換されると次のようになります (読まなくて大丈夫です):
// Enclave_t.cより抜粋
static sgx_status_t SGX_CDECL sgx_ecall_test(void* pms)
{
CHECK_REF_POINTER(pms, sizeof(ms_ecall_test_t));
//
// fence after pointer checks
//
sgx_lfence();
ms_ecall_test_t* ms = SGX_CAST(ms_ecall_test_t*, pms);
sgx_status_t status = SGX_SUCCESS;
const char* _tmp_message = ms->ms_message;
size_t _tmp_message_len = ms->ms_message_len;
size_t _len_message = _tmp_message_len;
char* _in_message = NULL;
CHECK_UNIQUE_POINTER(_tmp_message, _len_message);
//
// fence after pointer checks
//
sgx_lfence();
if (_tmp_message != NULL && _len_message != 0) {
_in_message = (char*)malloc(_len_message);
if (_in_message == NULL) {
status = SGX_ERROR_OUT_OF_MEMORY;
goto err;
}
if (memcpy_s((void*)_in_message, _len_message, _tmp_message, _len_message)) {
status = SGX_ERROR_UNEXPECTED;
goto err;
}
}
ms->ms_retval = ecall_test((const char*)_in_message, _tmp_message_len);
err:
if (_in_message) free((void*)_in_message);
return status;
}
このように、正直読んでも意味不明な代物な上に、SGXSDKが勝手に処理してくれる以上読む必要性自体皆無ですので、基本的にこのエッジ関数と関わる必要は全くありません。
しかし、唯一関わらねばならないとすれば、ECALLやOCALLを用いる際に以下のようにして特殊なヘッダをincludeする必要があるという所です:
- ECALLを用いる場合、Enclave 外のコードにて
#include "Enclave_u.h"
を記述する。 - OCALLを用いる場合、Enclave 内のコードにて
#include "Enclave_t.h"
を記述する。
というのも、edger8rが生成するエッジ関数が格納されたファイルが、Enclave_u.h
、Enclave_u.c
、Enclave_t.h
、Enclave_t.c
であるからです。
よって、ECALLやOCALLを行う場合には、明示的に上記のようなincludeを行わなければなりません (SGXを使う以上ECALLは絶対発生するはずですので、少なくともEnclave_u.hのincludeは必ず発生する事になるでしょう)。
逆に言えば、今回提供しているサンプルプログラムのようにMakefileでビルドが自動化されていれば、これくらいしかedger8rと関わる場面は無いという事です。
ちなみにですが、edger8rの読み方は「エジャレータ (edgerater)」だそうです[3]。最早接尾辞が付きすぎて何を意味したいのか皆目検討も付きませんが……。
ランタイムシステムコード
これは些細なお話ですが、SGXSDKでは気が狂うほど多数のライブラリがヘッダとして用意されています。この中で、例えばEnclaveの起動やデストラクト等のような、SGXにおける超基本的な操作を行う関数を用意してくれるライブラリが存在します。
例えば、sgx_urts.h
なんかは、その起動やデストラクトの処理を提供してくれるライブラリです。他にも様々なライブラリが存在しますので、自分の使いたい機能がどのライブラリを要求するか開発リファレンス[4]で確認し、includeを忘れないようにして下さい。
Enclaveイメージのビルド・署名
さて、実際にそれぞれのコードをどのように実装するかは後述するとして、SGX向けプログラムのビルドの流れを説明します。ビルドを行う前に、前述のedger8rによってエッジ関数を生成しておく必要があります (今回配布しているサンプルプログラムでは、Makefileで勝手にやってくれます)。
実際にSGXがEnclave内のコードを走らせる際は、所定の手続きによって生成されたEnclaveイメージ (共有オブジェクト)からEPC (詳細は前回記事をご参照下さい)上に展開し実行します。
よって、一通りコードを書き終わったら、以下の手続きによってEnclaveイメージを生成する必要があります:
- コンパイラによりEnclaveで駆動させるコードをアセンブルし、オブジェクトファイル (
Enclave.o
)を生成します。 - 1.のオブジェクトファイルと、その実行に必要なライブラリを静的リンクし、共有オブジェクト (
Enclave.so
)を生成します。
もしかすると人によっては*.so
ファイルに馴染みがないかも知れませんが、Windowsで言う*.dll
ファイルに相当するものです。 - SGX署名ツール
sgx_sign
を用いて、Enclave.so
に3072bit RSA秘密鍵 (private.pem)で署名し、Enclave.signed.so
を生成します。
些か短絡的な言い方をすれば、署名ツールのsgx_sign
は、Enclave.so
に対しSIGSTRUCT構造体を追加するのが最も重要なオペレーションとなります。Enclaveを実際に起動するEINIT命令は、このSIGSTRUCTの内容を検証し、正当であると確認して初めてEnclaveイメージからEPCへの展開を行います。
このSIGSTRUCTは、署名情報としてRSA署名の以下の情報を格納しています:
- RSAモジュラス
- RSA暗号化指数
- RSA署名
- Q1
- Q2
Q1、Q2は、m
をEnclave署名者(=開発者)のRSA公開鍵中のモジュラス、s
をEnclaveに対するRSA署名とした時に、それぞれ
Q_1 = \lfloor \frac{s^2}{m} \rfloor \\
Q_2 = \lfloor \frac{s^3-Q_1 \times s \times m}{m} \rfloor
という式で表される値です。
前回記事に出てきたMRSIGNERは、このRSAモジュラスのSHA-256ハッシュ値となっています。また、これも前回記事の通り、SIGSTRUCTはMRENCLAVEと完全に同値であるENCLAVEHASHも格納していますので、Enclave自体に関するメタデータもこのsgx_sign
によって付与されるということです。
故に、sgx_sign
によってSIGSTRUCTの付加が行われた後は、以降のEnclaveイメージに対する改竄はMRENCLAVEの検証により容易に検出されることを意味します。ただし、イメージのディスアセンブル自体は普通に可能ですので、秘密にしたい情報をハードコーディングするのはセキュリティ上絶対に行ってはいけません。
ちなみに、前述のサンプルコードにおける、ハードウェアモード(後述)でのEnclave.so
のビルドコマンドは以下のようになっています (サンプルコードを流用する場合、全てMakefileに記述してありますので、自前でこれを記述する必要はありません):
$ ld -o enclave.so Enclave.o \
-pie -eenclave_entry -nostdlib –nodefaultlibs \-nostart-files --no-undefined \
--whole-archive –lsgx_trts --no-whole-archive \
--start-group –lsgx_tstdc ––lsgx_tservice \-lsgx_crypto --end-group \
-Bstatic -Bsymbolic --defsym=__ImageBase=0 \
–export-dynamic \
--version-script=enclave.lds \
ld.gold --rosegment
ハードウェアモードとシミュレーションモード
ご存知の通り、SGXは比較的新しいハードウェアでなければその機能を利用することが出来ません。CPUは少なくとも第6世代以降のCore i5、i7、Xeonである必要がありますし、おまけにマザーボードもしっかり対応したものにする必要があります。
これでは流石に不便であると考えたのか、SGXにはこれらのハードウェア上でなくても擬似的に動作してくれるモードがあります。これをシミュレーションモードと呼びます。
シミュレーションモードはあくまでもシミュレーションですので、例えばリモート・アテステーションのように、SGX対応のCPUやハードウェア自体についての絶対的な保証が必要とされる処理については、残念ながら実現することが出来ません (Intelのサーバが、SGXマシンを検証しようとしているリモートのユーザに対して危険であるというレポートを出し、プロトコル上強制的に検証に失敗します)。
反対に、対応するハードウェアを有していれば、何も心配すること無くSGXの機能をフルに利用できます。このモードをハードウェアモードと呼びます。
SGXプログラミングの実践
いよいよSGXプログラミングの実践に移ります。SGXプログラミングといえども、今回のようなHello World程度ならば、主要コードを全て合算しても200行にも達しません。
SGXのソースコードは、大雑把に分けると以下の4つのパーツ構成されています:
- Enclaveの起動
- ECALLの実行・ECALLで呼び出す関数
- OCALLの実行・OCALLで呼び出す関数 (OCALLを使用しない場合はここは不要です)
- EDL
その他は、例えば簡単な設定ファイルだったり、edger8rが勝手に生成してくれるエッジ関数だったり、予め用意しておくべきEnclave署名用秘密鍵等、人の手によるコーディングにおける主要部分ではありませんですので、ここでは除外します。
なお、あまり重要でない操作に関してはこちらではコードを掲載しませんので、完全なコードを閲覧したい場合は前掲のサンプルコードを参照して下さい。
起動トークンの準備
前回記事でも述べた通り、この操作はほとんど無意味ではあるのですが、一応手続きとして存在しているので紹介します。コードを書くファイルは、Enclave外用のコードを記述するApp/App.cpp
です (勿論ファイル名やフォルダ構成はMakefileさえ書き換えれば開発者の自由です)。起動トークンは、sgx_launch_token_t
というニッチすぎる特殊な型の変数に格納する必要がありますので、始めに以下のようにして初期化して下さい:
sgx_launch_token_t token = {0};
こうする事で、仮にトークンの読み込みに失敗しても、新たにトークンが生成され通常通りEnclaveの起動が行われるようになります。もし起動トークンの読み込みにこだわらない場合は、起動トークン関連の操作はこれで終了です。
律儀に起動トークンを呼び出す場合は、トークンファイル名をenclave.token
と定めておき、このファイル名に準じて通常のC/C++におけるファイル操作を行います。読み込み方は完全に自由で、サンプルコードでは
size_t read_num = fread(token, 1, sizeof(sgx_launch_token_t), fp)
のようにして読み込むようにしています。
Enclaveの起動
どのような手段であれ、起動トークンの準備が完了したら、Enclave起動用のAPIであるsgx_create_enclave
により、RAM上にEnclaveを生成しEnclaveイメージを流し込みます。こちらもApp/App.cpp
に記述します。このAPIは以下のようにして使用します:
status = sgx_create_enclave(enclave_name.c_str(), SGX_DEBUG_FLAG,
&token, &updated, &global_eid, NULL);
引数を順に説明すると、
- Enclaveイメージのファイル名 (
const char*
型) - Enclaveがデバッグモードであるか (イメージビルド時にオプションで選択します)
- 起動トークンへのポインタ
- 起動トークンが新規作成・更新された場合に立つフラグ (
int
型) - EnclaveのID (
sgx_enclave_id_t
型)
となります。第2引数のデバッグモードであるかについては、このSGX_DEBUG_FLAG
を入れておけばSGXSDKが完全に自動的に処理してくれます。EnclaveのIDは、Enclaveを同時に複数使用する場合に識別用に用意する、実体がint
型のIDです。今回のように1つしか使用しない場合は、グローバル空間に適当に1つ作ってそれを使用すると楽です。
Enclaveの起動に成功したか否かは、戻り値のsgx_status_t
型変数を検証することにより確認できます。成功した場合にはSGX_SUCCESS
が、失敗した場合にはそのエラー内容に準じたエラーコードが返されます。エラーコード一覧については、以前簡易的に参照できたページが、本記事を執筆している2019/8/7現在Intelのサイトから消滅しているため、サンプルコードの内こちらのコードを参照して下さい。また、SGX開発リファレンス[4]にも掲載されています。
今後起動トークンを使い回したい場合には、C/C++標準の方法にてファイルに保存しておいて下さい。
Enclave内関数の記述
次に、Enclave内用関数を記述するEnclave/Enclave.cpp
に、Enclave内で動作させる関数を記述します。今回は、Enclave内に読み込んだchar
配列をOCALLで標準出力に吐き出すというシンプルかつ無意味な操作を行うので、次のように非常にコンパクトになります:
#include "Enclave_t.h"
#include <sgx_trts.h>
int ecall_test(const char *message, size_t message_len) {
ocall_print(message);
return 31337;
}
OCALL関数の記述
さて、前回記事でも説明した通り、SGXはOSを信頼していないため、システムコールなどのOSリソースに依存する処理はEnclave内で実行することが出来ません。よって、前述の通り、Enclave内のデータを標準出力する場合などは、OCALLでEnclave外の関数を呼び出す必要があります。
と言っても、OCALLで呼び出す関数自体はSGXSDKの文法と全く関係なくても問題なく、現に今回のサンプルでもApp/App.cpp
に以下のように記述しています:
void ocall_print(const char* str)
{
std::cout << "Output from OCALL: " << std::endl;
std::cout << str << std::endl;
return;
}
問題は、次に説明するEDLファイルの記述の仕方です。
EDLの記述
前述の通り、ECALL及びOCALLで呼び出す関数に関しては、別個EDLと呼ばれるC紛いの独自言語にて様々な属性を厳密に指定しなければなりません。EDLは、大きく分けてtrusted
ブロック (ECALLで呼び出す関数)とuntrusted
ブロック (OCALLで呼び出す関数)が存在し、それぞれに関数定義+αを列挙していきます。
今回のサンプルコードのEDLは次のようになっています:
enclave
{
trusted
{
/*These are ECALL defines.*/
public int ecall_test([in, size=message_len]const char *message,
size_t message_len);
};
untrusted
{
/*These are OCALL defines.*/
void ocall_print([in, string]const char *str);
};
};
EDLを書く上で重要なのは、ECALL関数の定義では原則的に必ずpublic
を先頭に付ける事と、引数の内ポインタに厳密な設定を記述する必要があるという点です。
public
に関しては、通常の使い方では付けないとビルド時にエラーが発生する為、ここではおまじないだと思って付けておいて下さい。
ポインタの属性設定に関しては、主にポインタの方向と大きさを指定してやる必要があります。ポインタの方向属性には、[in]
, [out]
, [in, out]
, [user_check]
が用意されています。
方向属性
[in]
属性は、そのポインタを渡す側にて保持している値を渡される側に反映させるものです。言うなればアップロードと考え方は似ているかも知れません。例えばECALLであれば、Enclave外で用意した (渡すポインタの指す)変数がEnclave内の変数に反映されます。逆に、この属性では渡した向こう側からの値を格納して取得する事はできません。
[out]
属性は、[in]
属性とは反対にポインタを渡した向こう側の保持する値を呼び出し元に"ダウンロード"するための方向属性です。これを用いると、例えばECALLであればEnclave内の変数を呼び出し元のEnclave外関数にリターンする事が出来ます。これにより、ポインタ引数を利用した複数の戻り値取得も可能になります。こちらの場合も、逆の方向属性である[in]
の操作を行うことは出来ません。
[in, out]
属性は、文字通り[in]
及び[out]
いずれの操作も可能な方向属性です。一見便利そうに見えますが、後述のバッファサイズ指定の関係上渡す時と戻る時の整合性を取るのが難しいため、意外に使用機会は限られたりします。
[user_check]
属性は、上記3つのような制限が一切入らない属性です。使い方によっては便利ですが、後述のバッファサイズ指定もスキップする為、潜在的にBoF等の危険性が存在します。また、SGX特有のブリッジ関数を跨いだバッファ引き渡しの不安定な仕様により、ちゃんと渡したはずなのに渡ってない、というケースがこの属性では比較的多いので、あくまで最終手段くらいに考えるのがベストです。
以下に[in]
, [out]
, [in, out]
のECALL時のイメージを描写した図を載せておきます (我ながら非常に分かりづらいので逆に混乱を招くかも知れませんが…):
バッファサイズ指定
こちらも厄介ですが、ブリッジ関数の引数としてバッファを渡す場合には、必ずそのバッファサイズを指定しなければなりません。よって、呼び出し先で呼び出し時点ではサイズ不定の動的に確保したバッファを[out]
属性で持ってきたい場合にサイズをピッタリ指定したい場合、一度別のブリッジ関数で必要サイズを何らかの手段で取得しそれをEDLのバッファサイズ指定変数に入れてやる、という非常に面倒臭い手続きを踏まなければなりません。
今回は、そのような面倒臭い処理のいらない例を2つ紹介します。1つ目は、方向属性の指定後にカンマで繋いだsize
属性を使って受け渡しバッファサイズを指定するものです。最もオーソドックスな例として、[in, size=16]
とすると、16バイトのみ受け渡す事になります。また、同時に渡す整数型の変数をバッファサイズ指定に利用する事も可能で、今回のサンプルコードは[in, size=message_len]
のようにしてこちらを利用しています。
2つ目は、渡す配列が**char
型** (uint8_t
型は駄目です)で、かつヌル文字終端されている場合に限り使用可能で、方向属性の後にstring
をカンマで繋ぐものです。これを用いると、バッファサイズを指定してやる手間が省けますので、使用可能なケースでは非常に有用です。今回のサンプルコードでは、[in, string]
のようにして利用しています。
ただし、string
属性は**[in]
または[in, out]
としか組み合わせられない** (つまり**[out]
は不可**)、そしてSGXはAPIぐるみで何かとuint8_t
型の配列を要求してくる事が非常に多いため、特に暗号処理を重点的にSGX上で行う場合は、この属性を活用できる機会は驚くほど少ないです。
ECALL関数の呼び出し
ここまで用意したら、後はApp/App.cpp
からEnclave内の関数を呼び出すだけです。今回のサンプルコードでは、以下のようにEnclave内の関数を呼び出しています:
sgx_status_t status = ecall_test(global_eid, &retval, message, message_len);
これを見て違和感を覚える方が多いのではないでしょうか。それもそのはずで、戻り値の型と引数の個数が、前述で指定したものとは大きく異なっているのです。
これは、edger8rがブリッジ関数の戻り値を強制的にsgx_status_t
に書き換え、かつ第1引数にsgx_enclave_id_t
を、そして第2引数に本来の戻り値を指定するようにし、第3引数目以降で元々の引数を受け取るように改変する為です。この仕様を知らないと正体不明のエラーに永遠に悩まされる事になりますので注意して下さい。
第1引数には使用したいEnclaveのID (今回は1つだけしかEnclaveが無いですが)を指定し、第2引数には元々の戻り値を受け取るための同じ型のポインタを用意してあげましょう。これら2つに関しては、Enclave内コード側及びEDLで記述する必要はありません。また、戻り値がvoid
である場合には、元々の戻り値を受け取る為の引数は用意されず、第2引数から元々の引数を指定するように上書きされます。
Enclaveの終了
一通り処理が完了しましたら、念の為以下のようにsgx_destroy_enclave
というAPIを用いてEnclaveをデストラクトしておいて下さい:
sgx_destroy_enclave(global_eid);
動作結果
サンプルコードをビルドし実行すると、成功すれば以下のような出力が吐き出されます:
Execute ECALL.
Output from OCALL:
Hello Enclave.
=============================================================================
SGX_SUCCESS
Exited SGX function successfully.
=============================================================================
Returned integer from ECALL is: 31337
Whole operations have been executed correctly.
まとめ
SGXプログラミングは (特にリモート・アテステーションまで手を出し始めると)控えめに言っても地獄のような苦行ですが、SGXプログラムの基本的な動作フローや構成は今回紹介したようなものとなります。SGXAPIの仕様や特殊な型を濫用する性質上、開発リファレンス[4]は欠かせないものとなりますので、何かあったら開発リファレンスを参照してみて下さい。
参考文献
[1] "Intel SGX Explained", by Costan and Devadas. Cryptology ePrint Archive: Report 2016/086, https://eprint.iacr.org/2016/086
[2] "An introduction to creating a sample enclave using Intel® Software Guard Extensions | Intel® Software", https://software.intel.com/en-us/articles/intel-software-guard-extensions-developing-a-sample-enclave-application
[3] "Video Series: Introduction: Enclave Definition Language | Intel® Software Guard Extensions | Intel® Software", https://software.intel.com/en-us/videos/introduction-to-the-enclave-definition-language-intel-sgx
[4] "Intel® Software Guard Extensions (Intel® SGX) SDK for Linux* OS Developer Reference", https://download.01.org/intel-sgx/linux-2.6/docs/Intel_SGX_Developer_Reference_Linux_2.6_Open_Source.pdf
[5] "Segfault While passing a big data through an Ocall #376", https://github.com/intel/linux-sgx/issues/376