Comienza a usar Sandbox2

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 la zona de pruebas y algunos ajustes avanzados, pero comunes. Utilice esta información como guía, junto con los ejemplos y la documentación de código que se encuentra en los archivos de encabezado.

1. Elige un método de ejecutor de la zona de pruebas

La zona de pruebas comienza con un ejecutor (consulta Ejecutor de zona de pruebas), que es responsable de ejecutar Sandboxee. El archivo de encabezado executor.h contiene la API necesaria para este fin. La API es muy flexible y te permite elegir lo que funciona mejor para tu caso de uso. En las siguientes secciones, se describen los 3 métodos diferentes que puedes elegir.

Método 1: Independiente (ejecuta un objeto binario con la zona de pruebas ya habilitada)

Esta es la forma más sencilla de usar la zona de pruebas y es el método recomendado cuando quieres usar la zona de pruebas de un objeto binario completo para el que no tienes un código fuente. También es la forma más segura de usar la zona de pruebas, ya que no hay una inicialización sin zona de pruebas que podría tener efectos adversos.

En el siguiente fragmento de código, definimos la ruta del objeto binario que se someterá a una zona de pruebas y los argumentos que debemos pasar a una llamada de sistema ejecutable. 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 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 ejecutor son estático y herramienta.

#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: Forkserver de Sandbox2: Indica al ejecutor cuándo debe establecerse una zona de pruebas

Este método ofrece la flexibilidad de no incluir la zona de pruebas durante la inicialización y, luego, elegir cuándo ingresar a la zona de pruebas llamando a ::sandbox2::Client::SandboxMeHere(). Es necesario que puedas definir en el código cuándo quieres comenzar la zona de pruebas y que debe ser de un solo subproceso (puedes consultar 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 más arriba. Sin embargo, para permitir que el programa se ejecute sin zona de pruebas durante la inicialización, llamaremos 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 Sandboxee la notifique, tenemos que crear una instancia de ::sandbox2::Client, configurar la comunicación entre el ejecutor y la Sandboxee, y luego notificar al ejecutor que finalizó nuestra inicialización y que queremos comenzar la zona de pruebas 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 ejecutor es crc4, en el que crc4bin.cc es el elemento Sandboxee y notifica al ejecutor (crc4sandbox.cc) cuando debe ingresar a la zona de pruebas.

Método 3: Servidor de bifurcación personalizado: Prepara un objeto binario, espera solicitudes de bifurcación y crea una zona de pruebas por tu cuenta

Este modo te permite iniciar un objeto binario, prepararlo para la zona de pruebas y, en un momento específico del ciclo de vida de tu objeto binario, ponerlo a disposición del ejecutor.

El ejecutor enviará una solicitud de bifurcación a tu objeto binario, que realizará el tipo fork() (a través de ::sandbox2::ForkingClient::WaitAndFork()). El proceso recién creado estará listo para una zona 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 aplicable solo en algunos casos específicos; por ejemplo, cuando tienes requisitos de memoria estrictos. Te beneficiarás con COW, pero la desventaja es que no existe una ASLR real. Otro ejemplo de uso típico sería cuando Sandboxee tiene una inicialización prolongada con uso intensivo de CPU que se puede ejecutar antes de que se procesen los datos que no son de confianza.

Para ver un ejemplo de este método ejecutor, consulta custom_fork.

2. Crea una política de zona de pruebas

Una vez que tengas un ejecutor, probablemente te convenga definir una política de la zona de pruebas para la zona de pruebas. De lo contrario, la zona de pruebas solo estará protegida por la política de Syscall predeterminada.

Con la política de la zona de pruebas, el objetivo es restringir las llamadas de sistema y los argumentos que puede crear la zona de pruebas, así como los archivos a los que puede acceder. Deberás tener una comprensión detallada de las llamadas del sistema que requiere el código que planeas usar en la zona de pruebas. Una forma de observar las llamadas del sistema es ejecutar el código con strace de la herramienta de línea de comandos de Linux.

Una vez que tengas la lista de llamadas de sistema, podrás usar PolicyBuilder para definir la política. PolicyBuilder incluye muchas funciones convenientes y auxiliares que permiten realizar muchas operaciones comunes. La siguiente lista es solo un pequeño extracto de las funciones disponibles:

  • Agrega a la lista de entidades permitidas cualquier llamada del sistema para el inicio del proceso:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Agrega a la lista de entidades permitidas cualquier llamada de sistema /read/write* abierta:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Agrega a la lista de entidades permitidas cualquier llamada de sistema relacionada con el estado, el acceso o la salida:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Agrega a la lista de entidades permitidas todas las llamadas de sistema relacionadas con el tiempo o el sueño:
    • AllowTime();
    • AllowSleep();

Estas funciones convenientes incluyen en la lista de entidades permitidas todas las llamadas de sistema relevantes. Esto tiene la ventaja de que se puede usar la misma política en arquitecturas diferentes en las que no están disponibles ciertas llamadas del sistema (p.ej., ARM64 no tiene una llamada de sistema ABIERTA), pero con el menor riesgo de seguridad de habilitar más sistemas de los que podrían ser necesarios. Por ejemplo, AllowOpen() permite que Sandboxee llame a cualquier llamada de sistema relacionada abierta. Si solo quieres incluir en la lista de entidades permitidas una llamada del sistema específica, puedes usar AllowSyscall(); para permitir varias llamadas del sistema a la vez y usar AllowSyscalls().

Hasta ahora, la política solo verifica el identificador de llamada del sistema. Si necesitas reforzar aún más la política y quieres definir una política en la que solo permitas una llamada de sistema con argumentos específicos, debes usar AddPolicyOnSyscall() o AddPolicyOnSyscalls(). Estas funciones no solo toman el ID de syscall como argumento, sino también un filtro seccomp-bpf sin procesar usando las macros auxiliares bpf del kernel de Linux. Consulta la documentación del kernel para obtener más información sobre BPF. Si notas que escribes código BPF repetitivo que crees que debería tener un wrapper de usabilidad, puedes enviar una solicitud de función.

Además de las funciones relacionadas con llamadas de sistema, PolicyBuilder también proporciona varias funciones relacionadas con el sistema de archivos, como AddFile() o AddDirectory(), para vincular y activar un archivo o directorio en la zona de pruebas. Se puede usar el ayudante AddTmpfs() para agregar almacenamiento temporal de archivos 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, idear las llamadas de sistema para incluirlas en la lista de entidades permitidas sigue siendo un poco de trabajo manual. Crea una política con las llamadas de sistema que conoces para tus necesidades binarias y ejecútala con una carga de trabajo común. Si se activa un incumplimiento, incluye la llamada del sistema en la lista de entidades permitidas y repite el proceso. Si encuentras un incumplimiento que crees que podría ser riesgoso incluirlo en la lista de entidades permitidas y el programa maneja los errores correctamente, puedes intentar que se muestre 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. Ajusta los límites

La política de la zona de pruebas evita que Sandboxee llame a llamadas de sistema específicas y, por lo tanto, reduce la superficie de ataque. Sin embargo, un atacante aún podría causar efectos no deseados si ejecuta un proceso de forma indefinida o agota la RAM y otros recursos.

Para abordar esta amenaza, Sandboxee se ejecuta con límites de ejecución estrictos 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 ejemplos de ajustes de límite. Todas las opciones disponibles están documentadas 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, la política y el ejecutor de la zona de pruebas, además de Sandboxee. El siguiente paso es crear el objeto Sandbox2 y ejecutarlo.

Ejecuta de forma 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 creación de instancias del objeto Sandbox2 y su ejecución síncrona. Para obtener un ejemplo más detallado, consulta estática.

#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, de modo que no se bloquee hasta que haya un resultado. Esto es útil, por ejemplo, cuando te comunicas con el objeto Sandboxee. En el siguiente fragmento de código, se muestra este caso de uso. Para ver ejemplos más detallados, consulta crc4 y la herramienta.

#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 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 Sandboxee o leer el resultado estándar de Sandboxee.

Sin embargo, lo más probable es que necesites una lógica de comunicación más compleja entre el ejecutor y Sandboxee. La API de comunicaciones (consulta el archivo de encabezado comms.h) se puede usar para enviar enteros, strings, 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 a la zona de pruebas. Se puede usar para compartir un archivo abierto desde el ejecutor y usarlo en Sandboxee. Puedes ver un ejemplo de uso en estática.

    // 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 par de sockets. Se puede usar para leer el resultado estándar o los errores estándar de la zona de pruebas. 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 comunicaciones

Sandbox2 proporciona una API de comunicaciones conveniente. Esta es una manera simple y fácil de compartir números enteros, strings o búferes de bytes entre el ejecutor y Sandboxee. A continuación, se muestran algunos fragmentos de código que puedes encontrar en el ejemplo de crc4.

Para comenzar a usar la API de comunicaciones, primero debes obtener el objeto de comunicaciones del objeto Sandbox2:

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

Una vez que el objeto de comunicaciones está disponible, los datos se pueden enviar a la zona de pruebas mediante una de la familia de funciones Send*. Puedes encontrar un ejemplo de uso de la API de comunicaciones en el ejemplo de crc4. El siguiente fragmento de código muestra un extracto 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 sin firma de 32 bits: uint32_t crc4;

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

Cómo compartir datos con búferes

Otra funcionalidad de uso compartido de datos consiste en usar la API de búfer para compartir grandes cantidades de datos y evitar copias costosas que se envían y reciben entre el ejecutor y 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 a Sandboxee usando comms->SendFD() en el ejecutor y comms->RecvFD() en 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 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());

Del lado de Sandboxee, también debes crear un objeto de búfer y leer los datos del descriptor de archivos que envió 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 finalizas la zona de pruebas, es decir, también la zona de pruebas.

Sal de una zona de pruebas que se ejecuta de forma síncrona

Si la zona de pruebas se ejecutó de forma síncrona, la ejecución solo se mostrará cuando finalice la zona de pruebas. 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();

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 la zona de pruebas y recibir el estado de ejecución final:

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

Como alternativa, puedes finalizar Sandboxee en cualquier momento, pero de todos modos se recomienda que llames a AwaitResult(), ya que este podría finalizar por otro motivo mientras tanto:

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

7. Probarla

Como cualquier otro código, tu implementación de la zona de pruebas debe tener pruebas. Las pruebas de zona de pruebas no tienen como objetivo evaluar la precisión del programa, sino comprobar si este puede ejecutarse sin problemas, como incumplimientos de la zona de pruebas. Esto también garantiza que la política de la zona de pruebas sea correcta.

Un programa de zona de pruebas se prueba de la misma manera en que lo ejecutarías en producción, con los argumentos y archivos de entrada que normalmente procesaría.

Estas pruebas pueden ser tan simples como una prueba de shell o pruebas de C++ con subprocesos. Revisa los ejemplos para inspirarte.

Conclusión

Gracias por leer hasta ahora. Esperamos que te haya gustado nuestra guía y que ahora te sientas empoderado para crear tus propias zonas de pruebas y 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 protegerte, recomendamos que un experto en seguridad revise tu política y tu código.