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

このページでは、Sandbox2 を使用して独自のサンドボックス環境を作成する方法について説明します。サンドボックス ポリシーの定義方法と、一般的な高度な調整について学習します。ヘッダー ファイルのとコード ドキュメントとともに、この情報をガイドとして使用してください。

1. サンドボックス実行メソッドを選択する

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

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

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

次のコード スニペットでは、サンドボックス化するバイナリのパスと、execve syscall に渡す引数を定義しています。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: カスタム Forkserver - バイナリを準備し、フォーク リクエストを待機して、独自にサンドボックス化する

このモードでは、バイナリを起動してサンドボックス化の準備を行い、バイナリのライフサイクルの特定の時点で実行プログラムで使用できるようにします。

実行プログラムはバイナリにフォーク リクエストを送信し、バイナリは fork()::sandbox2::ForkingClient::WaitAndFork() 経由)します。新しく作成されたプロセスは、::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 が実際には機能しないというデメリットがあります。もう 1 つの一般的な使用例は、Sandboxee に、信頼できないデータが処理される前に実行できる、CPU 使用率の高い長い初期化がある場合です。

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

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

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

サンドボックス ポリシーの目的は、サンドボックス化されたプロセスが実行できるシステムコールと引数、およびアクセスできるファイルを制限することです。サンドボックス化するコードに必要なシステムコールを詳しく理解しておく必要があります。システムコールを観察する方法の 1 つは、Linux のコマンドライン ツール strace でコードを実行することです。

システムコールのリストを取得したら、PolicyBuilder を使用してポリシーを定義できます。PolicyBuilder には、多くの一般的なオペレーションを可能にする便利なヘルパー関数が多数用意されています。次のリストは、使用可能な関数の一部にすぎません。

  • プロセスの起動時に任意の syscall を許可します。
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • 開く/読み取り/書き込み* の syscall を許可リストに登録します。
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • 終了/アクセス/状態に関連する syscall を許可リストに登録します。
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • スリープ/時間関連の syscall を許可リストに登録します。
    • AllowTime();
    • AllowSleep();

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

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

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

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

許可リストに登録するシステムコールを特定するには、まだ手動での作業が必要になります。バイナリに必要な syscall を含むポリシーを作成し、一般的なワークロードで実行します。違反がトリガーされた場合は、システムコールを許可リストに登録して、プロセスを繰り返します。許可リストに登録すると危険な違反が発生し、プログラムがエラーを適切に処理する場合は、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. 上限を調整する

サンドボックス ポリシーにより、サンドボックス化されたプロセスが特定のシステムコールを呼び出すことができなくなり、攻撃対象領域が縮小されます。ただし、攻撃者はプロセスを無期限に実行したり、RAM やその他のリソースを使い果たしたりすることで、望ましくない影響を引き起こす可能性があります。

この脅威に対処するため、Sandboxee はデフォルトで厳しい実行制限の下で実行されます。これらのデフォルトの制限がプログラムの正当な実行に問題を引き起こす場合は、エグゼキュータ オブジェクトで limits() を呼び出して、sandbox2::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 オブジェクトを作成して実行します。

Run synchronously

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

#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();

Run asynchronously

サンドボックスを非同期で実行することもできます。これにより、結果が得られるまでブロックされません。これは、たとえば 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 の標準出力を読み取ったりするだけの場合は、これで十分です。

ただし、実行プログラムと Sandboxee の間で、より複雑な通信ロジックが必要になる可能性は十分にあります。通信 API(comms.h ヘッダー ファイルを参照)を使用して、整数、文字列、バイトバッファ、protobuf、ファイル記述子を送信できます。

ファイル記述子の共有

プロセス間通信 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 は便利な comms API を提供します。これは、実行プログラムと Sandboxee の間で整数、文字列、バイトバッファを共有する簡単でシンプルな方法です。以下は、crc4 の例で見つかるコード スニペットです。

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

sandbox2::Comms* comms = s2.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 の間でやり取りされる高コストのコピーを回避することです。

エグゼキュータは、サイズと渡すデータによって、またはファイル記述子から直接、バッファを作成し、エグゼキュータの 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++ テストなど、シンプルなもので構いません。をご覧ください。

まとめ

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

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