Erste Schritte mit Sandbox2

Auf dieser Seite erfahren Sie, wie Sie mit Sandbox2 Ihre eigene Sandbox-Umgebung erstellen. Sie lernen, wie Sie eine Sandbox Richtlinie definieren und einige erweiterte, aber gängige Anpassungen vornehmen. Verwenden Sie die Informationen hier als Leitfaden, zusammen mit den Beispielen und Code-Dokumentation in den Header-Dateien.

1. Methode für Sandbox-Executor auswählen

Sandboxing beginnt mit einem Executor (siehe Sandbox Executor), der für die Ausführung des Sandboxee verantwortlich ist. Die executor.h enthält die für diesen Zweck erforderliche API. Die API ist sehr flexibel und Sie können auswählen, was für Ihren Anwendungsfall am besten geeignet ist. In den folgenden Abschnitten werden die drei verschiedenen Methoden beschrieben, aus denen Sie auswählen können.

Methode 1: Standalone – Binärdatei mit bereits aktiviertem Sandboxing ausführen

Dies ist die einfachste Möglichkeit, Sandboxing zu verwenden, und die empfohlene Methode, wenn Sie eine gesamte Binärdatei in einer Sandbox ausführen möchten, für die Sie keinen Quellcode haben. Es ist auch die sicherste Methode, da es keine nicht in einer Sandbox ausgeführte Initialisierung gibt, die negative Auswirkungen haben könnte.

Im folgenden Code-Snippet definieren wir den Pfad der Binärdatei, die in einer Sandbox ausgeführt werden soll, und die Argumente, die wir an einen execve-Systemaufruf übergeben müssen. Wie Sie in der executor.h Header-Datei sehen können, geben wir keinen Wert für envp an und kopieren daher die Umgebung aus dem übergeordneten Prozess. Das erste Argument ist immer der Name des auszuführenden Programms und unser Snippet definiert kein anderes Argument.

Beispiele für diese Executor-Methode sind: static und 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);

Methode 2: Sandbox2-Forkserver – Dem Executor mitteilen, wann Sandboxing verwendet werden soll

Diese Methode bietet die Flexibilität, während der Initialisierung nicht in einer Sandbox ausgeführt zu werden und dann auszuwählen, wann Sandboxing verwendet werden soll, indem Sie ::sandbox2::Client::SandboxMeHere() aufrufen. Dazu müssen Sie im Code definieren können, wann Sandboxing verwendet werden soll, und es muss Single-Threaded sein (siehe FAQ).

Im folgenden Code-Snippet verwenden wir denselben Code wie in Methode 1 oben beschrieben. Damit das Programm während der Initialisierung nicht in einer Sandbox ausgeführt werden kann, rufen wir jedoch set_enable_sandbox_before_exec(false) auf.

#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);

Da der Executor jetzt eine deaktivierte Sandbox hat, bis er vom Sandboxee benachrichtigt wird, müssen wir eine ::sandbox2::Client-Instanz erstellen, die Kommunikation zwischen dem Executor und dem Sandboxee einrichten und den Executor dann benachrichtigen, dass die Initialisierung abgeschlossen ist und wir jetzt Sandboxing verwenden möchten, indem wir sandbox2_client.SandboxMeHere() aufrufen.

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

Ein Beispiel für diese Executor-Methode ist crc4, wobei crc4bin.cc der Sandboxee ist und den Executor (crc4sandbox.cc) benachrichtigt, wenn Sandboxing verwendet werden soll.

Methode 3: Benutzerdefinierter Forkserver – Binärdatei vorbereiten, auf Fork-Anfragen warten und Sandboxing selbst durchführen

In diesem Modus können Sie eine Binärdatei starten, sie für Sandboxing vorbereiten und sie dem Executor zu einem bestimmten Zeitpunkt im Lebenszyklus der Binärdatei zur Verfügung stellen.

Der Executor sendet eine Fork-Anfrage an Ihre Binärdatei, die fork() (über ::sandbox2::ForkingClient::EnterForkLook()). Der neu erstellte Prozess kann mit ::sandbox2::Client::SandboxMeHere() in einer Sandbox ausgeführt werden.

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

Dieser Modus ist recht kompliziert und nur in wenigen spezifischen Fällen anwendbar, z. B. wenn Sie strenge Speicheranforderungen haben. Sie profitieren von COW, haben aber den Nachteil, dass es kein echtes ASLR gibt. Ein weiteres typisches Anwendungsbeispiel ist, wenn der Sandboxee eine lange, CPU-intensive Initialisierung hat, die ausgeführt werden kann, bevor die nicht vertrauenswürdigen Daten verarbeitet werden.

Ein Beispiel für diese Executor-Methode finden Sie unter custom_fork.

2. Sandbox-Richtlinie erstellen

Sobald Sie einen Executor haben, sollten Sie wahrscheinlich eine Sandbox Richtlinie für den Sandboxee definieren. Andernfalls ist der Sandboxee nur durch die Standard-Syscall Richtlinie geschützt.

Mit der Sandbox-Richtlinie soll die Anzahl der Systemaufrufe und Argumente, die der Sandboxee ausführen kann, sowie die Dateien, auf die er zugreifen kann, eingeschränkt werden. Sie müssen ein detailliertes Verständnis der Systemaufrufe haben, die für den Code erforderlich sind, den Sie in einer Sandbox ausführen möchten. Eine Möglichkeit, Systemaufrufe zu beobachten, besteht darin, den Code mit dem Linux-Befehlszeilentool strace auszuführen.

Sobald Sie die Liste der Systemaufrufe haben, können Sie mit dem PolicyBuilder die Richtlinie definieren.

PolicyBuilder bietet viele praktische Funktionen und Hilfsfunktionen, die viele gängige Vorgänge ermöglichen (viele davon sind bereits in DefaultPolicyBuilder() enthalten). Die folgende Liste ist nur ein kleiner Auszug der verfügbaren Funktionen:

  • Zulassungsliste für alle Systemaufrufe für den Prozessstart:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Zulassungsliste für alle Systemaufrufe vom Typ „open“„read“„write“:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Zulassungsliste für alle Systemaufrufe im Zusammenhang mit Beenden, Zugriff und Status:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Zulassungsliste für alle Systemaufrufe im Zusammenhang mit „sleep“ und Zeit:
    • AllowTime();
    • AllowSleep();

Diese praktischen Funktionen setzen alle relevanten Systemaufrufe auf die Zulassungsliste. Das hat den Vorteil, dass dieselbe Richtlinie für verschiedene Architekturen verwendet werden kann, bei denen bestimmte Systemaufrufe nicht verfügbar sind (z. B. hat ARM64 keinen OPEN-Systemaufruf). Es besteht jedoch das geringe Sicherheitsrisiko, dass mehr Systemaufrufe aktiviert werden, als erforderlich sind. Mit AllowOpen() kann der Sandboxee beispielsweise alle Systemaufrufe im Zusammenhang mit „open“ aufrufen. Wenn Sie nur einen bestimmten Systemaufruf auf die Zulassungsliste setzen möchten, können Sie AllowSyscall(); verwenden. Wenn Sie mehrere Systemaufrufe gleichzeitig auf die Zulassungsliste setzen möchten, können Sie AllowSyscalls() verwenden.

Bisher prüft die Richtlinie nur die Systemaufruf-ID. Wenn Sie die Richtlinie weiter stärken und eine Richtlinie definieren möchten, in der nur ein Systemaufruf mit bestimmten Argumenten zulässig ist, müssen Sie AddPolicyOnSyscall() oder AddPolicyOnSyscalls() verwenden. Diese Funktionen verwenden nicht nur die Systemaufruf-ID als Argument, sondern auch einen rohen seccomp-bpf-Filter mit den bpf-Hilfsmakros aus dem Linux-Kernel. Weitere Informationen zu BPF finden Sie in der Kernel Dokumentation. Wenn Sie sich dabei wiederholenden BPF-Code schreiben, für den Sie einen Usability-Wrapper benötigen, können Sie eine Funktionsanfrage einreichen.

Neben Systemaufruf-bezogenen Funktionen bietet der PolicyBuilder auch eine Reihe von dateisystembezogenen Funktionen wie AddFile() oder AddDirectory(), um eine Datei oder ein Verzeichnis in die Sandbox einzubinden. Mit der Hilfsfunktion AddTmpfs() kann ein temporärer Dateispeicher in der Sandbox hinzugefügt werden.

Eine besonders nützliche Funktion ist AddLibrariesForBinary(), die die Bibliotheken und den Linker hinzufügt, die für eine Binärdatei erforderlich sind.

Das Erstellen einer Zulassungsliste für Systemaufrufe ist leider immer noch etwas manuelle Arbeit. Erstellen Sie eine Richtlinie mit den Systemaufrufen, die Ihre Binärdatei benötigt, und führen Sie sie mit einer gängigen Arbeitslast aus. Wenn ein Verstoß ausgelöst wird, setzen Sie den Systemaufruf auf die Zulassungsliste und wiederholen Sie den Vorgang. Wenn Sie auf einen Verstoß stoßen, bei dem Sie der Meinung sind, dass es riskant sein könnte, ihn auf die Zulassungsliste zu setzen, und das Programm Fehler ordnungsgemäß verarbeitet, können Sie versuchen, stattdessen einen Fehler zurückzugeben, indem Sie BlockSyscallWithErrno() verwenden.

Im folgenden Beispiel wird sandbox2::PolicyBuilder direkt erstellt, um die Argumente write und mprotect einzuschränken und openat zu blockieren (was DefaultPolicyBuilder() andernfalls zulassen würde):

#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. Limits anpassen

Die Sandbox-Richtlinie verhindert, dass der Sandboxee bestimmte Systemaufrufe ausführt, und verringert so die Angriffsfläche. Ein Angreifer kann jedoch möglicherweise immer noch unerwünschte Auswirkungen verursachen, indem er einen Prozess unbegrenzt ausführt oder RAM und andere Ressourcen erschöpft.

Um diese Bedrohung zu vermeiden, wird der Sandboxee standardmäßig mit strengen Ausführungslimits ausgeführt. Wenn diese Standardlimits Probleme bei der legitimen Ausführung Ihres Programms verursachen, können Sie sie mit der Klasse sandbox2::Limits anpassen, indem Sie limits() für das Executor-Objekt aufrufen.

Das folgende Code-Snippet zeigt einige Beispiele für die Anpassung von Limits. Alle verfügbaren Optionen sind in der limits.h dokumentiert.

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

Ein Beispiel für die Verwendung der Klasse sandbox2::Limits finden Sie im Beispiel tool.

4. Sandbox ausführen

In den vorherigen Abschnitten haben Sie die Sandbox-Umgebung, die Richtlinie, den Executor und den Sandboxee vorbereitet. Als Nächstes erstellen Sie das Sandbox2-Objekt und führen es aus.

Synchron ausführen

Die Sandbox kann synchron ausgeführt werden und blockiert so, bis ein Ergebnis vorliegt. Das folgende Code-Snippet zeigt die Instanziierung des Sandbox2-Objekts und seine synchrone Ausführung. Ein detaillierteres Beispiel finden Sie unter „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();

Asynchron ausführen

Sie können die Sandbox auch asynchron ausführen, sodass sie nicht blockiert, bis ein Ergebnis vorliegt. Das ist beispielsweise nützlich, wenn Sie mit dem Sandboxee kommunizieren. Das folgende Code-Snippet zeigt diesen Anwendungsfall. Detailliertere Beispiele finden Sie unter crc4 und „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. Mit dem Sandboxee kommunizieren

Standardmäßig kann der Executor über Dateideskriptoren mit dem Sandboxee kommunizieren. Das reicht möglicherweise aus, wenn Sie beispielsweise nur eine Datei für den Sandboxee freigeben oder die Standardausgabe des Sandboxee lesen möchten.

Wahrscheinlich benötigen Sie jedoch eine komplexere Kommunikationslogik zwischen dem Executor und dem Sandboxee. Mit der comms API (siehe der comms.h Header-Datei) können Sie Ganzzahlen, Strings, Byte-Puffer, Protobufs oder Dateideskriptoren senden.

Dateideskriptoren freigeben

Mit der Inter-Process Communication API (siehe ipc.h), können Sie MapFd() oder ReceiveFd() verwenden:

  • Verwenden Sie MapFd(), um Dateideskriptoren vom Executor zum Sandboxee zuzuordnen. So können Sie eine Datei freigeben, die vom Executor geöffnet wurde, damit sie im Sandboxee verwendet werden kann. Ein Anwendungsbeispiel finden Sie unter „static“.

    // The executor opened /proc/version and passes it to the sandboxee as stdin
    executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
    
  • Verwenden Sie ReceiveFd(), um einen Socketpair-Endpunkt zu erstellen. So können Sie die Standardausgabe oder Standardfehler des Sandboxee lesen. Ein Anwendungsbeispiel finden Sie unter „tool“.

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

Comms API verwenden

Sandbox2 bietet eine praktische comms API. So können Sie auf einfache Weise Ganzzahlen, Strings oder Byte-Puffer zwischen dem Executor und dem Sandboxee freigeben. Unten finden Sie einige Code-Snippets, die Sie in dem Beispiel „crc4“ finden können.

Um mit der comms API zu beginnen, müssen Sie zuerst das comms-Objekt aus dem Sandbox2-Objekt abrufen:

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

Sobald das comms-Objekt verfügbar ist, können Daten mit einer der Send*-Funktionen an den Sandboxee gesendet werden. Ein Anwendungsbeispiel für die comms API finden Sie im Beispiel „crc4“. Das folgende Code-Snippet zeigt einen Auszug aus diesem Beispiel. Der Executor sendet ein unsigned char buf[size] mit SendBytes(buf, size):

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

Verwenden Sie eine der Recv*-Funktionen, um Daten vom Sandboxee zu empfangen. Das folgende Code Snippet ist ein Auszug aus dem crc4-Beispiel. Der Executor empfängt die Prüfsumme als vorzeichenlose 32-Bit-Ganzzahl: uint32_t crc4;

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

Daten mit Puffern freigeben

Eine weitere Funktion zur Datenfreigabe ist die Verwendung der Buffer API , um große Datenmengen freizugeben und teure Kopien zu vermeiden, die zwischen dem Executor und dem Sandboxee hin und her gesendet werden.

Der Executor erstellt einen Puffer entweder nach Größe und zu übergebenden Daten oder direkt aus einem Dateideskriptor und übergibt ihn mit comms->SendFD() im Executor und comms->RecvFD() im Sandboxee an den Sandboxee.

Im folgenden Code-Snippet sehen Sie die Seite des Executors. Die Sandbox wird asynchron ausgeführt und gibt Daten über einen Puffer für den Sandboxee frei:

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

Auf der Seite des Sandboxee müssen Sie auch ein Pufferobjekt erstellen und die Daten aus dem vom Executor gesendeten Dateideskriptor lesen:

// 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. Sandbox beenden

Je nachdem, wie Sie die Sandbox ausführen (siehe diesen Schritt), müssen Sie die Art und Weise anpassen, wie Sie die Sandbox und damit auch den Sandboxee beenden.

Synchron ausgeführte Sandbox beenden

Wenn die Sandbox synchron ausgeführt wurde, wird „Run“ erst zurückgegeben, wenn der Sandboxee fertig ist. Daher ist kein zusätzlicher Schritt zum Beenden erforderlich. Das folgende Code-Snippet zeigt dieses Szenario:

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

Asynchron ausgeführte Sandbox beenden

Wenn die Sandbox asynchron ausgeführt wurde, gibt es zwei Möglichkeiten zum Beenden. Zuerst können Sie einfach auf den Abschluss des Sandboxee warten und den endgültigen Ausführungsstatus erhalten:

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

Alternativ können Sie den Sandboxee jederzeit beenden. Es wird jedoch empfohlen, AwaitResult() aufzurufen, da der Sandboxee in der Zwischenzeit aus einem anderen Grund beendet werden könnte:

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

7. Test

Wie jeder andere Code sollte auch Ihre Sandbox-Implementierung Tests haben. Sandbox-Tests dienen nicht dazu, die Korrektheit des Programms zu testen, sondern zu prüfen, ob das in einer Sandbox ausgeführte Programm ohne Probleme wie Sandbox-Verstöße ausgeführt werden kann. So wird auch sichergestellt, dass die Sandbox-Richtlinie korrekt ist.

Ein in einer Sandbox ausgeführtes Programm wird auf dieselbe Weise getestet, wie Sie es in der Produktion ausführen würden, mit den Argumenten und Eingabedateien, die es normalerweise verarbeiten würde.

Diese Tests können so einfach wie ein Shell-Test oder C++-Tests mit Unterprozessen sein. Lassen Sie sich von den Beispielen inspirieren.

Fazit

Vielen Dank, dass Sie bis hierher gelesen haben. Wir hoffen, dass Ihnen unser Leitfaden gefallen hat und Sie jetzt in der Lage sind, Ihre eigenen Sandboxes zu erstellen, um Ihre Nutzer zu schützen.

Das Erstellen von Sandboxes und Richtlinien ist eine schwierige Aufgabe, bei der leicht subtile Fehler auftreten können. Um auf der sicheren Seite zu sein, empfehlen wir Ihnen, Ihre Richtlinie und Ihren Code von einem Sicherheitsexperten überprüfen zu lassen.