Erste Schritte mit Sandbox2

Auf dieser Seite erfahren Sie, wie Sie mit Sandbox2 Ihre eigene Sandbox-Umgebung erstellen. Sie erfahren, wie Sie eine Sandbox-Richtlinie definieren, sowie einige erweiterte, aber gängige Optimierungen. Nutzen Sie die hier aufgeführten Informationen sowie die Beispiele und die Code-Dokumentation in den Header-Dateien als Leitfaden.

1. Sandbox-Executor-Methode auswählen

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

Methode 1: Eigenständig – Binärprogramm mit aktiviertem Sandboxing ausführen

Dies ist die einfachste Möglichkeit, die Sandbox zu nutzen. Sie wird empfohlen, 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, die Sandbox-Technologie zu verwenden, da es keine Initialisierung ohne Sandbox gibt, die nachteilige 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 an einen ausführbaren Systemaufruf übergeben werden müssen. Wie Sie in der Header-Datei executor.h sehen können, geben wir keinen Wert für envp an und kopieren daher die Umgebung aus dem übergeordneten Prozess. Denken Sie daran, dass das erste Argument immer der Name des auszuführenden Programms ist und unser Snippet kein anderes Argument definiert.

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 – Teilen Sie dem Executor mit, wann in einer Sandbox ausgeführt werden soll.

Diese Methode bietet die Flexibilität, während der Initialisierung aus der Sandbox zu kommen und dann durch Aufrufen von ::sandbox2::Client::SandboxMeHere() auszuwählen, wann die Sandbox in Betrieb genommen wird. Sie müssen in der Lage sein, im Code zu definieren, wann Sie mit dem Sandboxing beginnen möchten, und es muss sich um einen Single-Thread-Vorgang handeln. Weitere Informationen finden Sie in den FAQs.

Im folgenden Code-Snippet verwenden wir denselben Code wie oben unter Methode 1 beschrieben. Damit das Programm während der Initialisierung jedoch ohne Sandbox ausgeführt werden kann, rufen wir 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 Sandbox deaktiviert 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 dann den Executor benachrichtigen, dass unsere Initialisierung abgeschlossen ist und wir jetzt durch Aufrufen von sandbox2_client.SandboxMeHere() mit dem Sandboxing beginnen möchten.

// 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. Dabei ist crc4bin.cc der Sandboxee und benachrichtigt den Executor (crc4sandbox.cc), wann er in die Sandbox gelangen soll.

Methode 3: Benutzerdefinierter Forkserver – Binärprogramm vorbereiten, auf Fork-Anfragen warten und eigenständig in einer Sandbox ausführen

In diesem Modus können Sie ein Binärprogramm starten, für das Sandboxing vorbereiten und an einem bestimmten Zeitpunkt im Lebenszyklus Ihres Binärprogramms für den Executor verfügbar machen.

Der Executor sendet eine Fork-Anfrage an Ihre Binärdatei, die fork() (über ::sandbox2::ForkingClient::WaitAndFork()). 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());

Beachten Sie, dass dieser Modus ziemlich kompliziert ist und nur in wenigen bestimmten Fällen anwendbar ist, z. B. bei geringen Arbeitsspeicheranforderungen. Du profitierst von COW, hast aber den Nachteil, dass es keine echte ASLR gibt. Ein weiteres typisches Anwendungsbeispiel wäre, 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, möchten Sie wahrscheinlich eine Sandbox-Richtlinie für die Sandboxee definieren. Andernfalls ist das Sandboxee nur durch die Standard-Syscall-Richtlinie geschützt.

Ziel der Sandbox-Richtlinie ist es, die Systemaufrufe und Argumente, die der Sandboxee ausführen kann, sowie die Dateien, auf die er zugreifen kann, einzuschränken. Sie müssen genau wissen, welche Systemaufrufe 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 PolicyBuilder die Richtlinie definieren. PolicyBuilder verfügt über viele Komfort- und Hilfsfunktionen, die viele gängige Operationen ermöglichen. Die folgende Liste enthält nur einen kleinen Auszug der verfügbaren Funktionen:

  • Setzen Sie alle Systemaufrufe für den Prozessstart auf die Zulassungsliste:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Setzen Sie alle offenen /read/write*-Syscalls auf die Zulassungsliste:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Setzen Sie alle Sysaufrufe im Zusammenhang mit Exit, Zugriff oder Status auf die Zulassungsliste:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Setzen Sie alle schlaf-/zeitbezogenen Systemanrufe auf die Zulassungsliste:
    • AllowTime();
    • AllowSleep();

Diese praktischen Funktionen setzen alle relevanten Systemaufrufe auf die Zulassungsliste. Dies hat den Vorteil, dass dieselbe Richtlinie für verschiedene Architekturen verwendet werden kann, in denen bestimmte Systemaufrufe nicht verfügbar sind (z.B. ARM64 hat keinen OFFENEN Systemaufruf), aber mit dem geringen Sicherheitsrisikos, das die Aktivierung von mehr Systemen als nötig darstellt. Mit der Funktion „AllowOpen()“ kann das Sandboxee beispielsweise einen beliebigen offenen Systemaufruf aufrufen. Wenn Sie nur einen bestimmten Systemaufruf auf die Zulassungsliste setzen möchten, können Sie AllowSyscall(); verwenden, um mehrere Systemaufrufe gleichzeitig zuzulassen. Verwenden Sie dazu AllowSyscalls().

Bisher wird mit der Richtlinie nur die Systemaufruf-ID geprüft. Wenn Sie die Richtlinie weiter stärken und eine Richtlinie definieren möchten, in der nur ein Systemaufruf mit bestimmten Argumenten zugelassen wird, müssen Sie AddPolicyOnSyscall() oder AddPolicyOnSyscalls() verwenden. Diese Funktionen verwenden nicht nur die Syscall-ID als Argument, sondern auch einen unbearbeiteten seccomp-bpf-Filter, der die bpf-Hilfsmakros aus dem Linux-Kernel verwendet. Weitere Informationen zu BPF finden Sie in der Kernel-Dokumentation. Wenn Sie sich wiederholende BPF-Codes schreiben, von denen Sie denken, dass sie einen Usability-Wrapper haben sollten, können Sie gerne eine Funktionsanfrage stellen.

Neben den syscall-bezogenen Funktionen bietet PolicyBuilder auch eine Reihe von dateisystembezogenen Funktionen wie AddFile() oder AddDirectory(), um eine Datei/ein Verzeichnis mit Bindung in der Sandbox bereitzustellen. Mit dem Helper AddTmpfs() kann ein temporärer Dateispeicher in der Sandbox hinzugefügt werden.

Eine besonders nützliche Funktion ist AddLibrariesForBinary(), die die für ein Binärprogramm erforderlichen Bibliotheken und Verknüpfungen hinzufügt.

Die Systemaufrufe für die Zulassungsliste zu erstellen, ist leider noch ein Teil des manuellen Prozesses. Erstellen Sie eine Richtlinie mit den Systemaufrufen, die Ihnen Ihre Binäranforderungen kennen, und führen Sie sie mit einer gemeinsamen 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, den Sie auf die Zulassungsliste setzen möchten, und das Programm Fehler ordnungsgemäß verarbeitet, können Sie versuchen, stattdessen mit BlockSyscallWithErrno() einen Fehler zurückzugeben.

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

Die Sandbox-Richtlinie verhindert, dass der Sandboxee bestimmte Systemaufrufe aufruft, und verringert so die Angriffsfläche. Angreifer können jedoch trotzdem unerwünschte Auswirkungen haben, wenn sie einen Prozess auf unbestimmte Zeit ausführen oder den RAM und andere Ressourcen erschöpfen.

Um dieser Bedrohung entgegenzuwirken, hat das Sandboxee standardmäßig enge Ausführungslimits. Wenn diese Standardlimits Probleme bei der ordnungsgemäßen Ausführung Ihres Programms verursachen, können Sie sie mithilfe der Klasse sandbox2::Limits anpassen, indem Sie limits() für das Executor-Objekt aufrufen.

Das folgende Code-Snippet zeigt einige Beispiele für Limitanpassungen. Alle verfügbaren Optionen sind in der Headerdatei 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 Beispieltool.

4. Sandbox ausführen

In den vorherigen Abschnitten haben Sie die Sandbox-Umgebung, Richtlinie, Executor und Sandboxee vorbereitet. Im nächsten Schritt erstellen Sie das Objekt Sandbox2 und führen es aus.

Synchron ausführen

Die Sandbox kann synchron ausgeführt werden und blockiert daher, bis ein Ergebnis vorliegt. Das folgende Code-Snippet zeigt die Instanziierung des Sandbox2-Objekts und seine synchrone Ausführung. Ein ausführlicheres Beispiel finden Sie unter statisch.

#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 wird, bis ein Ergebnis eintritt. Dies ist beispielsweise bei der Kommunikation mit dem Sandboxee nützlich. Das folgende Code-Snippet veranschaulicht diesen Anwendungsfall. Ausführlichere 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. Kommunikation mit dem Sandboxee

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

Sie benötigen jedoch höchstwahrscheinlich eine komplexere Kommunikationslogik zwischen dem Executor und dem Sandboxee. Die Kommunikations-API (siehe comms.h-Header-Datei) kann zum Senden von Ganzzahlen, Strings, Bytezwischenspeichern, Protokollpuffern oder Dateideskriptoren verwendet werden.

Dateideskriptoren freigeben

Über die Inter-Process Communication API (siehe ipc.h) können Sie MapFd() oder ReceiveFd() verwenden:

  • Verwenden Sie MapFd(), um Dateideskriptoren vom Executor dem Sandboxee zuzuordnen. Dies kann verwendet werden, um eine Datei, die vom Executor geöffnet wurde, für die Verwendung im Sandboxee freizugeben. Ein Anwendungsbeispiel ist in static zu sehen.

    // 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 Socket-Pair-Endpunkt zu erstellen. Damit können die Standardausgabe oder Standardfehler von Sandboxee gelesen werden. Ein Anwendungsbeispiel finden Sie im Tool.

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

Kommunikations-API verwenden

Sandbox2 bietet eine praktische Kommunikations-API. Dies ist eine einfache und einfache Möglichkeit, Ganzzahlen, Strings oder Byte-Puffer zwischen dem Executor und dem Sandboxee zu teilen. Im Folgenden finden Sie einige Code-Snippets, die im crc4-Beispiel zu sehen sind.

Um mit der Kommunikations-API zu beginnen, müssen Sie zuerst das Kommunikationsobjekt vom Sandbox2-Objekt abrufen:

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

Sobald das Kommunikationsobjekt verfügbar ist, können Daten mithilfe einer der Funktionen der Send*-Familie an den Sandboxee gesendet werden. Ein Beispiel für die Verwendung der Kommunikations-API finden Sie im crc4-Beispiel. 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 */
}

Um Daten vom Sandboxee zu erhalten, verwenden Sie eine der Recv*-Funktionen. Das folgende Code-Snippet ist ein Auszug aus dem crc4-Beispiel. Der Executor empfängt die Prüfsumme in einer vorzeichenlosen 32-Bit-Ganzzahl: uint32_t crc4;

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

Daten für Puffer freigeben

Eine weitere Funktion zur Datenfreigabe besteht darin, die Puffer-API zu verwenden, um große Datenmengen freizugeben und teure Kopien zu vermeiden, die zwischen dem Executor und dem Sandboxee hin- und hergeschickt werden.

Der Executor erstellt einen Puffer – entweder anhand der Größe und der 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 von Sandboxee müssen Sie auch ein Pufferobjekt erstellen und die Daten aus dem Dateideskriptor lesen, der vom Executor gesendet wurde:

// 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 Methode zum Beenden der Sandbox und somit auch die des Sandboxee anpassen.

Eine synchron ausgeführte Sandbox beenden

Wenn die Sandbox synchron ausgeführt wurde, wird die Ausführung erst dann wieder ausgeführt, wenn der Sandboxee fertig ist. Es ist also kein zusätzlicher Kündigungsschritt erforderlich. Das folgende Code-Snippet veranschaulicht 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, stehen zwei Optionen für die Beendigung zur Verfügung. Zuerst können Sie einfach warten, bis der Sandboxee abgeschlossen ist, und den endgültigen Ausführungsstatus erhalten:

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

Alternativ können Sie das Sandboxee jederzeit beenden. Es wird jedoch empfohlen, AwaitResult() aufzurufen, da das 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. Testen

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

Ein Sandbox-Programm wird genauso getestet, wie es in der Produktion ausgeführt wird, 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. Sehen Sie sich die Beispiele an, um sich inspirieren zu lassen.

Fazit

Vielen Dank, dass Sie sich diese Informationen angesehen haben. Wir hoffen, dass Ihnen unser Leitfaden gefallen hat und Sie jetzt Ihre eigenen Sandboxes erstellen können, um Ihre Nutzer zu schützen.

Das Erstellen von Sandboxes und Richtlinien ist eine schwierige Aufgabe und anfällig für subtile Fehler. Um auf der sicheren Seite zu bleiben, empfehlen wir Ihnen, Ihre Richtlinien und Ihren Code von einem Sicherheitsexperten prüfen zu lassen.