wtorek, 10 lutego 2026

Kampania APT28: Analiza techniczna EhStoreShell.dll (część 2)

Pierwszą część analizy (LINK) zakończyliśmy na kroku, gdy wymuszony został restart procesu explorer.exe. Po restarcie do procesu explorer.exe ładowana jest biblioteka ze złośliwym kodem o nazwie EhStoreShell.dll. W tej części artykułu zajmiemy się analizą właśnie tej biblioteki oraz utworzonego przez pierwszą bibliotekę pliku PNG.

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

 
W głównej funkcji możemy zidentyfikować funkcje, które widzieliśmy w pierwszej próbce a związane z odkodowywaniem ciągów znaków za pomocą operacji logicznej XOR. Po odkodowaniu są to nazwy bibliotek: kernel32.dll, ntdll.dll i uwaga ehstorshell.dll. ehstorshell.dll jest “legalną” biblioteką, która też będzie ładowana do pamięci procesu w celu zastosowania techniki DLL Proxying dla wybranych API (technikę DLL Proxying opisaliśmy w innym naszym artykule).

 

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
FUN_18000b290((byte *)&DWORD_180024438,(undefined1 (*) [32])local_78); // odkoduje DllGetClassObject
FUN_18000b290(&DAT_1800244a8,(undefined1 (*) [32])local_98); //odkoduje DllRegisterServer
FUN_18000b040(&DAT_1800244c0,(undefined1 (*) [32])local_b8); //odkoduje DllUnregisterServer

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

 

Kolejną ciekawą funkcjonalnością są próby wykrywania “zewnętrznej ingerencji” w działania malware. 

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

 

W kolejnym kroku złośliwe oprogramowanie wczytuje do pamięci plik z lokalizacji %programdata%\Microsoft OneDrive\setup\cache\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

 

Poniżej zawartość pliku SplashScreen.png.


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.
Poniżej fragment tego pliki. 3 bloki - czerwony (końcówka payload), żółty (ciągi znaków), niebieski (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

 

Na tym kończymy drugą część analizy złośliwego oprogramowania wykorzystywanego przez grupę APT28 podczas ostatniej kampanii. Wiemy, że biblioteka EhStoreShell.dll pełni rolę kolejnego etapu ataku, a jej głównym zadaniem jest bezpieczne odczytanie payload z pliku PNG, który następnie jest uruchamiany.

Ź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 );
}


Brak komentarzy:

Prześlij komentarz