Contents

Remus hides its collection workflow behind several distinct transformations: seeded CRC API lookup, mixed Boolean-arithmetic string initializers, ChaCha20-protected endpoints and server replies, and a four-byte XOR layer inside selected JSON fields. Its returned data takes a different route: named binary records, raw LZ4 compression, then a ChaCha20 envelope whose key and nonce follow the ciphertext. Reconstructing those boundaries exposes a precise command grammar—and prevents a successful HTTP exchange from being mistaken for a successful theft.

The analyzed payload supports file and registry collection, Chromium and Gecko profile collection, registration-controlled screenshots, automatic clipboard collection, and terminal secondary actions. Six original-loader executions established the results examined here: a complete file, a complete registry value, raw synthetic browser files, a complete desktop bitmap and clipboard marker, and a fixed benign executable written to disk and launched as a child. The browser results are not evidence of plaintext credential recovery; the secondary action has no execution-specific protocol acknowledgment.

Gen Digital's “Remus: Unmasking The 64-bit Variant of the Infamous Lumma Stealer”, published on 7 April 2026 by Vojtěch Krejsa and Jan Rubín, and Zscaler ThreatLabz's “2CLoader: A New Malware Loader Delivering Vidar and Remus”, published on 30 September 2026, provide prior research context. The task grammar, branch interpretation, and captured results below come from this specimen's recovered code and its own executions.

The analyzed sample

Artifact SHA-256 Role
Original 2CLoader executable 5edcaa75a28e5cd700bf7643b275fe5d28649aa41a0711f391ec1fca795a4e8a Launch specimen for the six native command cases.
Recovered Remus x64 PE 4713985869f284136d2d9f9c98a1cd43c2f327a7619d829e31fc69c3589f56cb Authenticated resource decryption/decompression output used for payload reverse engineering.

The family identification rests on the recovered payload, not on 2CLoader's ability to deliver multiple stealers. Its REMUS hard-error title, ordered five-vendor CPUID test, random 16-character browser desktop and native syscall dispatcher match the Remus implementation described by Gen Digital. The ChaCha20 endpoint configuration and EtherHiding bootstrap provide additional code-level context. Those matches support the attribution; they do not make another build's task identifiers interchangeable with the grammar recovered here.

The payload carries the date 25.08.2026, both as an embedded string and as build.date in its native Info.yml inventory. The adjacent build-tag value is literally {tag}. There is no established Remus semantic version: the payload has no resource or debug directory, and its image-version fields are 0.0. Its COFF timestamp represents 24 August 2026 at 22:19:07 UTC, but a header value is not independently verified compilation time. The wrapper's Easy Anti-Cheat/Epic Games version strings do not identify the Remus build.

The command exchanges used synthetic fixtures on Windows 11 x64 build 26200. The first endpoint was redirected to a local C2 simulator; external destinations, fallback infrastructure, and the loader's separate telemetry channel remained blocked. The final command cases ran the original loader without a debugger or runtime correction. Separate debugger observations explain implementation boundaries rather than replacing those outcomes.

Remus execution, task dispatch and result publication flow
Figure 1 — The recovered flow separates task-selected collectors from post-loop screen/clipboard collection and the terminal disk/process action. The dashed startup edge represents the code-selected local-first mapper, not a runtime branch trace. Collection uploads do not provide an execution-specific acknowledgment for the terminal action.

Persistence belongs to 2CLoader, not to a Remus task

The wrapper mutates startup state before decrypting its embedded payload. Its configuration sets persistence bits 0x3F, option bits 0x480, and a separate three-hour task interval. Those values explain why a run devoted to a single collection task can still produce a broad persistence footprint. They are loader configuration, not command identifiers delivered by the Remus server.

The four registry writes point to the original launch path: SecurityHealthService under both HKCU\Software\Microsoft\Windows\CurrentVersion\Run and RunOnce; Load under HKCU\Software\Microsoft\Windows NT\CurrentVersion\Windows; and UserInitMprLogonScript under HKCU\Environment. The loader also copies its original executable, with the same basename, into the current user's Startup directory.

Two root-level scheduled tasks complete the observed pattern. \SecurityHealthService has a user-logon trigger and an interactive-token executable action. \WindowsSecurityHealth has a time trigger with PT3H repetition. Both actions reference the original executable. Despite the service-like names, this reviewed chain does not install a Windows service. Creation and readback of all seven objects were observed; a subsequent logon-triggered or scheduled re-execution was not.

This ordering has consequences for incident response. A payload that fails its later startup checks can still leave the wrapper's persistence behind. The writes can replace same-named registry values, tasks, or a Startup file, and the loader does not automatically undo them when downstream work fails. Correlating the same executable path across four values, two tasks, and a Startup copy is more informative than detecting the name SecurityHealthService in isolation.

The configured launch dispatcher selects local LoadPE first because fl=0 and option 0x20 is clear. The reviewed mapper loads the recovered image into the current process and starts its entry on a local thread. A svchost.exe literal in that routine is not evidence of a new svchost process. Only if the local route returns false does the dispatcher attempt RunPE; that fallback can stage a delete-pending aw*.TMP file and create a dllhost.exe child, with the Explorer-parent wrapper selected by option 0x80.

The observed original-loader runs are consistent with the local route, but before/after process inventories cannot prove which mapper branch ran or exclude a transient child. Option 0x400 also enables environment-spoof hooks and WinHTTP/WinINet read-buffer hooks. Their presence is established in the configuration; their full runtime semantics are not. The loader's /api/beacon traffic to aware-cr1[.]com is a separate telemetry mechanism, not the Remus task reader.

Startup gates: modules, a PST marker, and a conspicuous warning

The recovered entry resolves core modules, constructs a native syscall table, evaluates an early module/PST predicate, checks the warning routine, initializes the URL cipher state, and enters protected main dispatch. Missing resolver prerequisites or an empty syscall table can exit before communication. Another call attempts to set the process instrumentation-callback information class, but its return status is not the startup gate.

The early abort predicate copies an 18-element hash table and walks loaded module names. This is not a conventional antivirus inventory: it does not query Windows Security Center, WMI, installed applications, services, or product files. Instead, it hashes names already present in the current process's PEB loader list and selects the early exit when a denylisted value matches. The resolved set spans antivirus hooks, containment and sandbox modules, a virtualization indicator, and analysis instrumentation.

Direct recomputation identified eleven entries. The contextual associations below follow Mandiant's capa anti-VM reference, al-khaser's loaded-DLL catalog, and Sandboxie's documentation of its injected user-mode DLL. The mappings are presented alphabetically rather than in embedded-table order.

Loaded-module filename · stored hash Why the entry is screened
api_log.dll · 48101B84 API-logging instrumentation associated with iDefense Labs; its presence suggests monitored sandbox execution.
avghooka.dll · 19E323D6 AVG user-mode hook artifact. This identifies an AVG module loaded into the process, not merely an installed AVG product.
avghookx.dll · 7413D625 A second AVG hook artifact covered by the same loaded-module test.
cmdvrt32.dll · EA330F69 32-bit Comodo containment/virtualization component.
cmdvrt64.dll · 35BE6B7A 64-bit Comodo containment/virtualization component.
dir_watch.dll · 3C479D15 Directory-monitoring instrumentation associated with iDefense Labs and legacy sandbox analysis.
pstorec.dll · 77E72FE2 Sunbelt Sandbox artifact used as an analysis-environment indicator.
sbiedll.dll · 4B777536 Sandboxie's injected user-mode DLL, loaded into processes running inside the sandbox.
snxhk.dll · 43D6CA05 Avast hook/sandbox artifact.
vmcheck.dll · 200178C7 Module commonly treated as a Microsoft Virtual PC indicator in anti-VM checks.
wpespy.dll · 10393D8E WPE Pro traffic-inspection hook, indicating packet-analysis instrumentation.

The remaining seven hashes are unnamed. These comparisons establish loaded-module screening, not a complete inventory of installed security products. A product that does not map one of these DLLs into the Remus process will not match this predicate.

If no module matches, the predicate expands %UserProfile%\Documents\Outlook Files and searches *.pst. Its callback decodes and compares the filename honey@pot.com.pst; the enumerator accumulates callback matches with Boolean OR. A single matching filename is sufficient to select the abort path. This is a marker check, not evidence that the routine collects Outlook mail. Its caller exits before both the warning routine and main dispatch when the predicate is true.

The path-expansion result feeds a separate filename search. In the excerpt, mw_init_obfuscated_pst_glob prepares the search pattern and mw_match_honey_pst_marker supplies the callback; neither is a mailbox-content reader.

Protected PST glob initialization and marker callback
Figure 2 — The filesystem branch decodes its PST glob and supplies the marker-matching callback to directory enumeration.

The warning itself makes the launch context unusually important. It reads the image path from the PEB process parameters, opens that file, and compares the DWORD at raw offset 0x313E4 with 0x8806F4B1. The intact recovered PE contains the expected value. A match clears the routine's success flag and routes to a Windows hard-error dialog titled REMUS, with the text CANCEL THE RUN TO PREVENT MALWARE FROM EXECUTING. Only response value 6 restores success and permits continuation.

The API behind that dialog is NtRaiseHardError, resolved through hash 0xF765DB0B, with status 0x50000018, three parameters, Unicode mask 3, and response option 0. An additional integrity comparison over eight embedded bytes takes its matching branch in the recovered image. The visible warning was observed during direct-payload research, but not during the unchanged original-loader command runs.

The service-table comparison selects NtRaiseHardError through hash 0xF765DB0B. The final harderror_response == REMUS_RESPONSE_YES comparison tests response six and makes the returned dialog response part of the continuation decision. The preceding self-file and integrity checks are not included in this excerpt.

Service-table lookup and native hard-error invocation
Figure 3 — The warning path looks up the hard-error service hash, invokes the native dispatcher, and tests response value 6.

The difference has a code-supported explanation: the original loader's bytes at raw offset 0x313E4 are zero, and the reviewed local mapper does not overwrite ImagePathName. If the payload retains the outer image path, the self-file comparison can skip the warning. Dialog-free native startup supports that interpretation without proving the precise runtime PEB or branch value. Calling the entire routine a generic anti-debug check would obscure this dependency.

CPUID is a separate, server-gated check. A literal JSON vm:true in registration enables it. The routine first tests ECX bit 31 from leaf 1; if clear, it queries leaf 0x40000000 and compares only ECX with five decoded fragments, in order: VMKV, CGTC, reVM, VBox, and MMXe. They correspond to KVM, QEMU/TCG, VMware, VirtualBox, and Xen vendor strings. A positive result returns before tasking. The routine's existence does not mean every execution in a VM is necessarily blocked: the registration flag and the local predicate both matter.

The two tests are distinct in the code: _RCX & 0x80000000 can end the predicate immediately, whereas the later path compares ECX with decoded constants. The excerpt includes only the first of the five vendor comparisons, not the registration flag that enables this routine.

CPUID hypervisor-present test and first vendor-fragment comparison
Figure 4 — The hypervisor-present bit returns immediately; otherwise a second CPUID query precedes a vendor-fragment comparison.

Two CRC families and dynamic syscall discovery

Remus's API resolution starts with the PEB rather than a comprehensive import table. The module resolver walks loaded modules through gs:[0x60], lowercases ASCII letters in UTF-16 module names, and processes code units using a reflected CRC32 polynomial, 0xEDB88320. Its initial register is 0x96577E78, followed by final bitwise inversion. Under this implementation, ntdll.dll produces 0x42DA7519 and kernel32.dll produces 0x82998EE8.

The obfuscated offset passed to __readgsqword simplifies to 0x60, yielding the PEB. The subsequent accesses reach peb->Ldr and the loader's module list; the candidate UTF-16 name comes from loader_entry->BaseDllName.Buffer. The hash loop operates on that name, not on an import-directory string.

PEB loader-list traversal in the module resolver
Figure 5 — Module lookup begins with a GS-based PEB read and follows the loader list to a candidate name buffer.

The range test on name_character limits the | 0x20 transformation to ASCII uppercase letters. Adding a character and subtracting twice the bitwise intersection reduces to XOR; the inner loop then advances the CRC eight times. The seed appears as signed decimal −1772650888, equivalent to 0x96577E78.

Lowercasing and seeded reflected CRC loop for module names
Figure 6 — ASCII uppercase characters are lowered before the eight-step CRC loop; the complemented result is compared with the requested hash.

The export resolver uses the same seed and polynomial on exact ASCII export names. This distinction matters when reproducing a hash: applying ordinary CRC32 to UTF-16 byte strings is not equivalent to the module routine's code-unit loop. A protected indirect jump also makes the export resolver appear as a short tail in Hex-Rays; the export-name iteration and CRC instructions remain in the disassembly.

The module/PST gate uses another CRC helper with seed 0xE585BD66, and does not perform the resolver's lowercasing. Reusing one hash algorithm indiscriminately would misidentify its table entries or miss case-sensitive differences. The separate seeds are a useful way to distinguish name resolution from environment screening during analysis.

For native calls, the payload validates the loaded ntdll image's MZ and PE headers and iterates export names beginning with Nt. It scans the first 0x20 bytes of each candidate for 4C 8B D1 B8: mov r10, rcx; mov eax, imm32. The immediate supplies the service number. A ret encountered before the pattern abandons that candidate. Export-hash/service-number pairs form a table that the central dispatcher searches before executing syscall with a service number, argument count, and arguments.

The compact dispatcher is the final call boundary, not the export-stub scanner. The service number and argument count arrive from its caller; calls with more than four arguments copy the remaining argument slots before syscall.

Variadic native dispatcher ending in syscall
Figure 7 — The central dispatcher handles additional stack arguments and executes the native syscall instruction.

This is dynamic discovery from the loaded image, not a fixed list of Windows-build SSNs or proof that every hooked stub can be bypassed. At least one recognized entry is required to continue startup. The table also resolves NtSetInformationProcess as 0x020A5284 and attempts information class 0x28 with a zeroed callback structure; the source does not make a successful status a prerequisite. NtClose, repeatedly used by native wrappers, hashes to 0x932DB88F.

Consequently, the payload's 40-entry import table substantially understates its API surface. Defenders should not treat missing registry, file, or process APIs in imports as evidence that those behaviors are absent. Conversely, resolving a hash and seeing a call site establishes implementation intent, not the success of every call on the execution system.

Protected strings and endpoint selection

The string initializers combine static arrays, overlapping stack writes, and per-string decoding loops. Several apparently elaborate expressions reduce to elementary operations: x + y − 2 × (x & y) is XOR, for example. Simplifying the mixed Boolean-arithmetic expression is only half the work; the loop's index convention and data width must also match. Some strings use (i + 1) rather than i, and multipliers differ between initializers. There is no single universal XOR key for all protected text.

This pattern recovers protocol headers, output filenames, action prefixes, and environment strings. Screenshot.bmp uses an indexed byte XOR multiplier 0x1A; Clipboard.txt uses 0x5F. The request header decoding yields Host: github.com. Other protected constants expose rundll32, PowerShell prefixes, and the RunAsInvoker retry environment. Correcting overlapping writes before applying the final loop avoids plausible-looking but wrong string decodes.

Endpoint protection is different. The image contains a 32-byte ChaCha20 key, an eight-byte nonce, and three 64-byte encrypted URL slots. The cipher uses the original eight-byte-nonce construction with a 64-bit counter beginning at zero, not the IETF twelve-byte-nonce layout. Offline decryption and a debugger observation at the first HTTP-helper boundary independently identify these candidates:

hxxp://fresok[.]top:9048/messages
hxxp://shhsift[.]click:7647/messages
hxxp://vexdico[.]shop:8539/invoices

The selector byte stores index XOR 0x17; its initial value 0x17 therefore selects index zero. Simplifying the rotation expression across all 256 byte values establishes an increment of the decoded index, followed by re-encoding. Fresh indices zero through two select the embedded slots, and index three selects EtherHiding. The natural progression reaches unsupported index four rather than wrapping back to zero. A separate encoded sentinel represents index 255 and bypasses the resolver body; it is not another endpoint.

The slot expression is visible as 4 * (selector ^ 0x17) over a 16-byte element array: four elements select one 64-byte slot. The transformation helper's cipher was analyzed separately; the final assignment updates the attempted-index cache rather than proving that a request succeeded.

Indexed protected URL-slot selection and selector cache write
Figure 8 — The decoded selector indexes a 64-byte endpoint slot, whose transformed bytes become the URL buffer.

The request wrapper makes up to five attempts at a selected endpoint. If the lower helper returns true, HTTP 200–299 or 405 makes the wrapper return true; 403 makes it return false immediately. Other statuses or failures enter the retry path. After each failed attempt, including the fifth, the code requests a relative NtDelayExecution interval of −50,000,000 hundred-nanosecond units: five seconds, provided that service entry was resolved. WinHTTP separately receives 150,000-ms connect, send, and receive timeouts. The backoff is not a whole-process deadline.

The −50000000 value is a relative native interval, not milliseconds. Below it, the obfuscated byte assignment precedes another mw_resolve_selected_c2_endpoint call. This establishes the delay/rotation linkage, not a successful fallback connection.

Relative delay followed by endpoint-selector advancement
Figure 9 — A failed-attempt path requests the five-second native delay before advancing the endpoint selector.

There is also a large-body branch: after failure with a body at least 0x200000 bytes, retry toggles a chunk-state flag, reset on a new endpoint's loop. That branch, alternate URLs, and native rotation were not exercised by the captured command cases. The observed transport was the locally redirected first URL, retaining logical Host: github.com, Chrome/117-style User-Agent, Cache-Control: no-cache, and Pragma: no-cache. Neither the Host header nor User-Agent identifies GitHub contact or a browser-originated request.

EtherHiding resolves a suffix, and its cache records attempts

At selector index three, the code builds a JSON-RPC eth_call request to ethereum-rpc[.]publicnode[.]com, with contract 0x999941b74F6bbc921D5174A5b29911562cd2D7CF, selector 0xc2fb26a6, and block parameter latest. This is an embedded bootstrap identity, not a destination accessed during this analysis.

The response parser looks up result, calculates its string length, and begins at length − 64. It converts the last 64 hexadecimal characters into up to 32 zero-extended UTF-16 WORDs. Invalid nibbles contribute zero. This is a fixed suffix interpretation, not a general Ethereum ABI string decoder. The bounded branch contains no visible local length >= 64 check before subtraction; upstream bounds and malformed-result behavior remain unresolved. No short response was sent to the sample, and the missing local guard is not presented as an exploit.

The cache has another notable property: it records the attempted index even when the HTTP request, JSON parse, or result lookup fails. A later call with the same index can return through the cache without rebuilding the endpoint buffer. “Cache of successfully resolved endpoints” would therefore be an inaccurate description; stale or empty endpoint use is a code-supported possibility, not an observed native outcome.

The HTTP helper requests security option 31 with DWORD 0x3300, the combination for ignoring certificate authority, name, date, and usage errors. That request is visible in source. It does not prove that the option succeeded, or that this specimen completed an HTTPS bootstrap. Keeping the selection state, HTTP status handling, and application response parsing separate is essential: the wrapper's true Boolean still says nothing about valid decrypted JSON.

Forms outside, encrypted JSON inside

The first Remus message is POST /messages, with an ordered URL-encoded registration body:

tag=<static 32-hex tag>&exp=<decimal expression>&hwid=<generated identifier>

The tag in this specimen is 03473d3f2d528ba787191417a6e1e5f0. Neither its purpose nor the field name exp establishes semantic-version, campaign, or expiration metadata. Registration replies supply vm, ss, and access_token. The parser compares its internal representation of literal Boolean true, so a string such as "true" is not interchangeable with true. The token is retained for subsequent task polls:

access_token=<server-provided token>&step=<decimal step>

The initial step is one. Diagnostic messages use the same form content type but contain access_token and debug, not step. They neither advance task polling nor consume a task reply. Browser collection can emit prefix and send diagnostics alongside its actual archive; those forms must not be counted as collection results.

Registration and task response bodies share one binary decoder. For total length L, the first 32 bytes are a ChaCha20 key, the next eight are its nonce, and the remaining L − 40 bytes are ciphertext. The counter starts at zero. The plaintext is JSON:

inbound HTTP body = key[32] | nonce[8] | ciphertext[remaining bytes]

No authentication tag is present in this recovered framing. Moreover, the key is carried with the ciphertext. A complete captured HTTP body therefore supplies the material required to decode it offline; this is not equivalent to authenticated encryption or to decryption of an unobserved HTTPS exchange.

The local decoder subtracts 40 before allocating the plaintext buffer and placing its NUL terminator, without an explicit local L >= 40 check. That observation is useful for parser review, but upstream constraints were not fully established and malformed native input was not tested. The article's command examples describe valid messages rather than probing those bounds.

A separate debugger-assisted registration stopped at the reply-decoder boundary. R13 held the complete response-body length, 0x70 bytes; RBX held the 0x48-byte plaintext length; and R15 pointed to the decoded JSON. The relationship 0x70 − 0x28 = 0x48 independently confirms removal of the 40-byte key-and-nonce prefix before parsing. This observation used ss:false; it is not the screen-enabled exchange shown later.

Decoded registration JSON at the reply-decoder boundary
Figure 10 — The decoder exposes the plaintext JSON after removing the 40-byte prefix. The lab access token is redacted; the Boolean fields remain visible.

Task JSON supplies a numeric type and type-dependent data. Type zero is a root-keyed object; types two through four use arrays. Type one skips work and continues polling. Type five terminates the ordinary loop, after which final inventory, optional screen capture, automatic clipboard collection, cleanup, and optional terminal action processing occur. An empty data:[] terminates without providing an action item.

The switch preserves separate handlers for each grammar. Type one and the terminal type-five branch lie outside this crop; their behavior is established in the surrounding dispatch analysis, not by these four cases.

Task switch routing File, Registry, Chromium and Gecko handlers
Figure 11 — Task types 0, 2, 3 and 4 reach distinct collection handlers.

The registration conversation below shows both layers at once: a readable URL-encoded request and an opaque binary reply. Host: github.com is a header constructed by the sample, not proof of a GitHub connection. The simulator's response is HTTP/1.0 200 OK to the sample's HTTP/1.1 request, with a 144-byte body. After removing its 40-byte key/nonce prefix, the remaining ciphertext decodes to the registration JSON. That JSON enabled ss for the screen case; the flag is not visible as plaintext in the packet view.

URL-encoded registration and encrypted server reply
Figure 12 — Registration carries tag, exp and hwid; the reply carries the binary envelope. The HWID and exposed key/nonce bytes are redacted, while the headers and remaining ciphertext are preserved.

The second decoder: XOR4-wrapped task fields

Selected task strings are not plain Base64. After Base64 decoding, the native routine treats the first four bytes as a repeating XOR key and compacts the remaining bytes through that key:

mw_decode_xor4_task_field subtracts four from the Base64 output length, applies key[i & 3] to each following byte, and compacts the result into the destination. The prefix-removal step is why plain Base64 decoding cannot recover a valid task path.

Base64 decode followed by four-byte repeating XOR and prefix removal
Figure 13 — The first four decoded bytes are the repeating key, not part of the plaintext.
D = Base64Decode(field)
K = D[0:4]
P[i] = D[i + 4] XOR K[i & 3]

The byte wrapper appends a byte NUL; the wide wrapper interprets the resulting bytes as UTF-16LE, appends a wide NUL, and reports half the byte count. Valid wide fields have even length and no BOM or pre-appended terminator. The Base64 helper accepts alternative +/- and //_ characters and treats a dot as termination, but its caller has no local four-byte minimum before subtraction. Malformed cases were not exercised.

The final lines show both unit conversions: the terminator uses plaintext_bytes & ~1, while the returned length uses plaintext_bytes >> 1. Those operations explain why a path's byte count and its UTF-16 character count must not be interchanged.

Wide-field wrapper adding a wide terminator and returning code-unit length
Figure 14 — The wide wrapper terminates at an even byte boundary and reports decoded length divided by two.

For the following grammar descriptions, W(text) means that XOR4/Base64 wrapper around UTF-16LE, and B(bytes) means the same wrapper around byte data. These are explanatory notation, not literal JSON functions or runnable task builders. A raw JSON string is identified explicitly where an action uses it instead.

The command examples below are analyst-decoded records, with the wrapping expressed in that notation and deployment paths normalized. They are not plaintext copied from Wireshark: the captured response bodies remain encrypted.

This layer explains an otherwise deceptive failure during research: JSON parsing succeeded and the file handler was reached, yet an earlier plain-Base64 field became a corrupted root directory. A bounded debugger memory observation matched the bytes predicted by treating the first four decoded characters as the XOR key. The corrected field representation then returned complete file and registry markers without runtime repair. Task-handler entry alone had not established a usable argument.

Returned archives reverse the key placement

The two directions share a cipher but not a byte layout. The diagram below connects the outer HTTP messages to the field decoder and record serializer: the inbound key and nonce precede JSON ciphertext; the outbound key and nonce follow the encrypted raw-LZ4 records. Neither direction carries an authentication tag in this build.

Inbound task envelope, XOR4 field encoding and outbound named-record layout
Figure 15 — Registration and task replies use the same inbound envelope. Selected JSON fields add a four-byte XOR/Base64 wrapper; returned records have little-endian lengths and reverse the envelope's key placement. Task types and upload kinds remain distinct. All values in the diagram are symbolic.

Collectors append consecutive binary records, not ZIP entries. Each record has a little-endian 16-bit name length including its terminating NUL, those name bytes, a little-endian 32-bit data length, and exactly that many data bytes:

LE16(name length including NUL) | name + NUL | LE32(data length) | data

The serializer adds no record for zero-length data. There is no central directory, ZIP header, or demonstrated separate member-count field. Reliable decoding walks the actual lengths and preserves names exactly, including a browser cookie member whose name retains a backslash amid otherwise forward-slash prefixes.

The serializer counts the name through its terminator, writes that length as a WORD, then stores the data length as a DWORD before copying the buffer. Its updates to the accumulated length make the record sequence self-delimiting without a ZIP directory.

Result-member serializer writing name and data lengths
Figure 16 — Each nonempty result appends a NUL-terminated name and a length-delimited data buffer.

The publisher compresses the accumulated records as a raw LZ4 block. Literal tokens, extended lengths, and little-endian 16-bit match offsets identify the format; there is no LZ4 frame header or uncompressed-length prefix inside the encrypted bytes. The allocation bound is n + floor(n/255) + 16. Fresh 32-byte key and eight-byte nonce material then encrypt the compressed block with ChaCha20 from counter zero.

The key-placement reversal comes directly from the publisher's buffer construction: after compressing and encrypting the records, it appends the freshly generated key and nonce as a 0x28-byte trailer.

The crop shows the caller of the separately identified raw-LZ4 compressor and ChaCha20 routine. It also shows random-material generation and the trailer copy; the compressor's token grammar and cipher-round implementation are not substituted for those calls in this figure.

Record compression, fresh key and nonce generation, encryption and trailer append
Figure 17 — The publisher encrypts the compressed records and appends the 40-byte key/nonce material.

The outbound binary part reverses the inbound ordering:

outbound file part = ciphertext | key[32] | nonce[8]

It is carried in multipart/form-data: text parts access_token and type, followed by the file part with filename="data" and Content-Type: application/octet-stream. Upload kind zero carries inventory, file, registry, and screen/clipboard records; kind one carries Chromium results; kind two carries Gecko results. These kinds are a different namespace from inbound task types—Chromium task three produces kind one, not kind three.

Automatic records such as Info.yml, Software.txt, and Processes.txt can share the same endpoint and kind as selected collection. Classifying a result requires its decoded members, not merely POST /messages, type=0, or HTTP 200. The publisher does not consume a response-body acknowledgment. Full member comparison is consequently stronger proof of a collector's effect than either transport status or process exit.

File collection: root-keyed specifications, not a single pathname

Task type zero maps each W(root directory) key in data to an array of search specifications. Each item contains path=W(relative path), name=B(output name), a mask list of W(glob) values, numeric depth and size, and Boolean link. Outer name=B(prefix) supplies the common archive prefix. The specification is more expressive than a simple “read this file” request: root and relative path select the search location, while prefix fields determine its result names.

The handler expands environment strings in the decoded root, joins it to the relative path with a backslash, and builds an output prefix from outer and item names with /. Its enumeration wrapper supplies a selection callback. Depth zero stays flat; a positive depth enables subdirectory traversal. link controls a separate .lnk COM/ShellLink-resolution branch, rather than being a synonym for recursion.

The path passes through the UTF-16 decoder, while the output name passes through the byte decoder. The remaining protected-field lookups assemble the specification used by the walker; the crop does not include the earlier root-key lookup or the walker's entire filtering logic.

File-search item parsing through wide path and byte output-name decoders
Figure 18 — Search specifications decode the path and output name through different wrappers.

Selection excludes *.exe, *.dll, *.msi, and *.sys. Positive masks are OR-ed, while a matching mask prefixed with ! vetoes a file. An exclusion-only list need not find a positive mask. A nonzero size limit rejects files larger than the cap instead of truncating them. Selected files are opened through the native NtCreateFile wrapper, read into an allocated buffer, and closed through NtClose. Their bytes are staged through the shared record serializer; empty files contribute no record.

A successful open leads to allocation and a native read. The name builder combines the output prefix and converted basename before calling mw_append_result_member; the earlier mask, link and size-rejection branches are outside the excerpt.

Selected file read and named-record append
Figure 19 — The callback reads a selected file and appends its complete buffer under the constructed archive name.

The native exercise chose one flat directory, *.txt, depth zero, link:false, and a 4,096-byte cap. The decoded result was RemusProbe/FileMarkers/marker.txt, containing the complete 43-byte value REMUS_FILE_COLLECTION_FIXED_BENIGN_MARKER\r\n. Every byte matched the source fixture. Recursive traversal, links, exclusion-only lists, and cap rejection remain code-mapped parameter branches, not outcomes inferred from that one file.

On the wire, the task arrived as encrypted JSON in a poll response. Its selected record appeared in a subsequent kind-zero multipart upload, whose trailing key/nonce decrypted a raw LZ4 block and the named archive. The later empty terminal task ended collection. This correlation distinguishes the 43-byte member from the automatic kind-zero inventory records sent during the same execution.

The analyst-decoded task is normalized below, not literal on-wire JSON. W(...) and B(...) denote the previously described wrappers:

{
  "type": 0,
  "name": "B(RemusProbe)",
  "data": {
    "W(<fixture_root>)": [{
      "path": "W(Files)", "name": "B(FileMarkers)",
      "mask": ["W(*.txt)"], "depth": 0, "link": false, "size": 4096
    }]
  }
}

The red request in the first conversation is the step-one poll, not the file upload. Its 84-byte form contains the token and step=1. The blue 395-byte reply contains the task envelope: 40 bytes of key/nonce and 355 bytes of encrypted JSON. Its inner wrapped root, path, name and mask lead to the specification above.

File command poll and encrypted type-zero response
Figure 20 — The poll receives the file specification in a 395-byte binary reply. The exposed token and envelope key/nonce are redacted.

The result conversation changes to multipart/form-data. Its text part type=0 identifies the upload kind; the binary part, named file with filename data, contains the compressed and encrypted records. Decoding that part produces RemusProbe/FileMarkers/marker.txt and the complete 43-byte marker. The closing boundary is followed by an empty 200 response—transport acceptance, not a separate declaration of file-read success.

File-result multipart upload followed by its empty response
Figure 21 — The kind-zero upload and response belong to the file result. Full member decoding, rather than the 200 status alone, establishes collection.

Before compression and encryption, the selected file entered the shared serializer as a named plaintext record. At 0x140003460, RDX identified RemusProbe/FileMarkers/marker.txt, R8 addressed the collected bytes, and R9 supplied the authoritative length 0x2B. The bounded memory view contains the complete 43-byte fixture; the upload decoded from the separate debugger-free command run contained the same record, name and length.

File record name, buffer and length at the shared serializer
Figure 22 — At serializer entry, RDX names the record, R8 points to its data and R9 limits it to 43 bytes. The lower panel is a magnified view from the same debugger stop; bytes beyond that bound are masked.

For detection, combine file enumeration or reads by the loader-launched process with its task/result connection and selected directory scope. A .txt read alone is ordinary activity. The stronger pattern is a non-browser process enumerating a supplied tree, serializing matching content, and immediately posting a binary multipart result to the same endpoint that provided its instructions.

Registry collection: raw value bytes under a supplied record name

Task type two uses a data array. Each item supplies path=W(key path), value=W(value name), and name=B(output record name). A path beginning with a backslash takes the native absolute-path route; a relative path obtains a current-user root handle. This is a collection operation, separate from the wrapper's startup registry writes.

The wide decoder feeds the value-query helper; the later byte decoder supplies the archive name. Passing the helper's returned length to mw_append_result_member preserves the collected value bytes rather than rendering a textual registry export.

Registry-value field decoding and result append
Figure 23 — The registry handler decodes the value name, obtains its data and appends it under the requested result name.

The query helper opens the key, obtains size information, allocates a buffer, reads the named value, and closes the handle. The native open wrapper resolves NtOpenKeyEx through seeded hash 0x3AFE5076; close uses NtClose. If the first route supplies no value bytes, the helper retries with an alternate access/view flag containing 0x200. Its protected return paths require care: the existence of a query call does not guarantee a result buffer.

The first query uses NtQueryKey with KeyFullInformation and reads MaxValueDataLen: the largest value-data length in the opened key, not the exact length of the requested value. That key-wide bound supplies the initial buffer capacity. NtQueryValueKey then retrieves the named value through KeyValuePartialInformation; its DataLength determines how many bytes are returned to the collector.

Registry size query, buffer allocation and data read
Figure 24 — The registry helper uses the key-wide maximum value length for allocation, then returns the selected value's actual data length.

A debugger-assisted stop at the registry helper entry captured the decoded operands before the native query sequence. RCX referenced the relative key path, RDX referenced Marker, R8 supplied the output address, and R9 carried the root handle. This establishes that the type-two fields reached the helper intact; it is not a complete trace of the later native calls.

Decoded registry key and value operands at helper entry
Figure 25 — The helper receives the relative key path, value name, output address and root handle derived from the task.

Returned bytes are appended with the task-selected record name. This is not a .reg export with key hierarchy, registry type, and textual conversion. The demonstrated fixture was a 45-byte REG_BINARY marker in a session-specific, non-startup HKCU research leaf, queried as a relative path. Its complete value, REMUS_REGISTRY_COLLECTION_FIXED_BENIGN_MARKER, returned in RegistryMarker.bin.

The task response used type two; the result used multipart kind zero and the same raw-LZ4/named-record format as file collection. The decoded name, length, and full byte comparison tie the result to the intended value rather than to generic registry activity or an inventory upload. Other hives, alternate views, and differing value types were not demonstrated by this marker.

The analyst-decoded task, normalized rather than literal on-wire JSON, supplies one relative HKCU query:

{"type": 2, "data": [{
  "path": "W(<synthetic_HKCU_key>)", "value": "W(Marker)",
  "name": "B(RegistryMarker.bin)"
}]}

The step-one reply is 280 bytes: its 40-byte prefix precedes 240 encrypted JSON bytes. The query's path and value decode as UTF-16LE, whereas the output name decodes as bytes. Those distinctions matter before the registry helper receives usable arguments.

Registry command poll and encrypted type-two response
Figure 26 — The poll reply carries the wrapped key path, value name and output name; none of those fields is plaintext in this conversation view.

The later kind-zero multipart upload contains the RegistryMarker.bin record. The response has no body, so it cannot attest to the key query. The complete 45-byte decoded member supplies that evidence and separates this result from the automatic inventory uploads that use the same route and kind.

Registry-result upload and empty HTTP response
Figure 27 — The selected registry result uses the same envelope as File, but returns the exact raw value bytes under its task-selected name.

After the query helper returned, RAX addressed the raw value buffer before the handler assigned its task-selected record name. The recorded length at [RSP+0x58] was 0x2D; the bounded view contains those 45 bytes without registry-export formatting. The independently decoded result archive preserved the same bytes under RegistryMarker.bin.

Raw registry value after the query helper returns
Figure 28 — The upper panels retain the post-query control flow and returned pointer. The lower panel magnifies the 45-byte value; adjacent allocation contents are masked.

Endpoint correlation should distinguish query activity from modification. A process associated with Remus tasking opening a specific user key, retrieving a value, and then uploading its bytes is the collection sequence. The loader's Run/RunOnce/Windows/Environment writes are another sequence, earlier in startup and driven by its own configuration. Combining them without that distinction would attribute persistence to the wrong command.

Chromium: six raw members from a key-free profile

Task type three supplies a data array with path=W(profile root), name=B(output prefix), optional wrapped-wide pub, exe, and lib, and flags history, credentials, cookies, log, and extensions. An outer extensions array can describe id, name, indb, and sync. These fields drive multiple paths inside the collector; the flag name credentials alone does not establish plaintext passwords.

The handler creates a random 16-character hidden desktop and enters the Chromium collector. The collector opens Local State and branches on os_crypt. An absent node selects the raw-file route and bypasses the key-recovery helper. The fixture intentionally used exactly {"profile":{"info_cache":{"Default":{}}}}, with no os_crypt. Default identifies the child directory; its inner object need not contain a name. The alternative fallback when info_cache is absent was not exercised.

The runtime branch agreed with that configuration. At 0x14000C66D, R12 was null and RAX was zero after test r12, r12 and setnz al, selecting dispatch slot zero. This establishes the key-free route used by the fixture; it does not demonstrate key recovery.

Key-free Chromium dispatch after the Local State check
Figure 29 — A null R12 produces selector zero at the protected dispatch. The visible os_crypt context identifies the decision, not a recovered browser key.

The loop chooses digits or letters for sixteen positions, terminates the name, and first calls the OpenDesktopW wrapper. A null result selects CreateDesktopW. This is preparatory code: it does not show a browser being launched or a key being recovered.

Random desktop-name generation with open-or-create fallback
Figure 30 — Chromium preparation generates a 16-character desktop name and opens or creates that desktop.

With history, credentials, and cookies true, the raw helper selects History; Login Data, Login Data For Account, and Web Data; and Network\Cookies, respectively. The root's Last Version is separately copied as Version.txt. log would select Local Storage\leveldb, and extension descriptors select other branches, but both were disabled here. Optional pub, exe, and lib were omitted.

The calls below are the raw-profile helper, not the Local State parser. Its flag tests precede the filename initialization and copy calls; decoding those initializers identifies the database set listed above. The root collector's absent-os_crypt branch is not visible in this crop.

Flag-selected raw Chromium database-copy calls
Figure 31 — Chromium flags select repeated raw-file copies after their protected filenames are initialized.

The shared file-copy helper first attempts ordinary access and reading. Particular sharing failures can reach section-based and file-holder handling; a later process-name fallback requires a non-null executable context. The synthetic databases were closed, and that context was absent. Successful copying therefore does not demonstrate locked-file bypass, browser termination, or use of the hidden desktop to launch a browser.

The native kind-one archive contained five complete 12,288-byte SQLite files and a 37-byte version member, totaling 61,746 bytes with record overhead. The databases contain marker tables, not normal browser credential schemas. Names were rooted at ChromiumRawMarkers/Default/, except ChromiumRawMarkers/Version.txt; the cookie member retained the literal Network\Cookies backslash. All six complete lengths and hashes matched the frozen inputs.

The type-three poll response, intermediate diagnostic forms, selected kind-one upload, and HTTP response were captured as separate exchanges. The result demonstrates the collector's raw copying and publication path. Neither an os_crypt key nor a plaintext password appeared as a claimed output. File names such as Login Data remain relevant to detecting the access pattern, but are not themselves evidence that the contents were decrypted.

The analyst-decoded task below normalizes the selected flags and omitted key-bearing operands; the W(...) and B(...) notation is not literal on-wire JSON:

{"type": 3, "extensions": [], "data": [{
  "path": "W(<fixture_root>/Chromium)", "name": "B(ChromiumRawMarkers)",
  "history": true, "credentials": true, "cookies": true,
  "log": false, "extensions": false
}]}

The normalized separator above identifies the fixture root; the actual decoded Windows path uses backslashes. The 405-byte poll reply contains this type-three record inside the shared 40-byte-prefix envelope. The figure shows delivery, not the os_crypt decision or any decrypted database content.

Chromium command poll and encrypted type-three response
Figure 32 — The wrapped profile root and enabled flags are inside the binary reply. pub, exe and lib were absent from the decoded task.

In the result, multipart type=1 distinguishes the Chromium archive from the type-three command that requested it. The compressed fixture databases produce a much smaller encrypted body than their aggregate raw size; HTTP content length must not be mistaken for the sum of file lengths. Decoding the upload recovered the five complete databases and Version.txt described above. The image joins the start and end of this one conversation so that both the multipart fields and the empty server response remain readable.

Chromium kind-one result and its response in joined conversation views
Figure 33 — Two genuine positions from the same HTTP stream, separated by a divider. The omitted middle is encrypted body data; no plaintext credentials are shown or implied.

At the shared serializer, RDX named ChromiumRawMarkers/Default/History, R8 pointed to the complete database, and R9 was 0x3000. The buffer began with SQLite format 3\0; the lower panel shows the fixed marker at R8+0x1FDD within the same paused call. Exporting exactly 0x3000 bytes from R8 produced the same SHA-256 as the prepared History fixture. This is raw profile-file acquisition, not plaintext credential recovery.

Chromium History database at the result serializer
Figure 34 — The named 12,288-byte History record retains its SQLite header and fixed benign marker before compression and encryption. The panels are two views of the same debugger stop.

For defenders, correlate a non-browser process reading Local State and several profile databases with a subsequent kind-one result. Closed-file collection and the more invasive key-bearing path should be distinguished in telemetry: the latter can involve browser process access, remote memory, and token operations that this raw exercise did not require.

What the key-bearing Chromium branch adds

When Local State contains os_crypt, the code examines encrypted_key and app_bound_encrypted_key and reaches a more complex browser-process path. The task-selected exe is compared case-insensitively with process names; lib selects a browser module. The target is not intrinsically fixed to chrome.exe or chrome.dll. A separate callback also searches for a locally decoded dpapi.dll and resolves its CryptUnprotectMemory export through hash 0x4EF852CF.

The browser-module branch scans readable remote memory with a primary masked signature and a shorter fallback. A match yields a RIP-relative candidate pointer, and another search looks for the corresponding eight-byte pointer in remote memory. This is consistent with an Encryptor vtable/object search, but the object match and protected key were not observed natively for this specimen. If both addresses are not available, the recovered branch retries the module walk at half-second intervals, with up to sixteen delayed attempts.

Token handling is equally important to interpret narrowly. The code queries the current token's user and applies a SYSTEM-SID-like prefix check, not a complete S-1-5-18 comparison. Its alternate route resolves SeImpersonatePrivilege, enumerates processes, checks token privilege entries, and duplicates a matching token for impersonation. That fallback searches for the privilege, not necessarily a SYSTEM SID. The acquired token is connected to NtSetInformationThread with ThreadImpersonationToken; a guaranteed SYSTEM elevation claim would overstate the checks.

A stub builder incorporates the resolved CryptUnprotectMemory address. Surrounding native calls read browser memory, allocate remote executable/read-write memory, write the stub, create a remote thread, wait, read back results, free the allocation, and close handles. Relevant resolved services include NtReadVirtualMemory, NtAllocateVirtualMemory, NtWriteVirtualMemory, NtCreateThreadEx, and NtWaitForSingleObject. This is concrete code-supported remote unprotect machinery, not merely an app_bound_encrypted_key string.

The helper makes two distinct allocations. The first uses allocation type 0x3000 and protection 4 (PAGE_READWRITE) for the data region. Its selector appears as signed decimal −1834021682, equivalent to the independently resolved NtAllocateVirtualMemory hash 0x92AF0CCE. This is not the executable allocation.

Remote data-region allocation in the browser-key helper
Figure 35 — The first allocation requests read/write data memory and stores the returned native status; it does not establish a recovered key.

A later call requests a separate 4,096-byte region with PAGE_EXECUTE_READWRITE. The returned address is retained in code_or_decode_slot, a machine-word local also used by earlier decoding operations. Stub construction precedes the excerpt, so the figure establishes the allocation parameters rather than the emitted code's complete behavior.

Separate executable-region allocation in the browser-key helper
Figure 36 — The later allocation requests executable/read/write memory. It is a different region from the earlier data allocation.

The write and thread calls reuse that address: it is the write destination and subsequently the remote start address. Their service selectors identify NtWriteVirtualMemory (0xD5EE04E0) and NtCreateThreadEx (0x82232132). The universal dispatcher retains the service number and argument count before the native operands. Waiting, reading back and freeing the remote regions occur outside these excerpts.

Remote write followed by thread creation in two excerpts
Figure 37 — Two excerpts from the same helper, separated by a divider, connect the executable allocation to the write and thread-start arguments. They show the implementation, not successful remote execution.

The raw Chromium result does not exercise that machinery. Matching patterns, object offsets, fallback guards, token outcomes, and key recovery remain bounded unresolved areas. No native remote injection, successful ABE unprotection, or plaintext credential result is claimed. For detection engineering, the source suggests a distinct chain of browser-handle acquisition, token impersonation, remote allocation/write/thread creation, and readback; it does not supply a captured API sequence proving that whole chain succeeded.

Gecko: profile discovery before raw copying

Task type four supplies a data array with path=W(parent directory), name=B(output prefix), history and extensions flags, and an outer extensions list with id and name descriptors. Unlike the Chromium raw case, the supplied path names a parent containing profile children. The callback accepts a candidate child only when its key4.db exists as a file.

For each accepted profile, the collector attempts raw copies of key4.db, cert9.db, cookies.sqlite, logins.json, and formhistory.sqlite before separately gated history and extension branches. The same shared file helper constructs source paths and archive names from prefix, child directory, and filename. Its protected sharing-failure branches exist, but closed synthetic files required only the ordinary copy route.

The unconditional sequence shown here initializes five protected names and invokes the same copy helper for each. The plaintext names come from decoding those initializers; this excerpt does not display an NSS decryptor or the candidate's key4.db existence test.

Five protected Gecko filenames and consecutive raw-copy calls
Figure 38 — Five Gecko profile files are copied before separately gated history and extension work.

The native fixture provided one SyntheticProfile child with four 12,288-byte SQLite marker databases and a 72-byte JSON file with an empty logins array. Both history and extensions were false, and the outer list was empty. All five files appeared beneath GeckoRawMarkers/SyntheticProfile/ in a kind-two archive, whose complete uncompressed length including records was 49,482 bytes. Every member matched its full input length and hash.

The poll response carried type four, followed by an empty terminal reply. The selected multipart result carried kind two. Those fields and the decoded filenames distinguish Gecko collection from Chromium's kind-one archive and from automatic inventory. The presence of both key databases and logins.json demonstrates raw acquisition of those files, not NSS decryption, usable passwords, or decrypted cookies. Additional history and extension results were not inferred from the five copied members.

The analyst-decoded profile-parent task below is normalized, not literal on-wire JSON; it disables the additional branches:

{"type": 4, "extensions": [], "data": [{
  "path": "W(<fixture_root>/Gecko)", "name": "B(GeckoRawMarkers)",
  "history": false, "extensions": false
}]}

Again, the actual Windows path uses backslashes. The 348-byte poll reply wraps that type-four JSON; the directory is a profile parent, not a database path or a Firefox executable. Candidate discovery then reaches SyntheticProfile through its key4.db gate.

Gecko command poll and encrypted type-four response
Figure 39 — The binary reply provides the profile-parent task. The profile eligibility check occurs afterward in the collector, not in HTTP parsing.

The selected result has multipart type=2. Its decoded named records contain the four full SQLite images and the empty-login JSON fixture, not a decrypted password list. The following joined view preserves the upload's fields and its closing boundary alongside the empty 200 response.

Gecko kind-two result and empty response in joined conversation views
Figure 40 — Two positions from the same HTTP conversation expose the result framing and response without shrinking the long body into unreadable text.

The accepted profile's key4.db reached the shared serializer as GeckoRawMarkers/SyntheticProfile/key4.db. R9 was 0x3000, the buffer began with the SQLite header, and the lower panel shows the fixed marker at R8+0x1FE0 in the same paused call. Exporting those 12,288 bytes produced the same SHA-256 as the prepared file. This confirms raw key4.db collection after profile discovery; it does not show NSS decryption or recovered credentials.

Gecko key4.db at the result serializer
Figure 41 — The selected profile contributes its complete raw key4.db record. The panels are two views of the same debugger stop, and the visible marker belongs to the synthetic fixture.

mw_init_result_context initializes the Gecko upload kind. The later enumeration call feeds the profile collector, and mw_publish_compressed_chacha_result publishes the context. This connects task type four to upload kind two without treating the two numbers as one namespace.

Kind-two context initialization, profile enumeration and publication
Figure 42 — The Gecko handler initializes kind two, enumerates profile children and publishes the accumulated result.

A useful endpoint pattern is one non-browser process traversing profile children and reading the key/certificate databases alongside login, cookie, and form-history files. Linking that cluster to the instruction connection and kind-two upload is stronger than a rule triggered by access to cookies.sqlite alone. The discovery gate also explains why an apparently valid parent directory without a candidate key4.db can produce no profile output.

Screenshot and clipboard are post-loop collectors

Screen capture is selected by registration's literal ss:true, not by another inbound task type. After ordinary polling ends, the dispatcher tests that saved flag, optionally calls the screenshot collector, and then calls the clipboard collector unconditionally. Both append to the same kind-zero context. An empty terminal reply can therefore produce a screenshot and clipboard without any nonempty action item.

The screenshot routine obtains GetDC(NULL) and reads the selected bitmap's dimensions through GetCurrentObject(OBJ_BITMAP) and GetObjectW. GetSystemMetrics(76) and (77) supply the virtual screen's x/y origin, not its width and height. A compatible DC and bitmap receive BitBlt(..., SRCCOPY) with raster operation 0x00CC0020, without CAPTUREBLT; GetDIBits extracts top-down 32-bit pixels.

The resulting Screenshot.bmp has a 14-byte file header, 40-byte DIB header, pixel offset 54, negative height, one plane, and uncompressed BI_RGB data. For the native 1920×1200 display, its full size was 9,216,054 bytes: 54 header bytes and 9,216,000 pixel bytes. Reserved header and alpha bytes are retained in the complete artifact rather than assumed to be zero.

The bitmap builder is the downstream format boundary: GetObjectW fills bmp_workspace.source_bitmap, while bmp_workspace.header holds the 40-byte DIB header passed to GetDIBits. The source BITMAP begins immediately after that header, at offset 0x28; its bmWidth and bmHeight supply the output dimensions. The packed planes/bit-depth fields and negative height explain the native artifact's top-down 32-bit layout. The screen-DC acquisition and BitBlt sequence are not shown in this excerpt.

Top-down 32-bit DIB extraction and BMP-header construction
Figure 43 — The builder records a negative height and writes the 54-byte BMP header before the pixel buffer.

The clipboard routine resolves OpenClipboard, GetClipboardData, and CloseClipboard, requests CF_UNICODETEXT (13), locks the returned HGLOBAL, converts the text to a byte buffer, and serializes the conversion length. It unlocks and closes afterward. This collector reads the clipboard; it does not clear or replace it. The corresponding record is Clipboard.txt, without the allocation's extra terminator byte.

The figure follows the resolved open/read/lock calls and text conversion. At the later append call, the native instructions pass the member-name pointer in RDX, the converted data in R8, and its saved byte count in R9. These are separate storage locations: the decompiler's merged stack temporary is not a faithful representation of the final append arguments. No particular captured clipboard value is displayed in this code view.

Unicode clipboard API resolution and text conversion
Figure 44 — The clipboard collector resolves its access functions, locks the Unicode text and prepares the converted byte buffer.

The captured case used ss:true and one empty terminal task. Its selected archive contained exactly Screenshot.bmp and the complete 35-byte marker REMUS_CLIPBOARD_FIXED_BENIGN_MARKER. Independent decoding compared the actual full bitmap and clipboard metadata with the received result. The bitmap's SHA-256 was 264183efcc7f66baf18c66b709f75a99002b16d78b08a2ff037c7fafbff13211.

The capture preserves the bitmap returned by GetDIBits, including cursor-region pixels. Those pixels do not establish an explicit DrawIcon call. Multi-monitor layout and layered-window coverage were not established by this exchange; the clipboard case used ASCII text, leaving other formats and general Unicode conversion unverified.

The decisive wire evidence is the registration response enabling ss, the empty type-five response ending polling, and the later complete kind-zero multipart upload with its HTTP response. A screenshot-only task label would hide the automatic clipboard companion. Detection should correlate screen-DC capture and clipboard reads by the same tasking process with that post-loop upload, rather than treating the two reads as independent operator commands.

The relevant decoded controls are a registration reply with ss:true, then {"type":5,"data":[]}. The empty terminal reply is 60 bytes in the capture: 40 envelope bytes plus 20 encrypted JSON bytes. It has no action item and is not a new screen opcode. The registration conversation earlier shows the encrypted enabling reply; the result below shows the post-loop upload.

That request has a 36,694-byte multipart body with type=0. Its compressed binary part expands to named records containing the 9,216,054-byte BMP and 35-byte clipboard value. This size difference follows from raw-LZ4 compression of the captured image; the HTTP content length is not the bitmap length. The joined start/end view preserves the request fields, closing boundary and empty response. The decoded BMP structure and full bytes, not those headers alone, establish the capture result.

Post-loop screenshot and clipboard upload with its empty response
Figure 45 — Two genuine positions from one conversation show the kind-zero upload and response. Its decoded archive contains both the bitmap and clipboard; neither appears as plaintext in the encrypted body view.

Terminal actions: one fixed PE establishes disk and process effects

Type five first terminates polling. If its parsed data contains items, a later parser passes numeric mode and nested type, optional raw JSON UTF-8 path, fn, and arg, and either wrapped url or wrapped inline data to the executor. The raw path strings are not W(...) fields. A supplied URL takes a fetch route; inline data takes the byte decoder and retains its exact decoded length.

The exercised action was only mode:0,type:0: one fixed, inline 4,608-byte benign Win32 executable, an ordinary absolute path under an already existing parent, and no URL, function, or argument. The fixture rejects arguments, displays a literal execution marker in its own window, and closes at a bounded monotonic deadline. It performs no network, registry, credential, injection, or child-launch work. Its full SHA-256 is 26423825f4f960de40091a14f5d4cc5075264ab2d030a8af5d569b70c8e7cbad.

The disk helper builds a native \??\ path and resolves NtCreateFile through hash 0xD699DDBC, using overwrite-if disposition. An existing leaf can therefore be truncated; the call does not create nonexistent intermediate directories. NtWriteFile, hash 0xFD3E3D1C, receives the pointer-width file handle and a 32-bit buffer length. If it returns STATUS_PENDING, the helper waits non-alertably, with no local timeout visible in that branch. The caller does not verify the completed byte count in IO_STATUS_BLOCK.Information, so status alone is not full-write proof.

The caller's sequence below joins the native open and write helpers, searches the service table for the close hash and releases the decoded buffer. The helper's completed byte count is not inspected here, making the full staged-file comparison necessary.

Decoded terminal buffer written to a native file then released
Figure 46 — The disk-backed terminal path opens the destination, writes the decoded buffer and closes the native handle.

The executor then resolves CreateProcessW through hash 0x92A4BFAC and attempts the direct path. Several branches return a true low byte even if process creation fails. Neither that Boolean, an HTTP response, nor the original process's normal exit can replace file and child evidence.

Both displayed process-creation calls test for a nonzero return. A successful first call skips the alternate-environment path; otherwise the executor decodes that environment and makes the second call. These conventional BOOL tests do not make the executor's later true return an execution guarantee: the child still needs to be established independently.

Conditional process-creation calls around alternate-environment decoding
Figure 47 — A second process-creation call supplies the decoded alternate environment after the first call returns zero.

In the native case, the complete inline buffer and staged file matched all 4,608 pinned bytes. A retained handle to the acquired child established its exact path, creation identity, and parent relationship to the sample; responding window metadata and retained normal child exit zero corroborated execution. This proves the disk-write/direct-process path independently of a success string.

The wire carried the encrypted terminal action in the poll response, but the protocol returned no action-specific execution acknowledgment. The exchange establishes task delivery, not execution. Independent endpoint observations supply the disk and process conclusion. For detection, correlate receipt of tasking, creation or overwrite of a PE, and immediate process creation from that path, while retaining the wrapper's prior persistence as a separate startup event.

The analyst-decoded terminal item is structurally different from the collectors. This normalized representation is not literal on-wire JSON:

{"type": 5, "data": [{
  "mode": 0, "type": 0,
  "path": "<existing_fixture_parent>/execution_marker.exe",
  "data": "B(<fixed 4608-byte executable>)"
}]}

The path is raw JSON UTF-8, normalized here; only the inline buffer uses the XOR4/Base64 wrapper. The step-one poll receives a 6,351-byte response containing 40 envelope bytes and 6,311 encrypted JSON bytes. That JSON length includes the Base64 expansion and path, so it is not the executable length. The complete captured body decodes to the pinned PE, while the viewport below shows its headers and leading ciphertext rather than all 6,351 bytes.

Terminal poll carrying the fixed disk-backed executable action
Figure 48 — The server delivers the action in its poll response. This conversation contains no execution acknowledgment; the disk and child observations establish the effect separately.

Other selectors do not inherit that result

Mode zero remains the disk route for other nested types. Type one uses .dll for generated names and constructs a rundll32 command with a quoted path and optional function/argument suffixes. Type two uses .ps1 and powershell -exec bypass -f "<path>". Other types in mode zero still follow file-write/direct-process logic, although the generated-name code assigns those suffixes only for types zero through two.

Mode one/type two converts the buffer into an inline command after powershell -exec bypass ; mode one/type three converts it directly to a wide command line. Script and inline-command branches use CREATE_NO_WINDOW. A separate process-creation call site supplies an ANSI, double-NUL-terminated __COMPAT_LAYER=RunAsInvoker environment. That compatibility setting is not elevation or a grant of a higher-privilege token.

The selector excerpt separates type three from type two before converting the input buffer. The later excerpt reconstructs and prepends a wide prefix, then selects the no-window flag. Decoding that protected initializer produces the PowerShell prefix above; the plaintext is not displayed in the pseudocode pixels. Neither inline-command selector was exercised by the disk-backed marker case.

Inline terminal selectors and protected prefix preparation
Figure 49 — Two excerpts from the terminal executor show the mode-one alternatives and prefix preparation. The divider marks omitted intermediate lines; these are not observations of inline-command execution.

Mode one with a type other than two or three—or a mode other than zero or one—selects CreateThread with a protected worker. Its initial MZ comparison and subsequent PE\0\0 discriminator are resolved. The equal and unequal branches continue through indirect tables beyond IDA's original function boundary. The initial decompiler tail therefore does not establish the whole worker.

Allocation, relocations, import handling, final entry transfer, and non-PE behavior remain unresolved. The header checks are not enough to claim complete manual mapping, injection, shellcode execution, or a successful native worker result. These are additional mapped action forms, not outcomes demonstrated by the fixed disk/process exercise.

Detection follows the boundaries, not the labels

Several implementation details offer stronger correlations than isolated indicators. Startup can create seven persistence objects referencing one original executable before any Remus task arrives. A sparse import table is contradicted by seeded export lookup and ntdll service discovery. Task polls and multipart results share an endpoint but have different field grammars, and upload kind zero spans both automatic inventory and selected collectors.

For network analysis, preserve the connection destination separately from Host: github.com. Decode complete HTTP bodies with the correct direction-dependent key placement, then distinguish selected records from diagnostics and inventory. Plain Base64 decoding of task fields is insufficient; the inner XOR key changes what a seemingly valid path actually means. Member names and full lengths provide attribution that HTTP status alone cannot.

For endpoint analysis, the observed behaviors are specific: flat file reads, one raw HKCU value, clustered browser-profile file access, GDI screen capture with clipboard reading, and a full-byte PE write followed by an attributable child. Key-bearing Chromium collection adds a different code-supported process/token/remote-memory chain, and the protected worker remains incomplete. Keeping those boundaries intact makes the report useful for detections without converting unexercised code into claimed results.

This build uses different grammars for task selection, selected-field decoding and result publication. Recovering each layer connects the returned file, registry, browser and screen records to their instructions. The terminal action requires separate disk and child-process evidence because its protocol supplies no execution acknowledgment. Key-bearing browser collection and the protected worker retain their own unresolved boundaries.