Kod relokowalny/wstrzykiwalny (x64)
🕰 ✒️ Dawid Farbaniec 📄 3020 słów0x01. Słowem wstępu
Każdy początkujący, który interesuje się analizą i zwalczaniem złośliwego oprogramowania (malware) powinien poznać sposób działania kodu relokowalnego (ang. relocatable code) określanego też jako kod niezależny od miejsca w pamięci (ang. position–independent code) czy wstrzykiwalny (ang. injectable). Typowe programy komputerowe są zależne od odwołań do zasobów takich jak np. funkcje interfejsu systemowego (API) czy po prostu dane potrzebne aplikacji (napisy, liczby itd.). Natomiast kod relokowalny powinien być self–contained, czyli tłumacząc opisowo: zawierać w sobie to co jest mu potrzebne do działania. Dzięki temu, gdy zostanie umieszczony gdziekolwiek w pamięci komputera, to nadal będzie działał poprawnie, a nawet miał możliwość przeniesienia swoich fragmentów w inne miejsca (ang. (self–)relocation).
0x02. Standardowa definicja z encyklopedii
Witryna encyclopedia.com przedstawia pojęcia position–independent code oraz relocatable code następująco:
Position-independent code — Program code that can be placed anywhere in memory, since all memory references are made relative to the program counter. Position-independent code can be moved at any time, unlike relocatable code, which can be loaded anywhere but once loaded must stay in the same position.
Definicja ta jest poprawna, ale sądzę, że wzięto pod uwagę typowe programy komputerowe np. na określone typy mikroprocesorów. W przypadku analizy i zwalczania malware oraz tematyki shellcode (z tego co zauważyłem) to pojęcia position–independent code oraz relocatable code są używane zamiennie.
0x03. Odwołania zewnętrzne są utrudnieniem
Instrukcje procesora, które nie odwołują się do funkcji API (przykładowe rozkazy: mov
,
add
, dec
, xor
itp.) mogą
być bez problemu wykonywane w dowolnym miejscu w pamięci.
Jednak w natywnych aplikacjach dla systemu Windows to funkcje WinAPI udostępniają główne funkcjonalności programiście. Utworzenie pliku, odczytanie pliku, połączenie sieciowe, wyświetlenie okna i wiele innych możliwości daje systemowe API. W typowych programach wywołanie funkcji WinAPI następuje (w uproszczeniu) poprzez podanie jej nazwy (i argumentów jeśli przyjmuje), która to nazwa zamieniana jest na adres (wartość liczbowa określająca miejsce funkcji w bibliotece systemowej) i wywoływana.
Kod relokowalny wstawiony do jakiegoś miejsca w pamięci musi sobie w nietypowy sposób poradzić z
wywoływaniem funkcji API systemu Windows. W zwykłym programie w języku Asembler wywołanie wykonuje się
poprzez rozkaz call
podając mu nazwę funkcji i wymagane argumenty poprzez odpowiednie rejestry/stos.
W kodzie relokowalnym opisywanym tutaj podanie nazwy funkcji jest niemożliwe. Adresów funkcji nie można
wpisać do kodu, bo są one zmienne. Standardowe pobranie adresu danej funkcji przez GetProcAddress
jest również niemożliwe,
ponieważ trzeba najpierw znać adres funkcji GetProcAddress
.
Konwencja wywoływania funkcji Microsoft x64
Wywołanie funkcji w Asemblerze x64 nazywane też wywołaniem podprogramu przenosi
sterowanie do innego miejsca w kodzie. Gdy blok kodu określany funkcją się wykona, to następuje
powrót, który jest możliwy poprzez odłożony wcześniej na stosie programu adres powrotny.
Funkcje wywołuje się instrukcją procesora call
. To ona odkłada na stos wspomniany wcześniej
adres powrotny i przekazuje kontrolę do wywoływanego podprogramu.
Nie ma jednego uniwersalnego sposobu na wywołanie funkcji. Zależne jest to od architektury, a to jak działa wywołanie i związane z nim operacje określają konwencje wywołania (ang. calling conventions).
Przypomnijmy sobie, że w Asemblerze MASM32 dla architektury x86-64 korzysta się
z konwencji stdcall, która jest domyślna dla API systemu Windows.
Wyczyszczenie stosu programu jest obowiązkiem funkcji, która jest wywoływana (ang. callee),
czyli programista korzystający z takiej funkcji ma spokój z czyszczeniem stosu.
Argumenty (nazywane też parametrami) przekazywane są poprzez stos „od końca”, czyli od prawej do lewej strony.
Jeśli funkcja zwraca jakiś rezultat, to znajdzie się on w rejestrze akumulatora EAX
.
Niektóre funkcje, gdy wynik jest większy niż 32-bity zwracają wynik w parze rejestrów EDX:EAX
.
W konwencji stdcall, jeśli chcemy (w naszej funkcji) modyfikować wartości rejestrów
ESI
, EDI
, EBP
i EBX
, to powinniśmy zachować ich wartości np. na stosie,
a następnie je przywrócić przed powrotem do Windows.
Listing 3.1. Wywołanie funkcji CreateFile w Visual C++
Listing 3.2. Wywołanie funkcji CreateFile w Asemblerze MASM32 (konwencja stdcall)
Nowoczesny Asembler MASM64 dla architektury x86-64 (w skrócie x64) korzysta z konwencji wywoływania funkcji
nazwanej Microsoft x64. Wyczyszczenie stosu programu jest obowiązkiem funkcji wywołującej (ang. caller).
Argumentów nie przekazuje się tylko przez stos, ale przez wybrane rejestry takie jak:
R9
, R8
, RDX
, RCX
. Ze stosu korzysta się, gdy argumentów jest więcej
niż cztery i odkłada się je „od końca”, czyli od prawej do lewej strony. Rezultat funkcji, jeśli ma rozmiar mniejszy niż
64-bity, zwracany jest w rejestrze akumulatora RAX
.
W konwencji Microsoft x64, jeśli chcemy (w naszej funkcji) modyfikować wartości rejestrów
RBP
, RBX
, RDI
, RSI
, RSP
, R12
, R13
, R14
i R15
,
to powinniśmy zachować ich wartości np. na stosie,
a następnie je przywrócić przed powrotem do Windows. Należy również pamiętać o wyrównaniu stosu do
okrągłych 16 bajtów. Ilość miejsca rezerwowanego na stosie wraz z adresem powrotnym powinna być podzielna przez 16
bez reszty.
Listing 3.3. Wywołanie funkcji CreateFile w Asemblerze MASM64 (konwencja Microsoft x64)
0x04. Thread Environment Block (TEB)
Punktem zaczepienia do pobrania adresów funkcji WinAPI w tworzonym kodzie relokowalnym jest zdobycie adresu bazowego systemowej biblioteki kernel32.dll. Pierwszym krokiem jest poznanie bloku TEB (nazywanego też TIB).
Thread Environment Block to struktura danych, która zawiera informacje
o aktualnie wykonywanym wątku. Dostęp do niej można uzyskać poprzez
rejestr segmentowy GS
w trybie 64-bitowym oraz rejestr FS
w
trybie 32-bitowym.
Pobranie adresu bazowego struktury TEB w Asemblerze MASM64 można wykonać np. tak:
czy poprzez użycie rozkazu procesora rdgsbase
:
Dodatkowo wspomnę, że w Visual C++ adres TEB można pobrać funkcją wewnętrzną (ang. intrinsic):
Istotną sprawą jest, że pomimo lekkiego owiania tajemnicą tej struktury (jest to wewnętrzna struktura systemowa), to możliwe jest zdobycie jej budowy bez dokonywania jakiejś inżynierii wstecznej (RCE). W pliku nagłówkowym winternl.h dla Visual C++ w środowisku Visual Studio można znaleźć definicję tej struktury:
Należy pamiętać, że są to wewnętrzne API i struktury, które mogą ulec zmianie. O czym informuje początek komentarza w pliku winternl.h:
Jako podsumowanie tego podrozdziału muszę zaznaczyć w jaki sposób poznanie tej struktury przybliża nas do celu.
Otóż poprzez strukturę TEB możliwe jest uzyskanie dostępu do innej struktury: PEB.
0x05. Process Environment Block (PEB)
Blok PEB podobnie jak wcześniej opisany TEB jest używaną wewnętrznie przez system strukturą danych.
W pliku nagłówkowym winternl.h dla Visual C++ można znaleźć definicję tej struktury:
W strukturze PEB w kodzie powyżej
należy zwrócić uwagę na pole Ldr
(od Loader).
Jest to struktura _PEB_LDR_DATA
, która w pliku nagłówkowym winternl.h
prezentuje się następująco:
W powyższej strukturze (_PEB_LDR_DATA
) znajduje się pole InMemoryOrderModuleList
,
które jest listą modułów, którą należy przeszukać, aby uzyskać adres bazowy kernel32.dll.
Struktura _LIST_ENTRY
prezentuje element wyżej wspomnianej listy, a jego definicja
w winnt.h wygląda następująco:
0x06. Adres bazowy modułu kernel32.dll
Po zerknięcie na rozmiar w bajtach poszczególnych pól wyżej opisanych struktur
możliwe jest przemieszczanie się po nich. Całość polega na wpisaniu
odpowiednich wartości przesunięć (ang. offset) oraz
dereferencji pamięci poprzez operator ptr
(dla wartości 64-bitowych qword ptr
).
Listing 6.1. Uzyskanie adresu bazowego modułu kernel32.dll poprzez strukturę PEB (Asembler MASM64)
Powyższy kod w Asemblerze MASM64 zwraca w rejestrze R10
adres bazowy modułu kernel32.dll.
Po uzyskaniu dostępu do listy InMemoryOrderModuleList
następuje przejście
do jej trzeciego elementu. Jest tak, gdyż to właśnie trzeci moduł to szukany kernel32.dll.
Pierwszy moduł to uruchomiony plik wykonywalny, a drugi to ntdll.dll.
Poniżej przedstawiono dodatkowo aplikację, która znaleziony w strukturze PEB
adres bazowy modułu kernel32.dll porównuje z adresem pobranym zwykłą metodą tj. przez
wywołanie funkcji LoadLibrary
. Oczywiście to sprawdzenie jest tylko
w celach debugowania/nauki i tego fragmentu nie będzie w tworzonym kodzie relokowalnym.
Listing 6.2. Wersja rozszerzona kodu z listingu 6.1
0x07. Kod relokowalny w Asemblerze MASM64
Jako, że zrozumienie poniższych terminów jest niezbędne do analizy przedstawionego dalej kodu, to postanowiłem umieścić tutaj te krótkie wyjaśnienia.
Przesunięcie w pliku (ang. file offset) — liczba wyznaczająca położenie określonych danych od ustalonego miejsca w pliku. Bardzo często miejscem startowym jest początek pliku, który ma offset równy zero.
Adres wirtualny (ang. Virtual Address, VA) — jest to adres do danych po załadowaniu ich do pamięci. Prawie zawsze określa się go po prostu adresem. Można spotkać też określenia: liniowy lub efektywny. Adres wirtualny zawiera w sobie tzw. ImageBase, czyli miejsce w pamięci pod które ładowany jest plik.
Relatywny adres wirtualny (ang. Relative Virtual Address, RVA) — ten adres można otrzymać odejmując ImageBase danego modułu od adresu wirtualnego (VA) w tym module.
We wcześniejszym podrozdziale zaprezentowane zostało znalezienie adresu bazowego
modułu kernel32.dll. Kolejny krok w przód to przeszukanie tego modułu
w celu znalezienia adresu funkcji WinAPI o nazwie GetProcAddress
,
która pozwala pobierać adresy innych funkcji Windows API.
Po załadowanym do pamięci module kernel32.dll porusza się jak po pliku PE/COFF, dlatego artykuł Format plików PE/PE32+ dla początkujących jak i inne dokumenty o strukturze plików PE będą przydatne.
Ogólny schemat działania prezentuje się następująco:
- (adres bazowy kernel32.dll jest pobrany poprzez PEB)
- Przejść do nagłówka PE (przesunięcie
3Ch
) - Przejść do nagłówka opcjonalnego PE (przesunięcie
18h
) - Przejść do sekcji eksportowanych funkcji (przesunięcie
70h
) - Odczytać liczbę eksportowanych funkcji (
PIMAGE_EXPORT_DIRECTORY->NumberOfNames
) - Odnaleźć nazwę funkcji (
PIMAGE_EXPORT_DIRECTORY->AddressOfNames
) - Przejść do struktury zawierającej numery kolejne funkcji (
AddressOfNameOrdinals
, przesunięcie024h
) - Pobrać numer określający kolejność funkcji
- Na podstawie numeru kolejnego uzyskać adres funkcji z
AddressOfFunctions
(przesunięcie01Ch
) - Pobraną funkcją
GetProcAddress
uzyskać adres funkcjiLoadLibraryA
- Załadować moduł user32.dll za pomocą
LoadLibraryA
- Pobrać adres funkcji
MessageBoxA
z user32.dll za pomocąGetProcAddress
- Wywołać funkcję
MessageBoxA
. Jeśli wszystko się udało powinien pojawić się komunikat.
Kompletny kod przykładu w Asemblerze MASM64 (ML64.EXE) znajduje się na listingu poniżej.
Listing 7.1. Przykładowy kod relokowalny wyświetlający proste okno komunikatu w Asemblerze MASM64
0x08. Zakończenie
Po zbudowaniu powyższego kodu do pliku wykonywalnego EXE w celu wydzielenia samodzielnego kodu relokowalnego
należy wyodrębnić bajty kodu maszynowego z pomiędzy znaczników --- CUT HERE ---
.
Po tej operacji kod w postaci ciągu bajtów nadaje się np. do umieszczenia w
dowolnym buforze w pamięci operacyjnej i wykonania.
Do wyodrębnienia samego kodu z pliku EXE można użyć edytora szesnastkowego (ang. hex editor).
Na koniec polecam również mój poprzedni artykuł o podobnej tematyce: Kod samo–modyfikujący się (x64).
Wykaz literatury (bibliografia)
[1] Advanced Micro Devices Inc., 2017 – AMD64 Architecture Programmer's Manual[2] Intel Corporation, 2019 – Intel 64 and IA-32 Architectures Software Developer's Manual
[3] Microsoft Corporation, 2019 – https://docs.microsoft.com/pl-pl/cpp/assembler/masm/masm-for-x64-ml64-exe [dostęp: 28.07.2020 r.]
[4] Microsoft Corporation, 2018 – https://docs.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb [dostęp: 28.07.2020 r.]