Premiers pas avec Sandbox2

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

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

Le bac à sable commence par un exécutant (voir Exécutant du bac à sable), qui est responsable de l'exécution du bac à sable. Le 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 parmi lesquelles vous pouvez choisir.

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

Il s'agit du moyen le plus simple d'utiliser le bac à sable et de la méthode recommandée lorsque vous souhaitez mettre en bac à sable un binaire entier pour lequel vous ne disposez pas de code source. C'est également le moyen le plus sûr d'utiliser le bac à sable, car il n'existe aucune initialisation non mise en bac à sable qui pourrait avoir des effets négatifs.

Dans l'extrait de code ci-dessous, 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 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écutant quand mettre en bac à sable

Cette méthode offre la flexibilité d'être non mise en bac à sable lors de l'initialisation, puis de choisir quand entrer dans le bac à sable en appelant ::sandbox2::Client::SandboxMeHere(). Vous devez pouvoir définir dans le code quand vous souhaitez commencer à mettre en bac à sable, et il doit être à thread unique (pour en savoir plus, consultez la FAQ).

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 de manière non mise en 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écutant dispose désormais d'un bac à sable désactivé jusqu'à ce qu'il soit averti par le bac à sable, nous devons créer une instance ::sandbox2::Client, configurer la communication entre l'exécutant et le bac à sable, puis informer l'exécutant que notre initialisation est terminée et que nous voulons commencer à mettre en 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();
  …

Un exemple de cette méthode d'exécution est crc4, où crc4bin.cc est le bac à sable et informe l'exécutant (crc4sandbox.cc) quand il doit entrer dans le bac à sable.

Méthode 3 : Forkserver personnalisé – Préparer un binaire, attendre les requêtes de fork et mettre en bac à sable vous-même

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

L'exécutant envoie une requête de fork à votre binaire, qui fork() (via ::sandbox2::ForkingClient::EnterForkLoop()). Le processus nouvellement créé sera 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 n'aurez pas de véritable ASLR. Un autre exemple d'utilisation typique serait lorsque le bac à sable a une initialisation longue et gourmande en ressources processeur qui peut être exécutée avant le traitement des données non approuvé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écutant, vous souhaiterez probablement définir une Sandbox Policy pour le bac à sable. Sinon, le bac à sable n'est protégé que par la règle d'appel système par défaut.

L'objectif de la règle 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 devrez comprendre en détail les 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 disposez de la liste des appels système, vous pouvez utiliser le 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 (dont beaucoup sont déjà incluses dans DefaultPolicyBuilder()). La liste suivante n'est qu'un petit extrait des fonctions disponibles :

  • Autoriser n'importe quel appel système pour le démarrage du processus :
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Autoriser tous les appels système ouverts/read/write*:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Autoriser tous les appels système liés à la sortie/l'accès/l'état :
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Autoriser tous les appels système liés au sommeil/à l'heure :
    • AllowTime();
    • AllowSleep();

Ces fonctions pratiques autorisent 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 le risque de sécurité mineur d'activer plus d'appels système que nécessaire. Par exemple, AllowOpen() permet au bac à sable d'appeler n'importe quel appel système ouvert. 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 de l'appel système. Si vous devez renforcer davantage la règle et que vous souhaitez définir une règle dans laquelle vous n'autorisez qu'un appel système avec des arguments particuliers, vous devez utiliser AddPolicyOnSyscall() ou AddPolicyOnSyscalls(). Ces fonctions prennent non seulement l'ID de l'appel système comme argument, mais aussi un filtre seccomp-bpf brut à l'aide des macros d'assistance bpf du noyau Linux. Pour en savoir plus sur BPF, consultez la documentation du noyau. Si vous écrivez du code BPF répétitif qui, selon vous, devrait avoir un wrapper d'utilisabilité, n'hésitez pas à envoyer une demande de fonctionnalité.

Outre les 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/répertoire dans le bac à sable. L'assistance AddTmpfs() peut être utilisée 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, il reste encore un peu de travail manuel pour trouver les appels système à ajouter à la liste d'autorisation. Créez une règle avec les appels système dont vous savez que votre binaire a besoin et exécutez-la avec une charge de travail courante. Si une violation est déclenchée, autorisez l'appel système et répétez le processus. Si vous rencontrez une violation que vous pensez risquée d'autoriser et que le programme gère les erreurs correctement, vous pouvez essayer de le faire renvoyer une erreur à la place avec BlockSyscallWithErrno().

L'exemple suivant construit directement sandbox2::PolicyBuilder pour illustrer la restriction des arguments write et mprotect et le blocage de openat (que DefaultPolicyBuilder() autoriserait sinon) :

#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 de 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 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, le bac à sable s'exécute par défaut avec des limites d'exécution strictes. Si ces limites par défaut posent des problèmes 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 exécutant.

L'extrait de code ci-dessous présente quelques exemples d'ajustements de limites. Toutes les options disponibles sont documentées dans le limits.h 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 outil.

4. Exécuter le bac à sable

Dans les sections précédentes, vous avez préparé l'environnement en bac à sable, la règle, l'exécutant 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, ce qui bloque jusqu'à ce qu'il y ait 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 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 ne bloque pas jusqu'à ce qu'il y ait un résultat. Cela est utile, par exemple, lorsque vous communiquez 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 le bac à sable

Par défaut, l'exécutant peut communiquer avec le bac à sable via des descripteurs de fichiers. Cela peut être tout ce dont vous avez besoin, par exemple si vous souhaitez simplement partager un fichier avec le bac à sable ou lire la sortie standard du bac à sable.

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

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écutant au bac à sable. Cela peut être utilisé pour partager un fichier ouvert à partir de l'exécutant afin de l'utiliser dans le bac à sable. 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);
    
  • Utilisez ReceiveFd() pour créer un point de terminaison socketpair. Cela peut être utilisé pour lire la sortie standard ou les erreurs standard du bac à sable. 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 comms 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écutant et le bac à sable. Vous trouverez ci-dessous quelques extraits de code que vous pouvez trouver 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 comms disponible, les données peuvent être envoyées au bac à sable à 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 présente un extrait de cet exemple. L'exécutant 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 du bac à sable, utilisez l'une des fonctions Recv*. L'extrait de code ci-dessous est un extrait de l'exemple crc4. L'exécutant reçoit la somme de contrôle dans 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 tampon pour partager de grandes quantités de données et éviter les copies coûteuses qui sont envoyées entre l'exécutant et le bac à sable.

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 au bac à sable à l'aide de comms->SendFD() dans l'exécuteur et comms->RecvFD() dans le bac à sable.

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

// 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é du bac à sable, vous devez également créer un objet tampon et lire les données du descripteur de fichier envoyé par l'exécutant :

// 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 le terminez, et donc également le bac à sable.

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

Si le bac à sable s'est exécuté de manière synchrone, Run ne sera renvoyé que lorsque le bac à sable sera terminé. Aucune autre étape de résiliation n'est donc requise. 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'est exécuté de manière asynchrone, deux options sont disponibles pour la résiliation. Tout d'abord, vous pouvez simplement attendre la fin du bac à sable 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 bac à sable à tout moment, mais il est toujours recommandé d'appeler AwaitResult(), car le bac à sable peut se terminer 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 de bac à sable ne sont pas destinés à tester l'exactitude du programme, mais plutôt à vérifier si le programme mis en bac à sable peut s'exécuter sans problème, comme des violations de bac à sable. Cela permet également de s'assurer que la règle de bac à sable est correcte.

Un programme mis 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. Consultez les exemples pour vous inspirer.

Conclusion

Merci d'avoir lu cet article jusqu'au bout. Nous espérons que notre guide vous a plu et que vous vous sentez désormais en mesure de 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ègle et votre code par un expert en sécurité.