Hashes
| Type | Hash |
|---|---|
| MD5 | 2ea437575102436bfb118efec842e09e |
| SHA1 | 4ead95f10054f37b540c42067c9bc22b7ebe9f6c |
| SHA256 | 9c692a0bf6d9d784e705dbd5f8fe8089d31eeab6fe196f9288573fd4aa3c5fa6 |
Summary
The binary is a loader for an embedded payload that is encrypted using the ChaCha20 cipher and injected into the loader-process through self-injection.
It contains several evasion techniques:
- Indirect syscalls with syscall numbers retrieved using the
Hell's GateandHalo's Gatetechniques - Functions whose syscalls are retrieved are resolved using a variant of the DJB2 API hashing algorithm
- If functions and libraries are resolved dynamically by name, their names are encrypted using RC4 ciphers with different 16-byte encryption keys
- EDR blinding by calling
ZwSetInformationProcess(for the current loader process) and passing a NULL value for the instrumentation callback - Inline user-mode API patching of the functions
AmsiScanBuffer,EtwEventWriteandEtwEventWriteFullto bypass and disable their functionality - Execution is obtained through self-injection via indirect syscalls to
ZwCreateSection,ZwMapViewOfSectionas well as direct API calls toCreateThreadandWaitForSingleObject
Please note that this is the first time I have seen many of these techniques in real malware and had to read-up most of the things while discovering them.
This is complicated by the fact that not all of the low-level API functions are well-documented.
So if you find mistakes or inaccuracies, please let me know and I will try to fix them.
Triage
As mentioned on Malwarebazaar, the file is signed:

Opening the file in malcat, we see that it is a PE file with a compilation timestamp from January 2025.

The Anomalies section contains the flag ImportByHash.
Clicking on the tag, we are shown the API function names whose corresponding hashes have been found:

The corresponding functions could hint at some sort of unpacking / injection.
Looking at the disassembly, one can infer that the function responsible for resolving the API hashes is located at 0x140002c60.

Static Analysis
For further investigation, we open the binary in BinaryNinja.
The triage view shows the address of the entry point (1400013e0), which corresponds to the _start function:

This function simply calls sub_140001190().
Around the end of that function, the main function is called:

No interesting functionality can be identified in the main function, besides the call to the start function towards the end:

The beginning of the start function looks as follows:

We start by looking at the code of the first function that is being called, namely sub_140002e70:

We can see that it begins by calling the function sub_140002c60 which we identified in malcat as likely being related to API hashing.
If the return value is false, it returns 0, likely indicating a failure to resolve the hash.
Otherwise, the function returns the result of the call sub_1400014a3(-1, 0x28, &var_18, 0x10).
API Hashing & Indirect Syscall Resolution
Let us further investigate the hashing algorithm.
We start by renaming sub_140002c60 to mw_fn_api_hash_resolution.
We also rename the first argument of that function to mw_target_api_hash.
The code of the function starts by calling sub_140002cd0.

Looking at the body of that function we can see that it retrieves information from the Process Environment Block (PEB):

The following steps are performed:
mov rax, qword [gs:0x60]moves the address of the PEB intoraxmov rax, qword [rax+0x18]moves the address ofPEB::Ldrintorax(type:struct _PEB_LDR_DATA*)mov rax, qword [rax+0x10]:rax+0x10points to the strucutre of typeLIST_ENTRYstored in the memberInLoadOrderModuleListinPEB::Ldr. The address dereferences the firstqword, i.e. theFlinkmember which is associated to the running executable itself.mov rax, qword [rax {_LDR_DATA_TABLE_ENTRY::InLoadOrderLinks.Flink}], dereferencesraxagain, i.e. the forward link in the linked list associated to the binary itself. The result thus points into theLDR_DATA_TABLE_ENTRYassociated toNTDLL.dll.mov rax, qword [rax+0xf8]dereferences the offset0xf8in the_LDR_DATA_TABLE_ENTRY.
Looking at a reference for the offsets, we can see that this corresponds to ULONGLONG OriginalBase;.
In summary, this function retrieves the base address of NTDLL.dll. We rename it to mw_fn_get_ntdll_base.
Notice:
- A good reference for looking up the offsets can be found on www.vergiliusproject.com.
- A great illustration of the PEB walking can be found in the blog post Easy Explanation of Process Environment Block For Malware Analysis (link), especially the figure:

After this, the function mw_fn_api_hash_resolution calls sub_1400031f0. Looking at the code of this function, there are traces that are reminiscent of RC4 encryption.
Here is a marked up version of the function body, showing the Sbox initialisation, the key-scheduling algorithm (KSA) as well as the pseudo-random generation algorithm (PRGA):

The key is sixteen (16) bytes long and given by: 09 5f 9b 55 35 2c f1 52 04 0a 39 57 a4 77 9c 33
The following line is responsible for the decryption:
1400032c8 *(&data_NtAddBootEntry + k) =
1400032c8 mw_Sbox[zx.q(Si + mw_Sbox[i1])] ^ *(&data_NtAddBootEntry_encrypted + k)
If we look at the data stored in data_NtAddBootEntry_encrypted, we see the encrypted buffer:

Copying the corresponding fourteen bytes into Cyberchef, we see that they decrypt to NtAddBootEntry:

For this reason, we rename the function sub_1400031f0 to mw_fn_rc4_decrypt_NtAddBootEntry.
After the RC4 decryption, the function mw_fn_api_hash_resolution calls sub_140002710(mw_ntdll_base, mw_target_fn_name: &data_NtAddBootEntry):

The function loops over the exports of the DLL (passed in as first argument) until it finds an export matching the name passed in as second argument (in this case NtAddBootEntry).
Hence, this function resolves an API function from a DLL by name, by walking the export directory.
Therefore, we rename sub_1400031f0 to mw_fn_import_api_by_name.
An interesting point is that we started off from findings in the triaging step indicating that we would be dealing with API hash resolution.
What was just described is a resolution by name, where the library (NTLDLL.dll) is resolved by walking the PEB and the name of the API is encrypted using RC4.
After that, the function mw_fn_import_api_by_name calls sub_140002ec0(mw_dll_base: mw_ntdll_base, &var_1c, mw_target_api_hash):

It starts by parsing the export directory of the provided DLL (in the first argument) and for each exported function calls mw_fn_compute_hash.
The body of that function reads:

This is a variant of the DJB2 hashing algorithm with seed 0x1505.
Notice, that all characters in the function name are converted to uppercase before hashing.
The following shows an implementation of the algorithm in Python.
Computing the API hash ZwSetInformationProcess, we can recover the hash value found in the malcat screenshot from the triage section above:
def djb2(name: str):
hasher = 0x1505
for c in name.upper():
hasher = (hasher * 0x21 + ord(c)) & 0xFFFFFFFF
return hasher
if __name__ == '__main__':
print(hex(djb2('ZwSetInformationProcess')))
Execution of the script indeed recovers the expected API hash:
$ python3 api_hash.py
0x31782747
Notice that the function is not done after resolving the hash.
First, it checks if the first byte at the resolved function address is different from 0x4c:

We will analyse later what happens in this case. If the first byte is equal to 0x4c the code jumps to the end:

There, it checks if the first bytes at the returned function address are:
4c 8b d1 b8 ?? ?? 00 00
Using Cyberchef, we can disassemble the byte pattern:

It corresponds to:
MOV R10,RCX
MOV EAX,0000????
It matches that of a syscall stub.
If the pattern matches, the code proceeds to read a DWORD (4 bytes) starting from offset 4.
This corresponds to the syscall number which is returned to the caller in the second parameter (which we rename to mw_out_syscall_id).
The described method of retrieving syscall numbers is known as Hell's Gate.
However, this method only works on a clean version of NTDLL.dll that has not been hooked by EDR.
If it has been hooked by EDR, this approach does not work and the byte pattern at the beginning of the resolved function address will not match the syscall stub.
To work around this, a technique named Halo's Gate (link) has been presented.
It builds on three (3) foundations:
- All function blocks in
NTDLL.dllare0x20bytes apart (see the image from the debugger in the mentioned blog post) - Not all functions in
NTDLL.dllare hooked - Syscall numbers of consecutive functions differ by one
If the returned function address does not match the syscall stub byte pattern, one can move up or down in jumps of 0x20 bytes until one finds a function that is not hooked, i.e. that matches the pattern.
One can then extract the syscall number and subtract / add (depending on the direction) the number of jumps made to obtain the syscall number of the function in question.
This is exactly what happens in the second part of the function that was mention above, i.e. the branch dealing with the case where the first byte of the resolved function is not 0x4c:

If the first byte is not 0x4c, it checks for a jmp (0xe9) and then moves on to check following (+0x20) or previous (-0x20) syscalls.
Based on this analysis, we rename the function to sub_140002ec0 to mw_fn_resolve_api_hash_and_extract_syscall_number_based_on_api_hash.
Similarly, we rename the function currently called mw_fn_api_hash_resolution to mw_fn_get_syscall_from_api_hash.
The current marked-up version looks as follows:

The final call in this function is to mw_fn_store_NtAddBootEntry_syscall_number_and_syscall_address:

It simply stores the retrieved syscall number and the address of the NTAddBootEntry syscall for later usage.
Start Function
Back in the start function, the first function (sub_0x140002e70) to be called seems to try to disable instrumentation callback functions.
This might be an attempt at blinding EDR solutions (which have put these callbacks in place):

It calls the function mw_fn_get_syscall_from_api_hash (that is analysed above) to retrieve the syscall associated to ZwSetInformationProcess.
Then, the syscall is performed by providing a null instrumentation callback function in the argument var_18.
After that, it uses several RC4 decryption routines (with different 16-bytes keys) to decrypt strings.
It decrypts the amsi.dll and ntdll.dll strings and loads the corresponding libraries by name through calls to LoadLibraryA.
The API name strings AmsiScanBuffer and EtwEventWrite, EventWriteFull are RC4 decrypted and the corresponding APIs are loaded via calls to GetProcAddress.
The functionality of these functions is disabled through inline user-mode patching. This is shown in the following screenshot for EtwEventWrite and EventWriteFull:

The first bytes at the resolved function address are set to 0xc3c03348.
Disassembling these bytes in Cyberchef, we can see that this removes any functionality from these functions and makes them return 0:

Then, the function sub_140002810 is called:

Looking at the beginning of the body of this function, traces of the ChaCha20 / Salsa20 encryption algorithms can be found in the form of the string expand 32-byte k:

Inside of this function, it calls sub_140002900. In the body of that function, one can find constants associated to the ChaCha20 cipher (link):

Based on intuition that we will shortly validate, we markup the function call of sub_140002810 shown in the screenshot above to:
mw_fn_chacha20(buffer: &data_payload_encrypted, length: 0x20850, key: &data_chacha20_key, nonce: &data_chacha20_nonce)
assuming that the parameters are the encrypted payload, the payload size, the chacha20 key and the nonce, respectively.
The following screenshot shows the final bytes of the payload as well as the definition of the key and nonce:

Replicating this information in Cyberchef, we obtain an output that starts with e8 and could therefore be valid code:

We extract the payload into payload.bin and note the hash for completeness:
sha256sum payload.bin
75da9dfe4d0ef6db1d68533ef66a70d4c26230fc82e50e6428f4931800f7b3b2 payload.bin
Opening the decrypted payload in Ghidra, we see that it starts with a function call:

The decompiled body of the function looks like valid code:

After the payload is decrypted, the malware proceeds to determine the syscall of ZwCreateSection and calls it indirectly to create a section object.
The maximal size argument provided has a value of 0x21000 and is therefore slightly bigger than the decrypted payload.

After that, ZwMapViewOfSection is called with the final argument having value 0x4 which corresponds to PAGE_READWRITE and the decrypted payload is copied to the base address of the new section through a call to mw_fn_cp_buf2_to_buf1.
After the payload is copied into the section base, the read-write section is unmapped from memory through an indirect syscall to ZwUnmapViewOfSection.
Now that the section contains the decrypted payload, another indirect syscall to ZwMapViewOfSection is performed, this time with the last argument Win32Protect having a value of 0x20.
This corresponds to PAGE_EXECUTE_READ:

Hence, the section is now executable.
Once this is done, the handle to the section is closed using an indirect syscall to ZwClose, preventing any future modifications to the section.
The final step is to pass the base address of the mapped executable section to the function mw_fn_create_thread:

This simply creates a thread through an CreateThread API call which obtains the base address of the mapped executable as lpStartAddress parameter, waits for the thread using WaitForSingleObject and finally closes it using CloseHandle.
Payload Analysis
The payload analysis might be handled in another writeup.