Chroot i Docker

1. Bezpieczne środowisko uruchamiania aplikacji

Jeśli aplikacja (np. serwer http) jest uruchamiana pod systemem operacyjnym, w szczególności np. z uprawnieniami roota, w przypadku umiejętnego wykorzystania błędu atakujący może uzyskać bardzo szerokie uprawnienia, m.in. nieograniczony dostęp do systemu plików. Włamywacze są bardzo łasi na wynajdowanie takich podatności.

W celu uniknięcia takich zagrożeń:

  • należy uruchamiać aplikację z minimalnymi niezbędnymi uprawnieniami, w szczególności jako użytkownik inny niż root. Takie podejście jest szeroko stosowane, np. serwer HTTP Apache może być uruchomiony jako użytkownik inny niż root (zwykle http lub www);
  • jeśli to możliwe, trzeba zadbać, aby proces miał dostęp do ograniczonych zasobów systemowych.

1.1. Ograniczenie przestrzeni aktywności procesu - chroot

W systemie Linux można uruchomić dowolny proces (i jego procesy potomne) ze zmienionym katalogiem głównym za pomocą polecenia chroot (można to też zrobić wewnątrz programu za pomocą wywołania systemowego chroot()). Uruchomiony w ten sposób proces nie może powrócić do rzeczywistego katalogu głównego w systemie plików. Można więc przygotować inny niż główny katalog zawierający pliki niezbędne dla aplikacji np. biblioteki czy pliki konfiguracyjne. Zwykle trzeba odwzorować część struktury katalogów i plików konfiguracyjnych, np. /etc, /usr/lib/, /var, /proc. Mechanizm chroot może być też przydatny dla celów testowania oprogramowania lub np. zrealizowania środowiska programistycznego ze specyficznym zestawem bibliotek i narzędzi. Do zalet należy także zaliczyć bardzo łagodną krzywą uczenia się przy korzystaniu z tego rozwiązania i brak konieczności modyfikacji jądra systemu operacyjnego.

1.1.1. Przykład użycia polecenia chroot

Jeśli docelowy katalog został przygotowany, można uruchomić proces, np.:

chroot /przygotowany_katalog /usr/sbin/apache2 -k start

1.1.2. Wady chroot

Sam w sobie chroot nie posiada mechanizmów limitowania zasobów używanych przez proces, użycie go nie zabezpiecza więc przed atakami DoS. (Ograniczenia takie można wprowadzić za to mechanizmem PAM).
Uruchomienie polecenia chroot wymaga uprawnień roota.
Chroot nie zmienia UID i GID procesu. Jeśli aplikacja sama nie zmieni UID, będzie wykonywana z prawami roota.
Istnieją sposoby, które pozwalają procesowi uruchomionemu z prawami roota, wyjście poza określony poleceniem chroot katalog -
opisano je w lekturze uzupełniajacej http://linux-vserver.org/Secure_chroot_Barrier.

Można przy okazji zacytować fragment listu Alana Coxa: "(...) chroot is not and never has been a security tool." :)

1.1.3 Przydatne triki

Jeżeli chcemy mimo wszystko przygotować katalog chroot, możemy ułatwić sobie życie na dwa sposoby. Po pierwsze można przygotować minimalną instalację jakiejś dystrybucji, aby uniknąć zgadywania, które pliki są potrzebne do pracy naszego programu; dotyczy to zwłaszcza bibliotek korzystających z wielu plików pomocniczych. Trzeba jednak pamiętać, aby nie umieszczać w systemie zbędnych programów z bitem SUID, które mogłyby być wykorzystane do „ucieczki” z chroota. Warto też pamiętać o poleceniu

mount --bind /proc /przygotowany_katalog/proc 
mount --rbind /dev /przygotowany_katalog/dev

pierwsza wersja powoduje, że pliki z systemu plików (lub jakiegoś katalogu z systemu plików) widocznego jako /proc będą też widoczne w katalogu /przygotowany_katalog/proc. Nie dotyczy to jednak katalogów podmonotwanych wewnątrz proc, np. /proc/sys/fs/binfmt_misc. Natomiast druga wersja obejmuje także katalogi podmontowane wewnątrz dowiązywanego katalogu.

1.2. Kontentery

Chociaż opanowanie podstawowej funkcjonalności chroot jest łatwe, to jak zaznaczyliśmy powyżej, użycie chroot do stworzenia szczelnie zamkniętego środowiska wykonawczego dedykowanego dla jednej aplikacji jest żmudne i wymaga sporego doświadczenia. Dlatego opracowane zostały bardziej kompleksowe rozwiązania, które pozwalają na lepsze opanowanie wskazanych trudności.

Naturalnym roszerzeniem chroota jest objęcie ograniczeniami także innych zasobów niż system plików. W idealnym świecie uruchomiony w takim ulepszonym chroocie – czyli kontenerze – program powinien zachowywać się tak, jakby był uruchomiony na maszynie wirtualnej na tym samym komputerze i z oddzielną kopią tego samego jądra. Jednak ponieważ naprawdę nie uruchamiamy nowego jądra, to nie występuje narzut spowodowany koniecznością obsługi wywołań systemowych przez dwa jądra, wirtualizacją urządzeń sprzętowych itp. Możemy także, jeżeli tego potrzebujemy, osłabić izolację w jakimś miejscu, aby programy bardziej efektywnie współpracowały. Ograniczeniem jest konieczność używania takiego samego jądra we wszystkich używanych kontenerach i systemie bazowym (choć to ograniczenie w ostatnich czasach zostało zupełnie zniwelowane).

W Linuksie implementacja kontenerów opiera się na uogólnieniu pojącia chroot do pojęcia namespace. Są one następujące i pozwalają izolować:

       Namespace   Constant          Isolates
       Cgroup      CLONE_NEWCGROUP   Cgroup root directory
       IPC         CLONE_NEWIPC      System V IPC, POSIX message queues
       Network     CLONE_NEWNET      Network devices, stacks, ports, etc.
       Mount       CLONE_NEWNS       Mount points
       PID         CLONE_NEWPID      Process IDs
       User        CLONE_NEWUSER     User and group IDs
       UTS         CLONE_NEWUTS      Hostname and NIS domain name

Tabelka pochodzi z man namespace(7). Tę stronę podręcznika systemowego należy przeczytać, a strony zależne przejrzeć. Należy także zapoznać się z możliwościami nakładania ograniczeń na procesy za pomocą grup cgroups(7).

Na podstawie tej infrastuktury zbudowanych jest kilka środowisk dających wygodne narzędzia do wirtualizacji, są to. np. Linux Containers (LXC), OpenVZ, Docker. Na tych zajęciach przyjrzymy się bliżej jako przykładowi temu ostatniemu rozwiązaniu.

1.3. Docker

Docker składa się z procesu serwera, który odpowiada za faktyczne tworzenie kontenerów, ich modyfikacje, itp. oraz klienta, którego funkcją jest zapewnianie możliwości korzystania z serwera. W celu skonfigurowania nowego kontenera, tworzymy w pustym katalogu plik o nazwie Dockerfile z zawartością odpowiadającą naszym potrzebom. Oto przykładowa zawartość:

FROM debian:buster
# O ile chcemy, aby proces działał w środowisku Debian 10
MAINTAINER Kto To Wie
 
RUN apt-get update && apt-get install -y apache2 
 
EXPOSE 80
 
CMD apachectl -D FOREGROUND

pierwsza linia oznacza obraz systemu operacyjnego, z którego korzystamy na początku pracy. Docker ma centralny rejestr obrazów, z których można korzystać przy tworzeniu kontenerów, są w nim w szczególności podstawowe instalacje różnych dystrybucji. Jeśli chcemy uniezależnić się od twórców Dockera, można też uruchomić własny taki serwer.

Linia z wpisem MAINTAINER jest wymagana, ale ma charakter informacyjny. Następnie mamy (być może kilka) linii z poleceniami RUN. Opisują one polecenia potrzebne do przygotowania docelowej konfiguracji kontenera. W celu uniknięcia zbyt częstego wykonywania tych samych poleceń Docker zapamiętuje stan kontenera po każdej linijce z pliku Dockerfile w sposób analogiczny do migawek zwykłych maszyn wirtualnych.

Polecenie EXPOSE 80 udostępnia światu port 80. Polecenie CMD określa polecenie, które będzie służyło do faktycznego uruchomienia kontenera - w tym przypadku jest to serwer Apache. Gdy podany proces zakończy działanie Docker zatrzyma wszystkie procesy w kontenerze

W celu uruchomienia naszego kontenera wchodzimy do odpowiedniego katalogu z plikiem Dockerfile i wykonujemy polecenia:

docker build .
docker container run <id_obrazu>

lub krócej

docker run <id_obrazu>

Polecenie docker build . zbuduje obraz na podstawie pliku Dockerfile (w tym przypadku na Debianie 10, ale np. z dołączonymi naszymi plikami), polecenie docker run id_obrazu uruchomi proces serwera Apache w nowym kontenerze w środowisku naszego obrazu.

Polecenie

docker images

lub

docker image ls

pozwala sprawdzić identyfikator (id) obrazu.

Jeśli chcemy szybko uruchomić proces w nowym kontenerze (w środowisku Debian 10) bez pisania pliku Dockerfile, możemy napisać

docker run -t -i debian:buster /bin/bash

Polecenie docker run wykona wtedy kilka kroków:

  • Ściągnie obraz Debiana bustera, jeśli wcześniej nie był ściągnięty za pomocą polecenia docker pull debian:buster
  • Utworzy nowy kontener.
  • Nadmontuje nad obrazem warstwę rw.
  • Ustanowi nowy adres IP.
  • Uruchomi nowy proces w środowisku określonym przez obraz/warstwę rw

Należy zwrócić uwagę, że istnieje duża różnica między docker run id_obrazu, a docker start id_kontenera. (W tym samym znaczeniu można wykonać docker container run id_obrazu lub docker container start id_kontenera). Polecenie Dockera run zawsze tworzy nowy kontener.

Kilka przykładów operacji na kontenerach:

docker container ls -a (listowanie wszystkich)
docker container run <id_obrazu> (uruchamia proces w nowym kontenerze, w środowisku obrazu o danym id)
docker container stop <id_kontenera> (zatrzymywanie kontenera)
docker container start <id_kontenera> (uruchamianie zatrzymanego kontenera)
docker container rm <id_kontenera> (usuwanie)
docker container prune (usuwanie wszystkich zatrzymanych)

Można też oczywiście usuwać obrazy (docker image rm id_obrazu lub docker image prune -- usuwa wszystkie nieużywane). Tak jak było widać już wcześniej, np. polecenie docker stop wykona to samo, co docker container stop, ale nie zadziała docker prune, trzeba użyć docker image prune lub docker container prune. Pierwsze usuwa obrazy, drugie kontenery. Warto sprawdzić jakie opcje są dostępne dla danego polecenia Dockera: docker --help, docker container --help, docker image --help, docker container ls --help itd.

Należy pamiętać, że obraz (image) i kontener (container) to nie to samo. Obraz daje możliwość utworzenia środowiska
dla procesu w kontenerze, środowiska opartego na konkretnym systemie operacyjnym (Debianie 9, Debianie 10, Ubuntu itp).
Można powiedzieć, iż kontenery korzystają z obrazów, z jednego obrazu może korzystać wiele kontenerów. Więcej informacji
na temat przechowywania danych, warstw z których składa się dockerowa pamięć nieulotna można znaleźć na https://docs.docker.com/storage/storagedriver/.

1.3.1 Ciąg dalszy

Docker oraz plik Dockerfile mają jeszcze wiele możliwości, których nie poznaliśmy. Co ciekawe, od wersji Docker Engine 19.03, istnieje możliwość pracy w trybie nie roota, w tej wersji cecha ta jest uważana za eksperymentalną. Do używania w tym trybie zalecana jest najnowsza wersja 20.10.

Warto też nadmienić, że chociaż początkowo korzystanie z Dockera pod Windows skazywało nas na korzystanie z systemów windowsowych wewnątrz kontenerów, a korzystanie z Linuksa z systemów linuksowych, to obecnie te ograniczenia zostały pokonane i pod Windows można korzystać z Linuksa i vice versa (choć w nieco bardziej skomplikowany sposób).

Pełna dokumentacja Dockera jest dostepna pod adresem https://docs.docker.com/get-started/overview/.

Zadanie pokazujące aktywność

Zainstaluj dockera, pod Debianem 10 wystarczy:

apt-get install docker.io

docker pull debian:buster
docker run -i -t debian:buster bash -l

Jako ćwiczenie umieść w kontenerze pliki z pierwszego zadania oraz drugiego. Uruchom w dokerze skrypt napisany przy pierwszym zadaniu i skompiluj zadanie z drugiego, a następnie uruchom w dokerze stworzoną wtedy małą aplikację. Odpowiedni Dockerfile prześlij na Moodle.