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);
Utilizza
ReceiveFd()
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.