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: 맞춤 포크 서버 - 바이너리를 준비하고 포크 요청을 기다리고 자체적으로 샌드박스 처리

이 모드를 사용하면 바이너리를 시작하고, 샌드박싱을 위해 준비하고, 바이너리 수명 주기의 특정 시점에 실행기에 제공할 수 있습니다.

실행기는 바이너리에 포크 요청을 전송하며, 바이너리는 fork() (::sandbox2::ForkingClient::WaitAndFork()를 통해)합니다. 새로 생성된 프로세스는 ::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의 샌드박스 정책을 정의하는 것이 좋습니다. 그렇지 않으면 샌드박스만 기본 시스템 호출 정책으로 보호됩니다.

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

시스템 호출 목록이 있으면 PolicyBuilder를 사용하여 정책을 정의할 수 있습니다. PolicyBuilder에는 많은 일반적인 작업을 허용하는 다양한 편의 기능과 도우미 기능이 함께 제공됩니다. 다음 목록은 사용 가능한 함수의 일부에 불과합니다.

  • 프로세스 시작을 위해 시스템 호출을 허용 목록에 추가합니다.
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • 열기/읽기/쓰기* syscall을 허용 목록에 추가합니다.
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • 종료/액세스/상태 관련 시스템 호출을 허용 목록에 추가합니다.
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • 절전 모드/시간 관련 시스템 호출을 허용 목록에 추가합니다.
    • AllowTime();
    • AllowSleep();

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

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

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

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

허용 목록에 추가할 시스템 호출을 파악하는 작업은 아직 수동으로 해야 합니다. 바이너리에 필요한 시스템 호출을 사용하여 정책을 만들고 일반적인 워크로드와 함께 실행합니다. 위반이 트리거되면 시스템 호출을 허용 목록에 추가하고 프로세스를 반복합니다. 허용 목록에 추가하는 것이 위험할 수 있다고 생각되는 위반이 발생하고 프로그램에서 오류를 적절하게 처리하는 경우 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. 한도 조정

샌드박스 정책은 샌드박스에서 특정 시스템 호출을 호출하는 것을 방지하므로 공격 노출 영역이 줄어듭니다. 하지만 공격자는 프로세스를 무기한 실행하거나 RAM 및 기타 리소스를 소진하여 원치 않는 효과를 유발할 수 있습니다.

이 위협에 대응하기 위해 샌드박스 앱은 기본적으로 엄격한 실행 제한을 적용받습니다. 이러한 기본 제한으로 인해 프로그램의 적법한 실행에 문제가 발생하는 경우 실행기 객체에서 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 클래스 사용 예는 tool 예시를 참고하세요.

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();

비동기적으로 실행

샌드박스를 비동기식으로 실행하여 결과가 나올 때까지 차단하지 않을 수도 있습니다. 이는 샌드박스와 통신할 때 유용합니다. 아래 코드 스니펫은 이 사용 사례를 보여줍니다. 자세한 예는 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. 샌드박스 처리 대상과 통신

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

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

파일 설명자 공유

프로세스 간 통신 API (ipc.h 참고)를 사용하여 MapFd() 또는 ReceiveFd()을 사용할 수 있습니다.

  • MapFd()를 사용하여 실행기에서 Sandboxee로 파일 설명자를 매핑합니다. 이는 샌드박스에서 사용할 실행기에서 열린 파일을 공유하는 데 사용할 수 있습니다. 사용 예는 static에서 확인할 수 있습니다.

    // 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);
    

comms API 사용

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

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

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

통신 객체를 사용할 수 있게 되면 Send* 함수 패밀리 중 하나를 사용하여 데이터를 샌드박스에 전송할 수 있습니다. crc4 예시에서 comms API의 사용 예를 확인할 수 있습니다. 아래 코드 스니펫은 해당 예의 발췌를 보여줍니다. 실행기는 SendBytes(buf, size)이 포함된 unsigned char buf[size]를 전송합니다.

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

샌드박스에서 데이터를 수신하려면 Recv* 함수 중 하나를 사용하세요. 아래 코드 스니펫은 crc4 예에서 발췌한 것입니다. 실행기는 32비트 부호 없는 정수 uint32_t crc4로 체크섬을 수신합니다.

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

버퍼와 데이터 공유

또 다른 데이터 공유 기능은 버퍼 API를 사용하여 대량의 데이터를 공유하고 실행기와 샌드박스 간에 앞뒤로 전송되는 비용이 많이 드는 복사를 방지하는 것입니다.

실행기는 전달할 크기와 데이터로 또는 파일 설명자에서 직접 버퍼를 만들고 실행기의 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());

샌드박스 측에서는 버퍼 객체를 만들고 실행기에서 전송한 파일 설명자에서 데이터를 읽어야 합니다.

// 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();

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

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

7. 테스트

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

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

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

결론

여기까지 읽어 주셔서 감사합니다. 가이드가 유용했기를 바라며, 이제 사용자를 안전하게 보호할 수 있는 자체 샌드박스를 만들 수 있기를 바랍니다.

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