Pierwsze kroki z Sandbox2

Z tej strony dowiesz się, jak utworzyć własne środowisko w piaskownicy za pomocą Sandbox2. Dowiesz się, jak zdefiniować zasady piaskownicy i wprowadzić zaawansowane, ale powszechne zmiany. Korzystaj z informacji podanych w tym przewodniku, a także z przykładów i dokumentacji kodu w plikach nagłówkowych.

1. Wybierz metodę wykonania w piaskownicy

Piaskownica zaczyna się od wykonawcy (patrz Wykonawca piaskownicy), który jest odpowiedzialny za uruchamianie Sandboxee. Plik nagłówkowy executor.h zawiera interfejs API potrzebny do tego celu. Interfejs API jest bardzo elastyczny i pozwala wybrać rozwiązanie, które najlepiej sprawdzi się w Twoim przypadku. W kolejnych sekcjach opisujemy 3 różne metody, spośród których możesz wybrać.

Metoda 1. Samodzielna – wykonaj plik binarny z włączoną piaskownicą

Jest to najprostszy sposób korzystania z piaskownicy i zalecana metoda, gdy chcesz umieścić w piaskownicy 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 inicjowania poza piaskownicą, które mogłoby mieć negatywne skutki.

W poniższym fragmencie kodu definiujemy ścieżkę pliku binarnego, który ma być umieszczony w piaskownicy, oraz argumenty, które musimy przekazać do wywołania systemowego execve. Jak widać w executor.h pliku nagłówkowym, nie określamy wartości envp, dlatego kopiujemy środowisko z procesu nadrzędnego. Pamiętaj, że pierwszym argumentem jest zawsze nazwa programu, który ma zostać wykonany, a nasz fragment nie definiuje żadnego innego argumentu.

Przykłady tej metody wykonania 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. Forkserver Sandbox2 – informuj wykonawcę, kiedy ma być w piaskownicy

Ta metoda zapewnia elastyczność, ponieważ podczas inicjowania można działać poza piaskownicą, a następnie wybrać, kiedy przejść do piaskownicy, wywołując ::sandbox2::Client::SandboxMeHere(). Wymaga to zdefiniowania w kodzie, kiedy chcesz rozpocząć korzystanie z piaskownicy, i musi być jednowątkowe (więcej informacji znajdziesz w sekcji Najczęstsze pytania).

W poniższym fragmencie kodu używamy tego samego kodu co w metodzie 1 powyżej. Aby jednak umożliwić programowi wykonywanie poza piaskownicą podczas inicjowania, wywołujemy 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 zostanie powiadomiony przez Sandboxee, musimy utworzyć instancję ::sandbox2::Client, skonfigurować komunikację między wykonawcą a Sandboxee, a następnie powiadomić wykonawcę, że inicjowanie zostało zakończone i chcemy teraz rozpocząć korzystanie z piaskownicy, 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 wykonania jest crc4, gdzie crc4bin.cc to Sandboxee, które powiadamia wykonawcę (crc4sandbox.cc), kiedy ma przejść do piaskownicy.

Metoda 3. Niestandardowy Forkserver – przygotuj plik binarny, poczekaj na żądania rozwidlenia i samodzielnie umieść go w piaskownicy

Ten tryb umożliwia uruchomienie pliku binarnego, przygotowanie go do umieszczenia w piaskownicy, a następnie w określonym momencie cyklu życia pliku binarnego udostępnienie go wykonawcy.

Wykonawca wyśle do Twojego pliku binarnego żądanie rozwidlenia, które spowoduje wywołanie fork() (za pomocą ::sandbox2::ForkingClient::EnterForkLoop()). 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 można go stosować tylko w kilku konkretnych przypadkach, np. gdy masz ograniczone wymagania dotyczące pamięci. Skorzystasz z COW, ale nie będziesz mieć prawdziwego ASLR. Innym typowym przykładem użycia jest sytuacja, gdy Sandboxee ma długie, wymagające dużej mocy obliczeniowej inicjowanie, które można uruchomić przed przetworzeniem niezaufanych danych.

Przykład tej metody wykonania znajdziesz w custom_fork.

2. Utwórz zasady piaskownicy

Gdy masz już wykonawcę, prawdopodobnie zechcesz zdefiniować zasady piaskownicy dla Sandboxee. 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ć Sandboxee, 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ć PolicyBuilder do zdefiniowania zasad.

PolicyBuilder zawiera wiele wygodnych funkcji pomocniczych, które umożliwiają wykonywanie wielu typowych operacji (wiele z nich jest już uwzględnionych w DefaultPolicyBuilder()). Poniższa lista to tylko niewielki fragment dostępnych funkcji:

  • Dodawanie do listy dozwolonych dowolnego wywołania systemowego na potrzeby uruchamiania procesu:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Dodawanie do listy dozwolonych dowolnych wywołań systemowych open/read/write*:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Dodawanie do listy dozwolonych dowolnych wywołań systemowych związanych z wyjściem/dostępem/stanem:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Dodawanie do listy dozwolonych dowolnych wywołań systemowych związanych z uśpieniem/czasem:
    • AllowTime();
    • AllowSleep();

Te wygodne funkcje dodają do listy dozwolonych wszystkie odpowiednie wywołania systemowe. Ma to tę zaletę, że tych samych zasad 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 bezpieczeństwa, ponieważ włączonych jest więcej wywołań systemowych niż może być konieczne. Na przykład AllowOpen() umożliwia Sandboxee wywoływanie dowolnych wywołań systemowych związanych z otwieraniem. Jeśli chcesz dodać do listy dozwolonych tylko jedno konkretne wywołanie systemowe, możesz użyć AllowSyscall(); aby dodać do listy dozwolonych kilka wywołań systemowych naraz, możesz użyć AllowSyscalls().

Do tej pory zasady sprawdzają tylko identyfikator wywołania systemowego. Jeśli chcesz dodatkowo wzmocnić zasady i zdefiniować zasady, w których zezwalasz tylko na wywołanie systemowe z określonymi argumentami, musisz użyć AddPolicyOnSyscall() lub AddPolicyOnSyscalls(). Te funkcje przyjmują jako argument nie tylko identyfikator wywołania systemowego, ale też surowy filtr seccomp-bpf używający makr pomocniczych bpf z jądra systemu Linux. Więcej informacji o BPF znajdziesz w dokumentacji jądra. Jeśli zauważysz, że piszesz powtarzający się kod BPF, który Twoim zdaniem powinien mieć otoczkę ułatwiającą użycie, zgłoś prośbę o dodanie 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ą montowanie pliku lub katalogu w piaskownicy. Funkcja pomocnicza AddTmpfs() umożliwia dodanie tymczasowego magazynu plików w piaskownicy.

Szczególnie przydatna jest funkcja AddLibrariesForBinary(), która dodaje biblioteki i linker wymagane przez plik binarny.

Niestety, wymyślanie wywołań systemowych, które należy dodać do listy dozwolonych, nadal wymaga trochę pracy ręcznej. Utwórz zasady z wywołaniami systemowymi, które Twoim zdaniem są potrzebne plikowi binarnemu, 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, aby dodać je do listy dozwolonych, a program obsługuje błędy w sposób prawidłowy, możesz spróbować spowodować, aby zamiast tego zwracał błąd, używając BlockSyscallWithErrno().

Poniższy przykład bezpośrednio tworzy sandbox2::PolicyBuilder, aby zademonstrować ograniczanie argumentów write i mprotect oraz blokowanie openat (które w przeciwnym razie DefaultPolicyBuilder() by zezwolił):

#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. Dostosuj limity

Zasady piaskownicy uniemożliwiają Sandboxee wywoływanie określonych wywołań systemowych, co zmniejsza powierzchnię ataku. Nadal jednak atakujący może spowodować niepożądane skutki, uruchamiając proces w nieskończoność lub wyczerpując pamięć RAM i inne zasoby.

Aby zapobiec temu zagrożeniu, Sandboxee domyślnie działa w ramach ścisłych limitów wykonania. Jeśli te domyślne limity powodują problemy z prawidłowym wykonaniem programu, możesz je dostosować za pomocą klasy sandbox2::Limits, wywołując limits() na obiekcie wykonawcy.

Poniższy fragment kodu pokazuje kilka przykładów dostosowania limitów. Wszystkie dostępne opcje są opisane w 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));

Przykład użycia klasy sandbox2::Limits znajdziesz w narzędziu przykładowym tool.

4. Uruchom piaskownicę

W poprzednich sekcjach przygotowaliśmy środowisko w piaskownicy, zasady, wykonawcę i Sandboxee. Następnym krokiem jest utworzenie obiektu Sandbox2 i jego uruchomienie.

Uruchom synchronicznie

Piaskownica może działać synchronicznie, blokując się do momentu uzyskania wyniku. Poniższy fragment kodu pokazuje tworzenie instancji obiektu Sandbox2 i jego synchroniczne wykonanie. Bardziej szczegółowy przykład znajdziesz w 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();

Uruchom asynchronicznie

Możesz też uruchomić piaskownicę asynchronicznie, dzięki czemu nie będzie się blokować do momentu uzyskania wyniku. Jest to przydatne np. podczas komunikacji z Sandboxee. Poniższy fragment kodu pokazuje ten przypadek użycia. Bardziej szczegółowe przykłady znajdziesz w 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 Sandboxee

Domyślnie wykonawca może komunikować się z Sandboxee za pomocą deskryptorów plików. Może to być wszystko, czego potrzebujesz, np. jeśli chcesz tylko udostępnić plik Sandboxee lub odczytać standardowy strumień wyjścia Sandboxee.

Najprawdopodobniej jednak będziesz potrzebować bardziej złożonej logiki komunikacji między wykonawcą a Sandboxee. Interfejs API comms (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

Za pomocą interfejsu API komunikacji międzyprocesowej (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ępniania pliku otwartego przez wykonawcę do użycia w Sandboxee. Przykład użycia znajdziesz w 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 socketpair. Można go użyć do odczytywania standardowego strumienia wyjścia lub standardowego strumienia błędów Sandboxee. 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 API comms

Sandbox2 udostępnia wygodny interfejs API comms. 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żna znaleźć w przykładzie crc4.

Aby rozpocząć korzystanie z interfejsu API comms, musisz najpierw pobrać obiekt comms z obiektu Sandbox2:

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

Gdy obiekt comms jest dostępny, dane można wysyłać do Sandboxee za pomocą jednej z funkcji z rodziny Send*. Przykład użycia interfejsu API comms znajdziesz w przykładzie crc4. Poniższy fragment kodu to fragment tego przykładu. Wykonawca wysyła unsigned char buf[size] za pomocą SendBytes(buf, size):

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

Aby odbierać dane z Sandboxee, użyj jednej z funkcji Recv*. Poniższy fragment kodu to fragment przykładu crc4. Wykonawca odbiera sumę kontrolną w 32-bitowej liczbie całkowitej bez znaku: uint32_t crc4;

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

Udostępnianie danych za pomocą buforów

Inną funkcją udostępniania danych jest używanie interfejsu API bufora do udostępniania dużych ilości danych i unikania kosztownych kopii, które są wysyłane między wykonawcą a Sandboxee.

Wykonawca tworzy bufor, określając jego rozmiar i dane do przekazania lub bezpośrednio z deskryptora pliku, i przekazuje go do Sandboxee za pomocą comms->SendFD() w wykonawcy i comms->RecvFD() w Sandboxee.

W poniższym fragmencie kodu możesz zobaczyć stronę wykonawcy. Piaskownica działa asynchronicznie i udostępnia dane za pomocą bufora Sandboxee:

// 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 zakończenia, a tym samym także Sandboxee.

Opuszczanie piaskownicy działającej synchronicznie

Jeśli piaskownica działa synchronicznie, funkcja Run zwróci wartość tylko wtedy, gdy Sandboxee zakończy działanie. Dlatego nie jest wymagany żaden dodatkowy krok zakończenia. Poniższy fragment kodu pokazuje ten scenariusz:

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

Opuszczanie piaskownicy działającej asynchronicznie

Jeśli piaskownica działa asynchronicznie, masz 2 opcje zakończenia. Po pierwsze, możesz po prostu 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 dowolnym momencie zakończyć działanie Sandboxee, ale nadal zalecamy wywołanie 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 każdy inny kod, implementacja piaskownicy powinna mieć testy. Testy piaskownicy nie mają na celu sprawdzenia poprawności programu, ale sprawdzenie, czy program w piaskownicy może działać bez problemów, takich jak naruszenia zasad piaskownicy. Dzięki temu masz też pewność, że zasady 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 normalnie przetwarza.

Testy te mogą być tak proste jak test powłoki lub testy C++ z użyciem podprocesów. Inspirację znajdziesz w przykładach.

Podsumowanie

Dziękujemy za przeczytanie tego przewodnika. Mamy nadzieję, że Ci się spodobał i że teraz możesz tworzyć własne piaskownice, aby chronić użytkowników.

Tworzenie piaskownic i zasad jest trudnym zadaniem, które może prowadzić do subtelnych błędów. Aby zachować bezpieczeństwo, zalecamy, aby specjalista ds. bezpieczeństwa sprawdził Twoje zasady i kod.