Bắt đầu sử dụng Hộp cát 2

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

Quá trình tạo hộp cát bắt đầu bằng một trình thực thi (xem Sandbox Executor), chịu trách nhiệm chạy Sandboxee. 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ã dưới đây, chúng ta xác định đường dẫn của tệp nhị phân cần đưa vào hộp cát và các đối số mà chúng ta phải truyền đến 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 biết thời điểm cần đưa trình thực thi 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ã dưới đây, 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 hộp cát trong quá trình khởi chạy, chúng ta 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 bắt đầu một tệp nhị phân, chuẩn bị tệp đó cho hộp cát và cung cấp tệp đó cho trình thực thi tại một thời điểm cụ thể trong vòng đời của tệp nhị phân.

Trình thực thi sẽ gửi một yêu cầu rẽ nhánh đến tệp nhị phân của bạn, tệp này sẽ fork() (thông qua ::sandbox2::ForkingClient::EnterForkLoop()). Quy trình mới tạo sẽ sẵn sàng để đư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. 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 sẽ chỉ được bảo vệ bằng Chính sách lệnh gọi hệ thống mặc định.

Với Chính sách hộp cát, mục tiêu 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 hiểu rõ về 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à hàm 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 phần trích dẫn 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 trạng thái/quyền truy cập/thoát 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 chế độ ngủ/thời gian 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à cùng một chính sách có thể được sử dụng trên nhiều cấu trúc khác nhau, nơi một số lệnh gọi hệ thống nhất định 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 có liên quan đến việc mở. Nếu chỉ muốn đưa một syscall cụ thể vào danh sách cho phép, bạn có thể dùng AllowSyscall();. Để đưa nhiều syscall vào danh sách cho phép cùng một lúc, bạn có thể dùng AllowSyscalls().

Cho đến nay, chính sách này chỉ kiểm tra giá trị nhận dạng lệnh gọi hệ thống. Nếu cần tăng cường chính sách hơn nữa 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ể, thì bạn cần sử dụng AddPolicyOnSyscall() hoặc AddPolicyOnSyscalls(). Các hàm này không chỉ lấy mã 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 lệnh gọi hệ thống, 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 gắn 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(), giúp thêm các thư viện và trình liên kết mà 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 một lỗi vi phạm được kích hoạt, hãy đưa syscall vào danh sách cho phép và 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ể, nhờ đó giảm thiểu bề mặt tấn công. Tuy nhiên, kẻ tấn công vẫn có thể gây ra những 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 việc 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 đó 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 tuỳ chọn có sẵn đề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ụ ví dụ.

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, để biết thêm ví dụ chi tiết, 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 các bộ 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 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 comms (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 giữa các quy trình (xem ipc.h), bạn có thể sử dụng MapFd() hoặc ReceiveFd():

  • Sử dụng MapFd() để liên kết chỉ số mô tả tệp từ trình thực thi với 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 API giao tiếp 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 giao tiếp, trước tiên, bạn phải lấy đối tượng giao tiếp từ đối tượng Sandbox2:

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

Sau khi có đối tượng giao tiếp, bạn có thể gửi dữ liệu đến Sandboxee bằng một trong các hàm thuộc nhó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] bằng SendBytes(buf, size):

if (!(comms->SendBytes(static_cast<const uint8_t*>(buf), sz))) {
  /* handle error */
}

Để nhận dữ liệu từ Sandboxee, hãy 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 bạn 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 phải điều chỉnh 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ó thể chọn một trong hai cách để chấm dứt. Trước tiên, bạn chỉ cần đợi Sandboxee hoàn tất và nhận 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, việc triển khai hộp cát của bạn phải có các 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 đảm bảo rằng 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 bạn cảm thấy có đủ khả năng để tạo hộp cát của riêng mình nhằm giúp người dùng luôn an toàn.

Việc tạo hộp cát và chính sách là một nhiệm vụ khó khăn và dễ xảy ra 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.