Premiers pas avec Sandbox2

Sur cette page, vous allez apprendre à créer votre propre environnement sandbox avec Sandbox2. Vous apprendrez à définir une règle de bac à sable et à effectuer quelques ajustements avancés, mais courants. Utilisez les informations fournies ici comme guide, en plus des exemples et de la documentation du code dans les fichiers d'en-tête.

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

La mise en bac à sable commence par un exécutant (voir Sandbox Executor), qui est responsable de l'exécution du Sandboxee. Le fichier d'en-tête executor.h contient l'API nécessaire à cet effet. 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 différentes que vous pouvez choisir.

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. Elle est recommandée lorsque vous souhaitez mettre en bac à sable un binaire entier dont vous ne possédez pas le code source. C'est également le moyen le plus sûr d'utiliser le bac à sable, car il n'y a pas d'initialisation non sandboxée qui pourrait avoir des effets indésirables.

Dans l'extrait de code suivant, nous définissons le chemin d'accès au binaire à mettre en bac à sable et les arguments que nous devons 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 et 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.

Voici des exemples de cette méthode d'exécution : static et 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éthode 2 : Forkserver Sandbox2 – Indiquer à l'exécuteur quand il doit être mis en bac à sable

Cette méthode offre la possibilité de ne pas être mise en bac à sable lors de l'initialisation, puis de choisir quand entrer dans le bac à sable en appelant ::sandbox2::Client::SandboxMeHere(). Vous devez être en mesure de définir dans le code quand vous souhaitez commencer à utiliser le bac à sable, et il doit être monothread (consultez les questions fréquentes pour en savoir plus).

Dans l'extrait de code suivant, 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 de manière non sandboxée 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);

L'exécuteur disposant désormais d'un bac à sable désactivé jusqu'à ce qu'il soit averti par le Sandboxee, nous devons créer une instance ::sandbox2::Client, configurer la communication entre l'exécuteur et le Sandboxee, puis informer l'exécuteur que notre initialisation est terminée et que nous souhaitons commencer le sandboxing 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, où crc4bin.cc est le Sandboxee et avertit l'exécuteur (crc4sandbox.cc) lorsqu'il doit entrer dans le bac à sable.

Méthode 3 : Forkserver personnalisé – Préparez un binaire, attendez les demandes de fork et mettez en bac à sable vous-même

Ce mode vous permet de démarrer un binaire, de le préparer pour le bac à sable et, à un moment précis du cycle de vie de votre binaire, de le mettre à la disposition de l'exécuteur.

L'exécuteur envoie une requête de fork à votre binaire, qui fork() (via ::sandbox2::ForkingClient::WaitAndFork()). Le processus nouvellement créé est prêt à être mis 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 compliqué et ne s'applique que dans quelques cas spécifiques, par exemple lorsque vous avez des exigences de mémoire strictes. Vous bénéficierez de COW, mais vous aurez l'inconvénient de ne pas disposer d'ASLR. Un autre exemple d'utilisation typique serait lorsque le Sandboxee a une longue initialisation gourmande en ressources processeur qui peut être exécutée avant le traitement des données non fiables.

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 avez un exécutant, vous voudrez probablement définir une règle de bac à sable pour le Sandboxee. Sinon, le Sandboxee n'est protégé que par la stratégie Syscall par défaut.

L'objectif de la stratégie 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. Pour observer les appels système, vous pouvez exécuter le code avec l'outil de ligne de commande strace de Linux.

Une fois que vous avez 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 d'effectuer de nombreuses opérations courantes. La liste suivante n'est qu'un petit extrait des fonctions disponibles :

  • Ajouter n'importe quel appel système à la liste d'autorisation pour le démarrage du processus :
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Ajoutez à la liste d'autorisation tous les appels système open/read/write* :
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Autorisez 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 au sommeil/au temps :
    • AllowTime();
    • AllowSleep();

Ces fonctions pratiques autorisent tout appel système pertinent. L'avantage est que la même stratégie 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). Toutefois, cela présente un risque de sécurité mineur, car cela permet d'activer plus d'appels système que nécessaire. Par exemple, AllowOpen() permet à Sandboxee d'appeler n'importe quel syscall associé à l'ouverture. Si vous ne souhaitez autoriser qu'un seul appel système spécifique, vous pouvez utiliser AllowSyscall();. Pour autoriser plusieurs appels système à la fois, vous pouvez utiliser AllowSyscalls().

Jusqu'à présent, la règle ne vérifie que l'identifiant syscall. Si vous avez besoin de renforcer davantage la règle et que vous souhaitez en définir une dans laquelle vous n'autorisez qu'un appel système avec des arguments spécifiques, vous devez utiliser AddPolicyOnSyscall() ou AddPolicyOnSyscalls(). Ces fonctions prennent non seulement l'ID syscall 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 vous retrouvez à écrire du code BPF répétitif qui, selon vous, devrait disposer d'un wrapper d'usabilité, 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 monter un fichier ou un répertoire dans le bac à sable. L'assistant AddTmpfs() peut être utilisé pour ajouter un espace de stockage de fichiers temporaires dans le bac à sable.

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

Malheureusement, l'identification des appels système à ajouter à la liste d'autorisation nécessite encore un peu de travail manuel. Créez une règle avec les appels système dont votre binaire a besoin et exécutez-la avec une charge de travail courante. 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 une infraction qui vous semble risquée à autoriser et que le programme gère les erreurs de manière fluide, vous pouvez essayer de la faire renvoyer une erreur à la place 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

La règle du bac à sable empêche le bac à sable d'appeler des appels système spécifiques et réduit ainsi la surface d'attaque. Toutefois, un pirate informatique peut toujours être en mesure de provoquer des effets indésirables en exécutant un processus indéfiniment ou en épuisant la RAM et d'autres ressources.

Pour faire face à cette menace, Sandboxee s'exécute par défaut avec des limites d'exécution strictes. Si ces limites par défaut posent problème pour l'exécution légitime de votre programme, vous pouvez les ajuster à l'aide de la classe sandbox2::Limits en appelant limits() sur l'objet d'exécution.

L'extrait de code ci-dessous montre quelques exemples d'ajustements de 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 sandboxé, la stratégie, l'exécuteur et Sandboxee. 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'exécution jusqu'à ce qu'un résultat soit disponible. L'extrait de code ci-dessous montre l'instanciation de l'objet Sandbox2 et son exécution synchrone. Pour obtenir un exemple plus détaillé, consultez 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, ce qui signifie qu'il ne se bloque pas tant qu'il n'y a pas de résultat. Cela peut être utile, par exemple, lorsque vous communiquez avec Sandboxee. 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 le Sandboxee

Par défaut, l'exécuteur peut communiquer avec le Sandboxee via des descripteurs de fichiers. Cela peut suffire, par exemple si vous souhaitez simplement partager un fichier avec le bac à sable ou lire la sortie standard du bac à sable.

Toutefois, vous aurez probablement besoin d'une logique de communication plus complexe entre l'exécuteur et Sandboxee. L'API comms (voir le fichier d'en-tête comms.h) peut être utilisée pour envoyer des entiers, des chaînes, des tampons d'octets, des fichiers protobuf ou des descripteurs de fichier.

Partager des descripteurs de fichiers

À l'aide de l'API de communication interprocessus (voir ipc.h), vous pouvez utiliser MapFd() ou ReceiveFd() :

  • Utilisez MapFd() pour mapper les descripteurs de fichiers de l'exécuteur à Sandboxee. Permet de partager un fichier ouvert à partir de l'exécuteur pour l'utiliser dans Sandboxee. Vous trouverez un exemple d'utilisation dans static.

    // The executor opened /proc/version and passes it to the sandboxee as stdin
    executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
    
  • UtilisezReceiveFd() pour créer un point de terminaison socketpair. Cela peut être utilisé pour lire la sortie standard ou les erreurs standards de Sandboxee. Un exemple d'utilisation est disponible dans l'outil.

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

Utiliser l'API Communications

Sandbox2 fournit une API de communication pratique. Il s'agit d'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 dans l'exemple crc4.

Pour commencer à utiliser l'API Comms, vous devez d'abord obtenir l'objet Comms à partir de l'objet Sandbox2 :

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

Une fois l'objet de communication disponible, les données peuvent être envoyées à Sandboxee à l'aide de l'une des fonctions de la famille Send*. Vous trouverez un exemple d'utilisation de l'API Communications dans l'exemple crc4. L'extrait de code ci-dessous montre 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 un extrait 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 de mémoire tampon pour partager de grandes quantités de données et éviter les copies coûteuses qui sont envoyées dans les deux sens entre l'exécuteur et Sandboxee.

L'exécuteur crée un tampon, soit par taille et données à transmettre, soit directement à partir d'un descripteur de fichier, et 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 le côté de l'exécuteur. Le bac à sable s'exécute de manière asynchrone et partage les données via un tampon avec 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());

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

Selon la façon dont vous exécutez le bac à sable (voir cette étape), vous devez ajuster la façon dont vous mettez fin au bac à sable, et donc également au Sandboxee.

Quitter un bac à sable s'exécutant de manière synchrone

Si le bac à sable s'exécute de manière synchrone, "Run" ne sera renvoyé qu'une fois le Sandboxee terminé. Aucune autre étape n'est donc requise pour la résiliation. 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 s'exécutant de manière asynchrone

Si le bac à sable s'exécute de manière asynchrone, deux options de résiliation sont disponibles. Tout d'abord, vous pouvez simplement attendre la fin de Sandboxee et recevoir l'état d'exécution final :

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

Vous pouvez également arrêter le Sandboxee à tout moment, mais il est toujours recommandé d'appeler AwaitResult(), car le Sandboxee peut s'arrêter pour une autre raison entre-temps :

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

7. Test

Comme tout autre code, votre implémentation de bac à sable doit comporter des tests. Les tests en 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ème, comme des cas de non-respect du bac à sable. Cela permet également de s'assurer que la règle du bac à sable est correcte.

Un programme en bac à sable est testé de la même manière que s'il était exécuté 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. Consultez les exemples pour trouver l'inspiration.

Conclusion

Merci d'avoir lu ce guide jusqu'au bout. Nous espérons qu'il vous a plu et que vous vous sentez désormais prêt à créer vos propres bacs à sable pour 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 plus de sécurité, nous vous recommandons de faire examiner votre règlement et votre code par un expert en sécurité.