Losing access to VirusTotal Intelligence at the start of the year was surprisingly productive. Unable to hunt for interesting new malware, I stopped adding to my “TODO” pile and finally worked through my backlog from last year. That led to a detailed examination of BeheMOF as well as the discovery of this malware. Upon closer inspection, a sample that did not seem too noteworthy at first turned out to have a distinctive design once I looked under the hood: a passive backdoor that opens no obvious listening port and carries no payload inside itself. It waits in memory doing nothing at all until one specifically crafted network packet reaches the machine, which is why I am calling it SLEEPWALKER.
What makes it worth writing up is what that packet carries: not a readable command, but a short program written in a command language of the backdoor’s own design. Its 23 instructions cover scheduling, several ways to move data, staged file delivery and running code directly in memory. Recovering the encryption key is not enough to understand one of these programs. The internal command language must be reverse engineered as well. From a reverse-engineering perspective, SLEEPWALKER has a cool design. Still, the implementation has several weaknesses and is not top-notch malware engineering. This could be an early version, however. Newer and improved builds may exist.
This post covers what the file itself reveals, how SLEEPWALKER gets loaded, how it starts up, how it stays hidden on the network, how its commands are protected and how its internal command language works. That last part explains most of what the backdoor is actually capable of doing, so I spend some time on it. It closes with an IOC section and an appendix containing a YARA rule and a read-only scanner script.
Executive summary
SLEEPWALKER is a passive backdoor with a command language of its own. It never contacts a fixed C2 address. Instead, it sniffs the network for a covert trigger packet. Only then does it wake up to decrypt and run an attacker-supplied task program. The program arrives as bytecode that only this file knows how to interpret, not as readable commands. The file carrying it is a 64-bit Windows DLL that impersonates Microsoft’s dpapi.dll and has a forged ESET Management Agent version resource. It is designed to be side-loaded into ERAAgent.exe, the Windows executable for ESET Management Agent. ESET describes the agent as an essential component of ESET PROTECT and ESET PROTECT On-Prem that connects managed endpoints and servers to the management platform and stores and enforces policies locally. SLEEPWALKER checks only the host process name, not its signature or path, and stays inactive unless that name is ERAAgent.exe.
The whole lifecycle of the backdoor:
The configuration built into the file decrypts, with AES-256-CCM and a verified authentication tag, to a single bootstrap command: watch every network interface indefinitely for that trigger. On its own, the file does nothing except wait. The backdoor carries a compact bytecode interpreter with 23 instructions covering scheduling, staged payload delivery with SHA-256 verification and in-memory shellcode execution. Its network capabilities include TCP, UDP, ICMP, SMB named pipes with lateral movement using supplied credentials, VMware’s internal VMCI channel between a guest and its host and raw-socket promiscuous sniffing. A second trigger channel can also carry commands in DNS queries.
To facilitate unauthenticated named-pipe access, SLEEPWALKER actively weakens the host: it enables anonymous SMB access and creates named pipes with permissions granted to Everyone and Anonymous Logon. All encryption is provided by a statically linked copy of mbedTLS, an open-source cryptography library, rather than anything loaded at runtime.
This combination is what makes SLEEPWALKER hard to catch from the network side: there is nothing to block until the operator sends that one crafted packet, and it can arrive inside traffic that looks completely ordinary, including a crafted DNS query. A passive implant triggered this way, using multiple covert transports including VMCI and deployed through side-loading into a trusted ESET management component, is most likely part of a targeted attack that also includes other unidentified components. Since the code is unfamiliar to anything I’ve seen in the past, I cannot attribute this malware to any particular actor.
Key points
- The file is unsigned, copies ESET’s file information and is loaded through DLL side-loading.
- It checks only the host process name and activates when that name is
ERAAgent.exe, the Windows executable for ESET Management Agent.- It does not contact any server on its own. It waits for one specific encrypted network packet before doing anything.
- Once triggered, it runs programs written in a small custom command language, supporting scheduling, several network methods, staged file delivery and running code directly in memory.
- The file itself contains no ready-made malicious payload. Everything beyond the single starting instruction has to arrive later, over the network.
- It changes local Windows settings so that unauthenticated network connections can reach it.
File characteristics
The sample is an unsigned 64-bit DLL for the Windows GUI subsystem. It is 59,904 bytes and has a compilation timestamp of 2024-06-10 09:18:27 UTC:
SHA-256: d347170752a28e2b8c4b8b9f3cab2e3a6541ba11682c94498d26eb9002779d60
SHA-1: 2ec8aa9661a33bccc002150ce1ed02d90c3986ff
MD5: 2318327b29bb1c0e2d2b5f0211fc7fac
Imphash: 4e2dbfa7e3efd4cca2f3662797df9735
To make the disguise, the file carries a version resource copied from ESET’s real Management Agent:
| Field | Value |
|---|---|
| CompanyName | ESET |
| ProductName | ESET Management Agent |
| FileDescription | ESET Management Agent Module |
| InternalName | ERAAgent |
| OriginalFilename | dpapi.dll |
| File / Product version | 11.2.2076.0 |
| LegalCopyright | Copyright (c) ESET, spol. s r.o. 1992-2024. |
The file exports the same name and the same seven functions as the real dpapi.dll: CryptProtectDataNoUI, CryptProtectMemory, CryptResetMachineCredentials, CryptUnprotectDataNoUI, CryptUnprotectMemory, CryptUpdateProtectedState and iCryptIdentifyProtection. Every one of them is a small stub that jumps through a pointer table, and that table starts out empty. The first time anything calls any of these seven functions, a shared resolver tries to load a file named dpapisvc.dll with LoadLibraryW, to find the real function inside it and write its address into the pointer table so the call can be forwarded.
That name does not belong to any genuine Windows component. No file called dpapisvc.dll ships with Windows. The closest real name is dpapisrv.dll, an unrelated file that exports only two LSA extension functions, nothing like the seven this code is looking for. A plain reference to dpapi.dll itself would not have worked either, since the malicious file already occupies that name inside the host process, and a bare LoadLibraryW call for it would just return a handle to itself rather than reaching the real one. Some other name or path was needed, but the one actually used matches nothing on a real system. When the load fails, the resolver exits the entire host process rather than failing that one call on its own. Whether this ever happens in practice depends on whether anything actually calls one of these seven specific functions, which this file alone cannot show. It is possible a fuller version of this attack drops a renamed copy of the real dpapi.dll under this same name alongside it, since a file in the application’s own folder would be found before Windows ever checks System32, the same search order this backdoor already relies on to get loaded in the first place. This file carries no such copy inside itself, though, and nothing here confirms one exists.
That same first call also quietly re-runs the backdoor’s own startup check, giving it a second chance to wake up if something interfered with the first one. The section on initialization below covers startup in full.
Initialization and startup sequence
Before doing anything else, the DLL checks the name of the process that loaded it. If that process is not called ERAAgent.exe, the DLL stays inactive, so it will not run inside a debugger, a sandbox or any other program unless that program happens to carry that exact name. Once that check passes, a short sequence of steps brings the backdoor to life:
- It starts a new background thread, separate from ESET’s own code, so the agent process is not blocked while it runs.
- It reserves a 128 KB block of memory to be used later for assembling programs that arrive in several pieces.
- It decrypts the one instruction stored inside the file.
- It prepares Windows networking and hands the decrypted instruction to its own internal interpreter, described further down.
That interpreter is not used just once. When a trigger later delivers a follow-up program, over any of the transports described further down, the exact same interpreter function runs it. This is why the full command language, covering scheduling, staged file delivery and running code in memory, is available from the very first trigger onward, rather than needing to be built into some separate second-stage component.
As a fallback, the same check and sequence run again the first time anything calls one of the seven exported data protection functions, before that call is forwarded. This gives the backdoor two separate chances to start.
Two separate paths reach the same startup code:
- Path 1: DLL loads into ERAAgent.exe (DllMain)
- Path 2: first call to any of the 7 forwarded DPAPI exports
Both paths, independently:
- Check the host process name
- Run the same startup sequence: start a background thread, reserve the 128 KB buffer, decrypt the bootstrap instruction, start the interpreter
Nothing checks whether the other path already ran, which is the root of the duplicate-worker problem covered later in this post.
The ERAAgent.exe string used for that check is not stored as readable text. It is rebuilt from a handful of numbers while running, the same trick used for three function names the file never lists among its normal imports: VirtualProtect for running shellcode, SetSecurityDescriptorDacl for the permissive pipe permissions described later and CryptGenRandom for its random pauses.
On DLL_PROCESS_DETACH, the DLL sets a process-wide stop flag that is polled by its interpreter, sleep, scheduling and listener loops. This requests that they exit, but it does not guarantee a clean dynamic unload. The thread helper immediately closes each worker handle after CreateThread, and the detach path does not wait for the workers to finish. A worker can therefore still be executing when the DLL is unmapped. During process termination, Windows has already terminated the other threads, so this risk mainly applies when the DLL is unloaded dynamically.
No autonomous beaconing or fixed servers
Most backdoors contact a server on the internet soon after they start so they can receive commands. SLEEPWALKER does not do this on its own. After confirming that its host process is named ERAAgent.exe, the embedded bootstrap makes no outbound connection. There are no domains, IP addresses or URLs built into the file.
This describes SLEEPWALKER’s own startup behavior, not all network activity from its host process. The legitimate ESET Management Agent normally checks in with ESET PROTECT according to its configured connection interval. ERAAgent.exe may therefore continue to produce legitimate ESET traffic while the backdoor remains dormant.
Instead, it puts the network card into a mode that lets it see every packet passing through, not just packets addressed to it. This is often called promiscuous mode. The backdoor then checks every packet it sees for a specific pattern: a calculated checksum, an encoded length value and a block of encrypted data. Only when a packet matches this pattern exactly does the backdoor decrypt the data inside and treat it as a command. This kind of trigger is often called a magic packet. Here is that check laid out step by step:
| Step | Check | If it fails |
|---|---|---|
| 1 | Packet is at least 48 bytes long | Ignored |
| 2 | XOR the packet’s last two 16-bit values together, then XOR the result with 0xAAAA, to get a candidate length |
N/A |
| 3 | Candidate length falls inside a valid range | Ignored |
| 4 | The byte pair at position (packet length minus candidate length) equals the sum, not the XOR, of the same two trailing values | Ignored |
| 5 | The block the candidate length points to passes its own CRC-32 check | Ignored |
| 6 | Decrypt with AES-256-CCM and treat the result as a command | N/A |
A check failing at any step drops the packet with no response. Only a packet that clears every step in order is treated as a command.
All of this runs against the raw contents of a packet, before Windows has even sorted out whether it is a TCP, UDP or other kind of packet. Because the check happens at that level, the trigger can travel inside almost any kind of IP traffic rather than one specific protocol.
The backdoor watches at most eight network interfaces at once, skipping the loopback interface and any address a computer assigns to itself when it cannot reach a network. After a successful trigger, it also waits at least three seconds before accepting another one, mainly so it does not act on the same packet twice rather than to block repeated attempts outright.
Because the backdoor never sends anything out on its own and does not open any obvious listening port by default, tools that watch for connections to known-bad domains or unusual outbound traffic will not see anything unusual. The only moment it becomes visible on the network is when the operator sends the trigger packet. The absence of outbound connections to known-bad infrastructure does not rule out an infection, either. A machine can be fully compromised by this backdoor while producing nothing at all for a network monitor to flag.
The configuration built into the file itself contains only one instruction: listen on every network interface, with no time limit, for a matching packet. Every other action the backdoor can take arrives later, over the network, already encrypted.
Command authentication and encryption
Before going through each piece, here is the shape of the whole pipeline a command travels through, from the moment it arrives to the moment it runs:
Trigger packet or DNS query
-> Framing and checksum check
-> AES-256-CCM decrypt
-> Bytecode interpreter
-> Command handler
Every command sent to the backdoor is encrypted using AES-256-CCM. This is a standard method of encryption that does two things at once: it hides the content of a message and proves the message was not changed after it was created. Commands sent through most of the backdoor’s channels use the same layout: a 12-byte value that changes every time, called a nonce, followed by a 16-byte check value, followed by the encrypted data itself. The hidden trigger sent inside DNS lookups uses a shorter version of the same layout, since there is less room to work with inside a DNS name.
On top of that encryption, the raw trigger packet described earlier carries its own separate checksum, calculated with CRC-32. This checksum has nothing to do with the encryption itself. It exists so the backdoor can reject a packet that does not match the expected pattern before spending any effort trying to decrypt it.
The encryption key is stored directly inside the DLL, and I recovered it during analysis, along with the nonce used for the embedded configuration specifically:
AES-256 key: 0x746531ff378dbb4bb51d2aa2b1d38d905350a959583186baf4c690f5f316b3ae
Config nonce: 0x3a6d357fb9bc51eacc8b8509
With this key and nonce, the 2,048-byte encrypted configuration built into the file decrypts cleanly and its authentication tag checks out, confirming both are correct.
Randomness, such as the jittered pause described later, comes from Windows’ own CryptGenRandom function, which the file resolves by name at runtime rather than importing normally.
The following table summarizes what SLEEPWALKER encrypts or encodes, how each type of content is protected and whether it enters, leaves or remains within the backdoor.
| Content | Direction | Encoding or encryption | Explanation |
|---|---|---|---|
| Task programs delivered through the raw trigger, TCP, UDP, named pipes or VMCI | Into SLEEPWALKER | AES-256-CCM | The command bytecode is encrypted and authenticated before interpretation. The nonce and authentication tag remain visible by design. |
| Task programs carried in DNS labels | Into SLEEPWALKER (the DNS query itself may enter or leave the host) | Base32 over AES-256-CCM | Base32 makes the encrypted envelope suitable for DNS labels. Decoding Base32 reveals the AES envelope, not the plaintext command. |
Task programs loaded from a file by RUN_FILE_SCRIPT |
Local | AES-256-CCM | The file contains an encrypted task envelope that is decrypted before interpretation. |
Nested programs used by CRON_SCHEDULE |
Internal | AES-256-CCM, then XOR in memory | The program arrives inside the encrypted task, then remains XOR-obfuscated between scheduled executions. |
Data transmitted by TCP_SEND, UDP_SEND, ICMP_SEND or PIPE_SEND |
Out of SLEEPWALKER | No automatic encryption | The instruction arrives encrypted, but the data it tells SLEEPWALKER to send is transmitted as supplied by the operator. |
| Network headers, trigger framing, CRC checksums, DNS markers, AES nonce and authentication tag | Accompanies task delivery | Visible metadata | These fields allow transport, recognition or validation. They do not expose the plaintext command bytecode. |
Bytecode format
Once decrypted, a command is not text or a document. It is a short sequence of raw bytes that only makes sense when read in a specific order. Seeing that order laid out helps explain both how compact these commands can be and how the instruction table further down was put together.
That design puts this backdoor a step beyond most others. The simplest ones send their commands as plain text and numbers, which anyone reading the file or watching the traffic can follow directly and which detection tools can match on without much work. More advanced ones encrypt that same plain text and numbers, but that protection ends the moment someone recovers the key. This one encrypts its commands and then puts a second barrier behind the first. Decrypting the data with the recovered key does not produce a readable command or a settings list. It produces a stream of opcodes, for the most part, in a format that exists nowhere but inside this one file, and it stays unreadable until that format has been worked out on its own. The key shows how to read the bytes. Only reversing the command language shows what they mean, which is what the rest of this section and the instruction table below set out.
Every instruction begins with a single byte that identifies which of the 23 kinds it is. What follows depends entirely on that first byte. A fixed-size number, such as a wait time, is written using a set number of bytes, most significant byte first. A piece of text or a block of data, which can be any length, is written as a small count of how many bytes follow, then the bytes themselves, so a reader always knows exactly where that piece ends and the next one begins.
Take the one instruction that was actually found stored inside the analyzed file. In full, it is five bytes:
Read from left to right, 87 is the opcode, identifying the instruction that watches the network for a hidden trigger. 01 is a length count, saying the next field is one byte long. 2A is that one byte, the character code for an asterisk, meaning every interface. 00 00 is a two-byte number read most significant byte first. It specifies how many seconds to keep watching, and zero means no limit. Broken down this way, the five bytes form a small tree:
Command = SNIFF_MAGIC_PACKET (0x87)
├── interface_filter
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
└── deadline_seconds = 0 (0x0000)
Five bytes fully describe the instruction “watch every interface forever.” This is also the entire useful content of the file’s built-in configuration. Blocks of raw data, such as network payloads or shellcode, are written the same way as text: a count followed by that many bytes. Only the meaning assigned to them differs.
The length count itself is written compactly, so small numbers take one byte while larger ones take more. Each byte holds seven bits of the actual number, plus one bit saying whether another byte follows. As a general rule, a length of 1, like the single character *, fits in the one byte 01. A length of 200 does not fit in seven bits alone and needs two bytes instead, C8 01.
Some instructions carry more than numbers or plain text. A handful of them carry an entire second program as one of their fields, and the same reading process applies to that inner program once its turn comes. The scheduled instruction is a clear example. In full, it is 22 bytes:
0E 00 00 00 00 00 00 00 01 00 00 02 00 FF FF FF FE 3E 03 9D FD C1
The single opcode byte is followed by four fixed-size numbers marking which minutes, hours, days and weekdays the schedule matches. After those, 03 is a length count, the same kind seen earlier, saying the inner program that follows is 3 bytes long, and 9D FD C1 is that block of bytes. Broken down, the pieces form a tree with a smaller tree inside it:
Command = CRON_SCHEDULE (0x0E)
├── minute_bitmask = minute 0 (0x0000000000000001)
├── hour_bitmask = hour 9 (0x00000200)
├── day_of_month_bitmask = any day (0xFFFFFFFE)
├── weekday_bitmask = Monday to Friday (0x3E)
└── xor_masked_script
├── length = 03 (3 bytes follow)
└── data = 9D FD C1
└── XORed with the recovered key 0x90FDFD02, this becomes:
Command = SLEEP_RANDOM_SECONDS (0x0D)
└── modulus_seconds = 60 (0x003C)
The three encrypted bytes only make sense once XORed with a short repeating key. Undone, they turn back into a complete second instruction: wait a random number of seconds, up to 60. This is what it means for one instruction to contain another. The outer instruction is fully described by its own bytes, and one of its fields is a smaller program in disguise, read the same way once its turn comes.
The decryption is deliberately temporary. The code XORs the nested-program buffer with the key, hands the plaintext to the interpreter and then applies the same XOR again to restore the encrypted bytes. In effect: decrypt, run, re-encrypt. The nested program remains XOR-protected while waiting between scheduled runs and is readable only during execution. This inner XOR layer is unique to CRON_SCHEDULE. The scheduled instruction itself is still delivered inside the AES-256-CCM envelope used for task programs.
Command language reference
Everything the backdoor does after the initial trigger is controlled by the instructions just described. There are 23 of them in total, and a few carry an inner program the way the scheduled instruction does above. This is what lets the backdoor combine a short list of instruction types into many different behaviors: a schedule can contain a network listener, which can contain a routine that waits for a file to be assembled and checked before it is allowed to run, and so on.
The table below lists every one of the 23 instructions, grouped by purpose, with a plain-English description, its parameters and each parameter’s actual wire type. string and blob are both length-prefixed and represent text and raw bytes, respectively. u8, u16, u32 and u64 are fixed-width big-endian integers of 1, 2, 4 and 8 bytes, carrying no length prefix at all. lzma_properties is a fixed 5-byte structure, also with no length prefix. A worked example follows the table for each one, showing the actual bytes of a working instruction next to the tree it decodes into.
| Instruction | What it does | Parameters |
|---|---|---|
| Basic control | ||
EXIT |
Sets the process-wide stop flag rather than ending one program. Every loop in the file checks that flag, so this halts all running programs and the packet listener with them. | None |
SPAWN_THREAD_SCRIPT |
Starts a second, smaller program running at the same time as the current one, in its own thread, so the first program can keep going. | Nested program to run (blob) |
| Timing and scheduling | ||
SLEEP_SECONDS |
Pauses for a fixed number of seconds before moving on to the next instruction. | Duration, in seconds (u16) |
SLEEP_RANDOM_SECONDS |
Pauses for a random number of seconds up to a chosen limit, adding jitter so repeated actions are not perfectly predictable. | Upper limit, in seconds (u16) |
CRON_SCHEDULE |
Checks the current minute, hour, day of month and weekday against four stored patterns and runs an inner program whenever all four match. | Minute mask (u64), hour mask (u32), day of month mask (u32), weekday mask (u8), nested program, encrypted (blob) |
REPEAT_N |
Runs a smaller program a fixed number of times in a row. | Repeat count (u16), nested program (blob) |
LOOP_FOREVER |
Runs a smaller program over and over, without a limit, until the backdoor is told to stop entirely. | Nested program (blob) |
| Sending data | ||
TCP_SEND |
Opens a TCP connection to a chosen address and port, sends a block of data and does not wait for a reply. The remote host can also be a VMware VMCI target instead of a normal network address. | Local address (string), local port (string), remote host (string), remote port (string), data (blob), deadline (u16) |
UDP_SEND |
Sends a single block of data over UDP to a chosen address and port, without waiting for a reply. The remote host can also be a VMware VMCI target instead of a normal network address. | Local address (string), local port (string), remote host (string), remote port (string), data (blob), deadline (u16) |
ICMP_SEND |
Hides a block of data inside a ping request and sends it to a target address. | Source address (string), remote host (string), data (blob), deadline (u16) |
PIPE_SEND |
Writes a block of data to a Windows named pipe on a chosen computer, optionally logging in with a username and password first. | Server name (string), pipe name (string), username (string), password (string), data (blob), deadline (u16) |
| Inbound task reception | ||
TCP_CONNECT_RECV |
Connects out to a chosen address and port and waits to receive a follow-up program. The infected machine reaches out, rather than waiting to be reached. The remote host can also be a VMware VMCI target instead of a normal network address. | Local address (string), local port (string), remote host (string), remote port (string), deadline (u16) |
TCP_LISTEN_RECV |
Opens a TCP port, waits for one connection and receives a follow-up program from whoever connects. The bind address can also be a VMware VMCI target instead of a normal network address. | Bind address (string), bind port (string), deadline (u16) |
UDP_BIND_RECV |
Opens a UDP port and waits for a single incoming block of data, treated as a follow-up program. The bind address can also be a VMware VMCI target instead of a normal network address. | Bind address (string), bind port (string), deadline (u16) |
PIPE_CLIENT_RECV |
Connects to a named pipe on a chosen computer and waits to receive a follow-up program, optionally using a username and password. | Server name (string), pipe name (string), username (string), password (string), deadline (u16) |
PIPE_SERVER_RECV |
Creates a local named pipe, waits for a connection and receives a follow-up program from whoever connects. | Pipe name (string), unused field (string), deadline (u16) |
| Building and running programs | ||
STAGE_WRITE |
Copies a piece of a larger program into a shared 128 KB work area in memory, at a chosen position, so a program can be assembled a little at a time. | Offset (u32), chunk of data (blob) |
STAGE_VERIFY_EXEC |
Compares a SHA-256 fingerprint of the pieces collected so far against one supplied with the instruction and only runs the assembled program on an exact match. | Length (u32), SHA-256 fingerprint (blob) |
DECOMPRESS_RUN |
Expands a program that was compressed before being sent back to its original size, then runs the result. | Unpacked size (u32), compression settings (lzma_properties), compressed data (blob) |
RUN_SHELLCODE |
Runs a block of raw machine code directly in memory, switching that memory from writable to executable right before calling it. | Machine code (blob) |
RUN_FILE_SCRIPT |
Reads a file already saved on the local disk, decrypts it the same way as any other command and runs the result. | File path (string) |
| Trigger detection | ||
SNIFF_MAGIC_PACKET |
Watches one or all network interfaces for the hidden trigger packet described earlier, for a chosen length of time or with no limit at all. This is the instruction actually stored in the analyzed file. | Interface (string), deadline (u16) |
SNIFF_MAGIC_PACKET_DNS |
Does everything the instruction above does and also watches for the DNS-based trigger described further down. Not the instruction found in the analyzed file. | Interface (string), deadline (u16) |
Every instruction from the table breaks down the same way the earlier walkthroughs did. The sections below follow the same grouping as the table, each one naming the instruction and its opcode byte, showing the actual bytes of a working example, describing what that example demonstrates, then showing the tree those bytes decode into.
Basic control
EXIT (0x06)
Example: 06
Shut the backdoor down.
A single byte and nothing else. There is no operand to decode. It sets the same shared flag used by the DLL’s unload path. Every sleep, repeat, schedule and packet listener polls that flag, so an EXIT instruction anywhere stops all of them rather than only the program in which it appears.
SPAWN_THREAD_SCRIPT (0x0B)
Example: 0B 05 87 01 2A 00 00
Run a background copy of the trigger listener while other work continues.
Command = SPAWN_THREAD_SCRIPT (0x0B)
└── script
├── length = 05 (5 bytes follow)
└── data = 87 01 2A 00 00
└── nested program:
Command = SNIFF_MAGIC_PACKET (0x87)
├── interface_filter
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
└── deadline_seconds = 0 (0x0000)
Timing and scheduling
SLEEP_SECONDS (0x0C)
Example: 0C 00 3C
Wait 60 seconds, then continue.
Command = SLEEP_SECONDS (0x0C)
└── duration_seconds = 60 (0x003C)
SLEEP_RANDOM_SECONDS (0x0D)
Example: 0D 01 2C
Wait somewhere between 0 and 299 seconds, then continue.
Command = SLEEP_RANDOM_SECONDS (0x0D)
└── modulus_seconds = 300 (0x012C)
CRON_SCHEDULE (0x0E)
Example: 0E 00 00 00 00 00 00 00 01 00 00 02 00 FF FF FF FE 3E 03 9D FD C1
Run every weekday at 09:00, then pause for a random interval.
Command = CRON_SCHEDULE (0x0E)
├── minute_bitmask = minute 0 (0x0000000000000001)
├── hour_bitmask = hour 9 (0x00000200)
├── day_of_month_bitmask = any day (0xFFFFFFFE)
├── weekday_bitmask = Monday to Friday (0x3E)
└── xor_masked_script
├── length = 03 (3 bytes follow)
└── data = 9D FD C1
└── XORed with the recovered key 0x90FDFD02, this becomes:
Command = SLEEP_RANDOM_SECONDS (0x0D)
└── modulus_seconds = 60 (0x003C)
The scheduled instruction was covered in full detail earlier in this section, including the length byte and the re-encryption step after it runs. This tree is repeated here only so it lines up with its row in the table.
REPEAT_N (0x0F)
Example: 0F 00 03 03 0C 00 0A
Run a 10-second pause three times in a row.
Command = REPEAT_N (0x0F)
├── repeat_count = 3 (0x0003)
└── script
├── length = 03 (3 bytes follow)
└── data = 0C 00 0A
└── nested program:
Command = SLEEP_SECONDS (0x0C)
└── duration_seconds = 10 (0x000A)
LOOP_FOREVER (0x10)
Example: 10 03 0C 00 3C
Repeat a 60-second pause without end.
Command = LOOP_FOREVER (0x10)
└── script
├── length = 03 (3 bytes follow)
└── data = 0C 00 3C
└── nested program:
Command = SLEEP_SECONDS (0x0C)
└── duration_seconds = 60 (0x003C)
Sending data
TCP_SEND (0x29)
Example: 29 01 2A 01 2A 0C 31 39 32 2E 31 36 38 2E 31 2E 31 30 03 34 34 33 03 69 64 0A 00 00
Send a short line of text to 192.168.1.10 on port 443.
Command = TCP_SEND (0x29)
├── local_bind_address
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
├── local_bind_port
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
├── remote_host
│ ├── length = 0C (12 bytes follow)
│ └── data = "192.168.1.10" (31 39 32 2E 31 36 38 2E 31 2E 31 30)
├── remote_port
│ ├── length = 03 (3 bytes follow)
│ └── data = "443" (34 34 33)
├── payload
│ ├── length = 03 (3 bytes follow)
│ └── data = "id\n" (69 64 0A)
└── deadline_seconds = 0 (0x0000)
The two "*" fields are the wildcard seen earlier: no specific local address or port is requested, so the operating system picks one automatically.
UDP_SEND (0x2A)
Example: 2A 01 2A 01 2A 08 31 30 2E 30 2E 30 2E 39 02 35 33 04 70 69 6E 67 00 00
Send the word “ping” to 10.0.0.9 on port 53.
Command = UDP_SEND (0x2A)
├── local_bind_address
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
├── local_bind_port
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
├── remote_host
│ ├── length = 08 (8 bytes follow)
│ └── data = "10.0.0.9" (31 30 2E 30 2E 30 2E 39)
├── remote_port
│ ├── length = 02 (2 bytes follow)
│ └── data = "53" (35 33)
├── payload
│ ├── length = 04 (4 bytes follow)
│ └── data = "ping" (70 69 6E 67)
└── deadline_seconds = 0 (0x0000)
ICMP_SEND (0x2B)
Example: 2B 01 2A 07 38 2E 38 2E 38 2E 38 04 CA FE BA BE 00 00
Send four bytes of data disguised as a ping to 8.8.8.8.
Command = ICMP_SEND (0x2B)
├── source_address
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
├── remote_host
│ ├── length = 07 (7 bytes follow)
│ └── data = "8.8.8.8" (38 2E 38 2E 38 2E 38)
├── payload
│ ├── length = 04 (4 bytes follow)
│ └── data = CA FE BA BE
└── deadline_seconds = 0 (0x0000)
PIPE_SEND (0x2C)
Example: 2C 04 44 43 30 31 07 73 70 6F 6F 6C 73 73 08 43 4F 52 50 5C 73 76 63 05 50 40 73 73 31 06 62 65 61 63 6F 6E 00 00
Write the word “beacon” to the spoolss pipe on a server named DC01, logging in as CORP\svc first.
Command = PIPE_SEND (0x2C)
├── server_name
│ ├── length = 04 (4 bytes follow)
│ └── data = "DC01" (44 43 30 31)
├── pipe_name
│ ├── length = 07 (7 bytes follow)
│ └── data = "spoolss" (73 70 6F 6F 6C 73 73)
├── username
│ ├── length = 08 (8 bytes follow)
│ └── data = "CORP\svc" (43 4F 52 50 5C 73 76 63)
├── password
│ ├── length = 05 (5 bytes follow)
│ └── data = "P@ss1" (50 40 73 73 31)
├── payload
│ ├── length = 06 (6 bytes follow)
│ └── data = "beacon" (62 65 61 63 6F 6E)
└── deadline_seconds = 0 (0x0000)
Inbound task reception
TCP_CONNECT_RECV (0x6F)
Example: 6F 01 2A 01 2A 04 76 6D 3A 32 04 39 30 30 30 00 3C
Connect out through VMware’s VMCI channel to context ID 2, the conventional host endpoint, on port 9000 instead of using a normal network address.
Command = TCP_CONNECT_RECV (0x6F)
├── local_bind_address
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
├── local_bind_port
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
├── remote_host
│ ├── length = 04 (4 bytes follow)
│ └── data = "vm:2" (76 6D 3A 32)
├── remote_port
│ ├── length = 04 (4 bytes follow)
│ └── data = "9000" (39 30 30 30)
└── deadline_seconds = 60 (0x003C)
The host field here is not an IP address. The vm: prefix selects VMware’s VMCI channel, and the decimal value after it is parsed as the destination context ID (svm_cid). The separate port string becomes the VMCI port (svm_port). In this example, CID 2 denotes the VMware host, not a virtual machine numbered 2.
TCP_LISTEN_RECV (0x70)
Example: 70 07 30 2E 30 2E 30 2E 30 04 38 34 34 33 00 00
Listen on port 8443 on any local address.
Command = TCP_LISTEN_RECV (0x70)
├── bind_address
│ ├── length = 07 (7 bytes follow)
│ └── data = "0.0.0.0" (30 2E 30 2E 30 2E 30)
├── bind_port
│ ├── length = 04 (4 bytes follow)
│ └── data = "8443" (38 34 34 33)
└── deadline_seconds = 0 (0x0000)
UDP_BIND_RECV (0x73)
Example: 73 07 30 2E 30 2E 30 2E 30 04 35 33 35 33 00 00
Listen on port 5353 on any local address.
Command = UDP_BIND_RECV (0x73)
├── bind_address
│ ├── length = 07 (7 bytes follow)
│ └── data = "0.0.0.0" (30 2E 30 2E 30 2E 30)
├── bind_port
│ ├── length = 04 (4 bytes follow)
│ └── data = "5353" (35 33 35 33)
└── deadline_seconds = 0 (0x0000)
PIPE_CLIENT_RECV (0x7D)
Example: 7D 04 57 4B 53 37 04 6D 6F 6A 6F 00 00 00 1E
Connect to a pipe named mojo on a workstation called WKS7, using the current login.
Command = PIPE_CLIENT_RECV (0x7D)
├── server_name
│ ├── length = 04 (4 bytes follow)
│ └── data = "WKS7" (57 4B 53 37)
├── pipe_name
│ ├── length = 04 (4 bytes follow)
│ └── data = "mojo" (6D 6F 6A 6F)
├── username
│ ├── length = 00 (0 bytes follow)
│ └── data = "" (0 bytes)
├── password
│ ├── length = 00 (0 bytes follow)
│ └── data = "" (0 bytes)
└── deadline_seconds = 30 (0x001E)
The empty username and password fields are still present on the wire as zero-length strings rather than being left out. This is what connecting with the currently logged-in account looks like.
PIPE_SERVER_RECV (0x7E)
Example: 7E 09 6D 6F 6A 6F 5F 70 69 70 65 00 00 00
Wait for a connection on a locally created pipe named mojo_pipe.
Command = PIPE_SERVER_RECV (0x7E)
├── pipe_name
│ ├── length = 09 (9 bytes follow)
│ └── data = "mojo_pipe" (6D 6F 6A 6F 5F 70 69 70 65)
├── reserved (unused)
│ ├── length = 00 (0 bytes follow)
│ └── data = "" (0 bytes)
└── deadline_seconds = 0 (0x0000)
Building and running programs
STAGE_WRITE (0x32)
Example: 32 00 00 00 00 06 65 04 48 31 C0 C3
Write six bytes to the very start of the work area. On its own this instruction does nothing else: it only fills the buffer, and something else has to check and run the contents afterward. The six bytes chosen here are a complete instruction in their own right, the RUN_SHELLCODE example shown further down.
Command = STAGE_WRITE (0x32)
├── buffer_offset = 0 (0x00000000)
└── chunk_data
├── length = 06 (6 bytes follow)
└── data = 65 04 48 31 C0 C3
The offset travels with the instruction, so chunks do not have to arrive in order and can fill the work area in any pattern. Before copying, the code checks the offset against the size of that area, then checks the offset and the chunk length together in a way that also catches the numeric wraparound a careless check would miss. Nothing is verified or run at this point, and the area keeps whatever it already held anywhere the new chunk does not cover.
STAGE_VERIFY_EXEC (0x33)
Example: 33 00 00 00 06 20 A0 A0 D4 5F 4B C3 12 59 D6 89 57 96 65 95 54 1F 60 24 C3 D5 F1 BB 36 81 C0 A2 7E 2C DE D5 68 C1
Confirm six previously written bytes match their expected fingerprint, then run them. The fingerprint is a SHA-256 hash, the same kind of check often used to confirm a downloaded file was not corrupted in transit, and a single byte out of place is enough for the instruction to refuse to run anything.
Command = STAGE_VERIFY_EXEC (0x33)
├── verified_length = 6 (0x00000006)
└── expected_sha256
├── length = 20 (32 bytes follow)
└── data = A0 A0 D4 5F ... DE D5 68 C1
This pairs with the STAGE_WRITE above because both act on the same buffer: the fingerprint here is the SHA-256 of exactly the six bytes that write placed there, so the check passes. A match hands the buffer contents back to the interpreter rather than to the processor, so a staged program is bytecode and can be any instruction the language offers. Staging a RUN_SHELLCODE instruction, as here, is how staged bytes end up as running machine code. RUN_SHELLCODE on its own needs no staging.
DECOMPRESS_RUN (0x1F)
Example: 1F 00 00 08 00 5D 00 00 10 00 04 00 11 22 33
Expand a compressed block back to its original size before running it. Everything the instruction needs travels with it: the claimed size of the output, the five settings bytes the decompressor requires and the compressed data itself.
Command = DECOMPRESS_RUN (0x1F)
├── unpacked_size = 2048 (0x00000800)
├── lzma_properties = lc=3, lp=0, pb=2, 1 MiB dictionary (5D 00 00 10 00)
└── compressed_data
├── length = 04 (4 bytes follow)
└── data = illustrative only, not a full compressed stream (00 11 22 33)
This instruction is self-contained and has nothing to do with the shared work area the two staging instructions above use. The compressed bytes are its own third field, so a complete program arrives in one message instead of being assembled from several. The output goes into a fresh block of memory taken from the process heap, sized by the claimed unpacked size rather than by anything measured from the data itself. What comes out is handed to the interpreter, not to the processor, so a decompressed program is bytecode like any other and still needs a RUN_SHELLCODE instruction inside it to reach native code. Staging and compression solve different problems: one splits up a program too large for a single message, the other packs it into one.
RUN_SHELLCODE (0x65)
Example: 65 04 48 31 C0 C3
Run a very short block of test machine code. Memory is initially writable, the code is copied into it and VirtualProtect then changes it to executable before the call. VirtualProtect is resolved by name at runtime rather than appearing in the file’s normal imports.
Command = RUN_SHELLCODE (0x65)
└── shellcode
├── length = 04 (4 bytes follow)
└── data = xor rax, rax ; ret (48 31 C0 C3)
This is the only instruction in the language that hands bytes to the processor rather than back to the interpreter. The two-step permission change prevents the block from being writable and executable at the same time, which is the safer sequence. The call happens on the current thread, so the interpreter waits until the code returns, and the block is released the moment it does, leaving nothing behind unless the code itself arranged otherwise.
RUN_FILE_SCRIPT (0x66)
Example: 66 14 43 3A 5C 50 72 6F 67 72 61 6D 44 61 74 61 5C 64 2E 64 61 74
Load and run a program stored in a file under C:\ProgramData.
Command = RUN_FILE_SCRIPT (0x66)
└── file_path
├── length = 14 (20 bytes follow)
└── data = "C:\ProgramData\d.dat" (43 3A 5C 50 72 6F 67 72 61 6D 44 61 74 61 5C 64 2E 64 61 74)
The entire file is read into memory and then passed through the same decryption the network channels use, with the same embedded key and envelope. A file on disk is not a different kind of payload, only a different way of delivering one. It holds an ordinary encrypted task program and reaches the interpreter through the same code path as the contents of a trigger packet. Nothing limits how large the file may be before it is read, and it is left in place afterward rather than deleted. Nothing in the command language puts that file there either. No instruction writes to disk, and every handle the backdoor opens asks for a file that already exists, so it cannot create one. From inside the language, only a RUN_SHELLCODE payload can create it with native code. Anything else has to come from elsewhere in the intrusion.
Trigger detection
SNIFF_MAGIC_PACKET (0x87)
Example: 87 01 2A 00 00
The instruction actually stored in the analyzed file: watch every interface, forever, for the raw trigger packet only. The DNS-based trigger covered further down is not active under this opcode.
Command = SNIFF_MAGIC_PACKET (0x87)
├── interface_filter
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
└── deadline_seconds = 0 (0x0000)
SNIFF_MAGIC_PACKET_DNS (0x88)
Example: 88 01 2A 00 00
The same instruction as above, watching every interface forever, but with the DNS-based trigger also active. This is the opcode that switches the DNS carrier on. It is not the opcode stored in the analyzed file.
Command = SNIFF_MAGIC_PACKET_DNS (0x88)
├── interface_filter
│ ├── length = 01 (1 byte follows)
│ └── data = "*" (0x2A)
└── deadline_seconds = 0 (0x0000)
Put together, this language lets an operator describe a wide range of behavior using a short list of building blocks. Despite that range, the single instruction actually stored and encrypted inside the analyzed file was short: listen on every network interface, with no time limit, for the trigger packet described earlier. Everything else in this section, from scheduling to staged file delivery to running code in memory, only exists as a capability the language provides. The programs an operator might actually choose to send still have to arrive later, over the network.
Alternative trigger channels and transports
Five of the networking instructions share an unusual extra capability: TCP_SEND, UDP_SEND, TCP_CONNECT_RECV, TCP_LISTEN_RECV and UDP_BIND_RECV all check whether the address they were given starts with vm:, and if it does, they use VMware’s internal channel for talking between a virtual machine and its host, known as VMCI, instead of a normal network address. If the infected machine is a virtual machine running on VMware software, this channel allows commands to pass between the guest and the host or between two guests on the same host without that traffic ever appearing on a regular network, since the communication happens through the virtualization layer itself rather than a network adapter. A packet capture between machines would not include any of it. To find the correct address family value for this channel, the backdoor opens the device object \\.\VMCI and asks it directly, the same way VMware’s own VMCI Sockets API does.
There is also a second way to deliver a trigger, hidden inside ordinary-looking DNS lookups, though it is not what the analyzed file actually uses. A separate opcode, one opcode value higher than the instruction stored in the file, enables this DNS-based trigger alongside the raw one. Activating it would require either a different build with that opcode embedded or a follow-up task delivered through another route after the deployed listener had already been reached. The backdoor treats certain DNS queries as commands by encoding the command with a text-safe scheme, similar to how email attachments are sometimes encoded, and splitting it across the parts of a domain name. This lets a command travel through networks that only allow DNS traffic out, which many networks do even when most other outbound traffic is restricted.
Before any of that, the packet has to look like a DNS question in the first place: UDP or TCP to port 53, carrying a standard query header that asks exactly one question and claims no answer, authority or additional records. Nothing else in the header is examined, including the transaction number and the record type being asked about. One detail makes UDP the practical carrier. A DNS query sent over TCP is prefixed with a two-byte length field, and this code never skips it, so a standards-compliant TCP query arrives two bytes out of step and fails to parse.
Each DNS label used this way, meaning one dot-separated part of a domain name, has its own small format, separate from the length-prefixed fields used everywhere else in this post. A label is built from three parts: one marker character, a run of Base32-encoded text in the middle and a second marker character. The two markers are not fixed letters. Between them they carry a single checksum byte covering the middle text, which is what lets the backdoor tell a genuine label apart from an ordinary one. Any label that does not satisfy that checksum is silently skipped, which matters because a real query usually has more than one label, for example the example and com parts of example.com, and only the specific label carrying the trigger needs to pass.
The label checksum is a CRC-8 using polynomial 0x31, run from a starting value of zero through a 256-entry lookup table, and it covers the middle characters only, not the markers themselves. The resulting byte is then split in half: the top four bits become the first marker and the bottom four bits the last, each added to the letter g. Four bits hold sixteen values, so both markers always land between g and v, and checking that range is the first thing the backdoor does. A label whose first or last character sits outside it is dropped before any checksum is calculated, which is why ordinary labels cost almost nothing to reject.
To show this end to end, I built and verified a trigger of my own, not something captured from real traffic, encoding the same SLEEP_SECONDS(60) instruction used earlier. Encrypted with the DNS channel’s own framing (7-byte nonce, 4-byte tag, then ciphertext, using the same embedded AES-256 key as every other channel), the instruction comes to 14 bytes:
81 5C 22 62 CC B7 09 31 24 6F D3 5F 34 4D
Base32 encoding those 14 bytes with the backdoor’s lowercase alphabet gives a 23-character string. Its CRC-8 works out to 0x65, so the markers are the letters standing for 6 and 5, which are m and l. Wrapping those around the middle turns it into a single valid label:
mqfoceywmw4etcjdp2nptitil
Placed in an otherwise ordinary-looking domain name, the full query becomes:
mqfoceywmw4etcjdp2nptitil.example.com
Reading it back the same way the backdoor would, m and l both sit between g and v, so they are treated as markers. Subtracting g from each gives 6 and 5, which recombine into 0x65. Recomputing the CRC-8 over the 23 characters between them produces that same 0x65, so the label is genuine. The example and com labels that follow are rejected on the range test alone. Because e and c both come before g, the backdoor skips them without any special handling and moves on. Base32 decoding the 23-character middle section gives back the exact 14 bytes shown above:
[label] mqfoceywmw4etcjdp2nptitil
├── marker (first) = "m"
├── payload (base32, 23 chars) = qfoceywmw4etcjdp2nptiti
└── marker (last) = "l"
└── decodes to 14 bytes: 81 5C 22 62 CC B7 09 31 24 6F D3 5F 34 4D
├── nonce = 81 5C 22 62 CC B7 09 (7 bytes)
├── tag = 31 24 6F D3 (4 bytes)
└── ciphertext = 5F 34 4D (3 bytes)
└── decrypted with AES-256-CCM and the embedded AES-256 key:
Command = SLEEP_SECONDS (0x0C)
└── duration_seconds = 60 (0x003C)
Everything after the decode is the same as any other channel. Joining the decoded labels back together produces the AES-256-CCM envelope shown above, and from there it is decrypted and handed to the interpreter just as a trigger packet’s contents are. DNS adds only a preceding encoding layer: the envelope arrives split across one or more labels rather than in one piece.
Taken together, the networking instructions described above use six underlying transports. A shared factory installs the appropriate send, receive, bind and listen functions for the selected transport, allowing each networking opcode to use its chosen channel consistently. None of these transports has a hard-coded address, domain or URL. Every target is supplied at runtime inside the task program.
| Transport | Mechanism | Notes |
|---|---|---|
| TCP | socket / connect / listen / accept | Client and server. Host and port are resolved with getaddrinfo. |
| UDP | sendto / recvfrom | One-shot send and bind-and-receive. |
| ICMP | IcmpSendEcho |
Data is smuggled inside ping echo-request payloads. |
| SMB named pipe | CreateNamedPipeW / CreateFileW on \\host\pipe\name |
Can mount the remote share with supplied credentials first, for lateral movement. |
| VMware VMCI | Address family resolved through \\.\VMCI |
A covert guest-to-host or guest-to-guest channel that never touches a physical network adapter. |
| Raw / promiscuous | Raw socket with promiscuous mode enabled | How the hidden trigger packet described earlier is received. |
Network reachability and attacker positioning
Two questions are worth separating here: how an operator delivers the first command to an idle backdoor and how far a task’s transport can reach once a task is running. They have different answers, summarized here and explained below:
| Channel | Internet | Firewall / NAT | Internal network | Target host |
|---|---|---|---|---|
| Raw trigger (first command) | Blocked | Blocked | Reaches | Reaches |
| DNS trigger (implemented, not active in this sample) | Reaches | Reaches | Reaches | Reaches |
| VMCI (guest/host channel) | Not applicable | Not applicable | Not applicable | Reaches only within the same VMware host or VMCI fabric |
| Outbound-initiated transports (after trigger) | Reaches | Reaches | Reaches | Reaches |
| Inbound-facing transports (after trigger) | Blocked | Blocked | Reaches | Reaches |
“Blocked” means a perimeter firewall or NAT gateway ordinarily stops it, not that it is impossible under every network configuration. The paragraphs below cover the exceptions.
Delivering the first command depends on an ordinary packet actually reaching the network interface the backdoor is watching. A perimeter firewall or a NAT gateway commonly blocks unsolicited raw traffic arriving from the open internet, so reaching the raw trigger in practice means the operator already has a path onto that network, either by already being on it or by pivoting from another machine that is. The ordinary exceptions apply here too: a host with a public IP address, a NAT or port-forwarding rule aimed at it or a host that is itself running a public-facing DNS service, can all be reached directly.
There is a less obvious exception. Each interface is captured using Windows’ SIO_RCVALL option set to receive everything crossing it, not only packets addressed to the local host. On an ordinary endpoint, this makes little difference. On a machine that routes or forwards traffic for others, such as a gateway, VPN server or host bridging two network segments, traffic addressed to a completely different machine would still cross the watched interface and could carry the trigger. A machine used this way does not need to be the operator’s actual destination at all.
A DNS-based trigger exists in the binary as a workaround for that more restrictive case, but it is not what the analyzed sample actually runs. The bootstrap embedded in the file selects the plain listener. The DNS-aware listener uses a separate opcode that an operator would have to select by shipping a different build or by sending a follow-up task through another route. Where it is used, DNS is one of the few kinds of traffic a network almost always allows through and one of the least closely inspected, so it is the channel best suited to crossing a boundary that would stop the raw trigger outright. It does not remove the need for a packet to reach the interface, only the need for the operator to already be close enough for a plain raw packet to get there. Such a trigger could also arrive without any inbound delivery if something on the machine is induced to make an outbound DNS lookup carrying the trigger. The same listener would see that query as it leaves.
Once a task is running, its reach depends on the transport it selects. Most transports do not need the same kind of access as the initial trigger. TCP_SEND, UDP_SEND, TCP_CONNECT_RECV and ICMP_SEND all have the infected machine connect or send outward to an address the task supplies, the same direction as any ordinary outbound connection, so they typically still work from behind a NAT gateway or firewall that would have blocked the initial trigger. Only TCP_LISTEN_RECV and UDP_BIND_RECV go the other way, waiting for something to connect or send to the infected machine, which carries the same inbound-reachability requirement as the initial trigger. An SMB named pipe is ordinary Windows networking, reachable across a local network the same way any file share is. VMware’s VMCI channel requires the operator endpoint and target to run as two guests or as a guest and host on the same physical VMware machine, a narrower and different kind of closeness than sharing a network.
The deployment fits this picture. Riding inside ERAAgent.exe means the realistic target is a managed 64-bit Windows endpoint or server with ESET Management Agent installed, the kind of machine normally placed behind a firewall and a NAT gateway rather than exposed directly to the internet. That is exactly the kind of setting in which the raw trigger alone would struggle to reach the target, which makes it notable that the analyzed sample relies on it anyway, without the DNS workaround switched on. So the first command favors an operator with some existing position on or next to the target’s network, or one of the narrower exceptions above, over a stranger on the open internet with nothing in hand. Once that command lands, though, most of what a task can do reaches outward rather than requiring anything to reach in, so the operator does not need to keep that position for everything that follows.
Host configuration changes
To facilitate unauthenticated named-pipe access, the backdoor changes two security settings on the infected computer:
- It sets
EveryoneIncludesAnonymous, causing permissions granted to Everyone to apply to anonymous access tokens. - It adds its pipe name to
NullSessionPipes, allowing that named pipe to be reached without a username or password.
It also creates its named pipes with permission rules that allow Everyone and Anonymous Logon to connect. Together, these changes allow unauthenticated callers to reach the backdoor’s named-pipe channel when the surrounding network permits it.
The code attempts to undo these changes later, but its bookkeeping does not reliably preserve the original configuration. In particular, it records whether adding the NullSessionPipes entry succeeded, not whether the entry already existed. Cleanup can therefore remove an entry that was present before the backdoor ran.
None of this involves privilege escalation. There is no code anywhere in the file that tries to bypass User Account Control or gain higher permissions than it starts with. Changing the two registry keys described above already requires local administrator rights. The backdoor relies on the security context of its host process rather than obtaining those rights itself. As long as the malicious file stays in the same folder as ERAAgent.exe, it can be loaded again whenever the ESET Management Agent service starts. The side-loading itself is the only persistence mechanism the backdoor uses.
A note on AI usage
SLEEPWALKER was one of several malware samples I used to compare frontier AI models for reverse engineering of Windows PE malware. I performed the initial analysis manually to get a basic understanding of the malware and preserve the hands-on challenge that makes malware analysis fun. AI then assisted with the detailed analysis and verification presented in this post.
I tested Claude Opus 5 and GPT-5.6-Sol. Opus 4.8 and Sonnet 5 were also used when safety restrictions prevented Opus 5 from continuing. I had planned to include Kimi K3, but I am still waiting for access. I excluded Fable because, in my testing, its security filters blocked even general questions whose answers might have dual-use applications. The test set combined several previously undisclosed samples from my backlog with a few publicly described samples whose binaries had not been released, such as STRAITBIZARRE (SBZ).
On SLEEPWALKER, the models produced broadly similar results and the differences were usually small. Claude performed better on some parts of the analysis, while GPT performed better on others. Neither model family was consistently ahead. One notable exception was the analysis of SNIFF_MAGIC_PACKET_DNS. On three separate attempts, Claude described SNIFF_MAGIC_PACKET (0x87) and SNIFF_MAGIC_PACKET_DNS (0x88) as functionally identical. GPT identified the important difference on its first attempt: opcode 0x87 enables only the raw-packet trigger, while opcode 0x88 also enables the DNS-based trigger.
My overall experience with GPT was better than expected, especially because I had not previously used it for malware analysis. Its weekly usage allowance was easier to work with than Claude’s hourly limit during long reverse-engineering sessions. None of my GPT tests were interrupted by safety refusals, even though I am not enrolled in the Trusted Access for Cyber program. By contrast, I eventually encountered a refusal in every malware-analysis run with Claude Opus or Sonnet. This sometimes happened early and sometimes only after substantial progress, despite my acceptance into the Cyber Verification Program. These interruptions made longer investigations difficult to complete in a continuous workflow.
Malware analysis can, of course, also be misused. A newly discovered technique or vulnerability could theoretically be repurposed, but this is an unlikely outcome when the work is done by a responsible analyst. In my opinion, Anthropic should apply stricter admission checks to applicants for programs such as the Cyber Verification Program and, in return, give approved researchers fewer restrictions when conducting legitimate reverse engineering. This would address concerns about potential abuse without repeatedly interrupting legitimate malware research.
Overall, AI is a powerful tool for accelerating malware analysis. Detailed dissections that once took hours, days, weeks or even months can be completed in a fraction of the time. This does not remove the need for technical expertise or careful verification. Every result still has to be checked against the code and available evidence, but doing so is usually much faster than performing every step manually. The same applies to reports and blog posts. For many malware researchers, myself included, dissecting the malware is the enjoyable part. After investing substantial time and energy in the investigation, turning all the findings into a clear and readable document can feel much like the documentation phase at the end of a long software project. AI can help organize notes, shape the structure and draft prose, but publication still requires substantial proofreading, technical verification and correction. It does not remove the work, but it can significantly shorten the path from completed analysis to a readable report.
What remains unknown
This analysis is based on one SLEEPWALKER binary, without related incident records or network captures. Several important parts of the larger picture remain unknown:
- Sample origin and victim: I have no collection context tying the file to a confirmed intrusion, so I cannot identify a victim, industry, country or affected organization. The requirement to run inside
ERAAgent.exepoints to a 64-bit Windows endpoint or server with ESET Management Agent installed, but it does not reveal whether the actual host was a workstation, server, gateway, VPN system or VMware guest. It does not prove that the sample was successfully deployed at all. - Initial access and delivery: DLL side-loading explains how SLEEPWALKER executes and persists after it has been placed beside
ERAAgent.exe. It does not explain how an operator first entered the environment, obtained the required administrator access or wrote the malicious DLL into that protected application directory. No dropper, installer, exploit or initial-access technique is present in this file. - Companion components and operator tooling: The backdoor cannot install itself, and its command language does not provide a general way to create the files it expects to find. The unresolved
dpapisvc.dllforwarding dependency may indicate that another component places a renamed genuine DLL beside it, but no such file accompanied this sample. The trigger generator, bytecode task builder, delivery mechanism and any later payloads must also exist outside this binary. This means a wider attack toolset is possible, but the sample cannot show whether those pieces belong to a reusable framework or were assembled specifically for one operation. - Commands actually received: The only encrypted task stored in the sample starts the raw-packet listener. The remaining instructions describe capabilities, not observed attacker behavior. Without captured trigger traffic, memory from an infected host or the local files referenced by later tasks, there is no way to know which commands were sent, which payloads ran, what data was collected or whether lateral movement occurred.
- Channels actually used: DNS triggering, VMCI, ICMP, named pipes and the other transports are implemented, but their presence does not prove that an operator used them. In particular, DNS support is not enabled by the embedded bootstrap. VMCI support does not by itself prove that the intended or actual victim was a VMware guest.
- Infrastructure and operator position: There are no hard-coded servers, domains, addresses or operator identifiers. The raw trigger favors someone already able to put a packet onto or through the target network, but the code cannot say whether that access came from another compromised host, an insider position, a routed system, a public-facing interface or some other path.
- Attribution, campaign and spread: Nothing in the file identifies its developer or operator. I found no related code that would support attribution to a known group, and this one sample cannot establish when or how widely SLEEPWALKER was deployed, whether variants exist or whether it belongs to a continuing campaign.
The binary supports the assessment of a targeted and technically capable operation, but the victim, operator, delivery chain and real post-compromise activity remain unconfirmed.
If you believe you have been targeted by SLEEPWALKER or have encountered a related sample, please contact me. I have created a toolkit to help decode its bytecode, examine encrypted and network artifacts, summarize behavior and indicators and safely reproduce its receiving pipeline without executing commands or transmitting traffic. I have also created a mitigation guide that includes a remediation script for use after SLEEPWALKER is detected.
Even if you cannot share the original evidence, sanitized technical details could help fill in the missing picture. I would be particularly interested in learning how the malware was delivered, what other files or tools accompanied it, which commands and payloads were observed, what infrastructure and transports were used and whether any tactics, techniques and procedures connect the operator or wider toolset to other activity.
Conclusion
SLEEPWALKER is a passive backdoor that does not beacon on its own, carries no embedded second-stage payload and is designed to run through DLL side-loading into ERAAgent.exe. It activates when the host process carries that name. The binary contains implementations for scheduling, six transports, staged delivery with SHA-256 verification and in-memory execution. What arrives later is the bytecode that selects and combines those capabilities.
Taken as a whole, the approach here is consistent with a targeted, well-resourced operation rather than an opportunistic one. It combines a passive implant woken by a single crafted packet, several covert transports including a rarely seen VMware channel and deployment through side-loading into a trusted ESET management component. The design favors an operator who can already get a packet onto the target’s network. The trigger has to reach an interface the backdoor is watching. The DNS-based trigger is the one feature that would loosen that requirement. It is implemented but not switched on: the bootstrap embedded in this sample listens for the raw trigger alone. I could not attribute this sample to a specific group, since I haven’t seen any similar code in the past and don’t have any information of the attack chain.
At the time of publication, I found no earlier public reporting of this backdoor, and detection coverage for the file remained low. This lack of exposure means SLEEPWALKER could still be in use and may still be under development, with later or modified builds that have not yet been identified.
File download
SLEEPWALKER can be downloaded here (pw: “sleepwalker_infected”): sleepwalker.zip
Indicators of compromise
- SHA-256:
d347170752a28e2b8c4b8b9f3cab2e3a6541ba11682c94498d26eb9002779d60 - An unexpected
dpapi.dllbesideERAAgent.exe - An unexpected
dpapisvc.dllin the same directory EveryoneIncludesAnonymousset to1- An unexpected entry in
NullSessionPipes
The registry values require comparison with a known-good baseline and are not proof of SLEEPWALKER on their own.
Appendix
This appendix provides two detection tools built from the findings in this post. They are starting points rather than finished products. Both were checked against the analyzed sample directly before being included here. Every hash, byte pattern and string in the YARA rule was confirmed in the actual file, and the scanner was run against synthetic test data and a copy of the real sample.
YARA detection rule
import "pe"
rule sleepwalker_backdoor
{
meta:
author = "Dominik Reichel"
description = "Detects the SLEEPWALKER passive backdoor."
sha256 = "d347170752a28e2b8c4b8b9f3cab2e3a6541ba11682c94498d26eb9002779d60"
date = "2026-08-11"
reference = "https://r136a1.dev/2026/08/24/sleepwalker-a-passive-backdoor-with-its-own-command-language/"
strings:
// Static AES-256 key used for every authenticated task envelope
$aes_key = { 74 65 31 FF 37 8D BB 4B B5 1D 2A A2 B1 D3 8D 90
53 50 A9 59 58 31 86 BA F4 C6 90 F5 F3 16 B3 AE }
// 12-byte nonce for the embedded-bootstrap task envelope
$config_nonce = { 3A 6D 35 7F B9 BC 51 EA CC 8B 85 09 }
// Trigger-packet validation logic: length check, then XOR the packet's
// last two 16-bit values together and XOR again with 0xAAAA to get a
// candidate length, checked against a minimum of 0x1C (28). This is
// the backdoor's own protocol code, not a masquerade string or the
// per-build task key, so it holds regardless of which system DLL a
// variant imitates or which vendor name it forges. It is still
// compiled code, so a rebuild with a different compiler or different
// optimization settings could change register choice and break the
// match. The 4-byte jump offset is wildcarded since it shifts if
// unrelated code elsewhere in the file changes size.
$magic_packet_algo = {
49 83 FC 30 // cmp r12, 0x30
0F 82 ?? ?? ?? ?? // jb ...
47 0F B7 44 25 FC // movzx r8d, word [r13+r12-4]
47 0F B7 4C 25 FE // movzx r9d, word [r13+r12-2]
B8 AA AA 00 00 // mov eax, 0xAAAA
41 0F B7 C8 // movzx ecx, r8w
66 41 33 C9 // xor cx, r9w
66 33 C8 // xor cx, ax
66 83 F9 1C // cmp cx, 0x1C
}
// Non-existent DPAPI service DLL
$dpapi_svc = "dpapisvc.dll" wide
condition:
uint16(0) == 0x5A4D and
uint32(uint32(0x3C)) == 0x00004550 and
(
any of ($aes_key, $config_nonce, $magic_packet_algo)
or (
pe.version_info["OriginalFilename"] contains "dpapi.dll" and
(
pe.version_info["FileDescription"] contains "ESET Management Agent Module" or
$dpapi_svc
) and
pe.exports("CryptProtectDataNoUI") and
pe.exports("CryptProtectMemory") and
pe.exports("CryptResetMachineCredentials") and
pe.exports("CryptUnprotectDataNoUI") and
pe.exports("CryptUnprotectMemory") and
pe.exports("CryptUpdateProtectedState") and
pe.exports("iCryptIdentifyProtection")
)
)
}
PowerShell detection script
The script reads and reports but never writes, so it is safe to run across an estate before deciding on a response. It was checked against the analyzed sample directly: the SHA-256 hash was confirmed against the real file, and the scan logic was run against synthetic test data and a copy of the sample.
It covers the host-side indicators from the companion guide: a dpapi.dll next to ERAAgent.exe, its SHA-256 hash, a dpapisvc.dll alongside it and the two registry values. The optional -IncludeMetadata switch also collects the candidate’s Authenticode status and version-resource claims. An optional -Path sweep searches any folder or file share for exact hash matches, filtering on the sample’s exact 59,904-byte size before hashing to keep large scans efficient.
The script reports the contents of NullSessionPipes without attributing individual entries to SLEEPWALKER. However, any nonempty list is classified as RegistryReviewRequired and produces exit code 1 so an analyst can compare it with a known-good baseline.
Registry access errors are suppressed by this compact scanner, so a clean result means that no readable indicators were found in the scanned scope. It does not prove that every registry value was successfully queried.
Exit codes make it usable in a scheduled sweep: 0 for nothing found, 1 for an anomaly or registry configuration requiring review and 2 for a confirmed hash match.
<#
.SYNOPSIS
Scans a Windows host for the SLEEPWALKER backdoor (masquerading as dpapi.dll,
side-loaded beside ESET's ERAAgent.exe).
.DESCRIPTION
Read-only. Checks for a dpapi.dll beside ERAAgent.exe, the known SHA-256,
dpapisvc.dll and the two registry values changed by the backdoor. NullSessionPipes
entries are reported for comparison with the host's baseline, not attributed
automatically to SLEEPWALKER.
.PARAMETER SearchRoot
Directories to search for ERAAgent.exe. Defaults to both Program Files locations.
.PARAMETER Path
Extra directories to sweep for the exact sample by size and SHA-256.
.PARAMETER IncludeMetadata
Collect Authenticode status and version-resource claims for candidate DLLs.
.PARAMETER AsJson
Emit one JSON object instead of formatted text, for collection at scale.
.NOTES
Exit codes: 0 nothing found, 1 anomaly or registry review required,
2 confirmed hash match. A confirmed match requires incident response.
Paths skipped due to access-denied/IO errors during the sweep are reported
(InaccessiblePathCount, or the "could not be scanned" line / -Verbose in
text mode) but do not change the exit code -- an incomplete scan is not
itself evidence of compromise, so check that count separately.
#>
[CmdletBinding()]
param(
[string[]] $SearchRoot,
[string[]] $Path,
[switch] $IncludeMetadata,
[switch] $AsJson
)
$ErrorActionPreference = 'Stop'
if (-not $SearchRoot) {
$programFilesX86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)')
$SearchRoot = @($env:ProgramFiles, $programFilesX86) | Where-Object { $_ }
}
$KnownBadSha256 = 'D347170752A28E2B8C4B8B9F3CAB2E3A6541BA11682C94498D26EB9002779D60'
$KnownBadSize = 59904
$CompanionDllName = 'dpapisvc.dll'
$LsaKeyPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
$LsaValueName = 'EveryoneIncludesAnonymous'
$LanmanParamsKeyPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters'
$NullSessionValueName = 'NullSessionPipes'
# Populated via -ErrorVariable +script:InaccessiblePaths in the sweeps below, so an
# access-denied subtree is reported instead of silently making the scan look clean.
$InaccessiblePaths = @()
function Find-SideLoadedDpapiDll {
param(
[string[]] $Roots,
[switch] $IncludeMetadata
)
Write-Verbose "Searching for ERAAgent.exe under: $($Roots -join ', ')"
$agents = foreach ($root in $Roots) {
if (Test-Path -LiteralPath $root) {
Get-ChildItem -LiteralPath $root -Filter 'ERAAgent.exe' -Recurse -File -ErrorAction SilentlyContinue -ErrorVariable +script:InaccessiblePaths
} else {
Write-Warning "Skipping '$root': not found."
}
}
if (-not $agents) {
[PSCustomObject]@{
Status = 'NoAgentFound'
Message = 'No ERAAgent.exe found under the given search roots. If ESET is installed elsewhere, pass -SearchRoot.'
}
return
}
foreach ($agent in $agents) {
$candidate = Join-Path $agent.DirectoryName 'dpapi.dll'
$companionPresent = Test-Path -LiteralPath (Join-Path $agent.DirectoryName $CompanionDllName)
if (-not (Test-Path -LiteralPath $candidate)) {
[PSCustomObject]@{
Status = if ($companionPresent) { 'AnomalousPresence' } else { 'Clean' }
AgentPath = $agent.FullName
DllPath = $candidate
CompanionDllFound = $companionPresent
Message = if ($companionPresent) {
"No dpapi.dll here, but a $CompanionDllName is present. No genuine Windows component uses that name, so review it."
} else {
'No dpapi.dll sitting beside this ERAAgent.exe. A legitimate install has no reason to carry one here.'
}
}
continue
}
$sha256 = $null
$hashError = $null
try {
$sha256 = (Get-FileHash -LiteralPath $candidate -Algorithm SHA256).Hash
} catch {
$hashError = $_.Exception.Message
}
$signatureStatus = $null
$claims = $null
if ($IncludeMetadata) {
try {
$signatureStatus = (Get-AuthenticodeSignature -LiteralPath $candidate).Status.ToString()
} catch {
$signatureStatus = 'Unavailable'
}
try {
$versionInfo = (Get-Item -LiteralPath $candidate).VersionInfo
$claims = "$($versionInfo.CompanyName) / $($versionInfo.ProductName)"
} catch {
$claims = 'Unavailable'
}
}
$isKnownBad = $null -ne $sha256 -and $sha256 -eq $KnownBadSha256
[PSCustomObject]@{
Status = if ($isKnownBad) { 'ConfirmedMatch' } else { 'AnomalousPresence' }
AgentPath = $agent.FullName
DllPath = $candidate
CompanionDllFound = $companionPresent
Sha256 = $sha256
HashError = $hashError
SignatureStatus = $signatureStatus
Claims = $claims
Message = if ($hashError) {
'A dpapi.dll exists next to ERAAgent.exe but could not be hashed. Review it manually.'
} elseif ($isKnownBad) {
'SHA-256 matches the known SLEEPWALKER sample exactly.'
} else {
'A dpapi.dll exists next to ERAAgent.exe but its hash does not match the known sample. Its location remains anomalous and warrants manual review as a possible variant.'
}
}
}
}
function Find-SampleByHash {
param([string[]] $Roots)
foreach ($root in $Roots) {
if (-not (Test-Path -LiteralPath $root)) {
Write-Warning "Skipping '$root': not found."
continue
}
Write-Verbose "Sweeping $root for files of exactly $KnownBadSize bytes."
Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue -ErrorVariable +script:InaccessiblePaths |
Where-Object { $_.Length -eq $KnownBadSize } |
ForEach-Object {
$file = $_
try {
$sha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
if ($sha256 -eq $KnownBadSha256) {
[PSCustomObject]@{
Status = 'ConfirmedMatch'
DllPath = $file.FullName
Sha256 = $sha256
Message = 'Contents match the known SLEEPWALKER sample, under a different name or location.'
}
}
} catch {
Write-Warning "Could not hash '$($file.FullName)': $($_.Exception.Message)"
}
}
}
}
$findings = @(Find-SideLoadedDpapiDll -Roots $SearchRoot -IncludeMetadata:$IncludeMetadata)
if ($Path) {
$findings += @(Find-SampleByHash -Roots $Path)
}
$lsaValue = (Get-ItemProperty -LiteralPath $LsaKeyPath -Name $LsaValueName -ErrorAction SilentlyContinue).$LsaValueName
$nullSessionEntries = (Get-ItemProperty -LiteralPath $LanmanParamsKeyPath -Name $NullSessionValueName -ErrorAction SilentlyContinue).$NullSessionValueName
$registryState = [PSCustomObject]@{
EveryoneIncludesAnonymous = $lsaValue
IsAnonymousShareAccessOn = ($lsaValue -eq 1)
NullSessionPipes = $nullSessionEntries
NullSessionPipeCount = @($nullSessionEntries | Where-Object { $_ }).Count
}
$confirmedCount = @($findings | Where-Object { $_.Status -eq 'ConfirmedMatch' }).Count
$anomalousCount = @($findings | Where-Object { $_.Status -eq 'AnomalousPresence' }).Count
$registryRequiresReview = $registryState.IsAnonymousShareAccessOn -or $registryState.NullSessionPipeCount -gt 0
$overallStatus = if ($confirmedCount -gt 0) {
'ConfirmedMatch'
} elseif ($anomalousCount -gt 0) {
'AnomalousFile'
} elseif ($registryRequiresReview) {
'RegistryReviewRequired'
} else {
'Clean'
}
$scanTimestamp = Get-Date
$inaccessiblePathMessages = @($InaccessiblePaths | ForEach-Object { $_.Exception.Message })
if ($AsJson) {
[PSCustomObject]@{
ScannedAtUtc = $scanTimestamp.ToUniversalTime().ToString('o')
ComputerName = $env:COMPUTERNAME
Findings = $findings
RegistryState = $registryState
RegistryRequiresReview = $registryRequiresReview
InaccessiblePathCount = $inaccessiblePathMessages.Count
InaccessiblePaths = $inaccessiblePathMessages
OverallStatus = $overallStatus
ConfirmedCount = $confirmedCount
AnomalousCount = $anomalousCount
} | ConvertTo-Json -Depth 5
} else {
Write-Host "`nSLEEPWALKER scan - $env:COMPUTERNAME - $($scanTimestamp.ToString('u'))" -ForegroundColor Cyan
Write-Host "`n== dpapi.dll beside ERAAgent.exe ==" -ForegroundColor Cyan
foreach ($finding in $findings) {
$color = switch ($finding.Status) {
'ConfirmedMatch' { 'Red' }
'AnomalousPresence' { 'Yellow' }
'NoAgentFound' { 'Gray' }
default { 'Green' }
}
Write-Host "[$($finding.Status)] $($finding.Message)" -ForegroundColor $color
}
Write-Host "`n$(($findings | Format-List | Out-String).Trim())"
Write-Host "`n== Registry state (read-only) ==" -ForegroundColor Cyan
Write-Host "`n$(($registryState | Format-List | Out-String).Trim())"
if ($registryState.IsAnonymousShareAccessOn) {
Write-Host 'EveryoneIncludesAnonymous is 1. The backdoor sets this so anonymous callers can reach its named pipe.' -ForegroundColor Yellow
}
if ($registryState.NullSessionPipeCount -gt 0) {
Write-Host 'NullSessionPipes currently contains:' -ForegroundColor Yellow
$registryState.NullSessionPipes | ForEach-Object { Write-Host " - $_" }
Write-Host 'This value is not modified automatically. Compare these entries against a known-good baseline and remove only entries confirmed as unauthorized.' -ForegroundColor Yellow
}
if ($inaccessiblePathMessages.Count -gt 0) {
Write-Host "`n$($inaccessiblePathMessages.Count) path(s) could not be scanned (access denied or I/O error) - coverage may be incomplete. Re-run elevated for full coverage, or with -Verbose to see which paths." -ForegroundColor Yellow
$inaccessiblePathMessages | ForEach-Object { Write-Verbose $_ }
}
Write-Host "`n== Result ==" -ForegroundColor Cyan
if ($confirmedCount -gt 0) {
Write-Host "$confirmedCount confirmed hash match(es). Treat this machine as compromised and rebuild it." -ForegroundColor Red
} elseif ($anomalousCount -gt 0) {
Write-Host "$anomalousCount anomalous finding(s) with no exact hash match. Review manually as a possible variant." -ForegroundColor Yellow
} elseif ($registryRequiresReview) {
Write-Host 'No SLEEPWALKER file indicator was found, but the registry configuration requires review.' -ForegroundColor Yellow
} else {
Write-Host 'No readable SLEEPWALKER file indicator or registry setting requiring review was found in the scanned scope.' -ForegroundColor Green
}
Write-Host 'This script changed nothing. It reports on local files and configuration only and cannot tell you which commands the backdoor may already have received and carried out.' -ForegroundColor Gray
}
if ($confirmedCount -gt 0) {
exit 2
} elseif ($anomalousCount -gt 0 -or $registryRequiresReview) {
exit 1
} else {
exit 0
}