Lęk separacyjny: samouczek dotyczący izolowania systemu za pomocą przestrzeni nazw systemu Linux
Opublikowany: 2022-03-11Wraz z pojawieniem się narzędzi takich jak Docker, Linux Containers i innych, bardzo łatwo stało się izolowanie procesów Linuksa do ich własnych, małych środowisk systemowych. Dzięki temu możliwe jest uruchamianie całej gamy aplikacji na jednej, rzeczywistej maszynie z systemem Linux i zapewnienie, że żadne dwie z nich nie będą się wzajemnie zakłócać, bez konieczności korzystania z maszyn wirtualnych. Narzędzia te były ogromnym dobrodziejstwem dla dostawców PaaS. Ale co dokładnie dzieje się pod maską?
Narzędzia te opierają się na wielu funkcjach i składnikach jądra Linuksa. Niektóre z tych funkcji zostały wprowadzone stosunkowo niedawno, podczas gdy inne nadal wymagają łatania samego jądra. Ale jeden z kluczowych komponentów, wykorzystujący przestrzenie nazw Linuksa, jest funkcją Linuksa od wersji 2.6.24, która została wydana w 2008 roku.
Każdy, kto zna chroot
, ma już podstawowe pojęcie o tym, co mogą robić przestrzenie nazw Linuksa i jak ogólnie używać przestrzeni nazw. Tak jak chroot
umożliwia procesom postrzeganie dowolnego katalogu jako katalogu głównego systemu (niezależnie od pozostałych procesów), przestrzenie nazw Linuksa umożliwiają również niezależne modyfikowanie innych aspektów systemu operacyjnego. Obejmuje to drzewo procesów, interfejsy sieciowe, punkty montowania, zasoby komunikacji między procesami i inne.
Dlaczego warto używać przestrzeni nazw do izolacji procesów?
Na komputerze z jednym użytkownikiem jedno środowisko systemowe może być w porządku. Jednak na serwerze, na którym chcesz uruchomić wiele usług, dla bezpieczeństwa i stabilności istotne jest, aby usługi były od siebie możliwie jak najbardziej odizolowane. Wyobraź sobie serwer, na którym działa wiele usług, z których jeden zostanie naruszony przez intruza. W takim przypadku intruz może wykorzystać tę usługę i dostać się do innych usług, a nawet może złamać cały serwer. Izolacja przestrzeni nazw może zapewnić bezpieczne środowisko, aby wyeliminować to ryzyko.
Na przykład, używając przestrzeni nazw, możliwe jest bezpieczne uruchamianie dowolnych lub nieznanych programów na serwerze. W ostatnim czasie rośnie liczba konkursów programistycznych i platform „hackathon”, takich jak HackerRank, TopCoder, Codeforces i wiele innych. Wiele z nich wykorzystuje zautomatyzowane potoki do uruchamiania i walidacji programów przesłanych przez zawodników. Często niemożliwe jest wcześniejsze poznanie prawdziwej natury programów uczestników, a niektóre mogą nawet zawierać złośliwe elementy. Uruchamiając te programy w przestrzeni nazw w całkowitej izolacji od reszty systemu, oprogramowanie może być testowane i sprawdzane bez narażania reszty maszyny na ryzyko. Podobnie usługi ciągłej integracji online, takie jak Drone.io, automatycznie pobierają Twoje repozytorium kodu i wykonują skrypty testowe na własnych serwerach. Ponownie izolacja przestrzeni nazw umożliwia bezpieczne świadczenie tych usług.
Narzędzia do przestrzeni nazw, takie jak Docker, umożliwiają również lepszą kontrolę nad wykorzystaniem zasobów systemowych przez procesy, dzięki czemu takie narzędzia są niezwykle popularne wśród dostawców PaaS. Usługi takie jak Heroku i Google App Engine wykorzystują takie narzędzia do izolowania i uruchamiania wielu aplikacji serwera WWW na tym samym rzeczywistym sprzęcie. Narzędzia te pozwalają im uruchamiać każdą aplikację (która mogła zostać wdrożona przez dowolnego z wielu różnych użytkowników) bez martwienia się, że jeden z nich użyje zbyt wielu zasobów systemowych lub będzie zakłócać i/lub kolidować z innymi wdrożonymi usługami na tym samym komputerze. Przy takiej izolacji procesu możliwe jest nawet posiadanie zupełnie różnych stosów oprogramowania zależnego (i wersji) dla każdego izolowanego środowiska!
Jeśli korzystałeś z narzędzi takich jak Docker, wiesz już, że te narzędzia są w stanie izolować procesy w małych „kontenerach”. Uruchamianie procesów w kontenerach Docker jest jak uruchamianie ich na maszynach wirtualnych, tylko te kontenery są znacznie lżejsze niż maszyny wirtualne. Maszyna wirtualna zazwyczaj emuluje warstwę sprzętową na wierzchu systemu operacyjnego, a następnie uruchamia na niej inny system operacyjny. Pozwala to na uruchamianie procesów wewnątrz maszyny wirtualnej w całkowitej izolacji od rzeczywistego systemu operacyjnego. Ale maszyny wirtualne są ciężkie! Z drugiej strony kontenery Dockera wykorzystują niektóre kluczowe funkcje Twojego prawdziwego systemu operacyjnego, w tym przestrzenie nazw, i zapewniają podobny poziom izolacji, ale bez emulacji sprzętu i uruchamiania jeszcze innego systemu operacyjnego na tym samym komputerze. Dzięki temu są bardzo lekkie.
Przestrzeń nazw procesów
Historycznie jądro Linuksa utrzymywało jedno drzewo procesów. Drzewo zawiera odniesienie do każdego procesu aktualnie uruchomionego w hierarchii rodzic-dziecko. Proces, o ile ma wystarczające uprawnienia i spełnia określone warunki, może przeprowadzić inspekcję innego procesu, dołączając do niego znacznik lub nawet go zabić.
Wraz z wprowadzeniem przestrzeni nazw Linuksa stało się możliwe posiadanie wielu „zagnieżdżonych” drzew procesów. Każde drzewo procesów może mieć całkowicie wyizolowany zestaw procesów. Może to zapewnić, że procesy należące do jednego drzewa procesów nie będą mogły sprawdzać ani zabijać - w rzeczywistości nie mogą nawet wiedzieć o istnieniu - procesów w innych rodzeństwie lub drzewach procesów nadrzędnych.
Za każdym razem, gdy komputer z systemem Linux uruchamia się, uruchamia się tylko jeden proces o identyfikatorze procesu (PID) 1. Proces ten jest korzeniem drzewa procesów i inicjuje resztę systemu, wykonując odpowiednie prace konserwacyjne i uruchamiając poprawne demony/usługi. Wszystkie inne procesy zaczynają się poniżej tego procesu w drzewie. Przestrzeń nazw PID umożliwia wydzielenie nowego drzewa z własnym procesem PID 1. Proces, który to robi, pozostaje w nadrzędnej przestrzeni nazw w oryginalnym drzewie, ale czyni dziecko korzeniem własnego drzewa procesów.
Dzięki izolacji przestrzeni nazw PID procesy w podrzędnej przestrzeni nazw nie mają możliwości dowiedzenia się o istnieniu procesu nadrzędnego. Jednak procesy w nadrzędnej przestrzeni nazw mają pełny widok procesów w podrzędnej przestrzeni nazw, tak jakby były jakimikolwiek innymi procesami w nadrzędnej przestrzeni nazw.
Możliwe jest utworzenie zagnieżdżonego zestawu podrzędnych przestrzeni nazw: jeden proces uruchamia proces podrzędny w nowej przestrzeni nazw PID, a ten proces podrzędny tworzy kolejny proces w nowej przestrzeni nazw PID i tak dalej.
Wraz z wprowadzeniem przestrzeni nazw PID, pojedynczy proces może mieć teraz powiązanych z nim wiele PID, po jednym dla każdej przestrzeni nazw, do której należy. W kodzie źródłowym Linuksa widzimy, że struktura o nazwie pid
, która kiedyś śledziła tylko jeden PID, teraz śledzi wiele PID poprzez użycie struktury o nazwie upid
:
struct upid { int nr; // the PID value struct pid_namespace *ns; // namespace where this PID is relevant // ... }; struct pid { // ... int level; // number of upids struct upid numbers[0]; // array of upids };
Aby utworzyć nową przestrzeń nazw PID, należy wywołać funkcję systemową clone()
ze specjalną flagą CLONE_NEWPID
. (C zapewnia opakowanie, które udostępnia to wywołanie systemowe, podobnie jak wiele innych popularnych języków.) Podczas gdy inne omówione poniżej przestrzenie nazw można również utworzyć za pomocą wywołania systemowego unshare()
, przestrzeń nazw PID można utworzyć tylko w momencie nowego proces jest odradzany za pomocą clone()
. Po wywołaniu clone()
z tą flagą, nowy proces natychmiast uruchamia się w nowej przestrzeni nazw PID, w nowym drzewie procesów. Można to zademonstrować za pomocą prostego programu w C:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("PID: %ld\n", (long)getpid()); return 0; } int main() { pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); printf("clone() = %ld\n", (long)child_pid); waitpid(child_pid, NULL, 0); return 0; }
Skompiluj i uruchom ten program z uprawnieniami roota, a zobaczysz wynik podobny do tego:
clone() = 5304 PID: 1
PID wydrukowany z child_fn
będzie miał wartość 1
.
Mimo że powyższy kod samouczka przestrzeni nazw nie jest dużo dłuższy niż „Hello, world” w niektórych językach, wiele się wydarzyło za kulisami. Funkcja clone()
, jak można się spodziewać, utworzyła nowy proces przez klonowanie bieżącego i rozpoczęła wykonywanie na początku funkcji child_fn()
. Jednak robiąc to, odłączył nowy proces od oryginalnego drzewa procesów i utworzył osobne drzewo procesów dla nowego procesu.
Spróbuj zastąpić static int child_fn()
następującą, aby wydrukować nadrzędny identyfikator PID z perspektywy izolowanego procesu:
static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }
Uruchomienie programu tym razem daje następujące dane wyjściowe:
clone() = 11449 Parent PID: 0
Zwróć uwagę, że nadrzędny PID z perspektywy izolowanego procesu wynosi 0, co oznacza brak rodzica. Spróbuj ponownie uruchomić ten sam program, ale tym razem usuń flagę CLONE_NEWPID
z wywołania funkcji clone()
:
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);
Tym razem zauważysz, że nadrzędny PID nie wynosi już 0:
clone() = 11561 Parent PID: 11560
To jednak dopiero pierwszy krok w naszym samouczku. Te procesy nadal mają nieograniczony dostęp do innych wspólnych lub współdzielonych zasobów. Na przykład interfejs sieciowy: jeśli proces potomny utworzony powyżej miałby nasłuchiwać na porcie 80, uniemożliwiłoby to wszystkim innym procesom w systemie nasłuchiwanie na nim.
Przestrzeń nazw sieci Linux
Tutaj przydaje się przestrzeń nazw sieci. Sieciowa przestrzeń nazw pozwala każdemu z tych procesów zobaczyć zupełnie inny zestaw interfejsów sieciowych. Nawet interfejs pętli zwrotnej jest inny dla każdej przestrzeni nazw sieci.
Wyizolowanie procesu we własnej sieciowej przestrzeni nazw wiąże się z wprowadzeniem kolejnej flagi do wywołania funkcji clone()
: CLONE_NEWNET
;
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
Wyjście:

Original `net` Namespace: 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000 link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff New `net` Namespace: 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
Co tu się dzieje? Fizyczne urządzenie ethernetowe enp4s0
należy do globalnej przestrzeni nazw sieci, jak wskazuje narzędzie „ip” uruchamiane z tej przestrzeni nazw. Jednak interfejs fizyczny nie jest dostępny w nowej sieciowej przestrzeni nazw. Co więcej, urządzenie pętli zwrotnej jest aktywne w oryginalnej sieciowej przestrzeni nazw, ale jest „nieaktywne” w podrzędnej sieciowej przestrzeni nazw.
Aby zapewnić użyteczny interfejs sieciowy w podrzędnej przestrzeni nazw, konieczne jest skonfigurowanie dodatkowych „wirtualnych” interfejsów sieciowych, które obejmują wiele przestrzeni nazw. Po wykonaniu tej czynności możliwe jest tworzenie mostów Ethernet, a nawet trasowanie pakietów między przestrzeniami nazw. Wreszcie, aby wszystko działało, „proces routingu” musi działać w globalnej przestrzeni nazw sieci, aby odbierać ruch z interfejsu fizycznego i kierować go przez odpowiednie interfejsy wirtualne do odpowiednich podrzędnych przestrzeni nazw sieci. Być może widzisz, dlaczego narzędzia takie jak Docker, które wykonują dla Ciebie wszystkie te ciężkie ładunki, są tak popularne!
Aby to zrobić ręcznie, możesz utworzyć parę połączeń wirtualnej sieci Ethernet między nadrzędną i podrzędną przestrzenią nazw, uruchamiając jedno polecenie z nadrzędnej przestrzeni nazw:
ip link add name veth0 type veth peer name veth1 netns <pid>
W tym przypadku <pid>
należy zastąpić identyfikatorem procesu w podrzędnej przestrzeni nazw obserwowanym przez rodzica. Uruchomienie tego polecenia ustanawia połączenie podobne do potoku między tymi dwiema przestrzeniami nazw. Nadrzędna przestrzeń nazw zachowuje urządzenie veth0
i przekazuje urządzenie veth1
do podrzędnej przestrzeni nazw. Wszystko, co wchodzi na jeden z końców, wychodzi przez drugi koniec, tak jak można by oczekiwać od prawdziwego połączenia Ethernet między dwoma rzeczywistymi węzłami. W związku z tym obu stronom tego wirtualnego połączenia Ethernet muszą być przypisane adresy IP.
Zamontuj przestrzeń nazw
Linux utrzymuje również strukturę danych dla wszystkich punktów montowania systemu. Zawiera informacje, takie jak, jakie partycje dyskowe są montowane, gdzie są montowane, czy są tylko do odczytu i tak dalej. W przypadku przestrzeni nazw Linuksa można sklonować tę strukturę danych, dzięki czemu procesy w różnych przestrzeniach nazw mogą zmieniać punkty montowania bez wzajemnego wpływu.
Tworzenie oddzielnej przestrzeni nazw montowania ma efekt podobny do robienia chroot()
. chroot()
jest dobry, ale nie zapewnia pełnej izolacji, a jego efekty są ograniczone tylko do punktu montowania root. Stworzenie oddzielnej przestrzeni nazw montowania pozwala każdemu z tych izolowanych procesów mieć zupełnie inny widok struktury punktów montowania całego systemu niż oryginalny. Dzięki temu możesz mieć inny katalog główny dla każdego izolowanego procesu, a także inne punkty montowania, które są specyficzne dla tych procesów. Używając go ostrożnie w tym samouczku, możesz uniknąć ujawniania jakichkolwiek informacji o podstawowym systemie.
Wymagana do tego flaga clone()
to CLONE_NEWNS
:
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
Początkowo proces potomny widzi dokładnie te same punkty montowania, co jego proces nadrzędny. Jednak będąc w nowej przestrzeni nazw montowania, proces potomny może montować lub odmontowywać dowolne punkty końcowe, a zmiana nie wpłynie ani na jego nadrzędną przestrzeń nazw, ani na jakąkolwiek inną przestrzeń nazw montowania w całym systemie. Na przykład, jeśli proces nadrzędny ma konkretną partycję dysku zamontowaną w katalogu głównym, wyizolowany proces zobaczy na początku dokładnie tę samą partycję dysku zamontowaną w katalogu głównym. Ale korzyść z wyizolowania przestrzeni nazw montowania jest widoczna, gdy izolowany proces próbuje zmienić partycję główną na coś innego, ponieważ zmiana wpłynie tylko na wyizolowaną przestrzeń nazw montowania.
Co ciekawe, sprawia to, że tworzenie docelowego procesu potomnego bezpośrednio z flagą CLONE_NEWNS
jest złym pomysłem. Lepszym podejściem jest uruchomienie specjalnego procesu „init” z flagą CLONE_NEWNS
, aby proces „init” zmienił odpowiednio „/”, „/proc”, „/dev” lub inne punkty montowania, a następnie uruchom proces docelowy . Zostało to omówione bardziej szczegółowo pod koniec tego samouczka dotyczącego przestrzeni nazw.
Inne przestrzenie nazw
Istnieją inne przestrzenie nazw, w których można wyizolować te procesy, a mianowicie user, IPC i UTS. Przestrzeń nazw użytkownika umożliwia procesowi posiadanie uprawnień administratora w przestrzeni nazw, bez nadawania mu dostępu do procesów poza przestrzenią nazw. Izolowanie procesu przez przestrzeń nazw IPC zapewnia mu własne zasoby komunikacji międzyprocesowej, na przykład komunikaty IPC Systemu V i komunikaty POSIX. Przestrzeń nazw UTS izoluje dwa konkretne identyfikatory systemu: nodename
i nazwa domainname
.
Szybki przykład pokazujący, jak izolowana jest przestrzeń nazw UTS, pokazano poniżej:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/utsname.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static void print_nodename() { struct utsname utsname; uname(&utsname); printf("%s\n", utsname.nodename); } static int child_fn() { printf("New UTS namespace nodename: "); print_nodename(); printf("Changing nodename inside new UTS namespace\n"); sethostname("GLaDOS", 6); printf("New UTS namespace nodename: "); print_nodename(); return 0; } int main() { printf("Original UTS namespace nodename: "); print_nodename(); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL); sleep(1); printf("Original UTS namespace nodename: "); print_nodename(); waitpid(child_pid, NULL, 0); return 0; }
Ten program daje następujące dane wyjściowe:
Original UTS namespace nodename: XT New UTS namespace nodename: XT Changing nodename inside new UTS namespace New UTS namespace nodename: GLaDOS Original UTS namespace nodename: XT
Tutaj child_fn()
wypisuje nazwę nodename
, zmienia ją na inną i wypisuje ją ponownie. Oczywiście zmiana następuje tylko w nowej przestrzeni nazw UTS.
Więcej informacji na temat tego, co zapewniają i izolują wszystkie przestrzenie nazw, można znaleźć w samouczku tutaj
Komunikacja między przestrzeniami nazw
Często konieczne jest nawiązanie pewnego rodzaju komunikacji między nadrzędną a podrzędną przestrzenią nazw. Może to służyć do wykonywania prac konfiguracyjnych w odizolowanym środowisku lub po prostu do zachowania możliwości podglądu stanu tego środowiska z zewnątrz. Jednym ze sposobów na to jest utrzymanie działającego demona SSH w tym środowisku. Możesz mieć osobnego demona SSH w każdej sieciowej przestrzeni nazw. Jednak posiadanie wielu działających demonów SSH zużywa wiele cennych zasobów, takich jak pamięć. W tym momencie posiadanie specjalnego procesu „inicjalizacji” ponownie okazuje się dobrym pomysłem.
Proces „init” może ustanowić kanał komunikacji między nadrzędną przestrzenią nazw a podrzędną przestrzenią nazw. Ten kanał może być oparty na gniazdach UNIX, a nawet może korzystać z protokołu TCP. Aby utworzyć gniazdo UNIX, które obejmuje dwie różne przestrzenie nazw montowania, należy najpierw utworzyć proces potomny, następnie gniazdo UNIX, a następnie wyizolować proces potomny do oddzielnej przestrzeni nazw montowania. Ale jak najpierw stworzyć proces, a później go wyizolować? Linux zapewnia unshare()
. To specjalne wywołanie systemowe umożliwia procesowi odizolowanie się od oryginalnej przestrzeni nazw, zamiast zmuszania rodzica do izolowania dziecka w pierwszej kolejności. Na przykład poniższy kod ma dokładnie taki sam efekt, jak kod wspomniany wcześniej w sekcji sieciowej przestrzeni nazw:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { // calling unshare() from inside the init process lets you create a new namespace after a new process has been spawned unshare(CLONE_NEWNET); printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
A ponieważ proces „init” jest czymś, co wymyśliłeś, możesz najpierw zmusić go do wykonania całej niezbędnej pracy, a następnie odizolować się od reszty systemu przed wykonaniem docelowego dziecka.
Wniosek
Ten samouczek to tylko przegląd tego, jak używać przestrzeni nazw w systemie Linux. Powinno to dać podstawowe pojęcie o tym, jak programista Linuksa może zacząć wdrażać izolację systemu, integralną część architektury narzędzi, takich jak Docker lub Linux Containers. W większości przypadków najlepiej byłoby po prostu skorzystać z jednego z tych istniejących narzędzi, które są już dobrze znane i przetestowane. Jednak w niektórych przypadkach sensowne może być posiadanie własnego, dostosowanego mechanizmu izolacji procesów, a w takim przypadku ten samouczek dotyczący przestrzeni nazw ogromnie ci pomoże.
Pod maską dzieje się o wiele więcej, niż omówiłem w tym artykule, i istnieje więcej sposobów na ograniczenie docelowych procesów w celu zwiększenia bezpieczeństwa i izolacji. Miejmy jednak nadzieję, że może to służyć jako użyteczny punkt wyjścia dla kogoś, kto jest zainteresowany dowiedzeniem się więcej o tym, jak naprawdę działa izolacja przestrzeni nazw w Linuksie.