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 Sandbox Policy (Chính sách hộp cát) và một số điểm đ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 các ví dụ và tài liệu về mã trong các 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 những gì 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 thức 1: Độc lập – Thực thi một tệp nhị phân đã 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 nên dùng 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 tạo không có hộp cát nào có thể gây ra tác động xấu.

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 ta 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 sẽ được 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à: 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);

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 đến sự linh hoạt khi không bị hạn chế trong quá trình khởi chạy, sau đó chọn thời điểm chuyển sang chế độ hạn chế 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 đây, chúng ta sử dụng cùng một mã như được 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 chạy, 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ị vô hiệu hoá 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 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 của chúng ta đã hoàn tất và chúng ta muốn bắt đầu tạo hộp cát ngay bây giờ 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();
  

Một ví dụ về phương thức thực thi này là crc4, trong đó crc4bin.cc là Sandboxee và thông báo cho trình thực thi (crc4sandbox.cc) khi trình thực thi này sẽ nhập hộp cát.

Phương thức 3: Forkserver tuỳ chỉnh – Chuẩn bị một tệp nhị phân, đợi 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 quá trình tạo 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::WaitAndFork()). 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ó một trình thực thi, bạn có thể muốn xác định một Sandbox Policy (Chính sách hộp cát) cho Sandboxee. Nếu không, Sandboxee sẽ chỉ được bảo vệ bằng Default Syscall Policy (Chính sách mặc định về lệnh gọi hệ thống).

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 tạo hộp cát yêu cầu. Một cách để quan sát các 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 syscall, bạn có thể 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 thực hiện nhiều thao tác phổ biến. 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 mở/đọc/ghi* 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();
  • Thêm 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 mọi syscall 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ỳ syscall 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ể dùng AllowSyscall();. Để đưa nhiều lệnh gọi hệ thống 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 hơn nữa chính sách và muốn xác định một chính sách mà trong đó 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ã 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 các macro trợ giúp bpf từ nhân Linux. Hãy xem tài liệu về nhân để 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 nghĩ 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(). 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 ra các lệnh gọi hệ thống để đưa vào danh sách cho phép vẫn cần một chút công sức thủ công. Tạo một chính sách với các lệnh gọi hệ thống mà bạn biết là tệp nhị phân của bạn 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 lệnh gọi hệ thống vào danh sách cho phép và lặp lại quy trình. Nếu gặp phải một 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 hiệu quả, bạn có thể thử khiến lỗi đó trả về một lỗi khác bằng 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. Đ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 hiệu ứ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 pháp chương trình của bạn, thì 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ề việc điều chỉnh hạn mức. 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ụ 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ạ việc 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 này. Để 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 crc4tool.

#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, chẳng hạn như nếu bạn chỉ muốn chia sẻ một tệp với Sandboxee hoặc đọc đầu ra tiêu 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 comms (xem tệp tiêu đề comms.h) để gửi số nguyên, chuỗi, vùng đệm byte, protobuf hoặc bộ mô tả tệp.

Chia sẻ chỉ số mô tả tệp

Khi sử dụng Inter-Process Communication API (xem ipc.h), bạn có thể sử dụng MapFd() hoặc ReceiveFd():

  • Sử dụng MapFd() để liên kết các bộ mô tả tệp từ trình thực thi đến Sandboxee. Bạn có thể dùng phương thức này để chia sẻ một tệp được mở từ trình thực thi để sử dụng trong Sandboxee. 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);
    
  • Dùng ReceiveFd() để tạo một điểm cuối socketpair. Bạn có thể dùng tuỳ chọn này để đọc đầu ra tiêu chuẩn hoặc lỗi tiêu 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 Comms API

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ụ 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 họ hàm Send*. Bạn có thể xem ví dụ về cách sử dụng API comms trong ví dụ 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 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 tổng kiểm trong số nguyên 32 bit chưa ký: uint32_t crc4;

if (!(comms->RecvUint32(&crc4))) {
  /* handle error */
}

Chia sẻ dữ liệu với các 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 sẽ được truyền, hoặc trực tiếp từ một bộ mô tả tệp, rồi truyền vùng đệm đó đến Sandboxee bằng cách sử dụ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 người 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ừ bộ 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. 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ó 2 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, 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à thay vào đó 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 đề gì hay không, chẳng hạn như lỗi vi phạm hộp cát. Điều này cũng giúp đảm bảo chính sách về hộp cát là chính xác.

Một chương trình được cách ly sẽ được kiểm thử theo cách mà bạn chạy chương trình đó trong quá trình sản xuấ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 con. Hãy xem 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 của chúng tôi và giờ đây cảm thấy có đủ khả năng để tạo các 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 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.