在此页面中,您将学习如何使用 Sandbox2 创建自己的沙盒化环境。您将了解如何定义沙盒政策,以及一些高级但常见的调整。请将此处的信息作为指南,同时参考头文件中的示例和代码文档。
1. 选择沙盒执行器方法
沙盒化从执行器(请参阅沙盒执行器)开始,该执行器负责运行 Sandboxee。executor.h 头文件包含实现此目的所需的 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()
选择何时进入沙盒。它要求您能够在代码中定义何时开始沙盒化,并且必须是单线程的(请参阅 FAQ 了解原因)。
在以下代码段中,我们使用了与上述方法 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::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 定义 Sandbox 政策。否则,Sandboxee 仅受默认系统调用政策保护。
沙盒政策的目标是限制 Sandboxee 可以发出的系统调用和参数,以及它可以访问的文件。您需要详细了解计划沙盒化的代码所需的系统调用。观察系统调用的方法之一是使用 Linux 的命令行工具 strace 运行代码。
获得系统调用的列表后,您可以使用 PolicyBuilder 来定义政策。PolicyBuilder 附带许多便捷的辅助函数,可用于执行许多常见操作。以下列表仅列出了部分可用函数:
- 允许进程启动的任何系统调用:
AllowStaticStartup();
AllowDynamicStartup();
- 将所有打开/读取/写入* 系统调用列入许可名单:
AllowOpen();
AllowRead();
AllowWrite();
- 将任何退出/访问/状态相关的系统调用列入许可名单:
AllowExit();
AllowStat();
AllowAccess();
- 将所有与休眠/时间相关的系统调用列入许可名单:
AllowTime();
AllowSleep();
这些便捷函数可将任何相关的系统调用列入许可名单。这样做的好处是,可以在某些系统调用不可用的不同架构上使用同一政策(例如,ARM64 没有 OPEN 系统调用),但可能会启用比必要更多的系统调用,从而带来轻微的安全风险。例如,AllowOpen() 可让沙盒进程调用任何与打开相关的系统调用。如果您只想将一个特定的系统调用列入许可名单,可以使用 AllowSyscall();
;如果您想同时将多个系统调用列入许可名单,可以使用 AllowSyscalls()
。
到目前为止,该政策仅检查系统调用标识符。如果您需要进一步加强政策,并希望定义一项只允许使用特定实参的系统调用政策,则需要使用 AddPolicyOnSyscall()
或 AddPolicyOnSyscalls()
。这些函数不仅将系统调用 ID 作为实参,还使用来自 Linux 内核的 bpf 辅助宏将原始 seccomp-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 默认在严格的执行限制下运行。如果这些默认限制导致您的程序无法正常执行,您可以使用 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 和 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 之间实现更复杂的通信逻辑。通信 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);
使用通信 API
Sandbox2 提供了一个便捷的 comms API。这是一种在执行器和 Sandboxee 之间共享整数、字符串或字节缓冲区的简单便捷的方式。以下是一些您可以在 crc4 示例中找到的代码段。
如需开始使用 Comms API,您必须先从 Sandbox2 对象获取 comms 对象:
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()
和 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++ 测试。如需获取灵感,请查看示例。
总结
感谢您阅读到这里,我们希望您喜欢我们的指南,并现在有信心创建自己的沙盒,以帮助保障用户的安全。
创建沙盒和政策是一项艰巨的任务,很容易出现细微的错误。 为确保安全,我们建议您让安全专家审核您的政策和代码。