Introduzione a Sandbox2

In questa pagina imparerai a creare il tuo ambiente con sandbox con Sandbox2. Imparerai a definire i criteri della sandbox e ad alcune modifiche avanzate ma comuni. Utilizza le informazioni qui riportate come guida, insieme agli esempi e alla documentazione relativa al codice nei file di intestazione.

1. Scegli un metodo di esecutore sandbox

La limitazione tramite sandbox inizia con un esecutore (vedi Esecutore sandbox), che è responsabile dell'esecuzione di Sandboxee. Il file di intestazione executor.h contiene l'API necessaria a questo scopo. L'API è molto flessibile e ti consente di scegliere cosa funziona meglio per il tuo caso d'uso. Le seguenti sezioni descrivono i 3 diversi metodi tra cui puoi scegliere.

Metodo 1: autonomo: esegui un programma binario con il sandboxing già abilitato

Questo è il modo più semplice per utilizzare il sandboxing ed è quello consigliato quando vuoi eseguire la sandbox di un intero programma binario per il quale non disponi di codice sorgente. È inoltre il modo più sicuro per utilizzare il sandboxing, poiché non esiste un'inizializzazione senza sandbox che potrebbe avere effetti negativi.

Nello snippet di codice riportato di seguito, definiamo il percorso del programma binario da includere nella sandbox e gli argomenti da passare a una chiamata di sistema execve. Come puoi vedere nel file di intestazione executor.h, non specifichiamo un valore per envp e quindi copiamo l'ambiente dal processo principale. Ricorda che il primo argomento è sempre il nome del programma da eseguire e il nostro snippet non definisce nessun altro argomento.

Esempi di questo metodo esecutore sono: statico e strumento.

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

Metodo 2: forkserver Sandbox2 - Indica all'esecutore quando deve essere eseguito il sandboxing

Questo metodo offre la flessibilità di disattivare la sandbox durante l'inizializzazione, per poi scegliere quando avviare la limitazione tramite sandbox chiamando ::sandbox2::Client::SandboxMeHere(). Richiede la possibilità di definire nel codice quando vuoi avviare il sandboxing, oltre a essere a thread unico (leggi i motivi nelle Domande frequenti).

Nello snippet di codice riportato di seguito viene utilizzato lo stesso codice descritto nel precedente Metodo 1. Tuttavia, per consentire l'esecuzione del programma senza sandbox durante l'inizializzazione, chiamiamo 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);

Poiché ora l'esecutore ha una sandbox disabilitata fino a quando non viene inviata una notifica da Sandboxee, dobbiamo creare un'istanza ::sandbox2::Client, configurare la comunicazione tra l'esecutore e Sandboxee e poi informare l'esecutore che l'inizializzazione è terminata e vogliamo iniziare subito il sandboxing chiamando 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();
  …

Un esempio di questo metodo esecutore è crc4, dove crc4bin.cc è il Sandboxee e avvisa l'esecutore (crc4sandbox.cc) quando deve entrare nella sandbox.

Metodo 3: forkserver personalizzato - Prepara un programma binario, attendi le richieste di fork e sandbox in autonomia

Questa modalità ti consente di avviare un programma binario, prepararlo per il sandboxing e, in un momento specifico del ciclo di vita del programma binario, renderlo disponibile per l'esecutore.

L'esecutore invierà una richiesta di fork al tuo programma binario, che verrà fork() (tramite ::sandbox2::ForkingClient::WaitAndFork()). Il processo appena creato sarà pronto per essere limitato tramite sandbox con ::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());

Tieni presente che questa modalità è piuttosto complicata e può essere applicata solo in alcuni casi specifici, ad esempio quando la memoria è richiesta. Trarrai beneficio da COW, ma hai il lato negativo del fatto che non esiste un'ASLR reale. Un altro esempio di utilizzo tipico è quando Sandboxee ha un'inizializzazione lunga e ad alta intensità di CPU che può essere eseguita prima che i dati non attendibili vengano elaborati.

Per un esempio di questo metodo esecutore, consulta custom_fork.

2. Crea un criterio nella sandbox

Una volta ottenuto un esecutore, è probabile che tu voglia definire un criterio della sandbox per Sandboxee. In caso contrario, Sandboxee è protetto solo dal criterio di chiamata di sistema predefinito.

Con il criterio della sandbox, l'obiettivo è limitare le chiamate di sistema e gli argomenti che può creare Sandboxee, nonché i file a cui può accedere. Devi avere una comprensione dettagliata delle chiamate di sistema richieste dal codice che prevedi di utilizzare la sandbox. Un modo per osservare le chiamate di sistema consiste nell'eseguire il codice con la strace dello strumento a riga di comando di Linux.

Una volta creato l'elenco delle chiamate di sistema, puoi utilizzare PolicyBuilder per definire il criterio. PolicyBuilder include molte funzioni helper e di pratico utilizzo che consentono molte operazioni comuni. Il seguente elenco rappresenta solo un breve estratto delle funzioni disponibili:

  • Inserisci nella lista consentita qualsiasi chiamata di sistema per l'avvio del processo:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Inserisci nella lista consentita qualsiasi chiamata di sistema aperta/lettura/scrittura*:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Inserisci nella lista consentita qualsiasi chiamata di sistema correlata a uscite/accesso/stato:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Inserisci nella lista consentita eventuali chiamate di sistema relative a sonno e orario:
    • AllowTime();
    • AllowSleep();

Queste funzioni di convenienza aggiungono qualsiasi chiamata di sistema pertinente alla lista consentita. Questo presenta il vantaggio che lo stesso criterio può essere utilizzato in diverse architetture in cui alcune chiamate di sistema non sono disponibili (ad es. ARM64 non ha una chiamata di sistema OPEN), ma con il rischio per la sicurezza minore di attivare più sysall del necessario. Ad esempio, allowOpen() consente a Sandboxee di chiamare qualsiasi chiamata di sistema aperta correlata. Se vuoi inserire nella lista consentita una sola chiamata di sistema specifica, puoi usare AllowSyscall(); per consentire più chiamate di sistema contemporaneamente, puoi usare AllowSyscalls().

Finora il criterio controlla solo l'identificatore di chiamata di sistema. Se hai bisogno di rafforzare ulteriormente il criterio e vuoi definire un criterio in cui sia consentita solo una chiamata di sistema con determinati argomenti, devi usare AddPolicyOnSyscall() o AddPolicyOnSyscalls(). Queste funzioni non prendono solo l'ID syscall come argomento, ma anche un filtro seccomp-bpf non elaborato che utilizza le macro bpf helper del kernel Linux. Per ulteriori informazioni su BPF, consulta la documentazione del kernel. Se ti capita di scrivere codice BPF ripetitivo che ritieni debba avere un wrapper per l'usabilità, non esitare a presentare una richiesta di funzionalità.

Oltre alle funzioni correlate a syscall, PolicyBuilder offre anche una serie di funzioni relative al file system, ad esempio AddFile() o AddDirectory() per il binding e il montaggio di un file/directory nella sandbox. L'helper AddTmpfs() può essere utilizzato per aggiungere uno spazio di archiviazione temporaneo per i file all'interno della sandbox.

Una funzione particolarmente utile è AddLibrariesForBinary(), che aggiunge le librerie e il linker richiesti da un programma binario.

Purtroppo, trovare nuove chiamate di sistema nella lista consentita richiede ancora un po' di lavoro manuale. Crea un criterio con chiamate di sistema note alle esigenze del programma binario ed eseguilo con un carico di lavoro comune. Se viene attivata una violazione, aggiungi la chiamata di sistema alla lista consentita e ripeti la procedura. Se riscontri una violazione che ritieni possa essere rischiosa da inserire nella lista consentita e il programma gestisce gli errori normalmente, puoi provare a fare in modo che restituisca un errore invece con 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. Regola i limiti

Il criterio della sandbox impedisce a Sandboxee di chiamare chiamate di sistema specifiche e riduce così la superficie di attacco. Tuttavia, un utente malintenzionato potrebbe comunque causare effetti indesiderati eseguendo un processo a tempo indeterminato o esaurendo la RAM e altre risorse.

Per risolvere questa minaccia, per impostazione predefinita Sandboxee viene eseguito con limiti di esecuzione stretti. Se questi limiti predefiniti causano problemi per l'esecuzione legittima del tuo programma, puoi modificarli utilizzando la classe sandbox2::Limits chiamando limits() sull'oggetto esecutore.

Lo snippet di codice riportato di seguito mostra alcuni esempi di aggiustamenti dei limiti. Tutte le opzioni disponibili sono documentate nel file di intestazione 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));

Per un esempio di utilizzo della classe sandbox2::Limits, vedi lo strumento di esempio.

4. Esegui la sandbox

Nelle sezioni precedenti hai preparato l'ambiente sandbox, il criterio, l'esecutore e la sandbox. Il passaggio successivo consiste nel creare l'oggetto Sandbox2 ed eseguirlo.

Esegui in modo sincrono

La sandbox può essere eseguita in modo sincrono, bloccando così fino a quando non viene generato un risultato. Lo snippet di codice riportato di seguito mostra la creazione di un'istanza dell'oggetto Sandbox2 e la sua esecuzione sincrona. Per un esempio più dettagliato, consulta la sezione statica.

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

Esegui in modo asincrono

Puoi anche eseguire la sandbox in modo asincrono, senza bloccarla finché non ci sono risultati. Questo è utile, ad esempio, durante la comunicazione con Sandboxee. Lo snippet di codice riportato di seguito illustra questo caso d'uso. Per esempi più dettagliati, vedi crc4 e lo strumento.

#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. Comunicare con il Sandboxee

Per impostazione predefinita, l'esecutore può comunicare con Sandboxee tramite descrittori di file. Questo potrebbe essere tutto ciò di cui hai bisogno, ad esempio se vuoi solo condividere un file con Sandboxee o leggere l'output standard di Sandboxee.

Tuttavia, è molto probabile che tu abbia bisogno di una logica di comunicazione più complessa tra l'esecutore e Sandboxee. L'API comms (vedi il file di intestazione comms.h) può essere utilizzata per inviare numeri interi, stringhe, buffer di byte, protobuf o descrittori di file.

Condivisione dei descrittori dei file

Con l'API Inter-Process Communication (vedi ipc.h), puoi utilizzare MapFd() o ReceiveFd():

  • Utilizza MapFd() per mappare i descrittori dei file dall'esecutore a Sandboxee. Può essere utilizzato per condividere un file aperto dall'esecutore per utilizzarlo in Sandboxee. Un esempio di utilizzo può essere visto in statico.

    // The executor opened /proc/version and passes it to the sandboxee as stdin
    executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
    
  • Utilizza ReceiveFd() per creare un endpoint della coppia di socket. Può essere utilizzato per leggere l'output standard o gli errori standard di Sandboxee. Un esempio di utilizzo è rappresentato dallo strumento.

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

Utilizzo dell'API comms

Sandbox2 offre una pratica API per le comunicazioni. Questo è un modo semplice e semplice per condividere numeri interi, stringhe o buffer di byte tra l'esecutore e Sandboxee. Di seguito sono riportati alcuni snippet di codice che puoi trovare nell'esempio crc4.

Per iniziare a utilizzare l'API comms, devi prima ottenere l'oggetto comms dall'oggetto Sandbox2:

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

Una volta che l'oggetto comms è disponibile, i dati possono essere inviati a Sandboxee utilizzando una delle funzioni della famiglia di funzioni Send*. Puoi trovare un esempio di utilizzo dell'API comms nell'esempio crc4. Lo snippet di codice riportato di seguito mostra un estratto di tale esempio. L'esecutore invia un unsigned char buf[size] con SendBytes(buf, size):

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

Per ricevere dati da Sandboxee, utilizza una delle funzioni di Recv*. Lo snippet di codice seguente è un estratto dell'esempio crc4. L'esecutore riceve il checksum in un numero intero senza segno a 32 bit: uint32_t crc4;

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

Condivisione di dati con i buffer

Un'altra funzionalità di condivisione dei dati consiste nell'utilizzare l'API buffer per condividere grandi quantità di dati ed evitare copie costose che vengono scambiate avanti e indietro tra l'esecutore e Sandboxee.

L'esecutore crea un buffer in base alle dimensioni e ai dati da trasmettere o direttamente da un descrittore di file e lo passa a Sandboxee utilizzando comms->SendFD() nell'esecutore e comms->RecvFD() in Sandbox.

Nello snippet di codice riportato di seguito puoi vedere il lato dell'esecutore. La sandbox viene eseguita in modo asincrono e condivide i dati tramite un buffer con 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());

Sul lato di Sandboxee, devi anche creare un oggetto buffer e leggere i dati dal descrittore del file inviato dall'esecutore:

// 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. Uscita dalla sandbox

A seconda di come esegui la sandbox (vedi questo passaggio), devi modificare il modo in cui termini la sandbox e quindi anche la sandbox.

Uscita da una sandbox eseguita in modo sincrono

Se la sandbox è stata eseguita in modo sincrono, viene restituito il valore Run solo al termine di Sandboxee. Pertanto, non sono necessari ulteriori passaggi per la risoluzione. Lo snippet di codice riportato di seguito mostra questo scenario:

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

Uscita da una sandbox eseguita in modo asincrono

Se la sandbox è stata eseguita in modo asincrono, sono disponibili due opzioni per la terminazione. Innanzitutto, puoi attendere il completamento di Sandboxee e ricevere lo stato di esecuzione finale:

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

In alternativa, puoi eliminare Sandboxee in qualsiasi momento, ma ti consigliamo comunque di chiamare AwaitResult() perché nel frattempo potrebbe interrompersi per altri motivi:

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

7. Testa

Come qualsiasi altro codice, l'implementazione della sandbox deve avere test. I test sandbox non hanno lo scopo di verificare la correttezza del programma, ma di verificare se il programma con sandbox può essere eseguito senza problemi come le violazioni della sandbox. Questa operazione assicura inoltre che il criterio della sandbox sia corretto.

Un programma con sandbox viene testato nello stesso modo in cui lo eseguiresti in produzione, con gli argomenti e i file di input che elaborerebbe normalmente.

Questi test possono essere semplici come un test della shell o dei test in C++ con processi secondari. Dai un'occhiata agli esempi per trarre ispirazione.

Conclusione

Grazie per aver letto fino a questo punto. Speriamo che la nostra guida ti sia piaciuta e che ora ti senta in grado di creare le tue sandbox per proteggere i tuoi utenti.

Creare sandbox e criteri è un'attività difficile e soggetta a lievi errori. Per rimanere al sicuro, ti consigliamo di far esaminare il criterio e il codice da un esperto di sicurezza.