Auf dieser Seite erfahren Sie, wie Sie mit Sandbox2 eine eigene Sandbox-Umgebung erstellen. Sie erfahren, 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 der Codedokumentation in den Headerdateien.
1. Sandbox-Ausführungsmethode auswählen
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 dafür 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 aktivierter Sandbox ausführen
Dies ist die einfachste Methode für die Verwendung von Sandboxing und wird empfohlen, wenn Sie ein gesamtes Binärprogramm ohne Quellcode in einer Sandbox ausführen möchten. Außerdem ist es die sicherste Methode, Sandboxing zu verwenden, da es keine Initialisierung ohne Sandbox gibt, die sich negativ auswirken könnte.
Im folgenden Code-Snippet definieren wir den Pfad des Binärprogramms, das in der Sandbox ausgeführt werden soll, und die Argumente, die wir an einen execve-Systemaufruf übergeben müssen. Wie Sie in der Headerdatei executor.h 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. In unserem Snippet wird kein anderes Argument definiert.
Beispiele für diese Ausführungsmethode 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 – Executor mitteilen, wann er in der Sandbox ausgeführt werden soll
Diese Methode bietet die Flexibilität, während der Initialisierung nicht in der Sandbox zu sein und dann durch Aufrufen von ::sandbox2::Client::SandboxMeHere()
zu entscheiden, wann die Sandbox aktiviert werden soll. Sie müssen im Code definieren können, wann die Sandbox gestartet werden soll, und sie muss Single-Threaded sein. Weitere Informationen
Im folgenden Code-Snippet verwenden wir denselben Code wie oben in Methode 1 beschrieben. Damit das Programm jedoch während der Initialisierung 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 die Ausführung jetzt eine deaktivierte Sandbox hat, bis sie vom Sandboxee benachrichtigt wird, müssen wir eine ::sandbox2::Client
-Instanz erstellen, die Kommunikation zwischen der Ausführung und dem Sandboxee einrichten und dann die Ausführung benachrichtigen, dass die Initialisierung abgeschlossen ist und wir jetzt mit dem Sandboxing beginnen 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. Dabei ist crc4bin.cc
der Sandboxee, der den Executor (crc4sandbox.cc
) benachrichtigt, wenn er in die Sandbox eintreten soll.
Methode 3: Benutzerdefinierter Forkserver – Binärdatei vorbereiten, auf Fork-Anfragen warten und selbst sandboxing durchführen
In diesem Modus können Sie ein Binärprogramm starten, es für das Sandboxing vorbereiten und es zu einem bestimmten Zeitpunkt im Lebenszyklus des Binärprogramms für den Executor verfügbar machen.
Der Executor sendet eine Fork-Anfrage an Ihr Binärprogramm, das fork()
(über ::sandbox2::ForkingClient::WaitAndFork()
) wird. Der neu erstellte Prozess kann dann 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 Anforderungen an den Arbeitsspeicher haben. Sie profitieren von COW, haben aber den Nachteil, dass es kein echtes ASLR gibt. Ein weiteres typisches Anwendungsbeispiel ist, wenn das Sandboxee eine lange, CPU-intensive Initialisierung hat, die vor der Verarbeitung der nicht vertrauenswürdigen Daten ausgeführt werden kann.
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 das Sandboxee definieren. Andernfalls ist die Sandboxee nur durch die Standard-Syscall-Richtlinie geschützt.
Mit der Sandbox-Richtlinie soll eingeschränkt werden, welche Systemaufrufe und Argumente der Sandboxee ausführen kann und auf welche Dateien sie zugreifen kann. Sie müssen die vom Code, den Sie in einer Sandbox ausführen möchten, benötigten Systemaufrufe genau kennen. 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 die Richtlinie mit PolicyBuilder definieren. PolicyBuilder bietet viele praktische Funktionen und Hilfsfunktionen, die viele gängige Vorgänge ermöglichen. Die folgende Liste ist nur ein kleiner Auszug der verfügbaren Funktionen:
- Alle Systemaufrufe für den Prozessstart auf die Zulassungsliste setzen:
AllowStaticStartup();
AllowDynamicStartup();
- Zulassungsliste für alle offenen/read/write*-Systemaufrufe:
AllowOpen();
AllowRead();
AllowWrite();
- Alle Syscalls im Zusammenhang mit Beenden, Zugriff und Status auf die Zulassungsliste setzen:
AllowExit();
AllowStat();
AllowAccess();
- Zulassungsliste für alle schlaf- und zeitbezogenen Systemaufrufe:
AllowTime();
AllowSleep();
Diese Hilfsfunktionen fügen alle relevanten Syscalls auf die Zulassungsliste. Das hat den Vorteil, dass dieselbe Richtlinie für verschiedene Architekturen verwendet werden kann, in denen bestimmte Systemaufrufe nicht verfügbar sind (z. B. hat ARM64 keinen OPEN-Systemaufruf). Es besteht jedoch das geringe Sicherheitsrisiko, dass mehr Systemaufrufe als nötig aktiviert werden. Mit AllowOpen() kann der Sandboxee beispielsweise jeden Open-bezogenen Systemaufruf aufrufen. Wenn Sie nur einen bestimmten Systemaufruf auf die Zulassungsliste setzen möchten, können Sie AllowSyscall();
verwenden. Wenn Sie mehrere Systemaufrufe gleichzeitig zulassen möchten, können Sie AllowSyscalls()
verwenden.
Bisher wird in der Richtlinie nur die Syscall-Kennung geprüft. Wenn Sie die Richtlinie weiter verschärfen und eine Richtlinie definieren möchten, in der Sie nur einen Systemaufruf mit bestimmten Argumenten zulassen, müssen Sie AddPolicyOnSyscall()
oder AddPolicyOnSyscalls()
verwenden. Diese Funktionen verwenden nicht nur die Syscall-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 wiederholt BPF-Code schreiben, für den Sie sich einen Wrapper wünschen, können Sie gerne einen Funktionsantrag stellen.
Neben syscall-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 dem AddTmpfs()
-Helfer können Sie einen temporären Dateispeicher in der Sandbox hinzufügen.
Eine besonders nützliche Funktion ist AddLibrariesForBinary()
, mit der die für eine Binärdatei erforderlichen Bibliotheken und der Linker hinzugefügt werden.
Das Erstellen der Zulassungsliste für die Syscalls erfordert leider immer noch etwas manuellen Aufwand. Erstellen Sie eine Richtlinie mit den Systemaufrufen, die Ihr Binärprogramm benötigt, und führen Sie sie mit einer gängigen Arbeitslast aus. Wenn ein Verstoß ausgelöst wird, fügen Sie den Systemaufruf auf die Zulassungsliste und wiederholen Sie den Vorgang. Wenn Sie auf einen Verstoß stoßen, der Ihrer Meinung nach riskant für die Zulassungsliste ist, und das Programm Fehler ordnungsgemäß behandelt, können Sie versuchen, 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. Limits anpassen
Die Sandbox-Richtlinie verhindert, dass der Sandboxee bestimmte Systemaufrufe aufruft, und verringert so die Angriffsfläche. Ein Angreifer kann jedoch möglicherweise unerwünschte Auswirkungen verursachen, indem er einen Prozess unbegrenzt ausführt oder RAM und andere Ressourcen erschöpft.
Um dieser Bedrohung entgegenzuwirken, wird die Sandboxee standardmäßig unter 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 Anpassungen von Limits. 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, die Richtlinie, den Executor und das 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, sodass sie blockiert wird, 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 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 die Ausführung nicht blockiert wird, bis ein Ergebnis vorliegt. Das ist z. B. nützlich, wenn Sie mit dem Sandboxee kommunizieren. Das folgende Code-Snippet veranschaulicht diesen Anwendungsfall. Weitere detaillierte 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 ausreichen, wenn Sie beispielsweise nur eine Datei für die Sandboxee freigeben oder die Standardausgabe der Sandboxee lesen möchten.
Wahrscheinlich benötigen Sie jedoch eine komplexere Kommunikationslogik zwischen dem Executor und dem Sandboxee. Mit der Comms API (siehe die Headerdatei comms.h) 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. Damit kann eine Datei, die vom Executor geöffnet wurde, für die Verwendung in der Sandboxee freigegeben werden. Ein Beispiel für die Verwendung 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. Damit kann die Standardausgabe oder die Standardfehler des Sandboxee gelesen werden. Ein Beispiel für die Verwendung finden Sie im 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 lassen sich Ganzzahlen, Strings oder Byte-Puffer einfach zwischen dem Executor und dem Sandboxee-Prozess austauschen. Im Folgenden finden Sie einige Code-Snippets aus dem Beispiel crc4.
Wenn Sie die Comms API verwenden möchten, müssen Sie zuerst das Comms-Objekt aus dem Sandbox2-Objekt abrufen:
sandbox2::Comms* comms = s2.comms();
Sobald das Kommunikationsobjekt verfügbar ist, können Daten mit einer der Send*-Funktionen an die Sandboxee gesendet werden. Ein Beispiel für die Verwendung der Comms API finden Sie im crc4-Beispiel. Das Code-Snippet unten 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 */
}
Wenn Sie Daten vom Sandboxee empfangen möchten, verwenden Sie eine der Recv*
-Funktionen. Das folgende Code-Snippet ist ein Auszug aus dem Beispiel crc4. Der Executor empfängt die Prüfsumme als vorzeichenlose 32-Bit-Ganzzahl: uint32_t crc4;
if (!(comms->RecvUint32(&crc4))) {
/* handle error */
}
Daten mit Buffers teilen
Eine weitere Funktion für die gemeinsame Nutzung von Daten 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 hergesendet 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 Code-Snippet unten sehen Sie die Seite des Executors. Die Sandbox wird asynchron ausgeführt und gibt Daten über einen Puffer an die Sandboxee weiter:
// 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 Sandboxee-Seite müssen Sie außerdem 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 verlassen
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.
Sandbox synchron beenden
Wenn die Sandbox synchron ausgeführt wurde, wird „Run“ erst zurückgegeben, wenn der Sandboxee-Prozess abgeschlossen ist. Daher ist kein zusätzlicher Schritt für die Kündigung 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, gibt es zwei Möglichkeiten zum Beenden. Zuerst können Sie einfach warten, bis die Sandboxee abgeschlossen ist, und den endgültigen Ausführungsstatus abrufen:
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();
Alternativ können Sie die Sandboxee jederzeit beenden. Es wird jedoch weiterhin empfohlen, AwaitResult()
aufzurufen, da die 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 enthalten. Sandbox-Tests dienen nicht dazu, die Korrektheit des Programms zu testen, sondern zu prüfen, ob das Programm in der Sandbox ohne Probleme wie Sandbox-Verstöße ausgeführt werden kann. So wird auch sichergestellt, dass die Sandbox-Richtlinie korrekt ist.
Ein Sandbox-Programm wird auf dieselbe Weise getestet, wie es in der Produktion ausgeführt würde, 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 untergeordneten Prozessen sein. Beispiele
Fazit
Vielen Dank, dass Sie diesen Leitfaden gelesen haben. Wir hoffen, dass er Ihnen gefallen hat und Sie sich jetzt in der Lage fühlen, eigene Sandboxes zu erstellen, um Ihre Nutzer zu schützen.
Das Erstellen von Sandboxes und Richtlinien ist schwierig und anfällig für subtile Fehler. Wir empfehlen Ihnen, Ihre Richtlinie und Ihren Code von einem Sicherheitsexperten überprüfen zu lassen.