Premiers pas avec Sandbox2

Sur cette page, vous allez apprendre à créer votre propre environnement de bac à sable avec Sandbox2. Vous allez apprendre à définir une règle de bac à sable et à effectuer quelques ajustements avancés, mais courants. Consultez les informations de ce guide, ainsi que les exemples et la documentation sur le code dans les fichiers d'en-tête.

1. Choisir une méthode d'exécution de bac à sable

Le processus de bac à sable commence par un exécuteur (voir Exécuteur de bac à sable), qui est chargé d'exécuter le Sandboxee. Le fichier d'en-tête executor.h contient l'API nécessaire à cette fin. L'API est très flexible et vous permet de choisir ce qui convient le mieux à votre cas d'utilisation. Les sections suivantes décrivent les trois méthodes disponibles.

Méthode 1: Autonome – Exécuter un binaire avec le bac à sable déjà activé

Il s'agit de la méthode la plus simple pour utiliser le bac à sable. C'est également la méthode recommandée lorsque vous souhaitez mettre en bac à sable un binaire entier pour lequel vous n'avez pas de code source. Il s'agit également de la méthode la plus sûre pour utiliser le bac à sable, car aucune initialisation hors bac à sable ne peut avoir des effets néfastes.

Dans l'extrait de code ci-dessous, nous définissons le chemin du binaire à mettre en bac à sable et les arguments à transmettre à un appel système "execve". Comme vous pouvez le voir dans le fichier d'en-tête executor.h, nous ne spécifions pas de valeur pour envp. Nous copions donc l'environnement à partir du processus parent. N'oubliez pas que le premier argument est toujours le nom du programme à exécuter et que notre extrait ne définit aucun autre argument.

static et tool sont des exemples de cette méthode d'exécution.

#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éthode 2: Serveur de transfert Sandbox2 – Indiquer à l'exécuteur quand le système doit lancer le bac à sable

Cette méthode offre la flexibilité de ne pas utiliser le bac à sable lors de l'initialisation, puis de choisir quand accéder au bac à sable en appelant ::sandbox2::Client::SandboxMeHere(). Vous devez être en mesure de définir dans le code à quel moment vous souhaitez lancer le bac à sable, et il doit être monothread (découvrez pourquoi dans les questions fréquentes).

Dans l'extrait de code ci-dessous, nous utilisons le même code que celui décrit dans la méthode 1 ci-dessus. Toutefois, pour permettre au programme de s'exécuter hors bac à sable lors de l'initialisation, nous appelons 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);

Comme l'exécuteur dispose désormais d'un bac à sable désactivé jusqu'à ce que le bac à sable en soit informé, nous devons créer une instance ::sandbox2::Client, configurer la communication entre l'exécuteur et le bac à sable, puis avertir l'exécuteur que l'initialisation est terminée et que nous souhaitons lancer le bac à sable en appelant 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();
  …

crc4 est un exemple de cette méthode d'exécution. crc4bin.cc correspond au bac à sable et informe l'exécuteur (crc4sandbox.cc) lorsqu'il doit entrer dans le bac à sable.

Méthode 3: Custom Forkserver – Préparer un binaire, attendre les requêtes de duplication et bac à sable

Ce mode vous permet de démarrer un binaire, de le préparer pour le bac à sable et, à un moment spécifique du cycle de vie du binaire, de le rendre disponible pour l'exécuteur.

L'exécuteur enverra une requête de duplication à votre binaire, qui fork() (via ::sandbox2::ForkingClient::WaitAndFork()). Le processus nouvellement créé sera prêt à être exécuté en bac à sable avec ::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());

N'oubliez pas que ce mode est assez complexe et ne s'applique que dans quelques cas spécifiques, par exemple lorsque vous avez des besoins importants en mémoire. Vous bénéficierez de COW, mais l'inconvénient est qu'il n'y a pas de véritable ASLR. Autre exemple d'utilisation typique : lorsque le Sandboxee effectue une initialisation longue et exigeante en processeur, qui peut être exécutée avant que les données non fiables soient traitées.

Pour obtenir un exemple de cette méthode d'exécution, consultez custom_fork.

2. Créer une règle de bac à sable

Une fois que vous disposez d'un exécuteur, vous souhaiterez probablement définir une règle de bac à sable pour le bac à sable. Sinon, le Sandboxee n'est protégé que par la stratégie d'appel système par défaut.

L'objectif des règles de bac à sable est de limiter les appels système et les arguments que le bac à sable peut effectuer, ainsi que les fichiers auxquels il peut accéder. Vous devez avoir une compréhension détaillée des appels système requis par le code que vous prévoyez de mettre en bac à sable. Une façon d'observer les appels système consiste à exécuter le code avec l'outil de ligne de commande strace de Linux.

Une fois que vous disposez de la liste des appels système, vous pouvez utiliser PolicyBuilder pour définir la règle. PolicyBuilder est fourni avec de nombreuses fonctions pratiques et d'assistance qui permettent de réaliser de nombreuses opérations courantes. La liste suivante n'est qu'un échantillon des fonctions disponibles:

  • Ajoutez un appel système à la liste d'autorisation pour le démarrage du processus :
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Ajoutez tous les appels système /read/write* à la liste d'autorisation :
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Ajoutez à la liste d'autorisation tous les appels système liés à la sortie, à l'accès ou à l'état :
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Ajoutez à la liste d'autorisation tous les appels système liés à l'heure de veille :
    • AllowTime();
    • AllowSleep();

Ces fonctions pratiques ajoutent à la liste d'autorisation tous les appels système pertinents. L'avantage est que la même règle peut être utilisée sur différentes architectures où certains appels système ne sont pas disponibles (par exemple, ARM64 n'a pas d'appel système OPEN), mais avec un risque de sécurité mineur : l'activation de plus de systèmes que nécessaire n'est nécessaire. Par exemple, AllowOpen() permet à Sandboxee d'appeler n'importe quel appel système associé ouvert. Si vous ne souhaitez ajouter à la liste d'autorisation qu'un seul appel système spécifique, utilisez AllowSyscall(); pour autoriser plusieurs appels système à la fois. Vous pouvez utiliser AllowSyscalls().

Jusqu'à présent, la stratégie ne vérifie que l'identifiant d'appel système. Si vous avez besoin de renforcer la règle et que vous souhaitez définir une stratégie dans laquelle vous n'autorisez qu'un appel système avec des arguments particuliers, vous devez utiliser AddPolicyOnSyscall() ou AddPolicyOnSyscalls(). Ces fonctions utilisent non seulement l'ID d'appel système comme argument, mais aussi un filtre seccomp-bpf brut utilisant les macros d'assistance bpf du noyau Linux. Pour en savoir plus sur BPF, consultez la documentation du noyau. Si vous écrivez un code BPF répétitif qui, selon vous, devrait avoir un wrapper facile à utiliser, n'hésitez pas à déposer une demande de fonctionnalité.

En plus des fonctions liées aux appels système, PolicyBuilder fournit également un certain nombre de fonctions liées au système de fichiers, telles que AddFile() ou AddDirectory(), pour installer un fichier ou un répertoire dans le bac à sable via des liaisons. L'outil d'aide AddTmpfs() peut être utilisé pour ajouter un stockage de fichiers temporaire dans le bac à sable.

AddLibrariesForBinary() est une fonction particulièrement utile, qui ajoute les bibliothèques et l'éditeur de liens requis par un binaire.

Malheureusement, trouver les appels système à la liste d'autorisation reste un peu de travail. Créez une stratégie avec les appels système dont vous connaissez les besoins en binaire et exécutez-la avec une charge de travail commune. Si une infraction est déclenchée, ajoutez l'appel système à la liste d'autorisation et répétez le processus. Si vous rencontrez un cas de non-respect que vous pensez risqué d'ajouter à la liste d'autorisation et que le programme gère les erreurs correctement, vous pouvez essayer de le faire renvoyer une erreur avec 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. Ajuster les limites

Les règles de bac à sable empêchent la sandboxee d'appeler des appels système spécifiques, ce qui réduit la surface d'attaque. Cependant, un pirate informatique peut toujours provoquer des effets indésirables en exécutant un processus indéfiniment ou en épuisant la mémoire RAM et d'autres ressources.

Pour contrer cette menace, le bac à sable s'exécute par défaut avec des limites d'exécution strictes. Si ces limites par défaut entraînent des problèmes d'exécution légitime de votre programme, vous pouvez les ajuster à l'aide de la classe sandbox2::Limits en appelant limits() sur l'objet exécuteur.

L'extrait de code ci-dessous présente quelques exemples d'ajustements des limites. Toutes les options disponibles sont documentées dans le fichier d'en-tête 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));

Pour obtenir un exemple d'utilisation de la classe sandbox2::Limits, consultez l'exemple d'outil.

4. Exécuter le bac à sable

Dans les sections précédentes, vous avez préparé l'environnement de bac à sable, la stratégie, l'exécuteur et le bac à sable. L'étape suivante consiste à créer l'objet Sandbox2 et à l'exécuter.

Exécuter de manière synchrone

Le bac à sable peut s'exécuter de manière synchrone, bloquant ainsi l'opération jusqu'à l'obtention d'un résultat. L'extrait de code ci-dessous illustre l'instanciation de l'objet Sandbox2 et son exécution synchrone. Pour obtenir un exemple plus détaillé, consultez l'article 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();

Exécuter de manière asynchrone

Vous pouvez également exécuter le bac à sable de manière asynchrone, et donc sans le bloquer tant qu'il n'y a pas de résultat. Cela s'avère utile, par exemple, pour communiquer avec le bac à sable. L'extrait de code ci-dessous illustre ce cas d'utilisation. Pour obtenir des exemples plus détaillés, consultez crc4 et 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. Communiquer avec les Sandboxee

Par défaut, l'exécuteur peut communiquer avec le Sandboxee via des descripteurs de fichier. C'est tout ce dont vous avez besoin, par exemple si vous voulez simplement partager un fichier avec le Sandboxee ou lire la sortie standard du Sandboxee.

Cependant, vous aurez probablement besoin d'une logique de communication plus complexe entre l'exécuteur et le bac à sable. Vous pouvez utiliser l'API comms (voir le fichier d'en-tête comms.h) pour envoyer des entiers, des chaînes, des tampons d'octets, des tampons de protocole ou des descripteurs de fichier.

Partager des descripteurs de fichier

Avec l'API Inter-Process Communication (voir ipc.h), vous pouvez utiliser MapFd() ou ReceiveFd():

  • Utilisez MapFd() pour mapper les descripteurs de fichier de l'exécuteur à l'environnement Sandboxee. Cela peut être utilisé pour partager un fichier ouvert à partir de l'exécuteur et l'utiliser dans Sandboxee. Un exemple d'utilisation est visible dans l'élément static.

    // The executor opened /proc/version and passes it to the sandboxee as stdin
    executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
    
  • Utilisez ReceiveFd() pour créer un point de terminaison de paire de sockets. Cela permet de lire la sortie ou les erreurs standards du Sandboxee. Vous trouverez un exemple d'utilisation dans l'outil.

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

Utiliser l'API comms

Sandbox2 fournit une API de communication pratique. C'est un moyen simple et facile de partager des entiers, des chaînes ou des tampons d'octets entre l'exécuteur et Sandboxee. Vous trouverez ci-dessous quelques extraits de code disponibles dans l'exemple crc4.

Pour faire vos premiers pas avec l'API comms, vous devez d'abord récupérer l'objet comms à partir de l'objet Sandbox2:

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

Une fois que l'objet comms est disponible, les données peuvent être envoyées au Sandboxee à l'aide de l'une des fonctions de la famille Send*. Vous trouverez un exemple d'utilisation de l'API comms dans l'exemple crc4. L'extrait de code ci-dessous reprend un extrait de cet exemple. L'exécuteur envoie un unsigned char buf[size] avec SendBytes(buf, size):

if (!(comms->SendBytes(static_cast<const uint8_t*>(buf), sz))) {
  /* handle error */
}

Pour recevoir des données de Sandboxee, utilisez l'une des fonctions Recv*. L'extrait de code ci-dessous est tiré de l'exemple crc4. L'exécuteur reçoit la somme de contrôle sous la forme d'un entier non signé de 32 bits : uint32_t crc4;

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

Partager des données avec des tampons

Une autre fonctionnalité de partage de données consiste à utiliser l'API tampon pour partager de grandes quantités de données et éviter les échanges de copies coûteuses entre l'exécuteur et le bac à sable.

L'exécuteur crée un tampon, soit par taille et par données à transmettre, soit directement à partir d'un descripteur de fichier, puis le transmet à Sandboxee à l'aide de comms->SendFD() dans l'exécuteur et de comms->RecvFD() dans Sandboxee.

Dans l'extrait de code ci-dessous, vous pouvez voir du côté de l'exécuteur. Le bac à sable s'exécute de manière asynchrone et partage des données avec le Sandboxee via un tampon:

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

Du côté de Sandboxee, vous devez également créer un objet tampon et lire les données du descripteur de fichier envoyé par l'exécuteur:

// 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. Quitter le bac à sable

Vous devez modifier la manière dont vous exécutez le bac à sable (voir cette étape), et donc comment celui-ci doit s'arrêter.

Quitter un bac à sable exécuté de manière synchrone

Si le bac à sable s'exécute de manière synchrone, la fonction Run n'est renvoyée qu'une fois le bac à sable terminé. Par conséquent, aucune étape supplémentaire n'est requise pour résilier. L'extrait de code ci-dessous illustre ce scénario:

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

Quitter un bac à sable exécuté de manière asynchrone

Si le bac à sable s'exécute de manière asynchrone, deux options d'arrêt sont disponibles. Tout d'abord, il vous suffit d'attendre la fin de l'opération Sandboxee pour obtenir l'état final de l'exécution:

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

Vous pouvez également fermer le Sandboxee à tout moment. Toutefois, il est toujours recommandé d'appeler AwaitResult(), car il pourrait s'arrêter pour une autre raison:

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

7. Test

Comme pour tout autre code, votre mise en œuvre de bac à sable doit comporter des tests. Les tests de bac à sable ne sont pas destinés à tester l'exactitude du programme, mais plutôt à vérifier si le programme en bac à sable peut s'exécuter sans problèmes, tels que des violations du bac à sable. Cela permet également de vérifier que la règle de bac à sable est correcte.

Un programme en bac à sable est testé de la même manière que vous l'exécuteriez en production, avec les arguments et les fichiers d'entrée qu'il traiterait normalement.

Ces tests peuvent être aussi simples qu'un test shell ou des tests C++ utilisant des sous-processus. Pour vous inspirer, consultez ces exemples.

Conclusion

Merci d'avoir lu ce guide. Nous espérons que vous avez apprécié notre guide et que vous avez désormais toutes les cartes en main pour créer vos propres bacs à sable afin de protéger vos utilisateurs.

La création de bacs à sable et de règles est une tâche difficile et sujette à des erreurs subtiles. Pour éviter tout problème de sécurité, nous vous recommandons de demander à un expert en sécurité d'examiner votre stratégie et votre code.