Primeiros passos com o Sandbox2

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

1. Escolher um método de executor do sandbox

O sandbox começa com um executor (consulte Executor de sandbox), 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 disponíveis.

Método 1: Independente – executa um binário com sandbox já ativado

Essa é a maneira mais simples de usar o sandbox e é o método recomendado quando você quer colocar um binário inteiro no sandbox para o qual não há código-fonte. Essa também é a maneira mais segura de usar o sandbox, já que não há uma inicialização fora do modo sandbox que possa ter efeitos adversos.

No snippet de código a seguir, definimos o caminho do binário que será colocado no sandbox e os argumentos que precisamos transmitir para uma chamada de sistema executiva. Como é possível observar no arquivo de cabeçalho executor.h, não especificamos um valor para envp e, portanto, copiamos o ambiente do processo pai. Lembre-se de que o primeiro argumento é sempre o nome do programa a ser executado. Nosso snippet não define nenhum outro argumento.

Os 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: Sandbox2 Forkserver: informar ao executor quando colocar no sandbox

Esse método oferece a flexibilidade de não estar no sandbox durante a inicialização e, em seguida, escolher quando entrar no sandbox chamando ::sandbox2::Client::SandboxMeHere(). Ele exige que você possa definir no código quando quer iniciar o sandbox e precisa ter uma linha de execução única. Saiba o motivo nas Perguntas frequentes.

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 sem sandbox 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, é necessário criar uma instância ::sandbox2::Client, configurar a comunicação entre o executor e o sandboxee e notificar o executor de que a inicialização foi concluída e que 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 é o crc4, em que crc4bin.cc é o sandboxee e notifica o executor (crc4sandbox.cc) quando ele precisa entrar no sandbox.

Método 3: Forkserver personalizado: prepare um binário, aguarde as solicitações de bifurcação e coloque o 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 bifurcação para o binário, que vai fork() (via ::sandbox2::ForkingClient::WaitAndFork()). O processo recém-criado vai estar pronto para ser colocado no 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());

Lembre-se de que esse modo é bastante complicado e se aplica apenas em alguns casos específicos, por exemplo, quando você tem requisitos de memória limitados. Você se beneficiará com o COW, mas a desvantagem é que não há um ASLR real. Outro exemplo de uso típico seria quando o sandboxee tem uma inicialização longa e que usa muita CPU que pode ser executada antes de os dados não confiáveis serem processados.

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

2. Criar uma política de sandbox

Depois de criar um executor, é provável que você queira definir uma política de sandbox para o sandbox. Caso contrário, o sandboxee será protegido somente pela política padrão de sistema de chamada.

O objetivo da política do sandbox é restringir as chamadas do sistema e os argumentos que o sandboxee pode gerar, bem como os arquivos que ele pode acessar. Você precisará ter uma compreensão detalhada das chamadas de sistema exigidas pelo código que planeja colocar no sandbox. Uma maneira de observar chamadas do sistema é executar o código com o strace da ferramenta de linha de comando do Linux.

Com a lista de chamadas do sistema, você pode usar o PolicyBuilder para definir a política. O PolicyBuilder vem com muitas funções auxiliares e conveniência que permitem muitas operações comuns. A lista a seguir é apenas um pequeno trecho das funções disponíveis:

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

Essas funções de conveniência colocam na lista de permissões qualquer chamada do sistema relevante. A vantagem disso é que a mesma política pode ser usada em diferentes arquiteturas em que certas chamadas do sistema não estão disponíveis (por exemplo, ARM64 não tem chamada de sistema ABERTA), mas com o menor risco de segurança de ativar mais scripts do que o necessário. Por exemplo, AllowOpen() permite que o Sandboxee chame qualquer chamada do sistema aberta relacionada. Se você quiser colocar apenas uma chamada do sistema específica na lista de permissões, use AllowSyscall(); para permitir várias chamadas de uma vez. Além disso, use AllowSyscalls().

Até o momento, a política verifica apenas o identificador syscall. Se você precisar fortalecer ainda mais a política e quiser definir uma política em que você permita apenas uma chamada de sistema com argumentos específicos, use AddPolicyOnSyscall() ou AddPolicyOnSyscalls(). Essas funções não só pegam o ID 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 saber mais sobre o BPF. Se você escrever um código BPF repetitivo e achar que deveria ter um wrapper de usabilidade, fique à vontade para enviar uma solicitação de recurso.

Além das funções relacionadas à chamada do sistema, o PolicyBuilder também fornece diversas funções relacionadas ao sistema de arquivos, como AddFile() ou AddDirectory(), para ativar a vinculação de um arquivo/diretório no sandbox. O auxiliar AddTmpfs() pode ser usado para adicionar um armazenamento temporário de arquivos ao sandbox.

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

Criar as chamadas do sistema para a lista de permissões ainda é um pouco trabalho manual, infelizmente. Crie uma política com as syscalls que você conhece das suas necessidades binárias e execute-a com uma carga de trabalho comum. Se uma violação for acionada, coloque a chamada do sistema na lista de permissões e repita o processo. Se você encontrar uma violação que acredita ser de risco para a lista de permissões, e o programa tratar os erros corretamente, tente fazer com que ela retorne um erro em vez de 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 chamadas do sistema específicas e, assim, reduz a superfície de ataque. No entanto, um invasor ainda pode causar efeitos indesejados executando um processo indefinidamente ou esgotando RAM e outros recursos.

Para lidar com essa ameaça, o Sandboxee é executado com limites rígidos por padrão. Se esses limites padrão causarem problemas para a execução legítima do programa, você poderá ajustá-los 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 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, o executor e o Sandboxee. A próxima etapa é criar o objeto Sandbox2 e executá-lo.

Executar de forma síncrona

O sandbox pode ser executado de maneira síncrona, bloqueando assim 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 ver um exemplo mais detalhado, consulte estático.

#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, para não bloquear 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 acessar 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. Como se comunicar com o sandboxee

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

No entanto, você provavelmente vai precisar 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 byte, protobufs ou descritores de arquivos.

Como compartilhar descritores de arquivos

Com a API Inter-Process Communication (consulte ipc.h), é possível usar MapFd() ou ReceiveFd():

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

    // 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 de socketpair. Pode ser usado para ler a saída padrão ou os erros padrão do sandboxee. Veja um exemplo de uso 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 de comunicações conveniente. Essa é uma maneira simples e fácil de compartilhar números inteiros, strings ou buffers de byte entre o executor e o sandboxee. Confira abaixo alguns snippets de código que podem ser encontrados no exemplo do crc4.

Para começar a usar a API comms, primeiro você precisa obter 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 para o Sandboxee usando uma da família de funções Enviar*. Confira um exemplo de uso da API comms no 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 do crc4. O executor recebe a soma de verificação em um número inteiro não assinado 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 de buffer para compartilhar grandes quantidades de dados e evitar cópias caras entre o executor e o sandboxee.

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

No snippet de código abaixo, é possível observar o lado do executor. O sandbox é executado de maneira assíncrona e compartilha dados por meio de 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, você também precisa 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. Como sair do sandbox

Dependendo de como você executa o sandbox (consulte esta etapa), é necessário ajustar a forma como você encerra o sandbox e, consequentemente, o sandboxee.

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

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

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

Sair de um sandbox em execução assíncrona

Se o sandbox estiver sendo executado de maneira assíncrona, duas opções estarão disponíveis para encerramento. Primeiro, basta aguardar a conclusão do sandbox e receber 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 é recomendado chamar AwaitResult(), porque ele pode ser encerrado por outro motivo:

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

7. Testar

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

Um programa no modo sandbox é testado da mesma forma que você seria executado na produção, com os argumentos e arquivos de entrada que ele processaria normalmente.

Esses testes podem ser simples, como um teste de shell ou testes de C++ usando subprocessos. Confira os exemplos para se inspirar.

Conclusão

Agradecemos por ler até aqui. Esperamos que tenha gostado do nosso guia e agora se sinta capaz de criar seus próprios sandboxes para 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 revise a política e o código.