Trên trang này, bạn sẽ tìm hiểu cách tạo môi trường hộp cát của riêng mình bằng Sandbox2. Bạn sẽ tìm hiểu cách xác định Chính sách hộp cát và một số cách điều chỉnh nâng cao nhưng phổ biến. Hãy sử dụng thông tin tại đây làm hướng dẫn, cùng với ví dụ và tài liệu về mã trong tệp tiêu đề.
1. Chọn một phương thức thực thi hộp cát
Hộp cát bắt đầu bằng một trình thực thi (xem Trình thực thi hộp cát), chịu trách nhiệm chạy Hộp cát. Tệp tiêu đề executor.h chứa API cần thiết cho mục đích này. API này rất linh hoạt và cho phép bạn chọn phương án phù hợp nhất với trường hợp sử dụng của mình. Các phần sau đây mô tả 3 phương pháp mà bạn có thể lựa chọn.
Phương pháp 1: Độc lập – Thực thi một tệp nhị phân khi đã bật tính năng hộp cát
Đây là cách đơn giản nhất để sử dụng hộp cát và là phương thức được đề xuất khi bạn muốn đưa toàn bộ tệp nhị phân mà bạn không có mã nguồn vào hộp cát. Đây cũng là cách an toàn nhất để sử dụng hộp cát, vì không có quá trình khởi chạy nào không sử dụng hộp cát có thể gây ra tác động bất lợi.
Trong đoạn mã sau, chúng ta xác định đường dẫn của tệp nhị phân cần được đưa vào hộp cát và các đối số mà chúng ta phải truyền đến một lệnh gọi hệ thống execve. Như bạn có thể thấy trong tệp tiêu đề executor.h, chúng tôi không chỉ định giá trị cho envp và do đó sao chép môi trường từ quy trình mẹ. Hãy nhớ rằng đối số đầu tiên luôn là tên của chương trình cần thực thi và đoạn mã của chúng ta không xác định bất kỳ đối số nào khác.
Ví dụ về phương thức thực thi này là: static và 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);
Phương thức 2: Sandbox2 Forkserver – Cho trình thực thi biết thời điểm cần đưa vào hộp cát
Phương thức này mang lại sự linh hoạt khi không được hộp cát trong quá trình khởi chạy, sau đó chọn thời điểm vào hộp cát bằng cách gọi ::sandbox2::Client::SandboxMeHere(). Bạn cần có thể xác định trong mã thời điểm bạn muốn bắt đầu tạo hộp cát và hộp cát phải là đơn luồng (đọc lý do trong phần Câu hỏi thường gặp).
Trong đoạn mã sau, chúng ta sử dụng cùng một mã như đã nêu trong Phương thức 1 ở trên. Tuy nhiên, để cho phép chương trình thực thi theo cách không bị giới hạn trong quá trình khởi tạo, chúng ta sẽ gọi 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);
Vì trình thực thi hiện có một hộp cát bị tắt cho đến khi được Sandboxee thông báo, nên chúng ta phải tạo một thực thể ::sandbox2::Client, thiết lập hoạt động giao tiếp giữa trình thực thi và Sandboxee, sau đó thông báo cho trình thực thi rằng quá trình khởi chạy đã hoàn tất và chúng ta muốn bắt đầu hộp cát ngay bằng cách gọi 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();
…
Ví dụ về phương thức thực thi này là crc4, trong đó crc4bin.cc là Hộp cát và thông báo cho trình thực thi (crc4sandbox.cc) khi cần vào hộp cát.
Phương thức 3: Máy chủ phân nhánh tuỳ chỉnh – Chuẩn bị tệp nhị phân, chờ yêu cầu phân nhánh và tự tạo hộp cát
Chế độ này cho phép bạn khởi động một tệp nhị phân, chuẩn bị tệp đó cho quá trình hộp cát và tại một thời điểm cụ thể trong vòng đời của tệp nhị phân, hãy cung cấp tệp đó cho trình thực thi.
Trình thực thi sẽ gửi yêu cầu phân nhánh đến tệp nhị phân của bạn, yêu cầu này sẽ fork() (thông qua ::sandbox2::ForkingClient::EnterForkLook()). Quy trình mới tạo sẽ sẵn sàng được đưa vào hộp cát bằng ::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());
Xin lưu ý rằng chế độ này khá phức tạp và chỉ áp dụng trong một số trường hợp cụ thể; ví dụ: khi bạn có yêu cầu nghiêm ngặt về bộ nhớ. Bạn sẽ được hưởng lợi từ COW nhưng có nhược điểm là không có ASLR thực sự. Một ví dụ khác về cách sử dụng điển hình là khi Sandboxee có một quá trình khởi tạo dài, tốn nhiều CPU có thể chạy trước khi dữ liệu không đáng tin cậy được xử lý.
Để biết ví dụ về phương thức thực thi này, hãy xem custom_fork.
2. Tạo chính sách hộp cát
Sau khi có trình thực thi, bạn có thể sẽ muốn xác định Chính sách hộp cát cho Sandboxee. Nếu không, Sandboxee chỉ được bảo vệ bằng Chính sách Syscall mặc định.
Mục tiêu của Chính sách hộp cát là hạn chế các lệnh gọi hệ thống và đối số mà Sandboxee có thể thực hiện, cũng như các tệp mà Sandboxee có thể truy cập. Bạn cần phải hiểu rõ các lệnh gọi hệ thống mà mã bạn dự định đưa vào hộp cát yêu cầu. Một cách để quan sát lệnh gọi hệ thống là chạy mã bằng công cụ dòng lệnh strace của Linux.
Sau khi có danh sách lệnh gọi hệ thống, bạn có thể sử dụng PolicyBuilder để xác định chính sách.
PolicyBuilder đi kèm với nhiều hàm trợ giúp và tiện lợi cho phép nhiều thao tác phổ biến (nhiều thao tác trong số đó đã có trong DefaultPolicyBuilder()). Danh sách sau đây chỉ là một đoạn trích nhỏ về các hàm có sẵn:
- Thêm mọi lệnh gọi hệ thống vào danh sách cho phép để khởi động quy trình:
AllowStaticStartup();AllowDynamicStartup();
- Đưa mọi lệnh gọi hệ thống open/read/write* vào danh sách cho phép:
AllowOpen();AllowRead();AllowWrite();
- Thêm mọi lệnh gọi hệ thống liên quan đến việc thoát/truy cập/trạng thái vào danh sách cho phép:
AllowExit();AllowStat();AllowAccess();
- Đưa mọi lệnh gọi hệ thống liên quan đến thời gian/chế độ ngủ vào danh sách cho phép:
AllowTime();AllowSleep();
Các hàm tiện lợi này cho phép bất kỳ lệnh gọi hệ thống nào có liên quan. Điều này có lợi thế là bạn có thể sử dụng cùng một chính sách trên nhiều cấu trúc mà một số lệnh gọi hệ thống không có sẵn (ví dụ: ARM64 không có lệnh gọi hệ thống OPEN), nhưng có rủi ro bảo mật nhỏ là cho phép nhiều lệnh gọi hệ thống hơn mức cần thiết. Ví dụ: AllowOpen() cho phép Sandboxee gọi bất kỳ lệnh gọi hệ thống nào liên quan đến việc mở. Nếu
chỉ muốn đưa một lệnh gọi hệ thống cụ thể vào danh sách cho phép, bạn có thể sử dụng AllowSyscall();
để cho phép nhiều lệnh gọi hệ thống cùng một lúc, bạn có thể sử dụng AllowSyscalls().
Cho đến nay, chính sách này chỉ kiểm tra giá trị nhận dạng syscall. Nếu cần tăng cường thêm chính sách và muốn xác định một chính sách mà bạn chỉ cho phép một lệnh gọi hệ thống có các đối số cụ thể, bạn cần sử dụng AddPolicyOnSyscall() hoặc AddPolicyOnSyscalls(). Các hàm này không chỉ lấy mã nhận dạng syscall làm đối số mà còn lấy bộ lọc seccomp-bpf thô bằng cách sử dụng macro trợ giúp bpf từ nhân Linux. Hãy xem tài liệu về nhân hệ điều hành để biết thêm thông tin về BPF. Nếu bạn thấy mình đang viết mã BPF lặp đi lặp lại mà bạn cho rằng nên có một trình bao bọc khả năng sử dụng, hãy thoải mái gửi yêu cầu về tính năng.
Ngoài các hàm liên quan đến syscall, PolicyBuilder cũng cung cấp một số hàm liên quan đến hệ thống tệp như AddFile() hoặc AddDirectory() để liên kết một tệp/thư mục vào hộp cát. Bạn có thể dùng trình trợ giúp AddTmpfs() để thêm bộ nhớ tệp tạm thời trong hộp cát.
Một hàm đặc biệt hữu ích là AddLibrariesForBinary(). Hàm này sẽ thêm các thư viện và trình liên kết mà một tệp nhị phân yêu cầu.
Rất tiếc, việc đưa các lệnh gọi hệ thống vào danh sách cho phép vẫn cần thực hiện thủ công. Tạo một chính sách có các lệnh gọi hệ thống mà bạn biết tệp nhị phân của mình cần và chạy chính sách đó với một khối lượng công việc phổ biến. Nếu xảy ra lỗi vi phạm, hãy đưa lệnh gọi hệ thống vào danh sách cho phép rồi lặp lại quy trình. Nếu gặp phải lỗi vi phạm mà bạn cho rằng có thể gây rủi ro khi đưa vào danh sách cho phép và chương trình xử lý lỗi một cách linh hoạt, bạn có thể thử đặt chương trình trả về lỗi bằng BlockSyscallWithErrno().
Ví dụ sau đây tạo trực tiếp sandbox2::PolicyBuilder để minh hoạ việc hạn chế các đối số write và mprotect, đồng thời chặn openat (nếu không thì DefaultPolicyBuilder() sẽ cho phép):
#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. Điều chỉnh giới hạn
Chính sách hộp cát ngăn Sandboxee gọi các lệnh gọi hệ thống cụ thể và do đó làm giảm bề mặt tấn công. Tuy nhiên, kẻ tấn công vẫn có thể gây ra các tác động không mong muốn bằng cách chạy một quy trình vô thời hạn hoặc làm cạn kiệt RAM và các tài nguyên khác.
Để giải quyết mối đe doạ này, theo mặc định, Sandboxee chạy trong giới hạn thực thi chặt chẽ. Nếu các giới hạn mặc định này gây ra vấn đề cho quá trình thực thi hợp lệ của chương trình, bạn có thể điều chỉnh các giới hạn này bằng cách sử dụng lớp sandbox2::Limits bằng cách gọi limits() trên đối tượng thực thi.
Đoạn mã dưới đây cho thấy một số ví dụ về cách điều chỉnh giới hạn. Tất cả các lựa chọn hiện có đều được ghi lại trong tệp tiêu đề 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));
Để biết ví dụ về cách sử dụng lớp sandbox2::Limits, hãy xem công cụ mẫu.
4. Chạy Hộp cát
Trong các phần trước, bạn đã chuẩn bị môi trường hộp cát, chính sách, trình thực thi và Sandboxee. Bước tiếp theo là tạo đối tượng Sandbox2 và chạy đối tượng đó.
Chạy đồng bộ
Hộp cát có thể chạy đồng bộ, do đó chặn cho đến khi có kết quả. Đoạn mã dưới đây minh hoạ cách tạo thực thể của đối tượng Sandbox2 và quá trình thực thi đồng bộ của đối tượng đó. Để xem ví dụ chi tiết hơn, hãy xem 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();
Chạy không đồng bộ
Bạn cũng có thể chạy hộp cát không đồng bộ, do đó không chặn cho đến khi có kết quả. Ví dụ: điều này hữu ích khi giao tiếp với Sandboxee. Đoạn mã dưới đây minh hoạ trường hợp sử dụng này. Để xem các ví dụ chi tiết hơn, hãy xem crc4 và 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. Giao tiếp với Sandboxee
Theo mặc định, trình thực thi có thể giao tiếp với Sandboxee thông qua chỉ số mô tả tệp. Đây có thể là tất cả những gì bạn cần, ví dụ: nếu bạn chỉ muốn chia sẻ một tệp với Sandboxee hoặc đọc đầu ra chuẩn của Sandboxee.
Tuy nhiên, rất có thể bạn cần có logic giao tiếp phức tạp hơn giữa trình thực thi và Sandboxee. Bạn có thể dùng API giao tiếp (xem tệp tiêu đề comms.h) để gửi số nguyên, chuỗi, vùng đệm byte, protobuf hoặc chỉ số mô tả tệp.
Chia sẻ chỉ số mô tả tệp
Khi sử dụng API Giao tiếp liên tiến trình (xem ipc.h), bạn có thể dùng MapFd() hoặc ReceiveFd():
Sử dụng
MapFd()để ánh xạ các chỉ số mô tả tệp từ trình thực thi đến Sandboxee. Bạn có thể dùng cách này để chia sẻ tệp được mở từ trình thực thi để sử dụng trong Hộp cát. Bạn có thể xem ví dụ về cách sử dụng trong static.// The executor opened /proc/version and passes it to the sandboxee as stdin executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);Sử dụng
ReceiveFd()để tạo điểm cuối socketpair. Bạn có thể dùng tệp này để đọc đầu ra chuẩn hoặc sai số chuẩn của Sandboxee. Bạn có thể xem ví dụ về cách sử dụng trong công cụ.// The executor receives a file descriptor of the sandboxee stdout int recv_fd1 = executor->ipc())->ReceiveFd(STDOUT_FILENO);
Sử dụng API liên lạc
Sandbox2 cung cấp một API comms thuận tiện. Đây là một cách đơn giản và dễ dàng để chia sẻ số nguyên, chuỗi hoặc vùng đệm byte giữa trình thực thi và Sandboxee. Dưới đây là một số đoạn mã mà bạn có thể tìm thấy trong ví dụ về crc4.
Để bắt đầu sử dụng API comms, trước tiên, bạn phải lấy đối tượng comms từ đối tượng Sandbox2:
sandbox2::Comms* comms = s2.comms();
Sau khi có đối tượng comms, dữ liệu có thể được gửi đến Sandboxee bằng một trong các nhóm hàm Send*. Bạn có thể xem ví dụ về cách sử dụng API giao tiếp trong ví dụ về crc4. Đoạn mã dưới đây cho thấy một đoạn trích từ ví dụ đó. Trình thực thi gửi một unsigned char
buf[size] có SendBytes(buf, size):
if (!(comms->SendBytes(static_cast<const uint8_t*>(buf), sz))) {
/* handle error */
}
Để nhận dữ liệu từ Sandboxee, hãy sử dụng một trong các hàm Recv*. Đoạn mã dưới đây là một phần trích dẫn từ ví dụ crc4. Trình thực thi nhận giá trị tổng kiểm trong số nguyên không dấu 32 bit: uint32_t crc4;
if (!(comms->RecvUint32(&crc4))) {
/* handle error */
}
Chia sẻ dữ liệu với vùng đệm
Một chức năng chia sẻ dữ liệu khác là sử dụng API vùng đệm để chia sẻ lượng lớn dữ liệu và tránh các bản sao tốn kém được gửi qua lại giữa trình thực thi và Sandboxee.
Trình thực thi tạo một vùng đệm, theo kích thước và dữ liệu cần truyền hoặc trực tiếp từ chỉ số mô tả tệp, rồi truyền vùng đệm đó đến Sandboxee bằng comms->SendFD() trong trình thực thi và comms->RecvFD() trong Sandboxee.
Trong đoạn mã dưới đây, bạn có thể thấy phía trình thực thi. Hộp cát chạy không đồng bộ và chia sẻ dữ liệu thông qua một vùng đệm với 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());
Về phía Sandboxee, bạn cũng phải tạo một đối tượng vùng đệm và đọc dữ liệu từ chỉ số mô tả tệp do trình thực thi gửi:
// 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. Đang thoát khỏi hộp cát
Tuỳ thuộc vào cách chạy hộp cát (xem bước này), bạn phải điều chỉnh cách chấm dứt hộp cát và do đó cũng là Sandboxee.
Thoát khỏi hộp cát đang chạy đồng bộ
Nếu hộp cát đang chạy đồng bộ, thì Run sẽ chỉ trả về khi Sandboxee hoàn tất. Do đó, bạn không cần thực hiện thêm bước nào để chấm dứt. Đoạn mã dưới đây minh hoạ trường hợp này:
Sandbox2::Result result = s2.Run();
LOG(INFO) << "Final execution status: " << result.ToString();
Thoát khỏi hộp cát đang chạy không đồng bộ
Nếu hộp cát đang chạy không đồng bộ, thì bạn có hai lựa chọn để chấm dứt. Trước tiên, bạn chỉ cần đợi Sandboxee hoàn tất và nhận được trạng thái thực thi cuối cùng:
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();
Ngoài ra, bạn có thể loại bỏ Sandboxee bất cứ lúc nào, nhưng bạn vẫn nên gọi AwaitResult() vì Sandboxee có thể chấm dứt vì một lý do khác trong thời gian chờ:
s2.Kill();
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();
7. Thử nghiệm
Giống như mọi mã khác, quá trình triển khai hộp cát của bạn phải có các bài kiểm thử. Các kiểm thử hộp cát không nhằm mục đích kiểm thử tính chính xác của chương trình, mà là để kiểm tra xem chương trình trong hộp cát có thể chạy mà không gặp vấn đề như vi phạm hộp cát hay không. Việc này cũng giúp đảm bảo chính sách hộp cát là chính xác.
Chương trình hộp cát được kiểm thử theo cách tương tự như khi bạn chạy chương trình đó trong môi trường thực tế, với các đối số và tệp đầu vào mà chương trình thường xử lý.
Các kiểm thử này có thể đơn giản như kiểm thử shell hoặc kiểm thử C++ bằng cách sử dụng các quy trình phụ. Hãy tham khảo các ví dụ để lấy cảm hứng.
Kết luận
Cảm ơn bạn đã đọc đến đây. Chúng tôi hy vọng bạn thích hướng dẫn này và giờ đây đã có thể tự tạo hộp cát để giúp bảo vệ người dùng.
Việc tạo hộp cát và chính sách là một việc khó khăn và dễ xảy ra các lỗi nhỏ. Để đảm bảo an toàn, bạn nên nhờ một chuyên gia bảo mật xem xét chính sách và mã của bạn.