Z tej strony dowiesz się, jak utworzyć własne środowisko piaskownicy za pomocą Sandbox2. Dowiesz się, jak zdefiniować zasady Sandbox oraz jak wprowadzić zaawansowane, ale powszechne zmiany. Skorzystaj z informacji podanych tutaj jako przewodnika, a także z przykładów i dokumentacji kodu w plikach nagłówkowych.
1. Wybieranie metody wykonania piaskownicy
Piaskownica zaczyna się od wykonawcy (patrz Sandbox Executor), który jest odpowiedzialny za uruchomienie Sandboxee. Plik nagłówkowy executor.h zawiera interfejs API potrzebny do tego celu. API jest bardzo elastyczny i umożliwia wybór opcji, która najlepiej sprawdzi się w Twoim przypadku użycia. W sekcjach poniżej znajdziesz opis 3 metod, które możesz wybrać.
Metoda 1. Samodzielna – uruchomienie pliku binarnego z włączonym piaskownicą
Jest to najprostszy sposób korzystania z piaskownicy i zalecana metoda, gdy chcesz umieścić w niej cały plik binarny, do którego nie masz kodu źródłowego. Jest to też najbezpieczniejszy sposób korzystania z piaskownicy, ponieważ nie ma niezabezpieczonej inicjalizacji, która mogłaby mieć negatywne skutki.
W poniższym fragmencie kodu definiujemy ścieżkę do pliku binarnego, który ma być uruchamiany w piaskownicy, oraz argumenty, które musimy przekazać do wywołania systemowego execve. Jak widać w pliku nagłówkowym executor.h, nie określamy wartości dla envp
, a więc kopiujemy środowisko z procesu nadrzędnego. Pamiętaj, że pierwszy argument to zawsze nazwa programu do wykonania, a nasz fragment kodu nie definiuje żadnego innego argumentu.
Przykłady tej metody wykonawcy to: static i 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);
Metoda 2. Serwer rozwidlenia Sandbox2 – informowanie wykonawcy, kiedy ma działać w piaskownicy
Ta metoda zapewnia elastyczność, ponieważ podczas inicjowania nie jest objęta piaskownicą, a następnie możesz wybrać moment, w którym chcesz ją włączyć, wywołując funkcję ::sandbox2::Client::SandboxMeHere()
. Wymaga to zdefiniowania w kodzie momentu, w którym chcesz rozpocząć piaskownicę. Musi ona działać w jednym wątku (odpowiedź na pytanie „dlaczego?” znajdziesz w najczęstszych pytaniach).
W poniższym fragmencie kodu używamy tego samego kodu, który został opisany powyżej w sekcji Metoda 1. Aby jednak umożliwić programowi wykonywanie działań w sposób nieograniczony podczas inicjowania, wywołujemy funkcję 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);
Ponieważ wykonawca ma teraz wyłączoną piaskownicę, dopóki nie otrzyma powiadomienia od procesu podrzędnego, musimy utworzyć instancję ::sandbox2::Client
, skonfigurować komunikację między wykonawcą a procesem podrzędnym, a następnie powiadomić wykonawcę, że inicjowanie zostało zakończone i chcemy rozpocząć piaskownicę, wywołując 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();
…
Przykładem tej metody wykonawcy jest crc4, gdzie crc4bin.cc
to Sandboxee, który powiadamia wykonawcę (crc4sandbox.cc
), kiedy powinien wejść do piaskownicy.
Metoda 3. Niestandardowy serwer rozwidlenia – przygotuj plik binarny, poczekaj na żądania rozwidlenia i utwórz piaskownicę
Ten tryb umożliwia uruchomienie pliku binarnego, przygotowanie go do piaskownicy i udostępnienie go wykonawcy w określonym momencie cyklu życia pliku binarnego.
Wykonawca wyśle do Twojego pliku binarnego żądanie rozwidlenia, które fork()
(za pomocą ::sandbox2::ForkingClient::WaitAndFork()
). Nowo utworzony proces będzie gotowy do umieszczenia w piaskownicy za pomocą ::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());
Pamiętaj, że ten tryb jest dość skomplikowany i ma zastosowanie tylko w kilku konkretnych przypadkach, np. gdy masz ograniczone wymagania dotyczące pamięci. Będziesz korzystać z COW, ale nie będziesz mieć prawdziwego ASLR. Innym typowym przykładem użycia jest sytuacja, w której piaskownica ma długą, wymagającą dużego obciążenia procesora inicjację, którą można uruchomić przed przetworzeniem niezaufanych danych.
Przykład tej metody wykonawcy znajdziesz w artykule custom_fork.
2. Tworzenie zasady piaskownicy
Gdy będziesz mieć wykonawcę, prawdopodobnie zechcesz zdefiniować zasady Piaskownicy dla procesu podrzędnego. W przeciwnym razie Sandboxee jest chroniony tylko przez domyślne zasady wywołań systemowych.
Zasady piaskownicy mają na celu ograniczenie wywołań systemowych i argumentów, które może wykonywać proces podrzędny, a także plików, do których może mieć dostęp. Musisz dokładnie znać wywołania systemowe wymagane przez kod, który chcesz umieścić w piaskownicy. Jednym ze sposobów obserwowania wywołań systemowych jest uruchomienie kodu za pomocą narzędzia wiersza poleceń strace w systemie Linux.
Gdy masz już listę wywołań systemowych, możesz użyć klasy PolicyBuilder, aby zdefiniować zasadę. PolicyBuilder zawiera wiele przydatnych funkcji pomocniczych, które umożliwiają wykonywanie wielu typowych operacji. Poniższa lista to tylko niewielki fragment dostępnych funkcji:
- Umieszczanie na liście dozwolonych dowolnego wywołania systemowego podczas uruchamiania procesu:
AllowStaticStartup();
AllowDynamicStartup();
- Lista dozwolonych dla wszystkich wywołań systemowych open/read/write*:
AllowOpen();
AllowRead();
AllowWrite();
- Dodaj do listy dozwolonych wszystkie wywołania systemowe związane z wyjściem, dostępem lub stanem:
AllowExit();
AllowStat();
AllowAccess();
- Dodaj do listy dozwolonych wszystkie wywołania systemowe związane z uśpieniem lub czasem:
AllowTime();
AllowSleep();
Te funkcje ułatwiające pracę dodają do listy dozwolonych wszystkie odpowiednie wywołania systemowe. Ma to tę zaletę, że tej samej zasady można używać w różnych architekturach, w których niektóre wywołania systemowe są niedostępne (np. ARM64 nie ma wywołania systemowego OPEN), ale wiąże się to z niewielkim ryzykiem związanym z bezpieczeństwem, ponieważ włącza więcej wywołań systemowych, niż może być konieczne. Na przykład funkcja AllowOpen() umożliwia procesowi Sandboxee wywoływanie dowolnego powiązanego wywołania systemowego open. Jeśli chcesz dodać do listy dozwolonych tylko jeden konkretny wywołanie systemowe, możesz użyć AllowSyscall();
. Aby dodać do listy dozwolonych kilka wywołań systemowych jednocześnie, możesz użyć AllowSyscalls()
.
Obecnie zasada sprawdza tylko identyfikator wywołania systemowego. Jeśli chcesz jeszcze bardziej wzmocnić zasady i zdefiniować zasady, w których zezwalasz tylko na wywołanie systemowe z określonymi argumentami, musisz użyć AddPolicyOnSyscall()
lub AddPolicyOnSyscalls()
. Funkcje te przyjmują jako argument nie tylko identyfikator wywołania systemowego, ale też surowy filtr seccomp-bpf, który korzysta z makr pomocniczych bpf z jądra systemu Linux. Więcej informacji o BPF znajdziesz w dokumentacji jądra. Jeśli piszesz powtarzalny kod BPF, który Twoim zdaniem powinien mieć otoczkę ułatwiającą korzystanie, możesz przesłać prośbę o dodanie takiej funkcji.
Oprócz funkcji związanych z wywołaniami systemowymi PolicyBuilder udostępnia też szereg funkcji związanych z systemem plików, takich jak AddFile()
czy AddDirectory()
, które umożliwiają zamontowanie pliku lub katalogu w piaskownicy. Za pomocą funkcji AddTmpfs()
możesz dodać tymczasowe miejsce na pliki w piaskownicy.
Szczególnie przydatna jest funkcja AddLibrariesForBinary()
, która dodaje biblioteki i linker wymagane przez plik binarny.
Określenie wywołań systemowych, które mają być dodane do listy dozwolonych, wymaga niestety trochę pracy ręcznej. Utwórz zasady z wywołaniami systemowymi, których potrzebuje Twój plik binarny, i uruchom je z typowym zbiorem zadań. Jeśli zostanie wykryte naruszenie, dodaj wywołanie systemowe do listy dozwolonych i powtórz proces. Jeśli napotkasz naruszenie, które Twoim zdaniem może być ryzykowne w przypadku dodania do listy dozwolonych, a program dobrze radzi sobie z błędami, możesz spróbować spowodować, aby zamiast tego zwracał błąd, używając 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. Dostosowywanie limitów
Zasady piaskownicy uniemożliwiają procesowi podlegającemu piaskownicy wywoływanie określonych wywołań systemowych, co zmniejsza obszar ataku. Atakujący może jednak nadal wywoływać niepożądane efekty, uruchamiając proces w nieskończoność lub wyczerpując pamięć RAM i inne zasoby.
Aby przeciwdziałać temu zagrożeniu, proces Sandboxee domyślnie działa w ramach ścisłych limitów wykonania. Jeśli te domyślne limity powodują problemy z prawidłowym wykonywaniem programu, możesz je dostosować za pomocą klasy sandbox2::Limits
, wywołując metodę limits()
na obiekcie wykonawcy.
Fragment kodu poniżej zawiera przykładowe zmiany limitów. Wszystkie dostępne opcje są opisane w pliku nagłówkowym 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));
Przykład użycia klasy sandbox2::Limits
znajdziesz w tym narzędziu.
4. Uruchamianie piaskownicy
W poprzednich sekcjach przygotowaliśmy środowisko piaskownicy, zasady, wykonawcę i piaskownicę. Następnym krokiem jest utworzenie obiektu Sandbox2
i jego uruchomienie.
Uruchamianie synchroniczne
Piaskownica może działać synchronicznie, blokując działanie do momentu uzyskania wyniku. Fragment kodu poniżej pokazuje tworzenie instancji obiektu Sandbox2
i jego synchroniczne wykonywanie. Bardziej szczegółowy przykład znajdziesz w sekcji statyczne.
#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();
Uruchom asynchronicznie
Możesz też uruchomić piaskownicę asynchronicznie, aby nie blokować jej działania do momentu uzyskania wyniku. Jest to przydatne np. podczas komunikacji z Sandboxee. Fragment kodu poniżej ilustruje ten przypadek użycia. Więcej szczegółowych przykładów znajdziesz w sekcjach crc4 i 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. Komunikacja z osobą testującą
Domyślnie wykonawca może komunikować się z piaskownicą za pomocą deskryptorów plików. Może to być wszystko, czego potrzebujesz, np. jeśli chcesz tylko udostępnić plik Sandboxee lub odczytać standardowe dane wyjściowe Sandboxee.
Najprawdopodobniej jednak potrzebujesz bardziej złożonej logiki komunikacji między wykonawcą a piaskownicą. Interfejs Comms API (patrz plik nagłówkowy comms.h) umożliwia wysyłanie liczb całkowitych, ciągów znaków, buforów bajtów, protobufów lub deskryptorów plików.
Udostępnianie deskryptorów plików
Korzystając z interfejsu Inter-Process Communication API (patrz ipc.h), możesz użyć MapFd()
lub ReceiveFd()
:
Użyj
MapFd()
, aby zmapować deskryptory plików z wykonawcy na Sandboxee. Można go użyć do udostępnienia pliku otwartego z wykonawcy do użycia w Sandboxee. Przykład użycia znajdziesz w sekcji static.// The executor opened /proc/version and passes it to the sandboxee as stdin executor->ipc()->MapFd(proc_version_fd, STDIN_FILENO);
Użyj
ReceiveFd()
, aby utworzyć punkt końcowy pary gniazd. Można go używać do odczytywania standardowych danych wyjściowych lub standardowych błędów piaskownicy. Przykład użycia znajdziesz w narzędziu.// The executor receives a file descriptor of the sandboxee stdout int recv_fd1 = executor->ipc())->ReceiveFd(STDOUT_FILENO);
Korzystanie z interfejsu Comms API
Sandbox2 udostępnia wygodny interfejs API komunikacji. Jest to prosty i łatwy sposób udostępniania liczb całkowitych, ciągów znaków lub buforów bajtów między wykonawcą a Sandboxee. Poniżej znajdziesz fragmenty kodu, które możesz znaleźć w przykładzie crc4.
Aby rozpocząć korzystanie z interfejsu Comms API, musisz najpierw pobrać obiekt Comms z obiektu Sandbox2:
sandbox2::Comms* comms = s2.comms();
Gdy obiekt komunikacji jest dostępny, dane można wysyłać do piaskownicy za pomocą jednej z funkcji z rodziny Send*. Przykład użycia interfejsu Comms API znajdziesz w przykładzie crc4. Fragment kodu
poniżej zawiera wyciąg z tego przykładu. Wykonawca testamentu wysyła unsigned char
buf[size]
z SendBytes(buf, size)
:
if (!(comms->SendBytes(static_cast<const uint8_t*>(buf), sz))) {
/* handle error */
}
Aby otrzymywać dane z piaskownicy, użyj jednej z funkcji Recv*
. Fragment kodu poniżej pochodzi z przykładu crc4. Wykonawca otrzymuje sumę kontrolną w postaci 32-bitowej liczby całkowitej bez znaku: uint32_t crc4;
if (!(comms->RecvUint32(&crc4))) {
/* handle error */
}
Udostępnianie danych buforom
Inną funkcją udostępniania danych jest użycie interfejsu Buffer API do udostępniania dużych ilości danych i unikania kosztownych kopii, które są przesyłane między wykonawcą a piaskownicą.
Wykonawca tworzy bufor, określając jego rozmiar i dane do przekazania lub bezpośrednio z deskryptora pliku, i przekazuje go do piaskownicy za pomocą funkcji comms->SendFD()
w wykonawcy i comms->RecvFD()
w piaskownicy.
W poniższym fragmencie kodu możesz zobaczyć stronę wykonawcy. Piaskownica działa asynchronicznie i udostępnia dane za pomocą bufora:
// 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());
Po stronie Sandboxee musisz też utworzyć obiekt bufora i odczytać dane z deskryptora pliku wysłanego przez wykonawcę:
// 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. Opuszczanie piaskownicy
W zależności od sposobu uruchomienia piaskownicy (patrz ten krok) musisz dostosować sposób jej zamykania, a tym samym także sposób zamykania procesu Sandboxee.
Zamykanie piaskownicy działającej synchronicznie
Jeśli piaskownica działała synchronicznie, funkcja Run zwróci wartość dopiero po zakończeniu działania procesu Sandboxee. Dlatego nie jest wymagany żaden dodatkowy krok związany z zakończeniem subskrypcji. Poniższy fragment kodu ilustruje ten scenariusz:
Sandbox2::Result result = s2.Run();
LOG(INFO) << "Final execution status: " << result.ToString();
Zamykanie piaskownicy działającej asynchronicznie
Jeśli piaskownica działała asynchronicznie, dostępne są 2 opcje zakończenia. Najpierw możesz poczekać na zakończenie działania Sandboxee i otrzymać ostateczny stan wykonania:
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();
Możesz też w każdej chwili zakończyć działanie Sandboxee, ale nadal zalecamy wywołanie funkcji AwaitResult()
, ponieważ Sandboxee może w międzyczasie zakończyć działanie z innego powodu:
s2.Kill();
sandbox2::Result result = s2.AwaitResult();
LOG(INFO) << "Final execution status: " << result.ToString();
7. Test
Podobnie jak w przypadku każdego innego kodu, implementacja piaskownicy powinna zawierać testy. Testy w piaskownicy nie służą do sprawdzania poprawności programu, ale do weryfikowania, czy program w piaskownicy może działać bez problemów, takich jak naruszenia zasad piaskownicy. Dzięki temu upewnisz się też, że zasady dotyczące piaskownicy są prawidłowe.
Program w piaskownicy jest testowany w taki sam sposób, jak w środowisku produkcyjnym, z argumentami i plikami wejściowymi, które zwykle przetwarza.
Mogą to być proste testy powłoki lub testy w C++ wykorzystujące podprocesy. Aby się zainspirować, zapoznaj się z przykładami.
Podsumowanie
Dziękujemy za przeczytanie tego artykułu. Mamy nadzieję, że nasz przewodnik Ci się spodobał i że teraz możesz tworzyć własne piaskownice, aby chronić użytkowników.
Tworzenie piaskownic i zasad jest trudne i może prowadzić do subtelnych błędów. Aby zachować bezpieczeństwo, zalecamy, aby ekspert ds. bezpieczeństwa sprawdził Twoje zasady i kod.