Pierwsze kroki z Sandbox2

Na tej stronie dowiesz się, jak utworzyć własne środowisko piaskownicy za pomocą Sandbox2. Dowiesz się, jak zdefiniować zasady piaskownicy, a także poznasz zaawansowane, ale często używane zmiany. Skorzystaj z podanych tu informacji jako przewodnika. Możesz także zapoznać się z przykładami i dokumentacją kodu w plikach nagłówkowych.

1. Wybierz metodę wykonawcy piaskownicy

Tryb piaskownicy rozpoczyna się od wykonawcy (patrz Wykonawca piaskownicy), który odpowiada za uruchomienie narzędzia Sandboxee. Plik nagłówka executor.h zawiera interfejs API potrzebny do tego. Interfejs API jest bardzo elastyczny i umożliwia wybranie rozwiązania, które najlepiej sprawdzi się w Twoim przypadku. W sekcjach poniżej opisujemy 3 różne metody do wyboru.

Metoda 1. samodzielny – uruchom plik binarny z włączonym piaskownicą

Jest to najprostszy sposób korzystania z piaskownicy i zalecana, gdy chcesz umieścić w piaskownicy cały plik binarny, dla którego nie masz kodu źródłowego. Jest to również najbezpieczniejszy sposób korzystania z piaskownicy, ponieważ nie ma inicjowania poza piaskownicą, które mogłoby mieć negatywne skutki.

W tym fragmencie kodu definiujemy ścieżkę pliku binarnego umieszczonego w piaskownicy oraz argumenty, które należy przekazać do wywołania syscall w narzędziu execve. Jak widać w pliku nagłówka executor.h, nie określamy wartości envp, więc skopiujemy środowisko z procesu nadrzędnego. Pamiętaj, że pierwszy argument to zawsze nazwa programu, który ma zostać wykonany, a nasz fragment nie zawiera ż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. Serwer widłowy Sandbox2 – poinformuj wykonawcy, kiedy ma zostać włączony do piaskownicy

Ta metoda zapewnia elastyczność pozbawienia się piaskownicy podczas inicjowania, a następnie wybrania czasu przejścia do piaskownicy przez wywołanie metody ::sandbox2::Client::SandboxMeHere(). Wymaga to określenia w kodzie, kiedy chcesz rozpocząć piaskownicę, i musi być jednowątkowe (przyczyny tego procesu znajdziesz w Najczęstszych pytaniach).

W poniższym fragmencie kodu korzystamy z tego samego kodu, który został opisany powyżej w metodzie 1. Aby jednak umożliwić wykonywanie programu w trakcie inicjowania poza piaskownicą, 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);

Wykonawca ma teraz wyłączoną piaskownicę, dopóki nie otrzyma o niej powiadomienia. Dlatego musimy utworzyć instancję ::sandbox2::Client, skonfigurować komunikację między wykonawcą a osobą w trybie piaskownicy, a następnie powiadomić wykonawcy o zakończeniu inicjowania i teraz od razu 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 jest crc4, gdzie crc4bin.cc to użytkownik w środowisku piaskownicy, a wykonawca (crc4sandbox.cc) otrzymuje powiadomienie o konieczności przejścia do piaskownicy.

Metoda 3. Niestandardowy serwer widłowy – przygotuj plik binarny, poczekaj na żądania rozwidlenie i samodzielnie uruchom 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 pliku binarnego żądanie rozwidlenia w odpowiedzi na żądanie fork() (za pomocą ::sandbox2::ForkingClient::WaitAndFork()). Nowo utworzony proces będzie gotowy do umieszczenia w piaskownicy za pomocą funkcji ::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 z niego korzystać tylko w nielicznych przypadkach, np. gdy masz zbyt mało pamięci. Korzyści przyniesie Ci korzyści, ale dodatkową wadą jest fakt, że w przypadku ASLR nie ma czegoś takiego. Innym przykładowym przykładem jest długie inicjowanie użytkownika piaskownicy, które inicjuje bardzo dużo procesora, zanim zostanie przetworzone niezaufane dane.

Przykład tej metody wykonawcy znajdziesz w sekcji custom_fork.

2. Tworzenie zasady piaskownicy

Gdy masz wykonawcę, prawdopodobnie warto zdefiniować zasady piaskownicy dla użytkownika piaskownicy. W przeciwnym razie użytkownik piaskownicy jest chroniony tylko przez domyślne zasady systemu Syscall.

Zasady piaskownicy umożliwiają ograniczenie wywołań syscall i argumentów, które może utworzyć użytkownik piaskownicy, oraz do plików, do których ma dostęp. Potrzebujesz dogłębnej wiedzy na temat wywołań syscall wymaganych przez kod, który zamierzasz umieścić w piaskownicy. Jednym ze sposobów obserwowania wywołań syscall jest uruchamianie kodu za pomocą paska narzędzi wiersza poleceń systemu Linux.

Gdy masz już listę wywołań syscall, możesz zdefiniować tę zasadę za pomocą obiektu PolicyBuilder. PolicyBuilder jest wyposażony w wiele funkcji pomocniczych i ułatwiających wygodne wykonywanie wielu typowych operacji. Poniższa lista to tylko mały wycinek dostępnych funkcji:

  • Dodaj dowolne wywołanie syscall do listy dozwolonych na potrzeby uruchamiania procesu:
    • AllowStaticStartup();
    • AllowDynamicStartup();
  • Dodaj do listy dozwolonych wszystkie otwarte wywołania systemu /read/write*:
    • AllowOpen();
    • AllowRead();
    • AllowWrite();
  • Dodaj do listy dozwolonych wszystkie wywołania systemowe związane z wyjściem, dostępem i stanem:
    • AllowExit();
    • AllowStat();
    • AllowAccess();
  • Dodaj do listy dozwolonych wszystkie połączenia systemowe związane ze snem/czasem:
    • AllowTime();
    • AllowSleep();

Te funkcje zapewniające wygodę dodają wszystkie odpowiednie wywołania Syscall do listy dozwolonych. Ta sama zasada ma tę zaletę, że może być używana w przypadku różnych architektur, w których niektóre wywołania syscall są niedostępne (np. ARM64 nie ma połączenia OPEN syscall), ale zmniejsza się ryzyko dla bezpieczeństwa związane z włączeniem większej liczby sycsall niż jest to konieczne. Na przykład zezwalanie na to, co umożliwia przeglądarce Sandboxee wywołanie dowolnego otwartego powiązanego wywołania syscall. Jeśli chcesz dodać do listy dozwolonych tylko 1 konkretny wywołania systemowego, możesz użyć AllowSyscall();, aby zezwolić na wiele wywołań systemowych jednocześnie. AllowSyscalls()

Obecnie zasada sprawdza tylko identyfikator syscall. Jeśli potrzebujesz dalszego wzmocnienia tej zasady i chcesz zdefiniować zasadę, w której zezwalasz na wywołanie systemu Syscall tylko z konkretnymi argumentami, musisz użyć właściwości AddPolicyOnSyscall() lub AddPolicyOnSyscalls(). Te funkcje wykorzystują nie tylko identyfikator połączenia syscall jako argument, ale także nieprzetworzony filtr seccomp-bpf wykorzystujący makra pomocnicze bpf z jądra systemu Linux. Więcej informacji na temat BPF znajdziesz w dokumentacji jądra. Jeśli stwierdzisz, że piszesz powtarzający się kod BPF, który powinien mieć opakowanie użyteczności, możesz zgłosić prośbę o dodanie funkcji.

Oprócz funkcji związanych z syscaller PolicyBuilder udostępnia też szereg funkcji związanych z systemem plików, takich jak AddFile() czy AddDirectory(), które pozwalają połączyć plik lub katalog w piaskownicy. Za pomocą aplikacji pomocniczej AddTmpfs() można dodać w piaskownicy tymczasową pamięć masową.

Szczególnie przydatną funkcją jest AddLibrariesForBinary(), która dodaje biblioteki i tag łączący wymagane przez plik binarny.

Wymyślenie instrukcji syscall do listy dozwolonych nadal wymaga ręcznej pracy. Utwórz zasadę z wywołaniami syscalls, które znasz, i uruchamiaj je z użyciem wspólnego zadania. Jeśli nastąpi naruszenie, dodaj wywołanie syscall do listy dozwolonych i powtórz ten proces. Jeśli napotkasz naruszenie, które uważasz za ryzykowne, a program dobrze radzi sobie z błędami, możesz spróbować zwrócić błąd, używając parametru 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. Dostosuj limity

Zasada piaskownicy uniemożliwia użytkownikowi piaskownicy wywoływanie konkretnych wywołań systemowych, co zmniejsza powierzchnię ataku. Osoba przeprowadzająca atak nadal może jednak spowodować niepożądane skutki, uruchamiając proces w nieskończoność lub wyczerpując pamięć RAM i inne zasoby.

Aby rozwiązać to zagrożenie, środowisko piaskownicy domyślnie działa w ścisłych limitach wykonywania. Jeśli te limity domyślne powodują problemy z prawidłowym wykonaniem programu, możesz je dostosować za pomocą klasy sandbox2::Limits, wywołując limits() w obiekcie wykonawcy.

Fragment kodu poniżej zawiera przykładowe dostosowania limitów. Wszystkie dostępne opcje są opisane w pliku nagłówka 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 przykładowym narzędziu.

4. Uruchom piaskownicę

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

Uruchamiaj synchronicznie

Piaskownica może działać synchronicznie, co blokuje odtwarzanie, dopóki nie uzyskasz wyniku. Fragment kodu poniżej przedstawia utworzenie instancji obiektu Sandbox2 i jego synchroniczne wykonanie. Bardziej szczegółowy przykład znajdziesz tutaj: 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();

Uruchamianie asynchroniczne

Możesz też uruchamiać piaskownicę asynchronicznie, dzięki czemu nie będzie ona blokowana do czasu uzyskania wyniku. Jest to przydatne na przykład podczas komunikacji z użytkownikiem piaskownicy. Fragment kodu poniżej przedstawia ten przypadek użycia. Bardziej szczegółowe przykłady znajdziesz w językach crc4 i w sekcji 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 wykonawcy mogą komunikować się z platformą Sandboxee za pomocą deskryptorów plików. To może być wszystko, co jest potrzebne, jeśli na przykład chcesz udostępnić plik użytkownikowi piaskownicy lub odczytać jego standardowe dane wyjściowe.

Najprawdopodobniej jednak potrzebujesz bardziej złożonej logiki komunikacji między wykonawcą a osobą w środowisku piaskownicy. Interfejs Comms API (patrz plik nagłówka comms.h) może służyć do wysyłania liczb całkowitych, ciągów tekstowych, buforów bajtów, protobufów lub deskryptorów plików.

Udostępnianie deskryptorów plików

Za pomocą interfejsu Inter-Process Communication API (patrz ipc.h) możesz użyć MapFd() lub ReceiveFd():

  • Użyj narzędzia MapFd(), aby zmapować deskryptory plików od wykonawcy na piaskownicę. Można go użyć, aby udostępnić plik otwarty przez wykonawcę do użycia w trybie piaskownicy. Przykładem użycia jest 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 parowania. Pozwala to odczytywać standardowe dane wyjściowe lub standardowe błędy Sandboxee. Przykładowe zastosowanie można zobaczyć 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 zapewnia wygodny interfejs API komunikacyjny. Jest to prosty i łatwy sposób udostępniania liczb całkowitych, ciągów tekstowych lub buforów bajtów między wykonawcą a środowiskiem piaskownicy. Poniżej znajdziesz kilka fragmentów kodu, które znajdziesz w przykładzie crc4.

Aby zacząć korzystać z interfejsu Comms API, musisz najpierw pobrać obiekt Comms z obiektu Sandbox2:

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

Gdy obiekt komunikacyjny będzie dostępny, dane można wysłać do piaskownicy, korzystając z jednej z funkcji Wyślij*. Przykład użycia interfejsu comms API znajdziesz w przykładzie crc4. Poniższy fragment kodu zawiera 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 otrzymywać dane z środowiska piaskownicy, użyj jednej z funkcji Recv*. Poniższy fragment kodu to fragment kodu z przykładu crc4. Wykonawca otrzymuje sumę kontrolną w 32-bitowej nieoznaczonej liczbie całkowitej: uint32_t crc4;

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

Udostępnianie danych buforom

Inną funkcją udostępniania danych jest korzystanie z interfejsu API buforowania w celu udostępniania dużych ilości danych i unikania kosztownych kopii przesyłanych tam i z powrotem między wykonawcą a środowiskiem piaskownicy.

Wykonawca tworzy bufor (na podstawie rozmiaru i przekazanych danych) lub bezpośrednio z deskryptora pliku i przekazuje go do piaskownicy za pomocą polecenia comms->SendFD() w wykonaniu i comms->RecvFD() w środowisku piaskownicy.

We fragmencie kodu poniżej widać stronę wykonawcy. Piaskownica działa asynchronicznie i udostępnia dane za pomocą bufora użytkownikowi piaskownicy:

// 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 piaskownicy 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. Zamykam piaskownicę

W zależności od sposobu uruchomienia piaskownicy (patrz ten krok) musisz dostosować sposób jej zamykania, a tym samym także trybu piaskownicy.

Synchronizowane wychodzenie z piaskownicy

Jeśli piaskownica działała synchronicznie, uruchomienie powróci dopiero po zakończeniu działania piaskownicy. W związku z tym nie musisz robić nic więcej. Fragment kodu poniżej pokazuje taką sytuację:

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

Asynchroniczne wychodzenie z piaskownicy

Jeśli piaskownica działała asynchronicznie, możesz ją zamknąć na 2 sposoby. Możesz poczekać na zakończenie działania obszaru piaskownicy i otrzymać ostateczny stan wykonania:

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

Możesz też zamknąć użytkownika piaskownicy w dowolnym momencie, ale nadal zalecamy wywołanie AwaitResult(), ponieważ w międzyczasie może on zostać zamknięty z innego powodu:

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

7. testowanie,

Tak jak w przypadku każdego innego kodu, implementacja w trybie piaskownicy powinna zawierać testy. Ich celem nie jest sprawdzanie poprawności programu, ale przede wszystkim sprawdzanie, czy program w trybie piaskownicy może działać bez problemów, na przykład w celu naruszenia zasad działania w trybie piaskownicy. Dodatkowo sprawdza się, czy zasada piaskownicy jest prawidłowa.

Program w piaskownicy jest testowany tak samo jak w środowisku produkcyjnym, z użyciem argumentów i plików wejściowych, które normalnie przetwarzałby.

Mogą to być tak proste jak test powłoki lub testy C++ wykorzystujące procesy podrzędne. Jeśli potrzebujesz inspiracji, zapoznaj się z przykładami.

Podsumowanie

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

Tworzenie piaskownicy i zasad to trudne zadanie, które często może powodować drobne błędy. Na wszelki wypadek zalecamy, aby specjalista ds. zabezpieczeń sprawdził Twoje zasady i kod.