サンドボックスのスタートガイド 2

このページでは、Sandbox2 を使用して独自のサンドボックス環境を作成する方法を学習します。サンドボックス ポリシーを定義する方法と、高度でありながら一般的な調整を行う方法を学びます。ここで紹介する情報は、ヘッダー ファイル内のやコードのドキュメントと併せて参照してください。

1. サンドボックス エグゼキュータ メソッドを選択する

サンドボックス化は、エグゼキュータ(サンドボックス エグゼキュータを参照)から始まります。エグゼキュータは Sandboxee を実行します。executor.h ヘッダー ファイルには、この目的に必要な API が含まれています。API は非常に柔軟性が高く、ユースケースに最適なものを選択できます。以下のセクションでは、この 3 種類の方法について説明します。

方法 1: スタンドアロン - サンドボックス化がすでに有効になっているバイナリを実行する

これはサンドボックスを使用する最も簡単な方法であり、ソースコードがないバイナリ全体をサンドボックス化したい場合に推奨される方法です。また、悪影響を及ぼす可能性があるサンドボックス化されていない初期化はないため、サンドボックスを使用する最も安全な方法です。

次のコード スニペットでは、サンドボックス化するバイナリのパスと、execve システムコールに渡す必要がある引数を定義しています。executor.h ヘッダー ファイルを見るとわかるように、envp の値は指定しないため、親プロセスから環境をコピーします。最初の引数は常に実行するプログラムの名前で、スニペットは他の引数を定義しません。

エグゼキュータ メソッドには、たとえば statictool があります。

#include "sandboxed_api/sandbox2/executor.h"

std::string path = "path/to/binary";
std::vector<std::string> args = {path};  // args[0] will become the sandboxed
                                         // process' argv[0], typically the
                                         // path to the binary.
auto executor = absl::make_unique<sandbox2::Executor>(path, args);

方法 2: Sandbox2 Forkserver - サンドボックス化するタイミングをエグゼキュータに伝える

この方法では、初期化時にサンドボックス化を解除し、::sandbox2::Client::SandboxMeHere() を呼び出してサンドボックス化を開始するタイミングを選択できます。サンドボックス化を開始するときは、コード内で定義でき、シングル スレッドである必要があります(よくある質問をご覧ください)。

次のコード スニペットでは、上記の方法 1 で説明したものと同じコードを使用します。ただし、初期化時にプログラムをサンドボックス化されていない方法で実行できるように、set_enable_sandbox_before_exec(false) を呼び出します。

#include "sandboxed_api/sandbox2/executor.h"

std::string path = "path/to/binary";
std::vector<std::string> args = {path};
auto executor = absl::make_unique<sandbox2::Executor>(path, args);
executor->set_enable_sandbox_before_exec(false);

Sandboxee から通知されるまでエグゼキュータのサンドボックスが無効になるため、::sandbox2::Client インスタンスを作成してエグゼキュータと Sandboxee 間の通信を設定してから、初期化が完了したこと、sandbox2_client.SandboxMeHere() を呼び出してサンドボックス化を開始することをエグゼキュータに通知する必要があります。

// main() of sandboxee
int main(int argc, char** argv) {
  gflags::ParseCommandLineFlags(&argc, &argv, false);

  // Set-up the sandbox2::Client object, using a file descriptor (1023).
  sandbox2::Comms comms(sandbox2::Comms::kSandbox2ClientCommsFD);
  sandbox2::Client sandbox2_client(&comms);
  // Enable sandboxing from here.
  sandbox2_client.SandboxMeHere();
  …

このエグゼキュータ メソッドの例は、crc4 です。crc4bin.cc は Sandboxee であり、サンドボックスに入るときにエグゼキュータ(crc4sandbox.cc)に通知します。

方法 3: カスタム フォークサーバー - バイナリを準備し、フォーク リクエストを待ってから、独自にサンドボックスを構築する

このモードでは、バイナリを開始し、サンドボックス化のために準備し、バイナリのライフサイクルの特定の時点でエグゼキュータで使用できるようにします。

エグゼキュータはフォーク リクエストをバイナリに送信し、バイナリは(::sandbox2::ForkingClient::WaitAndFork() を介して)fork() します。新しく作成されたプロセスは、::sandbox2::Client::SandboxMeHere() でサンドボックス化する準備が整います。

#include "sandboxed_api/sandbox2/executor.h"

// Start the custom ForkServer
std::string path = "path/to/binary";
std::vector<std::string> args = {path};
auto fork_executor = absl::make_unique<sandbox2::Executor>(path, args);
fork_executor->StartForkServer();

// Initialize Executor with Comms channel to the ForkServer
auto executor = absl::make_unique<sandbox2::Executor>(
    fork_executor->ipc()->GetComms());

このモードは非常に複雑で、限られたメモリ要件があるなど、いくつかの特定のケースにのみ適用されます。COW にはメリットがありますが、真の ASLR がないという欠点があります。もう一つの一般的な使用例としては、Sandboxee で、信頼できないデータが処理される前に、CPU 負荷の高い初期化を長時間実行できる場合が挙げられます。

このエグゼキュータ メソッドの例については、custom_fork をご覧ください。

2. サンドボックス ポリシーを作成する

エグゼキュータを設定したら、Sandboxee 用にサンドボックス ポリシーを定義することをおすすめします。それ以外の場合、Sandboxee はデフォルトの Syscall ポリシーでのみ保護されます。

サンドボックス ポリシーの目標は、Sandboxee が作成できるシステムコールと引数、およびアクセスできるファイルを制限することです。サンドボックス化するコードに必要なシステムコールを詳細に理解する必要があります。システムコールを監視する 1 つの方法は、Linux のコマンドライン ツールの strace を使用してコードを実行することです。

システムコールのリストを取得したら、PolicyBuilder を使用してポリシーを定義できます。PolicyBuilder には、多くの一般的な操作を実行できる便利なヘルパー関数が多数用意されています。使用可能な関数のごく一部のみを次のリストに示します。

  • プロセス起動のシステムコールを許可リストに登録します。
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • オープン状態の /read/write* のシステムコールを許可リストに登録します。
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • 終了/アクセス/状態に関連するシステムコールを許可リストに登録します。
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • 睡眠/時間に関連するシステムコールを許可リストに登録します。
    • AllowTime();
    • AllowSleep();

これらの便利な関数は、関連するシステムコールを許可リストに登録します。この利点は、特定のシステムコールが使用できないさまざまなアーキテクチャ(ARM64 には OPEN システムコールがないなど)でも同じポリシーを使用できるという利点がありますが、必要以上に多くの sycsall を有効にするというわずかなセキュリティ上のリスクがあります。たとえば、AllowOpen() は Sandboxee がオープンに関連するシステムコールを呼び出せるようにします。特定のシステムコールを 1 つだけ許可リストに登録する場合は、AllowSyscall(); を使用して一度に複数のシステムコールを許可し、AllowSyscalls() を使用します。

今のところ、ポリシーはシステムコール ID のみをチェックします。ポリシーをさらに強化する必要があり、特定の引数を持つシステムコールのみを許可するポリシーを定義する場合は、AddPolicyOnSyscall() または AddPolicyOnSyscalls() を使用する必要があります。これらの関数は、システムコール ID を引数として受け取るだけでなく、Linux カーネルの bpf ヘルパーマクロを使用する生の seccomp-bpf フィルタも受け取ります。BPF の詳細については、カーネル ドキュメントをご覧ください。ユーザビリティ ラッパーが必要なと思われる BPF コードを繰り返し記述していると思われる場合は、お気軽に機能リクエストをお送りください。

PolicyBuilder には、syscall 関連の関数とは別に、ファイル/ディレクトリをサンドボックスにバインド マウントするための AddFile()AddDirectory() などのファイルシステム関連の関数も数多く用意されています。AddTmpfs() ヘルパーを使用すると、サンドボックス内に一時ファイル ストレージを追加できます。

特に便利な関数は、バイナリに必要なライブラリとリンカーを追加する AddLibrariesForBinary() 関数です。

残念ながら、許可リストへのシステムコールはまだ手作業が多くかかります。バイナリのニーズがわかっているシステムコールでポリシーを作成し、一般的なワークロードでポリシーを実行します。違反が発生した場合は、システムコールを許可リストに登録して、このプロセスを繰り返します。許可リストに登録するとリスクが高いと思われる違反に遭遇し、プログラムでエラーが適切に処理されている場合は、BlockSyscallWithErrno() を使用して、エラーを返すようにすることができます。

#include "sandboxed_api/sandbox2/policy.h"
#include "sandboxed_api/sandbox2/policybuilder.h"
#include "sandboxed_api/sandbox2/util/bpf_helper.h"

std::unique_ptr<sandbox2::Policy> CreatePolicy() {
  return sandbox2::PolicyBuilder()
    .AllowSyscall(__NR_read)  // See also AllowRead()
    .AllowTime()              // Allow time, gettimeofday and clock_gettime
    .AddPolicyOnSyscall(__NR_write, {
        ARG(0),        // fd is the first argument of write (argument #0)
        JEQ(1, ALLOW), // allow write only on fd 1
        KILL,          // kill if not fd 1
    })
    .AddPolicyOnSyscall(__NR_mprotect, {
        ARG_32(2), // prot is a 32-bit wide argument, so it's OK to use *_32
                   // macro here
        JNE32(PROT_READ | PROT_WRITE, KILL), // prot must be the RW, otherwise
                                             // kill the process
        ARG(1), // len is a 64-bit argument
        JNE(0x1000, KILL),  // Allow single page syscalls only, otherwise kill
                            // the process
        ALLOW,              // Allow for the syscall to proceed, if prot and
                            // size match
    })
    // Allow the openat() syscall but always return "not found".
    .BlockSyscallWithErrno(__NR_openat, ENOENT)
    .BuildOrDie();
}

3. 制限を調整

サンドボックス ポリシーは、Sandboxee が特定のシステムコールを呼び出さないようにして、攻撃対象領域を縮小します。ただし、攻撃者はプロセスを無期限に実行したり、RAM などのリソースを枯渇させたりして、望ましくない結果を引き起こす可能性があります。

この脅威に対処するために、Sandboxee はデフォルトで厳しい実行制限のもとで実行されます。これらのデフォルトの制限が原因でプログラムの正当な実行に問題が生じる場合は、sandbox2::Limits クラスを使用してエグゼキュータ オブジェクトに対して limits() を呼び出すことで制限を調整できます。

以下のコード スニペットは、上限の調整の例を示しています。使用可能なすべてのオプションは、limits.h ヘッダー ファイルに記載されています。

// Restrict the address space size of the sandboxee to 4 GiB.
executor->limits()->set_rlimit_as(4ULL << 30);
// Kill sandboxee with SIGXFSZ if it writes more than 1 GiB to the filesystem.
executor->limits()->set_rlimit_fsize(1ULL << 30);
// Number of file descriptors which can be used by the sandboxee.
executor->limits()->set_rlimit_nofile(1ULL << 10);
// The sandboxee is not allowed to create core files.
executor->limits()->set_rlimit_core(0);
// Maximum 300s of real CPU time.
executor->limits()->set_rlimit_cpu(300);
// Maximum 120s of wall time.
executor->limits()->set_walltime_limit(absl::Seconds(120));

sandbox2::Limits クラスの使用例については、ツールの例をご覧ください。

4. サンドボックスを実行する

前のセクションでは、サンドボックス化された環境、ポリシー、エグゼキュータ、および Sandboxee を用意しました。次のステップでは、Sandbox2 オブジェクトを作成して実行します。

同期的に実行する

サンドボックスは同期的に実行できるため、結果が出るまでブロックを実行します。以下のコード スニペットは、Sandbox2 オブジェクトのインスタンス化と同期実行を示しています。より詳細な例については、静的をご覧ください。

#include "sandboxed_api/sandbox2/sandbox2.h"

sandbox2::Sandbox2 s2(std::move(executor), std::move(policy));
sandbox2::Result result = s2.Run();  // Synchronous
LOG(INFO) << "Result of sandbox execution: " << result.ToString();

非同期に実行される

サンドボックスを非同期で実行し、結果が発生するまでブロックしないようにすることもできます。これは、Sandboxee と通信する場合などに役立ちます。以下のコード スニペットは、このユースケースを示しています。より詳細な例については、crc4tool をご覧ください。

#include "sandboxed_api/sandbox2/sandbox2.h"

sandbox2::Sandbox2 s2(std::move(executor), std::move(policy));
if (s2.RunAsync()) {
  // Communicate with sandboxee, use s2.Kill() to kill it if needed
  // ...
}
Sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();

5. サンドボックス担当者とのやり取り

デフォルトでは、エグゼキュータはファイル記述子を介して Sandboxee と通信できます。Sandboxee とファイルを共有する場合や、Sandboxee の標準出力を読み取る場合などは、これらで十分です。

ただし、ほとんどの場合、エグゼキュータと Sandboxee の間にはより複雑な通信ロジックが必要になります。comms API(comms.h ヘッダー ファイルを参照)は、整数、文字列、バイトバッファ、protobuf、ファイル記述子を送信するために使用できます。

ファイル記述子の共有

Inter-Process Communication API(ipc.h を参照)を使用すると、MapFd() または ReceiveFd() を使用できます。

  • MapFd() を使用して、エグゼキュータから Sandboxee にファイル記述子をマッピングします。これを使用して、Sandboxee で使用するためにエグゼキュータから開いたファイルを共有できます。使用例は、static で確認できます。

    // The executor opened /proc/version and passes it to the sandboxee as stdin
    executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
    
  • ReceiveFd() を使用して、Socketpair エンドポイントを作成します。これを使用して、Sandboxee の標準出力または標準エラーを読み取ることができます。使用例は、ツールで確認できます。

    // The executor receives a file descriptor of the sandboxee stdout
    int recv_fd1 = executor->ipc())->ReceiveFd(STDOUT_FILENO);
    

comms API の使用

Sandbox2 には、便利な通信 API が用意されています。これは、エグゼキュータと Sandboxee の間で整数、文字列、バイトバッファを簡単かつ簡単に共有できる方法です。crc4 のサンプルで、以下のコード スニペットを確認できます。

comms API の使用を開始するには、まず Sandbox2 オブジェクトから comms オブジェクトを取得する必要があります。

sandbox2::Comms* comms = s2.comms();

comms オブジェクトが使用可能になると、Send* ファミリー関数のいずれかを使用して Sandboxee にデータを送信できます。comms API の使用例は、crc4 のサンプルをご覧ください。以下のコード スニペットは、この例からの抜粋を示しています。エグゼキュータは SendBytes(buf, size)unsigned char buf[size] を送信します。

if (!(comms->SendBytes(static_cast<const uint8_t*>(buf), sz))) {
  /* handle error */
}

Sandboxee からデータを受信するには、Recv* 関数のいずれかを使用します。以下のコード スニペットは、crc4 の例からの抜粋です。エグゼキュータは、32 ビットの符号なし整数でチェックサムを受け取ります。uint32_t crc4;

if (!(comms->RecvUint32(&crc4))) {
  /* handle error */
}

バッファを使用してデータを共有する

もう 1 つのデータ共有機能は、バッファ API を使用して大量のデータを共有し、エグゼキュータと Sandboxee の間でコストの高いコピーが送受信されることを回避することです。

エグゼキュータは、サイズとデータによって、またはファイル記述子から直接 Buffer を作成し、エグゼキュータの comms->SendFD() と Sandboxee の comms->RecvFD() を使用して、Sandboxee に渡します。

以下のコード スニペットは、エグゼキュータ側を示しています。サンドボックスは非同期で実行され、バッファを介して Sandboxee とデータを共有します。

// start the sandbox asynchronously
s2.RunAsync();

// instantiate the comms object
sandbox2::Comms* comms = s2.comms();

// random buffer data we want to send
constexpr unsigned char buffer_data[] = /* random data */;
constexpr unsigned int buffer_dataLen = 34;

// create sandbox2 buffer
absl::StatusOr<std::unique_ptr<sandbox2::Buffer>> buffer =
     sandbox2::Buffer::CreateWithSize(1ULL << 20 /* 1Mib */);
std::unique_ptr<sandbox2::Buffer> buffer_ptr = std::move(buffer).value();

// point to the sandbox2 buffer and fill with data
uint8_t* buf = buffer_ptr‑>data();
memcpy(buf, buffer_data, buffer_data_len);

// send the data to the sandboxee
comms‑>SendFd(buffer_ptr‑>fd());

Sandboxee 側では、バッファ オブジェクトを作成し、エグゼキュータから送信されたファイル記述子からデータを読み取る必要もあります。

// establish the communication with the executor
int fd;
comms.RecvFD(&fd);

// create the buffer
absl::StatusOr<std::unique_ptr<sandbox2::Buffer>> buffer =
     sandbox2::Buffer::createFromFd(fd);

// get the data
auto buffer_ptr = std::move(buffer).value();
uint8_t* buf = buffer_ptr‑>data();

/* work with the buf object */

6. サンドボックスの終了

サンドボックスの実行方法(このステップを参照)によっては、サンドボックスの終了方法、つまり Sandboxee の終了方法を調整する必要があります。

同期で実行されるサンドボックスを終了する

サンドボックスが同期的に実行されている場合は、Sandboxee が終了したときにのみ Run に戻ります。そのため、終了のための追加手順は必要ありません。以下のコード スニペットは、このシナリオを示しています。

Sandbox2::Result result = s2.Run();
LOG(INFO) << "Final execution status: " << result.ToString();

非同期で実行されるサンドボックスを終了する

サンドボックスが非同期で実行されている場合は、次の 2 つの方法で終了できます。まず、Sandboxee が完了して最終的な実行ステータスが届くのを待ちます。

sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();

Sandboxee はいつでも終了できますが、それまでの間も別の理由で Sandboxee が終了する可能性があるため、AwaitResult() を呼び出すことをおすすめします。

s2.Kill();
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();

7. テスト

他のコードと同様に、サンドボックスの実装にもテストが必要です。サンドボックス テストは、プログラムの正確性をテストするものではなく、サンドボックス化されたプログラムがサンドボックス違反などの問題なしに実行できるかどうかを確認するためのものです。これにより、サンドボックス ポリシーが正しいことも確認されます。

サンドボックス化されたプログラムは、本番環境で実行するのと同じ方法でテストされます。引数と入力ファイルは通常処理するものです。

これらのテストは、シェルテストやサブプロセスを使用する C++ テストと同じくらい単純なものでもかまいません。からインスピレーションを得ましょう。

まとめ

ここまでお読みいただき、ありがとうございました。このガイドがお役に立てば幸いです。独自のサンドボックスを作成して、ユーザーの安全を確保できるようになりました。

サンドボックスとポリシーの作成は困難な作業であり、微妙なエラーも発生しがちです。安全を確保するため、セキュリティの専門家にポリシーとコードを確認してもらうことをおすすめします。