Sandbox2 시작하기

이 페이지에서는 Sandbox2를 사용하여 자체 샌드박스 환경을 만드는 방법을 알아봅니다. 샌드박스 정책을 정의하는 방법과 고급이지만 일반적인 몇 가지 조정 방법을 알아봅니다. 헤더 파일의 예시 및 코드 문서와 함께 여기에 있는 정보를 가이드로 사용하세요.

1. 샌드박스 실행기 메서드 선택

샌드박스는 Sandboxee 실행을 담당하는 실행기 (Sandbox Executor 참고)로 시작됩니다. executor.h 헤더 파일에는 이 목적에 필요한 API가 포함되어 있습니다. API는 매우 유연하며 사용 사례에 가장 적합한 것을 선택할 수 있습니다. 다음 섹션에서는 선택할 수 있는 세 가지 메서드를 설명합니다.

메서드 1: 독립형 – 샌드박스가 이미 사용 설정된 바이너리 실행

이는 샌드박스를 사용하는 가장 간단한 방법이며 소스 코드가 없는 전체 바이너리를 샌드박스 처리하려는 경우 권장되는 메서드입니다. 또한 악영향을 미칠 수 있는 샌드박스 처리되지 않은 초기화가 없으므로 샌드박스를 사용하는 가장 안전한 방법입니다.

아래 코드 스니펫에서는 샌드박스 처리할 바이너리의 경로와 execve syscall에 전달해야 하는 인수를 정의합니다. executor.h 헤더 파일에서 볼 수 있듯이 envp의 값을 지정하지 않으므로 상위 프로세스에서 환경을 복사합니다. 첫 번째 인수는 항상 실행할 프로그램의 이름이며 스니펫은 다른 인수를 정의하지 않습니다.

이 실행기 메서드의 예로는: static 및 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);

메서드 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() (::sandbox2::ForkingClient::EnterForkLoop()를 통해)합니다. 새로 생성된 프로세스는 ::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는 기본 Syscall 정책으로만 보호됩니다.

샌드박스 정책의 목표는 Sandboxee가 실행할 수 있는 syscall 및 인수와 액세스할 수 있는 파일을 제한하는 것입니다. 샌드박스 처리하려는 코드에 필요한 syscall을 자세히 이해해야 합니다. syscall을 관찰하는 한 가지 방법은 Linux의 명령줄 도구 strace로 코드를 실행하는 것입니다.

syscall 목록이 있으면 PolicyBuilder 를 사용하여 정책을 정의할 수 있습니다.

PolicyBuilder에는 많은 일반적인 작업을 허용하는 편리한 도우미 함수가 많이 있습니다 (대부분은 이미 DefaultPolicyBuilder()에 포함되어 있음). 다음 목록은 사용 가능한 함수의 일부 발췌문일 뿐입니다.

  • 프로세스 시작을 위한 모든 syscall 허용 목록:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • 모든 open/read/write* syscall 허용 목록:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • 모든 종료/액세스/상태 관련 syscall 허용 목록:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • 모든 절전/시간 관련 syscall 허용 목록:
    • AllowTime();
    • AllowSleep();

이러한 편의 함수는 관련 syscall을 허용 목록에 추가합니다. 이렇게 하면 특정 syscall을 사용할 수 없는 여러 아키텍처에서 동일한 정책을 사용할 수 있지만 (예: ARM64에는 OPEN syscall이 없음) 필요 이상으로 많은 syscall을 사용 설정하는 약간의 보안 위험이 있습니다. 예를 들어 AllowOpen()을 사용하면 Sandboxee가 모든 open 관련 syscall을 호출할 수 있습니다. 특정 syscall 하나만 허용 목록에 추가하려면 AllowSyscall();을 사용하고 여러 syscall을 한 번에 허용하려면 AllowSyscalls()를 사용합니다.

지금까지 정책은 syscall 식별자만 확인합니다. 정책을 더 강화해야 하고 특정 인수가 있는 syscall만 허용하는 정책을 정의하려는 경우 AddPolicyOnSyscall() 또는 AddPolicyOnSyscalls()를 사용해야 합니다. 이러한 함수는 syscall ID를 인수로 사용할 뿐만 아니라 Linux 커널의 bpf 도우미 매크로를 사용하여 원시 seccomp-bpf 필터를 사용합니다. BPF에 관한 자세한 내용은 커널 문서 를 참고하세요. 사용성 래퍼가 있어야 한다고 생각되는 반복적인 BPF 코드를 작성하는 경우 언제든지 기능 요청을 제출하세요.

syscall 관련 함수 외에도 PolicyBuilder는 AddFile() 또는 AddDirectory()와 같은 여러 파일 시스템 관련 함수를 제공하여 파일/디렉터리를 샌드박스에 바인드 마운트합니다. AddTmpfs() 도우미를 사용하여 샌드박스 내에 임시 파일 저장소를 추가할 수 있습니다.

특히 유용한 함수는 바이너리에 필요한 라이브러리와 링커를 추가하는 AddLibrariesForBinary()입니다.

허용 목록에 추가할 syscall을 찾는 것은 여전히 약간의 수동 작업입니다. 바이너리에 필요한 syscall로 정책을 만들고 일반적인 워크로드로 실행합니다. 위반이 트리거되면 syscall을 허용 목록에 추가하고 프로세스를 반복합니다. 허용 목록에 추가하는 것이 위험할 수 있다고 생각되고 프로그램이 오류를 정상적으로 처리하는 위반이 발생하면 BlockSyscallWithErrno()를 사용하여 오류를 반환하도록 할 수 있습니다.

다음 예에서는 sandbox2::PolicyBuilder를 직접 구성하여 write 및 mprotect 인수를 제한하고 openat(그렇지 않으면 DefaultPolicyBuilder()가 허용함)을 차단하는 방법을 보여줍니다.

#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가 특정 syscall을 호출하지 못하도록 하여 공격 표면을 줄입니다. 하지만 공격자는 프로세스를 무기한 실행하거나 RAM 및 기타 리소스를 소진하여 원치 않는 영향을 미칠 수 있습니다.

이러한 위협에 대처하기 위해 Sandboxee는 기본적으로 엄격한 실행 한도 내에서 실행됩니다. 이러한 기본 한도로 인해 프로그램의 합법적인 실행에 문제가 발생하는 경우 실행기 객체에서 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 및 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의 표준 출력을 읽으려는 경우 이 방법이 필요할 수 있습니다.

하지만 실행기와 Sandboxee 간에 더 복잡한 통신 로직이 필요할 가능성이 높습니다. comms API (comms.h 헤더 파일 참고)를 사용하여 정수, 문자열, 바이트 버퍼, 프로토콜 버퍼 또는 파일 설명자를 전송할 수 있습니다.

파일 설명자 공유

프로세스 간 통신 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의 표준 출력 또는 표준 오류를 읽는 데 사용할 수 있습니다. 사용 예는 tool을 참고하세요.

    // The executor receives a file descriptor of the sandboxee stdout
    int recv_fd1 = executor->ipc())->ReceiveFd(STDOUT_FILENO);
    

comms API 사용

Sandbox2는 편리한 comms API를 제공합니다. 이는 실행기와 Sandboxee 간에 정수, 문자열 또는 바이트 버퍼를 공유하는 간단하고 쉬운 방법입니다. 아래는 crc4 예에서 찾을 수 있는 코드 스니펫입니다.

comms API를 시작하려면 먼저 Sandbox2 객체에서 comms 객체를 가져와야 합니다.

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

comms 객체를 사용할 수 있게 되면 Send* 함수 계열 중 하나를 사용하여 데이터를 Sandboxee로 전송할 수 있습니다. comms API의 사용 예는 crc4 예에서 확인할 수 있습니다. 아래 코드 스니펫은 해당 예의 발췌문입니다. 실행기는 unsigned char buf[size]로 SendBytes(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를 종료하는 방법도 조정해야 합니다.

동기적으로 실행되는 샌드박스 종료

샌드박스가 동기적으로 실행된 경우 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를 종료할 수 있지만 그동안 다른 이유로 Sandboxee가 종료될 수 있으므로 AwaitResult()를 호출하는 것이 좋습니다.

s2.Kill();
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();

7. 테스트

다른 코드와 마찬가지로 샌드박스 구현에도 테스트가 있어야 합니다. 샌드박스 테스트는 프로그램의 정확성을 테스트하기 위한 것이 아니라 샌드박스 처리된 프로그램이 샌드박스 위반과 같은 문제 없이 실행될 수 있는지 확인하기 위한 것입니다. 또한 샌드박스 정책이 올바른지 확인합니다.

샌드박스 처리된 프로그램은 일반적으로 처리하는 인수와 입력 파일로 프로덕션에서 실행하는 것과 동일한 방식으로 테스트됩니다.

이러한 테스트는 셸 테스트 또는 하위 프로세스를 사용하는 C++ 테스트만큼 간단할 수 있습니다. 영감을 얻으려면 예를 참고하세요.

결론

여기까지 읽어주셔서 감사합니다. 이 가이드가 마음에 드셨기를 바라며 이제 사용자를 안전하게 보호하는 데 도움이 되는 자체 샌드박스를 만들 수 있다고 생각합니다.

샌드박스와 정책을 만드는 것은 어려운 작업이며 미묘한 오류가 발생하기 쉽습니다. 안전한 상태를 유지하려면 보안 전문가가 정책과 코드를 검토하는 것이 좋습니다.