Sandbox2 使用入门

在本页中,您将学习如何使用 Sandbox2 创建自己的沙盒环境。您将学习如何定义一个 沙盒 政策,以及一些高级 但常见的调整。请将此处的信息作为指南,并结合 示例和 头文件中的代码文档。

1. 选择沙盒执行器方法

沙盒化从执行器(请参阅Sandbox Executor)开始,该执行器 负责运行 Sandboxee。 executor.h 头文件包含此目的所需的 API。该 API 非常灵活,可让您选择最适合您的使用场景的 API。以下部分介绍了 3 种不同的方法,您可以从中进行选择。

方法 1:独立 - 执行已启用沙盒化的二进制文件

这是使用沙盒化的最简单方法,也是当您想要沙盒化没有源代码的整个二进制文件时推荐的方法。这也是使用沙盒化的最安全方法,因为没有可能产生不利影响的非沙盒化初始化。

在下面的代码段中,我们定义了要沙盒化的二进制文件的路径,以及必须传递给 execve 系统调用的参数。如您在 executor.h 头文件中所见,我们没有为 envp 指定值,因此从父进程复制 环境。请注意,第一个参数始终是要执行的程序的名称,而我们的代码段未定义任何其他参数。

此执行器方法的示例包括: 静态和 工具。

#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 请求,该二进制文件将 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 定义沙盒 政策。 否则,Sandboxee 仅受 默认系统调用 政策的保护。

沙盒政策的目标是限制 Sandboxee 可以进行的系统调用和参数,以及它可以访问的文件。您需要详细了解计划沙盒化的代码所需的系统调用。观察系统调用的一种方法是使用 Linux 的命令行工具 strace 运行代码。

获得系统调用列表后,您可以使用 PolicyBuilder 定义政策。

PolicyBuilder 附带许多便捷函数和帮助程序函数,可实现许多 常见操作(其中许多已包含在 DefaultPolicyBuilder() 中)。以下列表仅是可用函数的一小部分:

  • 允许进程启动的任何系统调用:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • 允许任何打开/读取/写入* 系统调用:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • 允许任何退出/访问/状态相关系统调用:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • 允许任何睡眠/时间相关系统调用:
    • AllowTime();
    • AllowSleep();

这些便捷函数允许任何相关系统调用。这样做的好处是,相同的政策可以用于某些系统调用不可用的不同架构(例如,ARM64 没有 OPEN 系统调用),但存在启用比必要更多的系统调用的轻微安全风险。例如,AllowOpen() 使 Sandboxee 能够调用任何打开相关系统调用。如果您只想允许一个特定的系统调用,可以使用 AllowSyscall();,如需一次允许多个系统调用,可以使用 AllowSyscalls()。

到目前为止,该政策仅检查系统调用标识符。如果您需要进一步加强政策,并希望定义一项政策,其中仅允许使用特定参数的系统调用,则需要使用 AddPolicyOnSyscall() 或 AddPolicyOnSyscalls()。这些函数不仅将系统调用 ID 作为参数,还使用来自 Linux 内核的 bpf 帮助程序宏作为原始 seccomp-bpf 过滤器。如需详细了解 BPF,请参阅 内核 文档 。如果您发现自己编写重复的 BPF 代码,并且认为这些代码应该有一个可用性封装容器,请随时提交功能请求。

除了与系统调用相关的函数之外,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 和其他资源来造成不良影响。

为了应对这种威胁,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 通信时,这非常有用。下面的代码段演示了此使用场景,如需查看更详细的示例,请参阅 crc4 和 工具。

#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 之间更复杂的通信逻辑。comms API(请参阅 comms.h 头文件)可用于发送整数、字符串、字节缓冲区、protobuf 或 文件描述符。

共享文件描述符

使用进程间通信 API(请参阅 ipc.h), 您可以使用 MapFd() 或 ReceiveFd():

  • 使用 MapFd() 将文件描述符从执行器映射到 Sandboxee。 这可用于共享从执行器打开的文件,以供 Sandboxee 使用。如需查看使用示例,请参阅 静态。

    // The executor opened /proc/version and passes it to the sandboxee as stdin
    executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
    
  • 使用 ReceiveFd() 创建套接字对端点。这可用于读取 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();

获得 comms 对象后,可以使用 Send* 系列函数之一将数据发送到 Sandboxee。您可以在 crc4 示例中找到 comms 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 中将其传递给 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 的方式。

退出同步运行的沙盒

如果沙盒已同步运行,则 Run 仅在 Sandboxee 完成时返回。因此,无需执行额外的终止步骤。下面的代码段展示了此场景:

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. 测试

与任何其他代码一样,您的沙盒实现应包含测试。沙盒测试并非旨在测试程序的正确性,而是检查沙盒化程序是否可以正常运行,而不会出现沙盒违规等问题。 这还可以确保沙盒政策正确无误。

沙盒化程序的测试方式与您在生产环境中运行它的方式相同,使用它通常会处理的参数和输入文件。

这些测试可以像 shell 测试或使用子进程的 C++ 测试一样简单。 如需获取灵感,请查看示例。

总结

感谢您阅读到这里,我们希望您喜欢我们的指南,并且现在有能力创建自己的沙盒,以帮助保障用户的安全。

创建沙盒和政策是一项困难的任务,并且容易出现细微的错误。 为了确保安全,我们建议您让安全专家审核您的政策和代码。