Sandbox2 시작하기

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

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

샌드박스는 Sandboxee를 실행하는 실행자 (샌드박스 실행자 참고)로 시작합니다. executor.h 헤더 파일에는 이 용도로 필요한 API가 포함되어 있습니다. API는 매우 유연하여 사용 사례에 가장 적합한 방식을 선택할 수 있습니다. 다음 섹션에서는 선택할 수 있는 3가지 방법을 설명합니다.

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

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

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

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

방법 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는 샌드박스 대상이고 샌드박스에 진입해야 할 때 실행자 (crc4sandbox.cc)에 알립니다.

방법 3: 맞춤 포크 서버 – 바이너리를 준비하고, 포크 요청을 대기하고, 직접 샌드박스 처리

이 모드를 사용하면 바이너리를 시작하고 샌드박스를 준비하고 바이너리 수명 주기의 특정 시점에 실행자가 사용할 수 있도록 할 수 있습니다.

실행자가 바이너리에 포크 요청을 전송하고 ::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에 대한 샌드박스 정책을 정의하는 것이 좋습니다. 그렇지 않으면 Sandboxee가 기본 Syscall 정책에 의해서만 보호됩니다.

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

syscall의 목록이 준비되면 PolicyBuilder를 사용하여 정책을 정의할 수 있습니다. PolicyBuilder에는 여러 일반적인 작업을 허용하는 여러 편의성 및 도우미 함수가 함께 제공됩니다. 다음 목록은 사용 가능한 함수의 일부일 뿐입니다.

  • 프로세스 시작을 위한 모든 syscall을 허용합니다.
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • 모든 열린 /read/write* syscall을 허용합니다.
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • 모든 이탈/액세스/상태 관련 syscall을 허용합니다.
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • 모든 수면/시간 관련 syscall을 허용합니다.
    • AllowTime();
    • AllowSleep();

이러한 편의 함수는 모든 관련 syscall을 허용 목록에 추가합니다. 이렇게 하면 특정 syscall을 사용할 수 없는 여러 아키텍처에 대해 동일한 정책을 사용할 수 있다는 이점이 있습니다 (예: ARM64에는 OPEN syscall이 없음). 그러나 필요 이상으로 많은 sycsall을 사용 설정할 수 있는 사소한 보안 위험이 있습니다. 예를 들어 AllowOpen()은 Sandboxee가 공개 관련 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()를 사용하여 오류를 반환하도록 시도할 수 있습니다.

#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. 샌드박스 실행

이전 섹션에서는 샌드박스 환경, 정책, 실행자, 샌드박스를 준비했습니다. 다음 단계는 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와 통신할 때 유용합니다. 아래의 코드 스니펫은 이러한 사용 사례를 보여줍니다. 자세한 예는 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. 샌드박스 사용자와 커뮤니케이션

기본적으로 실행자는 파일 설명자를 통해 샌드박스와 통신할 수 있습니다. 예를 들어 Sandboxee와 파일을 공유하거나 Sandboxee의 표준 출력을 읽으려는 경우 이 작업만 필요할 수도 있습니다.

그러나 실행자와 Sandboxee 간에 더 복잡한 통신 로직이 필요할 수 있습니다. 커뮤니케이션 API (comms.h 헤더 파일 참고)는 정수, 문자열, 바이트 버퍼, protobuf, 파일 설명자를 전송하는 데 사용할 수 있습니다.

파일 설명자 공유

Inter-Process Communication 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()를 사용하여 소켓 쌍 엔드포인트를 만듭니다. 이 값은 샌드박스 대상자의 표준 출력 또는 표준 오류를 읽는 데 사용될 수 있습니다. 사용 예는 도구에서 확인할 수 있습니다.

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

커뮤니케이션 API 사용

Sandbox2는 편리한 커뮤니케이션 API를 제공합니다. 이는 실행자와 Sandboxee 간에 정수, 문자열 또는 바이트 버퍼를 공유할 수 있는 간단하고 쉬운 방법입니다. 다음은 crc4 예에서 확인할 수 있는 코드 스니펫입니다.

커뮤니케이션 API를 시작하려면 먼저 Sandbox2 객체에서 커뮤니케이션 객체를 가져와야 합니다.

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

커뮤니케이션 객체를 사용할 수 있게 되면 Send* 함수 제품군 중 하나를 사용하여 Sandboxee에 데이터를 전송할 수 있습니다. 커뮤니케이션 API의 사용 예는 crc4 예에서 확인할 수 있습니다. 아래의 코드 스니펫은 이 예의 일부를 발췌한 것입니다. 실행자는 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 간에 전송되는 비용이 많이 드는 복사본을 피하는 것입니다.

실행자는 전달할 크기와 데이터를 기준으로 또는 파일 설명자에서 직접 Buffer를 만들어 실행자의 comms->SendFD()와 Sandboxee의 comms->RecvFD()를 사용해 Buffer를 샌드박스에 전달합니다.

아래 코드 스니펫에서는 실행자 측면을 확인할 수 있습니다. 샌드박스는 비동기식으로 실행되며 버퍼를 통해 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이 반환됩니다. 따라서 계정 해지를 위한 추가 단계가 필요하지 않습니다. 아래의 코드 스니펫은 이러한 시나리오를 보여줍니다.

Sandbox2::Result result = s2.Run();
LOG(INFO) << "Final execution status: " << result.ToString();

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

샌드박스가 비동기식으로 실행 중이면 두 가지 옵션을 사용하여 종료할 수 있습니다. 먼저 샌드박스가 완료될 때까지 기다렸다가 최종 실행 상태를 수신하면 됩니다.

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

또는 언제든지 샌드박스를 종료할 수 있지만 그동안 다른 이유로 샌드박스가 종료될 수 있으므로 여전히 AwaitResult()를 호출하는 것이 좋습니다.

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

7. 테스트

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

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

이러한 테스트는 셸 테스트나 하위 프로세스를 사용한 C++ 테스트만큼 간단할 수 있습니다. 예시에서 아이디어를 얻으세요.

결론

지금까지 읽어 주셔서 감사합니다. 가이드가 도움이 되셨기를 바라며, 이제 나만의 샌드박스를 만들어 사용자를 안전하게 보호할 수 있기를 바랍니다.

샌드박스와 정책을 만드는 것은 어려운 작업이며 미세한 오류가 발생하기 쉽습니다. 보안을 유지하기 위해 보안 전문가에게 정책 및 코드를 검토하도록 하는 것이 좋습니다.