Primeiros passos com o Sandbox2

Nesta página, você vai aprender a criar seu próprio ambiente isolado com o Sandbox2. Você vai aprender a definir uma política de sandbox e alguns ajustes avançados, mas comuns. Use as informações aqui como um guia, junto com os exemplos e a documentação de código nos arquivos de cabeçalho.

1. Escolher um método de executor de sandbox

O sandbox começa com um executor (consulte Sandbox Executor), que é responsável por executar o Sandboxee. O arquivo de cabeçalho executor.h contém a API necessária para essa finalidade. A API é muito flexível e permite que você escolha o que funciona melhor para seu caso de uso. As seções a seguir descrevem os três métodos diferentes que você pode escolher.

Método 1: independente – execute um binário com o sandboxing já ativado

Essa é a maneira mais simples de usar o sandbox e é o método recomendado quando você quer colocar em sandbox um binário inteiro sem código-fonte. Também é a maneira mais segura de usar o sandbox, já que não há inicialização sem sandbox que possa ter efeitos adversos.

No snippet de código a seguir, definimos o caminho do binário a ser isolado em sandbox e os argumentos que precisamos transmitir para uma syscall execve. Como você pode ver no arquivo de cabeçalho executor.h, não especificamos um valor para envp e, portanto, copiamos o ambiente do processo principal. O primeiro argumento é sempre o nome do programa a ser executado, e nosso snippet não define nenhum outro argumento.

Exemplos desse método de executor são: static e 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);

Método 2: Forkserver do Sandbox2 – informar ao executor quando usar o sandbox

Esse método oferece a flexibilidade de não estar em sandbox durante a inicialização e, em seguida, escolher quando entrar no sandbox chamando ::sandbox2::Client::SandboxMeHere(). É necessário definir no código quando você quer iniciar o isolamento em sandbox, e ele precisa ser de uma única linha de execução. Leia o motivo no FAQ.

No snippet de código a seguir, usamos o mesmo código descrito no Método 1 acima. No entanto, para permitir que o programa seja executado de maneira não isolada durante a inicialização, chamamos 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);

Como o executor agora tem um sandbox desativado até ser notificado pelo Sandboxee, precisamos criar uma instância ::sandbox2::Client, configurar a comunicação entre o executor e o Sandboxee e notificar o executor que a inicialização foi concluída e queremos iniciar o sandbox agora chamando 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();
  

Um exemplo desse método de executor é crc4, em que crc4bin.cc é o Sandboxee e notifica o executor (crc4sandbox.cc) quando ele precisa entrar na sandbox.

Método 3: Forkserver personalizado – prepare um binário, aguarde solicitações de fork e crie um sandbox por conta própria

Esse modo permite iniciar um binário, prepará-lo para o sandbox e, em um momento específico do ciclo de vida do binário, disponibilizá-lo para o executor.

O executor vai enviar uma solicitação de fork para seu binário, que vai fork() (via ::sandbox2::ForkingClient::WaitAndFork()). O processo recém-criado estará pronto para ser isolado em sandbox com ::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());

Esse modo é bastante complicado e aplicável apenas em alguns casos específicos, por exemplo, quando você tem requisitos de memória restritos. Você vai se beneficiar do COW, mas terá a desvantagem de não ter ASLR real. Outro exemplo de uso típico seria quando o Sandboxee tem uma inicialização longa e com uso intenso da CPU que pode ser executada antes do processamento dos dados não confiáveis.

Para ver um exemplo desse método de executor, consulte custom_fork.

2. Criar uma política de sandbox

Depois de ter um executor, você provavelmente vai querer definir uma política do Sandbox para o Sandboxee. Caso contrário, o Sandboxee será protegido apenas pela política de syscall padrão.

Com a política do sandbox, o objetivo é restringir as chamadas de sistema e os argumentos que o sandbox pode fazer, bem como os arquivos que ele pode acessar. Você precisa entender detalhadamente as syscalls exigidas pelo código que planeja colocar em sandbox. Uma maneira de observar as chamadas de sistema é executar o código com a ferramenta de linha de comando strace do Linux.

Depois de ter a lista de syscalls, use o PolicyBuilder para definir a política. O PolicyBuilder vem com muitas funções convenientes e auxiliares que permitem várias operações comuns. A lista a seguir é apenas um pequeno trecho das funções disponíveis:

  • Adicionar à lista de permissões qualquer syscall para inicialização do processo:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Adicione à lista de permissões qualquer chamada de sistema open/read/write*:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Adicione à lista de permissões todas as chamadas de sistema relacionadas a saída/acesso/estado:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Adicione à lista de permissões qualquer chamada de sistema relacionada a tempo/suspensão:
    • AllowTime();
    • AllowSleep();

Essas funções de conveniência permitem a inclusão na lista de permissões de qualquer chamada do sistema relevante. Isso tem a vantagem de que a mesma política pode ser usada em diferentes arquiteturas em que determinados syscalls não estão disponíveis (por exemplo, o ARM64 não tem o syscall OPEN), mas com o pequeno risco de segurança de ativar mais syscalls do que o necessário. Por exemplo, AllowOpen() permite que o Sandboxee chame qualquer syscall relacionada a abertura. Se você quiser adicionar apenas uma syscall específica à lista de permissões, use AllowSyscall();. Para adicionar várias syscalls de uma só vez, use AllowSyscalls().

Até agora, a política só verifica o identificador de syscall. Se você precisar fortalecer ainda mais a política e quiser definir uma política em que só permite uma syscall com argumentos específicos, use AddPolicyOnSyscall() ou AddPolicyOnSyscalls(). Essas funções não apenas usam o ID da syscall como argumento, mas também um filtro seccomp-bpf bruto usando as macros auxiliares bpf do kernel do Linux. Consulte a documentação do kernel para mais informações sobre o BPF. Se você estiver escrevendo um código BPF repetitivo que acha que deveria ter um wrapper de usabilidade, envie uma solicitação de recurso.

Além das funções relacionadas a syscalls, o PolicyBuilder também oferece várias funções relacionadas ao sistema de arquivos, como AddFile() ou AddDirectory(), para vincular a montagem de um arquivo/diretório no sandbox. O auxiliar AddTmpfs() pode ser usado para adicionar um armazenamento de arquivos temporário na sandbox.

Uma função particularmente útil é AddLibrariesForBinary(), que adiciona as bibliotecas e o vinculador exigidos por um binário.

Infelizmente, criar a lista de permissões de syscalls ainda é um trabalho manual. Crie uma política com as syscalls que seu binário precisa e execute-a com uma carga de trabalho comum. Se uma violação for acionada, coloque a syscall na lista de permissões e repita o processo. Se você encontrar uma violação que considere arriscada para a lista de permissões e o programa processe erros normalmente, tente fazer com que ela retorne um erro usando 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. Ajustar limites

A política do sandbox impede que o Sandboxee chame syscalls específicos e, portanto, reduz a superfície de ataque. No entanto, um invasor ainda pode causar efeitos indesejados executando um processo indefinidamente ou esgotando a RAM e outros recursos.

Para lidar com essa ameaça, o Sandboxee é executado com limites de execução rígidos por padrão. Se esses limites padrão causarem problemas na execução legítima do programa, ajuste-os usando a classe sandbox2::Limits chamando limits() no objeto executor.

O snippet de código abaixo mostra alguns exemplos de ajustes de limite. Todas as opções disponíveis estão documentadas no arquivo de cabeçalho 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));

Para conferir um exemplo do uso da classe sandbox2::Limits, consulte a ferramenta de exemplo.

4. Executar o sandbox

Nas seções anteriores, você preparou o ambiente em sandbox, a política e o executor e o Sandboxee. A próxima etapa é criar o objeto Sandbox2 e executá-lo.

Executar de forma síncrona

A sandbox pode ser executada de forma síncrona, bloqueando até que haja um resultado. O snippet de código abaixo demonstra a instanciação do objeto Sandbox2 e a execução síncrona dele. Para um exemplo mais detalhado, consulte 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();

Executar de maneira assíncrona

Você também pode executar o sandbox de forma assíncrona, sem bloqueio até que haja um resultado. Isso é útil, por exemplo, ao se comunicar com o Sandboxee. O snippet de código abaixo demonstra esse caso de uso. Para exemplos mais detalhados, consulte crc4 e 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. Comunicação com o Sandboxee

Por padrão, o executor pode se comunicar com o Sandboxee usando descritores de arquivo. Isso pode ser tudo o que você precisa, por exemplo, se quiser apenas compartilhar um arquivo com o Sandboxee ou ler a saída padrão dele.

No entanto, é muito provável que você precise de uma lógica de comunicação mais complexa entre o executor e o Sandboxee. A API comms (consulte o arquivo de cabeçalho comms.h) pode ser usada para enviar números inteiros, strings, buffers de bytes, protobufs ou descritores de arquivo.

Compartilhamento de descritores de arquivos

Usando a API de comunicação entre processos (consulte ipc.h), é possível usar MapFd() ou ReceiveFd():

  • Use MapFd() para mapear descritores de arquivo do executor para o Sandboxee. Isso pode ser usado para compartilhar um arquivo aberto do executor para uso no Sandboxee. Um exemplo de uso pode ser visto em static.

    // The executor opened /proc/version and passes it to the sandboxee as stdin
    executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
    
  • Use ReceiveFd() para criar um endpoint socketpair. Isso pode ser usado para ler a saída padrão ou os erros padrão do Sandboxee. Um exemplo de uso pode ser visto na ferramenta.

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

Como usar a API Comms

O Sandbox2 oferece uma API comms conveniente. Essa é uma maneira simples e fácil de compartilhar números inteiros, strings ou buffers de bytes entre o executor e o Sandboxee. Confira abaixo alguns snippets de código que podem ser encontrados no exemplo crc4.

Para começar a usar a API Comms, primeiro você precisa extrair o objeto Comms do objeto Sandbox2:

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

Quando o objeto de comunicação estiver disponível, os dados poderão ser enviados ao Sandboxee usando uma das funções da família Send*. Confira um exemplo de uso da API de comunicações no exemplo crc4. O snippet de código abaixo mostra um trecho desse exemplo. O executor envia um unsigned char buf[size] com SendBytes(buf, size):

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

Para receber dados do Sandboxee, use uma das funções Recv*. O snippet de código abaixo é um trecho do exemplo crc4. O executor recebe a soma de verificação em um número inteiro sem assinatura de 32 bits: uint32_t crc4;

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

Compartilhamento de dados com buffers

Outra funcionalidade de compartilhamento de dados é usar a API buffer para compartilhar grandes quantidades de dados e evitar cópias caras que são enviadas entre o executor e o Sandboxee.

O executor cria um buffer, seja por tamanho e dados a serem transmitidos, ou diretamente de um descritor de arquivo, e o transmite ao Sandboxee usando comms->SendFD() no executor e comms->RecvFD() no Sandboxee.

No snippet de código abaixo, você pode ver o lado do executor. O sandbox é executado de forma assíncrona e compartilha dados por um buffer com o 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());

No lado do Sandboxee, também é necessário criar um objeto de buffer e ler os dados do descritor de arquivo enviado pelo executor:

// 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. Sair do sandbox

Dependendo de como você executa o sandbox (consulte esta etapa), é necessário ajustar a maneira de encerrar o sandbox e, portanto, também o Sandboxee.

Como sair de um sandbox em execução síncrona

Se o sandbox estiver sendo executado de forma síncrona, "Run" só vai retornar quando o Sandboxee for concluído. Portanto, nenhuma etapa adicional para rescisão é necessária. O snippet de código abaixo mostra esse cenário:

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

Como sair de um sandbox em execução de forma assíncrona

Se a sandbox estiver sendo executada de forma assíncrona, duas opções estarão disponíveis para encerramento. Primeiro, aguarde a conclusão do Sandboxee e receba o status de execução final:

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

Como alternativa, você pode encerrar o Sandboxee a qualquer momento, mas ainda é recomendável chamar AwaitResult() porque o Sandboxee pode ser encerrado por outro motivo nesse período:

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

7. Teste

Como qualquer outro código, a implementação do sandbox precisa ter testes. Os testes de sandbox não são feitos para testar a correção do programa, mas sim para verificar se o programa em sandbox pode ser executado sem problemas, como violações do sandbox. Isso também garante que a política da sandbox esteja correta.

Um programa em sandbox é testado da mesma forma que seria executado em produção, com os argumentos e arquivos de entrada que normalmente processaria.

Esses testes podem ser tão simples quanto um teste de shell ou testes em C++ usando subprocessos. Confira os exemplos para se inspirar.

Conclusão

Agradecemos por ler até aqui. Esperamos que você tenha gostado do nosso guia e agora se sinta pronto para criar seus próprios sandboxes e ajudar a manter os usuários seguros.

Criar sandboxes e políticas é uma tarefa difícil e propensa a erros sutis. Para garantir a segurança, recomendamos que um especialista em segurança analise sua política e seu código.