En esta página, aprenderás a crear tu propio entorno aislado con Sandbox2. Aprenderás a definir una política de zona de pruebas y algunos ajustes avanzados, pero comunes. Usa la información que se incluye aquí como guía, junto con los ejemplos y la documentación del código en los archivos de encabezado.
1. Elige un método de ejecutor de zona de pruebas
El aislamiento comienza con un ejecutor (consulta Ejecutor de zona de pruebas), que es responsable de ejecutar el Sandboxee. El executor.h contiene la API necesaria para este propósito. La API es muy flexible y te permite elegir lo que mejor se adapte a tu caso de uso. En las siguientes secciones, se describen los 3 métodos diferentes entre los que puedes elegir.
Método 1: Independiente: Ejecuta un objeto binario con el aislamiento ya habilitado
Esta es la forma más sencilla de usar el aislamiento y es el método recomendado cuando deseas aislar un objeto binario completo para el que no tienes código fuente. También es la forma más segura de usar el aislamiento, ya que no hay inicialización sin aislamiento que pueda tener efectos adversos.
En el siguiente fragmento de código, definimos la ruta de acceso del objeto binario que se aislará y los argumentos que debemos pasar a una llamada al sistema execve. Como puedes ver en el
executor.h
archivo de encabezado, no especificamos un valor para envp y, por lo tanto, copiamos el
entorno del proceso superior. Recuerda que el primer argumento siempre es el nombre del programa que se ejecutará, y nuestro fragmento no define ningún otro argumento.
Algunos ejemplos de este método de ejecutor son: static y 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: Indica al ejecutor cuándo debe estar aislado
Este método ofrece la flexibilidad de no estar aislado durante la inicialización y, luego, elegir cuándo ingresar al aislamiento llamando a ::sandbox2::Client::SandboxMeHere(). Requiere que puedas definir en
el código cuándo deseas iniciar el aislamiento y debe ser de un solo subproceso (lee
por qué en las Preguntas frecuentes).
En el siguiente fragmento de código, usamos el mismo código que se describe en el Método 1 anterior.
Sin embargo, para permitir que el programa se ejecute de forma no aislada durante la inicialización, llamamos a 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 el ejecutor ahora tiene una zona de pruebas inhabilitada hasta que el Sandboxee le notifica, debemos crear una instancia de ::sandbox2::Client, configurar la comunicación entre el ejecutor y el Sandboxee, y, luego, notificar al ejecutor que finalizó nuestra inicialización y que queremos iniciar el aislamiento ahora llamando a 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 ejemplo de este método de ejecutor es
crc4, en el que crc4bin.cc es el
Sandboxee y notifica al ejecutor (crc4sandbox.cc) cuándo debe ingresar a la
zona de pruebas.
Método 3: Forkserver personalizado: Prepara un objeto binario, espera solicitudes de bifurcación y aísla por tu cuenta
Este modo te permite iniciar un objeto binario, prepararlo para el aislamiento y, en un momento específico del ciclo de vida del objeto binario, ponerlo a disposición del ejecutor.
El ejecutor enviará una solicitud de bifurcación a tu objeto binario, que fork() (a través de ::sandbox2::ForkingClient::EnterForkLoop()). El proceso recién creado estará listo para aislarse 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());
Ten en cuenta que este modo es bastante complicado y solo se aplica en algunos casos específicos; por ejemplo, cuando tienes requisitos de memoria ajustados. Te beneficiarás de COW, pero tendrás la desventaja de que no hay ASLR real. Otro ejemplo de uso típico sería cuando el Sandboxee tiene una inicialización larga y que consume mucha CPU que se puede ejecutar antes de que se procesen los datos no confiables.
Para ver un ejemplo de este método de ejecutor, consulta custom_fork.
2. Crea una política de zona de pruebas
Una vez que tengas un ejecutor, es probable que desees definir una política de zona de pruebas para el Sandboxee. De lo contrario, el Sandboxee solo estará protegido por la política de llamadas al sistema predeterminada.
Con la política de zona de pruebas, el objetivo es restringir las llamadas al sistema y los argumentos que puede realizar el Sandboxee, así como los archivos a los que puede acceder. Deberás comprender en detalle las llamadas al sistema que requiere el código que planeas aislar. Una forma de observar las llamadas al sistema es ejecutar el código con la herramienta de línea de comandos strace de Linux.
Una vez que tengas la lista de llamadas al sistema, puedes usar el PolicyBuilder para definir la política.
PolicyBuilder incluye muchas funciones de conveniencia y funciones auxiliares que permiten muchas
operaciones comunes (muchas de las cuales ya están incluidas en
DefaultPolicyBuilder()). La siguiente lista es solo un pequeño extracto de
funciones disponibles:
- Incluir en la lista de entidades permitidas cualquier llamada al sistema para el inicio del proceso:
AllowStaticStartup();AllowDynamicStartup();
- Permite cualquier llamada al sistema abierta/read/write*:
AllowOpen();AllowRead();AllowWrite();
- Permite cualquier llamada al sistema relacionada con la salida, el acceso o el estado:
AllowExit();AllowStat();AllowAccess();
- Permite cualquier llamada al sistema relacionada con el tiempo o la suspensión:
AllowTime();AllowSleep();
Estas funciones de conveniencia permiten cualquier llamada al sistema pertinente. Esto tiene la ventaja de que se puede usar la misma política en diferentes arquitecturas en las que no están disponibles ciertas llamadas al sistema (p.ej., ARM64 no tiene llamadas al sistema OPEN), pero con el riesgo de seguridad menor de habilitar más llamadas al sistema de las que podrían ser necesarias. Por ejemplo, AllowOpen() permite que el Sandboxee llame a cualquier llamada al sistema relacionada con la apertura. Si solo deseas permitir una llamada al sistema específica, puedes usar AllowSyscall(); para permitir varias llamadas al sistema a la vez, puedes usar AllowSyscalls().
Hasta ahora, la política solo verifica el identificador de la llamada al sistema. Si necesitas fortalecer aún más la política y deseas definir una política en la que solo permitas una llamada al sistema con argumentos particulares, debes usar AddPolicyOnSyscall() o AddPolicyOnSyscalls(). Estas funciones no solo toman el ID de la llamada al sistema como argumento, sino también un filtro seccomp-bpf sin procesar que usa las macros de asistencia bpf del kernel de Linux. Consulta la documentación
del kernel
para obtener más información sobre BPF. Si escribes código BPF repetitivo que crees que debería tener un wrapper de usabilidad, no dudes en presentar una solicitud de función.
Además de las funciones relacionadas con las llamadas al sistema, PolicyBuilder también proporciona varias funciones relacionadas con el sistema de archivos, como AddFile() o AddDirectory(), para montar un archivo o directorio en la zona de pruebas. Se puede usar el asistente AddTmpfs() para agregar un almacenamiento de archivos temporales dentro de la zona de pruebas.
Una función particularmente útil es AddLibrariesForBinary(), que agrega las bibliotecas y el vinculador que requiere un objeto binario.
Lamentablemente, encontrar las llamadas al sistema para incluir en la lista de entidades permitidas sigue siendo un poco de trabajo manual. Crea una política con las llamadas al sistema que sabes que necesita tu objeto binario y ejecútala con una carga de trabajo común. Si se activa una infracción, permite la llamada al sistema y repite el proceso. Si te encuentras con una infracción que crees que podría ser riesgosa para incluir en la lista de entidades permitidas y el programa controla los errores correctamente, puedes intentar que muestre un error en su lugar con BlockSyscallWithErrno().
En el siguiente ejemplo, se construye sandbox2::PolicyBuilder directamente para demostrar la restricción de los argumentos write y mprotect y el bloqueo de openat (que DefaultPolicyBuilder() permitiría de otro modo):
#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. Ajusta los límites
La política de zona de pruebas impide que el Sandboxee llame a llamadas al sistema específicas y, por lo tanto, reduce la superficie de ataque. Sin embargo, un atacante aún podría causar efectos no deseados ejecutando un proceso de forma indefinida o agotando la RAM y otros recursos.
Para abordar esta amenaza, el Sandboxee se ejecuta con límites de ejecución ajustados de forma predeterminada. Si estos límites predeterminados causan problemas para la ejecución legítima de tu programa, puedes ajustarlos con la clase sandbox2::Limits llamando a limits() en el objeto ejecutor.
En el siguiente fragmento de código, se muestran algunos ajustes de límite de ejemplo. Todas las opciones disponibles se documentan en el 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 ver un ejemplo del uso de la clase sandbox2::Limits, consulta la herramienta de ejemplo
.
4. Ejecuta la zona de pruebas
En las secciones anteriores, preparaste el entorno aislado, la política, el ejecutor y el Sandboxee. El siguiente paso es crear el objeto Sandbox2 y ejecutarlo.
Se ejecutan de manera síncrona.
La zona de pruebas se puede ejecutar de forma síncrona, por lo que se bloquea hasta que haya un resultado. En el siguiente fragmento de código, se muestra la instancia del objeto Sandbox2 y su ejecución síncrona. Para obtener un ejemplo más detallado, consulta
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();
Se ejecutan de manera asíncrona.
También puedes ejecutar la zona de pruebas de forma asíncrona, por lo que no se bloquea hasta que haya un resultado. Esto es útil, por ejemplo, cuando se comunica con el Sandboxee. En el siguiente fragmento de código, se muestra este caso de uso. Para obtener ejemplos más detallados, consulta crc4 y 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. Comunicación con el Sandboxee
De forma predeterminada, el ejecutor puede comunicarse con el Sandboxee a través de descriptores de archivos. Esto podría ser todo lo que necesitas, por ejemplo, si solo deseas compartir un archivo con el Sandboxee o leer la salida estándar del Sandboxee.
Sin embargo, es muy probable que necesites una lógica de comunicación más compleja entre el ejecutor y el Sandboxee. Se puede usar la API de comms (consulta el comms.h archivo de encabezado) para enviar números enteros, cadenas, búferes de bytes, protobufs o descriptores de archivos.
Cómo compartir descriptores de archivos
Con la API de comunicación entre procesos (consulta
ipc.h),
puedes usar MapFd() o ReceiveFd():
Usa
MapFd()para asignar descriptores de archivos del ejecutor al Sandboxee. Esto se puede usar para compartir un archivo abierto desde el ejecutor para usarlo en el Sandboxee. Se puede ver un ejemplo de uso en static.// The executor opened /proc/version and passes it to the sandboxee as stdin executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);Usa
ReceiveFd()para crear un extremo de socketpair. Esto se puede usar para leer la salida estándar o los errores estándar del Sandboxee. Se puede ver un ejemplo de uso en la herramienta.// The executor receives a file descriptor of the sandboxee stdout int recv_fd1 = executor->ipc())->ReceiveFd(STDOUT_FILENO);
Usa la API de comms
Sandbox2 proporciona una API de comms conveniente. Esta es una forma sencilla y fácil de compartir números enteros, cadenas o búferes de bytes entre el ejecutor y el Sandboxee. A continuación, se muestran algunos fragmentos de código que puedes encontrar en el crc4.
Para comenzar a usar la API de comms, primero debes obtener el objeto comms del objeto Sandbox2:
sandbox2::Comms* comms = s2.comms();
Una vez que el objeto comms esté disponible, se pueden enviar datos al Sandboxee con una de las funciones de la familia Send*. Puedes encontrar un ejemplo de uso de la API de comms
en el crc4. En el siguiente fragmento de código, se muestra un fragmento de ese ejemplo. El ejecutor envía un unsigned char
buf[size] con SendBytes(buf, size):
if (!(comms->SendBytes(static_cast<const uint8_t*>(buf), sz))) {
/* handle error */
}
Para recibir datos del Sandboxee, usa una de las funciones Recv*. El siguiente fragmento de código
es un fragmento del
crc4. El ejecutor recibe la suma de comprobación en un número entero de 32 bits sin firma: uint32_t crc4;
if (!(comms->RecvUint32(&crc4))) {
/* handle error */
}
Comparte datos con búferes
Otra función de uso compartido de datos es usar la API de búfer para compartir grandes cantidades de datos y evitar copias costosas que se envían entre el ejecutor y el Sandboxee.
El ejecutor crea un búfer, ya sea por tamaño y datos que se pasarán, o directamente
desde un descriptor de archivos, y lo pasa al Sandboxee con comms->SendFD()
en el ejecutor y comms->RecvFD() en el Sandboxee.
En el siguiente fragmento de código, puedes ver el lado del ejecutor. La zona de pruebas se ejecuta de forma asíncrona y comparte datos a través de un búfer con el 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());
En el lado del Sandboxee, también debes crear un objeto de búfer y leer los datos del descriptor de archivos que envía el ejecutor:
// 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. Sal de la zona de pruebas
Según cómo ejecutes la zona de pruebas (consulta este paso), debes ajustar la forma en que la finalizas y, por lo tanto, también el Sandboxee.
Sal de una zona de pruebas que se ejecuta de forma síncrona
Si la zona de pruebas se ejecutó de forma síncrona, Run solo se mostrará cuando finalice el Sandboxee. Por lo tanto, no se requiere ningún paso adicional para la finalización. En el siguiente fragmento de código, se muestra esta situación:
Sandbox2::Result result = s2.Run();
LOG(INFO) << "Final execution status: " << result.ToString();
Sal de una zona de pruebas que se ejecuta de forma asíncrona
Si la zona de pruebas se ejecutó de forma asíncrona, hay dos opciones disponibles para la finalización. Primero, puedes esperar a que se complete el Sandboxee y recibir el estado de ejecución final:
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();
Como alternativa, puedes detener el Sandboxee en cualquier momento, pero se recomienda llamar a AwaitResult() porque el Sandboxee podría finalizar por otro motivo mientras tanto:
s2.Kill();
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();
7. Prueba
Al igual que cualquier otro código, la implementación de la zona de pruebas debe tener pruebas. Las pruebas de zona de pruebas no están diseñadas para probar la exactitud del programa, sino para verificar si el programa aislado se puede ejecutar sin problemas, como infracciones de la zona de pruebas. Esto también garantiza que la política de zona de pruebas sea correcta.
Un programa aislado se prueba de la misma manera que lo ejecutarías en producción, con los argumentos y los archivos de entrada que normalmente procesaría.
Estas pruebas pueden ser tan simples como una prueba de shell o pruebas de C++ con subprocesos. Consulta los ejemplos para inspirarte.
Conclusión
Gracias por leer hasta aquí. Esperamos que te haya gustado nuestra guía y que ahora te sientas capaz de crear tus propias zonas de pruebas para ayudar a mantener seguros a tus usuarios.
Crear zonas de pruebas y políticas es una tarea difícil y propensa a errores sutiles. Para mantenerte seguro, te recomendamos que un experto en seguridad revise tu política y tu código.