En esta página, aprenderás a crear tu propio entorno de zona de pruebas 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 proporciona 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 ejecución de zona de pruebas
La zona de pruebas comienza con un ejecutor (consulta Sandbox Executor), que es responsable de ejecutar el Sandboxee. El archivo de encabezado 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 archivo binario con el aislamiento de zona de pruebas ya habilitado
Esta es la forma más sencilla de usar el aislamiento y es el método recomendado cuando deseas aislar un archivo binario completo del que no tienes el código fuente. También es la forma más segura de usar la zona de pruebas, ya que no hay inicialización sin zona de pruebas que pueda tener efectos adversos.
En el siguiente fragmento de código, definimos la ruta de acceso del archivo binario que se debe ejecutar en el sandbox y los argumentos que debemos pasar a una llamada al sistema execve. Como puedes ver en el archivo de encabezado executor.h, no especificamos un valor para envp
y, por lo tanto, copiamos el entorno del proceso principal. 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 ejecución 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: Servidor fork de Sandbox2: Indica al ejecutor cuándo debe estar en zona de pruebas
Este método ofrece la flexibilidad de no estar en un entorno de pruebas durante la inicialización y, luego, elegir cuándo ingresar al entorno de pruebas llamando a ::sandbox2::Client::SandboxMeHere()
. Requiere que puedas definir en el código cuándo quieres iniciar la zona de pruebas 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 describió 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 notifique, 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 comenzar a usar la zona de pruebas 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 al sandbox.
Método 3: Forkserver personalizado: Prepara un objeto binario, espera solicitudes de bifurcación y crea un entorno de pruebas 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 archivo binario, que fork()
(a través de ::sandbox2::ForkingClient::WaitAndFork()
). El proceso recién creado estará listo para ejecutarse en un entorno de pruebas 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 estrictos. Te beneficiarás de COW, pero tendrás la desventaja de que no hay una verdadera ASLR. Otro ejemplo de uso típico sería cuando el Sandboxee tiene una inicialización larga y que requiere mucha CPU que se puede ejecutar antes de que se procesen los datos no confiables.
Para ver un ejemplo de este método de ejecución, 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 Sandbox para el Sandboxee. De lo contrario, el Sandboxee solo está 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 tener un conocimiento detallado de las llamadas al sistema que requiere el código que planeas ejecutar en el entorno de pruebas. 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 PolicyBuilder para definir la política. PolicyBuilder incluye muchas funciones auxiliares y de conveniencia que permiten realizar muchas operaciones comunes. La siguiente lista es solo un pequeño fragmento de las funciones disponibles:
- Permite cualquier llamada al sistema para el inicio del proceso:
AllowStaticStartup();
AllowDynamicStartup();
- Incluye en la lista de entidades permitidas cualquier llamada al sistema abierta/de lectura/escritura*:
AllowOpen();
AllowRead();
AllowWrite();
- Incluye en la lista de entidades permitidas cualquier llamada al sistema relacionada con la salida, el acceso o el estado:
AllowExit();
AllowStat();
AllowAccess();
- Incluye en la lista de entidades permitidas cualquier llamada al sistema relacionada con el tiempo o la suspensión:
AllowTime();
AllowSleep();
Estas funciones de conveniencia incluyen en la lista de entidades permitidas 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 hay ciertas llamadas al sistema disponibles (p.ej., ARM64 no tiene la llamada 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 syscall relacionado con la apertura. Si solo deseas incluir en la lista de entidades permitidas 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 reforzar aún más la política y quieres 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 auxiliares de bpf del kernel de Linux. Consulta la documentación del kernel para obtener más información sobre BPF. Si te encuentras escribiendo 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 activar la vinculación de un archivo o directorio en el entorno de pruebas. El asistente AddTmpfs()
se puede usar para agregar un almacenamiento de archivos temporal 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, determinar qué llamadas al sistema se deben incluir en la lista de entidades permitidas sigue siendo un trabajo un poco 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 un incumplimiento, agrega a la lista de entidades permitidas la llamada al sistema y repite el proceso. Si te encuentras con un incumplimiento que crees que podría ser riesgoso incluir en la lista de entidades permitidas y el programa controla los errores de forma correcta, puedes intentar que devuelva un error 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. Ajustar límites
La política de zona de pruebas impide que el Sandboxee llame a syscalls específicas y, por lo tanto, reduce la superficie de ataque. Sin embargo, un atacante 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 estrictos de forma predeterminada. Si estos límites predeterminados causan problemas en la ejecución legítima de tu programa, puedes ajustarlos con la clase sandbox2::Limits
llamando a limits()
en el objeto del ejecutor.
En el siguiente fragmento de código, se muestran algunos ejemplos de ajustes de límites. Todas las opciones disponibles se documentan en el archivo de encabezado 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 de zona de pruebas, la política, el ejecutor y Sandboxee. El siguiente paso es crear el objeto Sandbox2
y ejecutarlo.
Ejecutar de forma síncrona
El sandbox puede ejecutarse de forma síncrona, lo que lo bloquea hasta que haya un resultado. En el siguiente fragmento de código, se muestra la creación de instancias del objeto Sandbox2
y su ejecución síncrona. Para ver 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 bloqueará hasta que haya un resultado. Esto es útil, por ejemplo, cuando te comunicas con el Sandboxee. En el siguiente fragmento de código, se muestra este caso de uso. Para ver 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 Sandboxee a través de descriptores de archivos. Esto podría ser todo lo que necesitas, por ejemplo, si solo quieres compartir un archivo con el Sandboxee o leer su salida estándar.
Sin embargo, es muy probable que necesites una lógica de comunicación más compleja entre el ejecutor y Sandboxee. La API de comms (consulta el archivo de encabezado comms.h) se puede usar 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. Se puede usar para compartir un archivo abierto desde el ejecutor para usarlo en el Sandboxee. Puedes 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. Se puede usar para leer la salida estándar o los errores estándar de Sandboxee. Puedes 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 comunicación 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 incluyen algunos fragmentos de código que puedes encontrar en el ejemplo de crc4.
Para comenzar a usar la API de Comms, primero debes obtener el objeto de Comms del objeto Sandbox2:
sandbox2::Comms* comms = s2.comms();
Una vez que el objeto de comunicación está disponible, se pueden enviar datos a Sandboxee con una de las funciones de la familia Send*. Puedes encontrar un ejemplo de uso de la API de Comms en el ejemplo de crc4. El siguiente fragmento de código 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 de Sandboxee, usa una de las funciones Recv*
. El siguiente fragmento de código es un extracto del ejemplo de crc4. El ejecutor recibe la suma de verificación en un número entero de 32 bits sin firma: uint32_t crc4;
if (!(comms->RecvUint32(&crc4))) {
/* handle error */
}
Cómo compartir datos con Buffers
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 de un lado a otro 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 archivo, 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. El entorno 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 de Sandboxee, también debes crear un objeto de búfer y leer los datos del descriptor de archivo 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. Cómo salir de la zona de pruebas
Según cómo ejecutes la zona de pruebas (consulta este paso), deberás ajustar la forma en que finalizas la zona de pruebas y, por lo tanto, también el Sandboxee.
Cómo salir 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 devolverá un valor cuando Sandboxee haya terminado. Por lo tanto, no se requiere ningún paso adicional para la rescisió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();
Cómo salir de una zona de pruebas que se ejecuta de forma asíncrona
Si el sandbox se ejecutó de forma asíncrona, hay dos opciones disponibles para la finalización. Primero, puedes esperar a que se complete 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 detenerse 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, tu implementación de zona de pruebas debe tener pruebas. Las pruebas de zona de pruebas no están diseñadas para probar la corrección del programa, sino para verificar si el programa en zona de pruebas puede ejecutarse sin problemas, como incumplimientos de la zona de pruebas. Esto también garantiza que la política de zona de pruebas sea correcta.
Un programa en zona de pruebas se prueba de la misma manera en que se ejecutaría 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++ que usan 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 proteger a tus usuarios.
Crear políticas y zonas de pruebas es una tarea difícil y propensa a errores sutiles. Para mayor seguridad, te recomendamos que un experto en seguridad revise tu política y tu código.