Sandbox2 使用入门

在本页中,您将了解如何使用 Sandbox2 创建自己的沙盒环境。您将了解如何定义沙盒政策,以及一些高级但常见的调整。您可以参考本文中的信息,以及头文件中的示例和代码文档。

1. 选择沙盒执行器方法

沙盒从执行器(请参阅沙盒执行器)开始,该执行器负责运行 Sandboxeeexecutor.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:自定义 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。另一个典型的使用示例是,Sandboxee 需要进行长时间的 CPU 密集型初始化,可在处理不可信数据之前运行该过程。

如需查看此执行器方法的示例,请参阅 custom_fork

2. 创建沙盒政策

有了执行器后,您可能需要为 Sandboxee 定义沙盒政策。否则,Sandboxee 仅受默认系统调用政策的保护。

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

获得系统调用列表后,您可以使用 PolicyBuilder 定义政策。PolicyBuilder 附带许多便捷函数和辅助函数,支持执行许多常见操作。以下列表只是可用函数的一小部分摘录:

  • 将进程启动期间的任何系统调用列入许可名单:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • 将任何打开的 /read/write* 系统调用列入许可名单:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • 将所有与退出/访问/状态相关的系统调用列入许可名单:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • 将任何与睡眠/时间相关的系统调用列入许可名单:
    • AllowTime();
    • AllowSleep();

这些便捷函数会将任何相关的系统调用列入许可名单。这样做的好处是,可以在某些系统调用不可用的不同架构中使用相同的政策(例如,ARM64 没有开放系统调用),但启用过多的 Sycsall 会造成不必要的安全风险。例如,AllowOpen() 可让沙盒化对象调用任何打开的相关系统调用。如果您只想将一个特定的系统调用列入许可名单,可以使用 AllowSyscall(); 一次允许多个系统调用,也可以使用 AllowSyscalls()

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

除了与系统调用相关的函数之外,PolicyBuilder 还提供许多与文件系统相关的函数(如 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 默认在严格的执行限制下运行。如果这些默认限制会导致程序的合法执行出现问题,您可以通过对 executor 对象调用 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 对象的实例化及其同步执行。如需查看更详细的示例,请参阅 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();

异步运行

您也可以异步运行沙盒,因此在获得结果之前不会阻塞。例如,在与 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 或文件描述符。

共享文件描述符

借助 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() 创建套接字对端点。此参数可用于读取 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();

通信对象可用后,可以使用 Send* 系列函数之一将数据发送到 Sandboxee。您可以在 crc4 示例中找到 Commms 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 */
}

与缓冲区共享数据

另一个数据共享功能是使用 buffer API 共享大量数据,以及避免在执行器和 Sandboxee 之间来回发送开销非常大的副本。

执行器会按照要传递的大小和数据创建 Buffer,也可以直接从文件描述符创建 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 的方式。

退出同步运行的沙盒

如果沙盒已在同步运行,则“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++ 测试。查看示例,获取灵感。

总结

感谢您阅读本文,希望您喜欢我们的指南,现在可以放心地创建自己的沙盒来帮助保障用户的安全。

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