開始使用 Sandbox2

本頁面將說明如何使用 Sandbox2 建立自己的沙箱環境。您將瞭解如何定義沙箱政策,以及一些進階但常見的調整。請參考這裡的資訊,以及標頭檔案中的範例和程式碼說明文件。

1. 選擇 Sandbox Executor 方法

沙箱機制會從執行工具 (請參閱「沙箱執行工具」) 開始,負責執行沙箱。executor.h 標頭檔案包含這項用途所需的 API。這項 API 非常靈活,您可以根據用途選擇最合適的做法。以下各節將說明 3 種不同的方法,您可以選擇其中一種。

方法 1:獨立執行 - 執行已啟用沙箱功能的二進位檔

這是最簡單的沙箱機制使用方式,如果您想對沒有原始碼的整個二進位檔進行沙箱處理,建議採用這個方法。這也是使用沙箱機制最安全的方式,因為沒有未經沙箱處理的初始化作業,不會產生不良影響。

在下方程式碼片段中,我們定義了要沙箱化的二進位檔路徑,以及必須傳遞至 execve syscall 的引數。如 executor.h 標頭檔案所示,我們並未指定 envp 的值,因此會從父項程序複製環境。請注意,第一個引數一律為要執行的程式名稱,而我們的程式碼片段並未定義任何其他引數。

這類執行工具方法的範例包括:static 和 tool。

#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 要求,然後自行進行沙箱測試

這個模式可讓您啟動二進位檔、準備沙箱,並在二進位檔生命週期的特定時刻,將其提供給執行器。

執行器會將分支要求傳送至二進位檔,該二進位檔會 fork() (透過 ::sandbox2::ForkingClient::EnterForkLoop())。新建立的程序將準備好使用 ::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 定義 Sandbox 政策。否則,沙箱只會受到預設 Syscall 政策的保護。

沙箱政策的目標是限制沙箱可執行的系統呼叫和引數,以及可存取的檔案。您需要詳細瞭解要將程式碼沙箱化時所需的系統呼叫。如要觀察系統呼叫,其中一個方法是使用 Linux 的指令列工具 strace 執行程式碼。

取得系統呼叫清單後,您可以使用 PolicyBuilder 定義政策。

PolicyBuilder 隨附許多便利和輔助函式,可執行許多常見作業 (其中許多已包含在 DefaultPolicyBuilder() 中)。以下清單僅列出部分可用函式:

  • 將任何用於啟動程序的系統呼叫加入許可清單:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • 將任何 open/read/write* 系統呼叫加入許可清單:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • 將任何與結束/存取/狀態相關的系統呼叫加入許可清單:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • 將任何與休眠/時間相關的系統呼叫加入許可清單:
    • AllowTime();
    • AllowSleep();

這些便利函式會將所有相關系統呼叫加入許可清單。這種做法的優點是,在某些系統呼叫無法使用的不同架構中,可以使用相同的政策 (例如 ARM64 沒有 OPEN 系統呼叫),但啟用不必要的系統呼叫會帶來輕微的安全風險。舉例來說,AllowOpen() 可讓 Sandboxee 呼叫任何開放相關的系統呼叫。如要只將一個特定系統呼叫加入許可清單,可以使用 AllowSyscall();;如要一次加入多個系統呼叫,可以使用 AllowSyscalls()。

目前,這項政策只會檢查 syscall 識別碼。如要進一步強化政策,並定義只允許使用特定引數的系統呼叫政策,請使用 AddPolicyOnSyscall() 或 AddPolicyOnSyscalls()。這些函式不僅會將系統呼叫 ID 做為引數,也會使用 Linux 核心的 bpf 輔助巨集,將原始 seccomp-bpf 篩選器做為引數。如要進一步瞭解 BPF,請參閱核心說明文件。如果您發現自己編寫的 BPF 程式碼重複,且認為應該有可用性包裝函式,歡迎提出功能要求。

除了 syscall 相關函式外,PolicyBuilder 也提供多個檔案系統相關函式,例如 AddFile() 或 AddDirectory(),可將檔案/目錄繫結掛接至沙箱。AddTmpfs() 輔助程式可用於在沙箱中新增暫存檔案儲存空間。

AddLibrariesForBinary() 是一項特別實用的函式,可新增二進位檔所需的程式庫和連結器。

很遺憾,要找出允許清單的系統呼叫,仍需要一些手動作業。建立政策,其中包含二進位檔需要的系統呼叫,並使用常見工作負載執行政策。如果觸發違規事項,請將系統呼叫加入允許清單,然後重複上述程序。如果遇到您認為可能不適合加入許可清單的違規事項,且程式會妥善處理錯誤,您可以嘗試使用 BlockSyscallWithErrno() 讓程式改為傳回錯誤。

以下範例直接建構 sandbox2::PolicyBuilder,示範如何限制 write 和 mprotect 引數,以及封鎖 openat (否則 DefaultPolicyBuilder() 會允許):

#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 和其他資源,造成不良影響。

為因應這項威脅,沙箱預設會在嚴格的執行限制下運作。如果這些預設限制導致程式無法正常執行,您可以呼叫執行器物件上的 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 物件並執行。

以同步方式執行

沙箱可以同步執行,因此會封鎖直到有結果為止。下列程式碼片段示範如何建立 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 通訊時,這項功能就相當實用。下方程式碼片段示範這個用途,如需更詳細的範例,請參閱 crc4 和 tool。

#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 或檔案描述元。

共用檔案描述元

使用 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);
    

使用通訊 API

Sandbox2 提供便利的 comms API。這是在執行器和 Sandboxee 之間共用整數、字串或位元組緩衝區的簡單方法。以下是 crc4 範例中的一些程式碼片段。

如要開始使用通訊 API,請先從 Sandbox2 物件取得通訊物件:

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

通訊物件可用後,即可使用其中一個 Send* 系列函式將資料傳送至 Sandboxee。您可以在 crc4 範例中找到通訊 API 的使用範例。以下程式碼片段是該範例的摘錄。執行者會傳送含有 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 */
}

與緩衝區共用資料

另一項資料共用功能是使用緩衝區 API 共用大量資料,並避免在執行器和 Sandboxee 之間來回傳送昂貴的副本。

執行器會根據要傳遞的大小和資料,或直接從檔案描述元建立緩衝區,並使用執行器中的 comms->SendFD() 和沙箱中的 comms->RecvFD(),將緩衝區傳遞至沙箱。

在下方的程式碼片段中,您可以看到執行器的端點。沙箱會以非同步方式執行,並透過緩衝區與 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();

結束以非同步方式執行的沙箱

如果沙箱是以非同步方式執行,則有兩種終止方式。首先,您可以等待 Sandboxee 完成,並接收最終執行狀態:

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

或者,您也可以隨時終止 Sandboxee,但仍建議呼叫 AwaitResult(),因為 Sandboxee 可能會因其他原因而終止:

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

7. 測試

與其他程式碼一樣,沙箱實作也應進行測試。沙箱測試並非用來測試程式的正確性,而是檢查沙箱程式是否能順利執行,不會發生沙箱違規等問題。這也能確保沙箱政策正確無誤。

測試沙箱程式的方式,與在正式環境中執行程式的方式相同,包括使用程式通常會處理的引數和輸入檔案。

這些測試可以很簡單,例如殼層測試或使用子程序的 C++ 測試。請參考這些範例,激發靈感。

結論

感謝您閱讀本指南,希望您喜歡這份指南,並有信心建立自己的沙箱,協助保護使用者安全。

建立沙箱和政策並不容易,而且容易發生細微錯誤。 為求保險起見,建議您請資安專家檢查政策和程式碼。