1. Overview
SnappyClient has only ever been seen delivered by HijackLoader, but what sits between the two varies. In some samples HijackLoader loads the payload directly. In others its config carries a 32-bit PE that unpacks SnappyClient and runs it first.
Zscaler have written about overlaps between HijackLoader and SnappyClient, and they raised a possible connection between the developers. That is what I went looking for.
The SnappyClient sample here is version 0.1.22, compiled on 2026-04-02.
This detail is important, as I’ve observed throughout my tracking, that newer versions, from 0.1.29 do not have the loader,
and HijackLoader started loading the payload directly.
This post focuses on the intermediary loader. This middle stage is a 32-bit PE, and nearly all of its 1.4 MB is a single compressed section holding the payload. It decompresses two sections, maps one of them over its own image base, resolves imports, patches up the PEB, and transfers execution to the mapped image’s entry point.
Self-hollowing is not particularly interesting and the loader is not groundbreaking.
What makes this intermediary stage worth writing up is where the code came
from. Loading it in IDA next to HijackLoader’s ti64 (or ti) orchestrator
module, the resemblance is immediate and then keeps getting stronger the further
in you go. The same context structure, the same
CRC-32 implementation, the same configuration format, the same
API resolution order, and the same distinctive dead code.
This is a loader compiled from HijackLoader’s source.
I have covered HijackLoader itself in two earlier posts, part one and part two, which cover a different campaign.
A note on naming, because it could cause confusion throughout. SnappyClient is the final payload at the end of the infection chain. The file I spend this post reversing is the loader that unpacks it. They are separate PE files and I keep the two words distinct everywhere below. Group-IB track the payload as SilabRAT.
This post covers the loader end to end. The packed sections, the self-hollowing routine, the runtime context and hashed API resolution, the syscall table, the stack spoofing, the configuration blob it carries, and the evidence tying all of it back to HijackLoader. SnappyClient itself, with all its functionality, will get its own writeup.
2. Delivery
In this analysis, my sample was obtained from MalwareBazaar. It’s an MSI image. The MSI image carries a HijackLoader configuration blob, along with the rest of HijackLoader’s components. Extracting the modules and payload can be done by running the config file through my HijackLoader config extractor that writes the modules and the appended payload to disk.

The decrypted payload is a 32-bit PE, which is the focus of this article.
3. The loader
The loader is a 32-bit executable. It has 5 sections in total. 3 of these sections are regular, and 2 are not.
| Section | Contents |
|---|---|
.xyz |
LZMA compressed - SnappyClient |
.text |
The unpacker |
.data |
LZMA compressed - Module table |
.reloc |
A single relocation entry |
kaj |
Unknown - Not used by the code |
I have inspected in total 3 different MSI delivered samples, and they all shared this structure.
The only thing different in each case was the name of the last custom section. The .xyz section name was uniform across all 3.
To unpack the loader, I created a tool which decompresses the .xyz section to recover SnappyClient,
and parses the module table out of the decompressed .data configuration.
The tool can be found here.
Looking at the size distribution between the sections, it becomes apparent that it’s not the final stage.
At the entrypoint, execution begins directly with the malware code. There is no CRT initialization.
msvcrt.dll is later loaded manually and functions are resolved for later use.
The first things the malware does are three reads from the thread’s TLS slot 0, each compared against
the constant 0x1D, returning immediately if any of them match.
The loader has no TLS directory of its own, so slot 0 belongs to the first
loaded module that does, which in this case is KernelBase.dll.
Its TLS block is eight bytes, so only the first of the three reads lands inside it.
The other two read into unowned heap. Nothing in the loader ever writes this constant anywhere.
if ( *(*NtCurrentTeb()->ThreadLocalStoragePointer + 4) == 0x1D )
return 0;
if ( *(*NtCurrentTeb()->ThreadLocalStoragePointer + 12) == 0x1D )
return 0;
v0 = NtCurrentTeb()->ThreadLocalStoragePointer;
if ( *(*v0 + 8) == 0x1D )
return 0;
These checks pass, and execution continues on. Next, the loader gets its own image base and parses its own NT header (though it is at this point disregarded).
Next, it allocates 600 bytes of memory for the runtime context structure, which is then initialized.
This is where things get interesting. Taking a look at this function, it bears a striking resemblance to
the context initialization function found within HijackLoader’s ti64 module.
Upon closer inspection, they are not only similar, but an almost exact match.
During my analysis of HijackLoader, I have created a struct for the runtime context, and porting it to 32-bit
resulted in a near-perfect match, with the order of resolved functions matching as well.


The differences lie in the fact that the loader resolves fewer functions. This in some cases is caused by the x86-x64 difference. In my HijackLoader analysis I have only reversed the ti64 module, as my sample was built for x64 machines, and because of this, I can’t confirm if the structs would match exactly with that of the ti module’s, though I’d assume so.
Nevertheless, the struct remains the same, with blank fields in place of the missing resolved function addresses.
Moving forward in the initialization function, more similarities are visible.


Moving forward, both the loader and HijackLoader build their syscall table the same way: read ntdll.dll from disk, walk the export directory, keep every export whose name begins with Zw, and pull the service number out of the function body. The only difference is how they locate it.
On x64 a Zw stub opens like so:
4C 8B D1 mov r10, rcx
B8 XX XX XX XX mov eax, <service number>
The mov eax sits three bytes in, behind the mov r10, rcx that every x64 stub carries, so in this case the ti64 module scans for the opcode instead of assuming its position.
while ( function_code < a1 + 10 )
{
v2 = *function_code++;
if ( v2 == 0xB8 )
return *function_code;
}
On x86 there is no such prologue. The stub begins with mov eax directly:
B8 XX XX XX XX mov eax, <service number>
BA XX XX XX XX mov edx, Wow64Transition
And the loader checks only a single byte:
if ( *export_stub == 0xB8 )
syscall_num_tmp = *(export_stub + 1);
Next, in both cases, a fresh copy of ntdll.dll is mapped from disk and a second batch of functions is resolved from it. Resolving them from a clean mapping means the pointers land in unmodified code rather than in whatever a security product has patched into the loaded image. If the mapping fails, both fall back to the loaded copy.
With the context built, the loader locates the sections it needs
xyz_section = mw_get_first_section(v3);
if ( !xyz_section )
return 0;
data_section_header = mw_get_section_by_name('tad.');
if ( !data_section_header )
return 0;
text_section_header = mw_get_section_by_name('xet.');
Both compressed sections share the same nine-byte header:
+0x00 DWORD decompressed size
+0x04 BYTE[5] LZMA properties
+0x09 ... LZMA stream
Both are decompressed by the same function:
int __cdecl mw_decompress_section(mw_ctx *mw_ctx, int section_addr, _DWORD *decompressed_size)
{
v7 = mw_get_imagebase(v3) + *(section_addr + 0xC); // section VirtualAddress
v8 = mw_get_decomrpessed_size(&v7); // reads the DWORD, advances v7 by 4
v6 = (mw_ctx->fn_malloc)(v8);
*decompressed_size = v8;
v5 = v8 - 5;
mw_LZMA_decompress(v6, &v8, v7 + 5, &v5, v7, 5u); // props at +4, stream at +9
return v6;
}
Once both sections have been decompressed, some information about them is stored in a new struct.
mw_launch_ctx = (mw_ctx->fn_malloc)(112);
mw_zeromem(mw_launch_ctx, 112);
mw_launch_ctx->ctx = mw_ctx;
mw_launch_ctx->xyz_buffer = v10;
mw_launch_ctx->xyz_size = a3;
mw_launch_ctx->image_base = mw_get_imagebase(v4);
mw_launch_ctx->data_buffer = v9;
Then, two writes happen directly into the decompressed data section buffer, however, none of these values are used by the loader later.
*(mw_launch_ctx->data_buffer + 406) = text_section_header->VirtualAddress + v7;
*(mw_launch_ctx->data_buffer + 814) = text_section_header->Misc.PhysicalAddress - 4096;
Finally, it clears the memory it will use for the next steps.
if ( (mw_ctx->fn_VirtualProtect)(mw_launch_ctx->image_base, *(xyz_section + 8), PAGE_READWRITE, &old_protect) )
{
mw_zeromem(mw_launch_ctx->image_base, *(xyz_section + 8));
mw_self_hollow(mw_launch_ctx);
}
*(xyz_section + 8) is the first section header’s virtual size, which in this case is big enough to span the whole image.
The loader marks its own image writable, zeroes it and then calls into the function that is responsible for mapping the final payload in its place. From this point on the process is executing out of memory it has just erased.
The next in line is self-hollowing. This is done in 4 steps.
The first of these steps is mapping the target image. This is done by parsing its PE header, and then copying the headers into their place. Next, each section is copied from its file offset to its virtual address.
nt_headers = (mw_launch_ctx->xyz_buffer + *(mw_launch_ctx->xyz_buffer + 0xF));
size_of_headers = mw_pe_query(mw_launch_ctx, 0, mw_launch_ctx->xyz_buffer);
for ( i = 0; i < size_of_headers; ++i )
*(mw_launch_ctx->image_base + i) = *(mw_launch_ctx->xyz_buffer + i);
num_sections = mw_pe_query(mw_launch_ctx, 4, mw_launch_ctx->xyz_buffer);
for ( j = 0; j < num_sections; ++j )
{
for ( k = 0; k < pe_section->SizeOfRawData; ++k )
*(mw_launch_ctx->image_base + pe_section->VirtualAddress + k) =
*(mw_launch_ctx->xyz_buffer + pe_section->PointerToRawData + k);
++pe_section;
}
mw_pe_query is a utility function, used to index into the NT header of the file. It picks between 2 implementations:
if ( a1->field_34 )
result = mw_pe_query_pe64(a1, a2, nt_headers);
else
result = mw_pe_query_pe32(a1, a2, nt_headers);
Interestingly, field_34 is never set, meaning that there is a complete 64-bit implementation sitting behind a flag nothing writes.
0 returns SizeOfHeaders, 4 returns NumberOfSections, and others return the relocation, import and TLS data directories.
The mapper then applies base relocations before returning.
The second step wipes a buffer that doesn’t exist. mw_zeromem takes a pointer and a length, both read from the launch context, and neither field is ever set.
mw_zeromem(mw_launch_ctx->field_1C, mw_launch_ctx->field_20);
The next step is import resolution.
The loader begins by counting the descriptors, but the variable is then never used.
v4 = 0;
while ( import_descriptor->Name )
{
++v4;
++import_descriptor;
}
Then, each descriptor’s DLL is loaded by name, and every thunk resolved:
v7 = (a1->ctx->fn_LoadLibraryA)(a1, a1->image_base + import_descriptor->Name);
if ( !v7 )
return 0;
v11 = a1->image_base + import_descriptor->FirstThunk;
for ( i = (a1->image_base + import_descriptor->Characteristics); *i; ++i )
{
if ( *i >= 0 )
{
v6 = (a1->image_base + *i);
sub_7B8190(v2, v6->Name); // build an ANSI_STRING
status = (a1->ctx->fn_LdrGetProcedureAddress)(v7, v2, 0, v11);
}
else
{
status = (a1->ctx->fn_LdrGetProcedureAddress)(a1, v7, 0, *i, v11);
}
v11 += 4;
}
A positive thunk is an RVA to an IMAGE_IMPORT_BY_NAME, so the name gets wrapped in an ANSI_STRING and looked up.
A negative one has the high bit set, marking an ordinal, which is passed through directly.
Step four, the final step, is getting the binary ready and handing off execution to it.
First, section protections are derived from each section’s characteristics and applied through the syscall table, with a VirtualProtect fallback:
if ( !mw_syscall_ZwProtectVirtualMemory(
mw_launch_ctx->ctx, section_base, section_base >> 31,
section->SizeOfRawData, section_protect, &old_protect) )
(mw_launch_ctx->ctx->fn_VirtualProtect)(section_base, section->SizeOfRawData, section_protect, &old_protect);
The instruction cache is then flushed twice, once for the whole process and once for the mapped range:
(mw_launch_ctx->ctx->fn_FlushInstructionCache)(-1, 0, 0);
(mw_launch_ctx->ctx->fn_FlushInstructionCache)(mw_launch_ctx, -1, mw_launch_ctx->image_base, nt_headers->OptionalHeader.SizeOfImage);
TLS comes next. The loader allocates a block for the payload, copies its template in, and installs it at the payload’s index in the thread’s TLS vector. Any callbacks in the directory are then invoked with (base, DLL_PROCESS_ATTACH, 0), but the payload has no TLS directory, so no callbacks ever run.
Then the execution handover, which has three paths but the same one is taken every time.
field_54, flag_5C and field_48 are all zero and never written, meaning control flows into the final branch every time.
The two calls at the top of that branch, mw_check_mutex and sub_7B6F60, both read the configuration blob found in the .data section. This configuration will be covered in a later section.
Before jumping, the loader writes the mapped base into the PEB and into the payload’s own OptionalHeader.ImageBase:
mw_get_PEB()->Mutant = a1->image_base;
result = a1->image_base;
*(result + result[15] + 52) = result;
Finally, the entry point of the payload is called, with the only argument being the PEB. Not how an executable’s entrypoint is normally invoked, but it’s the only thing the loader hands over.
4. Stack spoofing
Stack spoofing as a technique is found both in HijackLoader and this loader. They do however differ in how this is done.
HijackLoader writes TinyCallProxy64 into a random address inside the proxy DLL’s .text, calls through it with a fabricated return address, then restores the original bytes.
The loader walks the live stack, tests each saved return address against the recorded .text ranges, and swaps the ones falling outside for random proxy-DLL addresses, restoring them once the call returns.
In the loader, the decision to spoof a call is made like so:
if ( mw_ctx->is_wow64 )
sub_7B96C0(mw_ctx->stack_spoof_ctx, syscall_table_entry->syscall_number, 5); //number of args
else
mw_syscall_invoke_direct(mw_ctx, syscall_table_entry, 5, a4);
Spoofing is only done under WoW64, otherwise the function is called from the clean ntdll copy.
In HijackLoader, spoofing is done if the spoof context exists.
Both start with rpcrt4.dll staged as a placeholder proxy, but in HijackLoader, it’s replaced by whatever is in the SM module. There is no equivalent to this module in the loader, meaning it sticks with the placeholder.
Both call the setup function with a null trampoline pointer and a size of zero. HijackLoader fills them in later, once TinyCallProxy64 has been located in the config. The loader has no such module and no equivalent second call, so the fields stay zero.
5. The .data section
The .data section, as described above, initially is LZMA compressed. Upon decompression, it turned out to be a configuration, in the exact format as can be found in HijackLoader’s configuration file. Not only that, but parsing it revealed that the modules present are also from HijackLoader. They match in size exactly with modules from my previous analysis.
The code to parse this module table could be copied 1:1 from my HijackLoader config extractor (found above). Adding it to my tool to unpack the .xyz section, here are the results:
These modules are never used by the sample. It does check for the MUTEX module along with another one but neither is present in this configuration.
These modules are not passed to the new binary, and they aren’t used by the loader either. The configuration has no appended PE file, unlike HijackLoader’s.
6. Conclusion
Having worked through all of the matching code, it is clear that the loader has been compiled from the same source as HijackLoader.
The evidence is ample, and enough to conclude that the similarities could not have been the result of binary level code lifting, but source code access.
The fact that there are dead code paths, most related to distinctly different options, gave me the idea that this may be a crypter-as-a-service. This would explain the different versions of invoking the entrypoint of the payload after the hollowing, and it would also explain the complete 64-bit implementations sitting behind flags that are never set. The fact that there are modules never used within the .data section config signals the same thing, as if they had been included by default by a builder.
Group-IB attribute SnappyClient and AsmCrypt to the same threat actor, so I took a look at AsmCrypt. Its builder menu options line up by name with HijackLoader’s modules, with Anti-VM Options, UAC Options and WinDef Exclusion matching ANTIVM, modUAC and modWD.

For attribution, I cannot say anything conclusively. If this loader is AsmCrypt, and the seller advertising it is its developer, then that developer had access to HijackLoader’s source. Whether that means they worked on HijackLoader themselves or got hold of the source some other way, I have no way of telling. This is of course speculation on my part.
7. IOCs
Files
| Type | SHA256 |
|---|---|
| MSI installer | 562D8F8381AD20A7140A5B7A060FF4EE60484EB815BF442F7071E293678CA95D |
Loader (.xyz variant, 1,454,080 bytes) |
02C3D5189655456C8C42E264E1BF08EF60F30472E78AEB93F14AAAE29F1EC5EA |
| SnappyClient payload | 8A31A652912CBC7106BD0ABDADB8220B2E5988D328027F52A37A8B17A1AAD55B |
Sample details
| Field | Value |
|---|---|
| SnappyClient Version | 0.1.22 |
| Compiled | 2026-04-02 |
| Source | MalwareBazaar |
8. References
| Source | Link |
|---|---|
| Zscaler | Technical analysis of SnappyClient |
| Group-IB | SilabRAT and HijackLoader |
| neso.re | My HijackLoader analysis part 1 |
| neso.re | My HijackLoader analysis part 2 |
| GitHub | HijackLoader config extractor |
| GitHub | SnappyClient unpacker |










