Introduzione a Sandbox2

In questa pagina imparerai a creare il tuo ambiente sandbox con Sandbox2. Scoprirai come definire una norma Sandbox e alcuni aggiustamenti avanzati, ma comuni. Utilizza le informazioni riportate qui come guida, insieme agli esempi e alla documentazione del codice nei file di intestazione.

1. Scegliere un metodo di esecuzione della sandbox

Il sandboxing inizia con un executor (vedi Sandbox Executor), 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 la soluzione migliore per il tuo caso d'uso. Le sezioni seguenti descrivono i tre metodi diversi tra cui puoi scegliere.

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

Questo è il modo più semplice per utilizzare il sandboxing ed è il metodo consigliato quando vuoi eseguire il sandboxing di un intero binario per il quale non disponi del codice sorgente. È anche il modo più sicuro per utilizzare il sandboxing, in quanto non esiste un'inizializzazione non sottoposta a sandbox che potrebbe avere effetti negativi.

Nello snippet di codice riportato di seguito, definiamo il percorso del file binario da isolare in 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 padre. Ricorda che il primo argomento è sempre il nome del programma da eseguire e il nostro snippet non definisce altri argomenti.

Esempi di questo metodo di esecuzione sono: 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);

Metodo 2: Forkserver Sandbox2: indica all'executor quando deve essere sottoposto a sandbox

Questo metodo offre la flessibilità di non essere sottoposto a sandbox durante l'inizializzazione e di scegliere quando attivare la sandbox chiamando ::sandbox2::Client::SandboxMeHere(). Richiede di poter definire nel codice quando vuoi iniziare il sandboxing e deve essere single-thread (leggi il motivo nelle domande frequenti).

Nello snippet di codice riportato di seguito, utilizziamo lo stesso codice descritto nel metodo 1 sopra. Tuttavia, per consentire l'esecuzione del programma in modo non in 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é l'executor ha ora una sandbox disattivata fino a quando non riceve una notifica da Sandboxee, dobbiamo creare un'istanza ::sandbox2::Client, configurare la comunicazione tra l'executor e Sandboxee e quindi notificare all'executor che l'inizializzazione è terminata e che vogliamo iniziare ora 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 di esecuzione è crc4, dove crc4bin.cc è Sandboxee e notifica all'executor (crc4sandbox.cc) quando deve entrare nel sandbox.

Metodo 3: Custom Forkserver: prepara un binario, attendi le richieste di fork e crea un sandbox autonomamente

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

L'esecutore invierà una richiesta di fork al tuo binario, che fork() (tramite ::sandbox2::ForkingClient::WaitAndFork()). Il processo appena creato sarà pronto per essere sottoposto al sandboxing 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 applicabile solo in alcuni casi specifici, ad esempio quando hai requisiti di memoria rigorosi. Beneficerai del COW, ma avrai lo svantaggio di non avere un ASLR reale. Un altro esempio di utilizzo tipico si verifica quando Sandboxee ha un'inizializzazione lunga e che richiede un utilizzo elevato della CPU che può essere eseguita prima dell'elaborazione dei dati non attendibili.

Per un esempio di questo metodo di esecuzione, vedi custom_fork.

2. Crea una policy sandbox

Una volta nominato un esecutore, probabilmente vorrai definire un criterio sandbox per il sandboxee. In caso contrario, il sandboxee è protetto solo dal criterio di chiamata di sistema predefinito.

Con la Sandbox Policy, l'obiettivo è limitare le chiamate di sistema e gli argomenti che Sandboxee può effettuare, nonché i file a cui può accedere. Devi avere una conoscenza dettagliata delle chiamate di sistema richieste dal codice che prevedi di inserire nel sandbox. Un modo per osservare le chiamate di sistema è eseguire il codice con lo strumento a riga di comando strace di Linux.

Una volta ottenuto l'elenco delle chiamate di sistema, puoi utilizzare PolicyBuilder per definire la policy. PolicyBuilder include molte funzioni di praticità e di assistenza che consentono di eseguire molte operazioni comuni. Il seguente elenco è solo un piccolo estratto delle funzioni disponibili:

  • Inserisci nella lista consentita qualsiasi chiamata di sistema per l'avvio del processo:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Consenti qualsiasi chiamata di sistema open/read/write*:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Inserisci nella lista consentita tutte le chiamate di sistema relative a uscita/accesso/stato:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Inserisci nella lista consentita tutte le chiamate di sistema relative al sonno/al tempo:
    • AllowTime();
    • AllowSleep();

Queste funzioni di utilità consentono di inserire nella lista consentita qualsiasi chiamata di sistema pertinente. Ciò ha il vantaggio che la stessa policy può essere utilizzata su architetture diverse in cui alcune chiamate di sistema non sono disponibili (ad es. ARM64 non ha la chiamata di sistema OPEN), ma con il rischio di sicurezza minore di abilitare più chiamate di sistema del necessario. Ad esempio, AllowOpen() consente a Sandboxee di chiamare qualsiasi syscall correlata aperta. Se vuoi inserire nella lista consentita una sola chiamata di sistema specifica, puoi utilizzare AllowSyscall();. Per consentire più chiamate di sistema contemporaneamente, puoi utilizzare AllowSyscalls().

Finora il criterio controlla solo l'identificatore della chiamata di sistema. Se hai bisogno di rafforzare ulteriormente la policy e vuoi definirne una in cui consenti solo una chiamata di sistema con argomenti particolari, devi utilizzare AddPolicyOnSyscall() o AddPolicyOnSyscalls(). Queste funzioni non solo prendono l'ID syscall come argomento, ma anche un filtro seccomp-bpf non elaborato utilizzando le macro helper bpf del kernel Linux. Per saperne di più su BPF, consulta la documentazione del kernel. Se ti ritrovi a scrivere codice BPF ripetitivo che ritieni dovrebbe avere un wrapper di usabilità, non esitare a inviare una richiesta di funzionalità.

Oltre alle funzioni correlate alle chiamate di sistema, PolicyBuilder fornisce anche una serie di funzioni correlate al file system come AddFile() o AddDirectory() per montare un file/una directory nella sandbox. L'helper AddTmpfs() può essere utilizzato per aggiungere uno spazio di archiviazione temporaneo dei file all'interno della sandbox.

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

Purtroppo, l'individuazione delle chiamate di sistema da inserire nella lista consentita è ancora un po' un lavoro manuale. Crea un criterio con le chiamate di sistema di cui sai che il tuo binario ha bisogno e esegui il criterio con un workload comune. Se viene attivata una violazione, inserisci nella lista consentita la chiamata di sistema e ripeti la procedura. Se riscontri una violazione che ritieni possa essere rischioso inserire nella lista consentita e il programma gestisce gli errori in modo controllato, puoi provare a far sì che restituisca un errore 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. Modifica limiti

Il criterio di sandbox impedisce a Sandboxee di chiamare syscall specifiche e riduce quindi la superficie di attacco. Tuttavia, un malintenzionato potrebbe comunque causare effetti indesiderati eseguendo un processo all'infinito o esaurendo la RAM e altre risorse.

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

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, consulta lo strumento di esempio.

4. Esegui la sandbox

Nelle sezioni precedenti hai preparato l'ambiente limitato tramite sandbox, la policy e l'executor e Sandboxee. Il passaggio successivo consiste nel creare l'oggetto Sandbox2 ed eseguirlo.

Esegui in modo sincrono

La sandbox può essere eseguita in modo sincrono, bloccando l'esecuzione fino a quando non viene restituito un risultato. Lo snippet di codice riportato di seguito mostra l'istanza dell'oggetto Sandbox2 e la sua esecuzione sincrona. Per un esempio più dettagliato, vedi 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();

Esegui in modo asincrono

Puoi anche eseguire la sandbox in modo asincrono, senza bloccare fino a quando non viene restituito un risultato. Ciò è utile, ad esempio, quando comunichi con il Sandboxee. Lo snippet di codice riportato di seguito mostra questo caso d'uso. Per esempi più dettagliati, vedi 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. Comunicazione con il sandboxee

Per impostazione predefinita, l'esecutore può comunicare con Sandboxee tramite i descrittori di file. 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 probabilmente hai bisogno di una logica di comunicazione più complessa tra l'executor 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 di file

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

  • Utilizza MapFd() per mappare i descrittori di file dall'executor a Sandboxee. Può essere utilizzato per condividere un file aperto dall'executor per l'utilizzo nel Sandboxee. Un esempio di utilizzo è disponibile in static.

    // The executor opened /proc/version and passes it to the sandboxee as stdin
    executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
    
  • UtilizzaReceiveFd() per creare un endpoint socketpair. Può essere utilizzato per leggere l'output standard o gli errori standard del sandboxee. Un esempio di utilizzo è visibile nello strumento.

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

Utilizzo dell'API comms

Sandbox2 fornisce una comoda API comms. Questo è un modo semplice e facile per condividere numeri interi, stringhe o buffer di byte tra l'executor 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 disponibile l'oggetto comms, i dati possono essere inviati a Sandboxee utilizzando una delle funzioni della famiglia Send*. Puoi trovare un esempio di utilizzo dell'API comms nell'esempio crc4. Lo snippet di codice di seguito mostra un estratto di questo esempio. L'esecutore testamentario 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 dal sandboxee, utilizza una delle funzioni Recv*. Lo snippet di codice riportato di seguito è 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 */
}

Condividere dati con i buffer

Un'altra funzionalità di condivisione dei dati è l'utilizzo dell'API buffer per condividere grandi quantità di dati ed evitare costose copie inviate avanti e indietro tra l'executor e Sandboxee.

L'executor crea un buffer, in base alle dimensioni e ai dati da trasferire, oppure direttamente da un descrittore di file e lo passa a Sandboxee utilizzando comms->SendFD() nell'executor e comms->RecvFD() in Sandboxee.

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

Dal lato del sandboxee, devi anche creare un oggetto buffer e leggere i dati dal descrittore di file inviato dall'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. Uscire dalla sandbox

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

Uscita da una sandbox in esecuzione in modo sincrono

Se la sandbox è stata eseguita in modo sincrono, Run restituirà un valore solo al termine dell'esecuzione di Sandboxee. Pertanto, non è necessario alcun passaggio aggiuntivo 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 in esecuzione in modo asincrono

Se la sandbox è stata eseguita in modo asincrono, sono disponibili due opzioni per la terminazione. Innanzitutto, puoi semplicemente 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 terminare Sandboxee in qualsiasi momento, ma è comunque consigliabile chiamare AwaitResult() perché Sandboxee potrebbe terminare per un altro motivo nel frattempo:

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

7. Test

Come qualsiasi altro codice, l'implementazione della sandbox deve avere dei test. I test sandbox non hanno lo scopo di verificare la correttezza del programma, ma di controllare se il programma in modalità sandbox può essere eseguito senza problemi come violazioni della sandbox. In questo modo, viene verificata anche la correttezza del criterio della sandbox.

Un programma in 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 test C++ che utilizzano sottoprocessi. Dai un'occhiata agli esempi per trarre ispirazione.

Conclusione

Grazie per aver letto fin qui. Ci auguriamo che la nostra guida ti sia piaciuta e che ora ti senta in grado di creare le tue sandbox per contribuire a proteggere i tuoi utenti.

La creazione di sandbox e policy è un'attività difficile e soggetta a errori sottili. Per sicurezza, ti consigliamo di far esaminare la tua policy e il tuo codice da un esperto di sicurezza.