Chasing a Ghost: ConfuserEx, Donut, and the Malware That Wore Microsoft’s Badge

Malware Analysis · Notes from the lab

A fresh, undetected-looking .NET dropper, a stage that measured as pure noise, and three wrong conclusions before the truth fell out of a memory dump. This is how osopprov_TRUE.exe finally gave up its C2.

Sample osopprov_TRUE.exeDate 12 Sep 2026Scope static + dynamic + memoryVerdict ConfuserEx → Donut → native x64 implant
TL;DR

The sample is a ConfuserEx-protected .NET dropper that RC4-decrypts a Donut shellcode stage. Donut Chaskey-decrypts an embedded instance, aPLib-decompresses it, and reflectively loads a native x86-64 C++ DLL called fBVajj.dll with a single export, likeMe. That DLL decrypts its C2 strings only at runtime and beacons to:

https://coolfeedback.com

The C2 traffic is disguised as Microsoft Update / Edge CRX update requests, complete with X-Microsoft-Update-* parameters and an IE11-on-Windows-8.1 user agent. It is not a .NET Apollo agent, and there is no anti-VM to speak of.

The sample

The artifact arrived as a password-protected archive that unpacked into another archive, and inside that, a single managed executable. Two things stood out immediately: the file had been submitted to VirusTotal only two days earlier, and nothing about the name osopprov turns up in any public write-up, MalwareBazaar signature list, or sibling-sample search. This is an in-the-wild sample that nobody has documented yet — which is exactly the kind of thing that makes for a fun weekend.

ArtifactSHA256Notes
osopprov_TRUE.exe8a5731386a0d2a903b718a86a824b65b3d89101dd7be9c257d3d4f50db5f5c5828/76 engines malicious · Kaspersky HEUR:Trojan.MSIL.Donut.gen
inner assembly1a28b780544d40d5637068bf80587bc4f543f80d49218e41293eb5cfbafd634cResource-only DLL · only 3/76 detections
loader resource4dd608e348f4bdfcf16c4767347fac786c00be695be919549358028da492c3fa258,881 bytes · the RC4-encrypted Donut stage

That detection spread tells you a lot before you open a single tool. The outer binary is caught by three-quarters of the engines, but the inner assembly — the one carrying the actual payload — is caught by only three. Signatures are chasing the packer, not the implant. And when Zenbox detonated it in the cloud sandbox, the verdict came back harmless. No traffic, no detonation, nothing to see. That is your first hint that this thing has a multi-stage decrypt that no sandbox is going to finish inside its timeout.

Peeling the ConfuserEx onion

The outer PE is a managed assembly wrapped in ConfuserEx, which means the real code is hiding behind a module initializer and a pile of generated control-flow junk. Instead of fighting the obfuscation by hand, I simulated the compressor routine at the IL level: the module .cctor calls a decompression method that uses an xorshift32 keystream chained into XOR, and the result is an LZMA stream. Running that pipeline offline produced a 7.3 MB inner assembly named yudVdxYSinTRcfXTqZxMOEzZGAJt.dll — a resource-only DLL with no methods and no entry point. Pure cargo.

de4dot cleans up the outer layer nicely, but it introduced its own bug: it renamed a scrambled resource link to a generic Class7.resources, silently breaking the pointer to the 7 MB resource set. The original binary's link names are the authoritative ones here. Worth remembering — a cleaner that renames resources can quietly sever your extraction path, and if you only ever analyze the cleaned copy you will spend an evening wondering why the resource stream comes back empty.

The stage that looked like noise

With the outer layer off, the chain gets simple and quick. The entry point fakes a bit of service behaviour — ServiceBase.Run, a one-second timer, a decoy VirtualAlloc(RW) immediately followed by VirtualFree — and then reads a resource called WindowsService1.Resources.loader.bin, 258,881 bytes.

The loader treats the first 32 bytes as a key and the remainder as ciphertext, and hands both to a decrypt routine. Reading the IL tells you exactly what that routine is: a textbook RC4. No substitutions, no cleverness:

// KSA
j = (j + S[i] + key[i % keylen]) % 256;
swap(S[i], S[j]);

// PRGA
i = (i + 1) % 256;
j = (j + S[i]) % 256;
swap(S[i], S[j]);
out = data ^ S[(S[i] + S[j]) % 256];

I verified it the honest way — by invoking the authentic cipher through reflection in both the original and the de4dot-cleaned binaries — and got byte-identical output, which also confirmed the cleaner had not corrupted the crypto.

And then the output was garbage. 258,849 bytes, entropy 7.995, no MZ header, no strings, no structure. I tried every key layout I could think of — key at the start, at the end, lengths from 16 to 128 bytes, data before and after — and every single combination landed between 7.99 and 8.00 entropy with nothing to show for it.

Wrong conclusion #1

"The RC4 is broken, or this stage is a decoy and the C2 is unrecoverable." I executed the loader end-to-end in an instrumented VM, dumped 121 MB of process memory, and went hunting. The decrypted buffer was there in memory, byte for byte — and still perfectly random. FLOSS dug through the stage and surfaced nothing but 448 random ASCII strings. Network capture showed zero malware connections. The evidence all pointed the same way: the stage had been encrypted with a key that simply is not carried in this binary.

That conclusion was defensible, and it was also completely wrong. What broke the case open was a detail I had been staring past: Donut shellcode always looks like noise. A Donut stub is dominated by its Chaskey-encrypted payload and its compressed module, so it measures at almost exactly 8.0 entropy. High entropy is not the same as garbage — and the way to tell the difference is not to measure the blob, it is to disassemble the front of it.

Donut, not randomness

The first few bytes of the decrypted stage are E8 <inst_len> — a call that jumps cleanly over an embedded structure, exactly the layout a Donut loader stub uses. That one instruction invalidated a week of "the stage is unrecoverable" reasoning. The whole chain then falls into place:

  1. osopprov_TRUE.exeManaged PE, ConfuserEx-protected
  2. <Module>.cctorxorshift32 + chained XOR → LZMA
  3. yudVdxYSinTRcfXTqZxMOEzZGAJt.dllResource-only inner assembly, 7.3 MB
  4. Class6.MainServiceBase.Run → Timer(1000 ms) → method_0
  5. WindowsService1.Resources.loader.bin258,881 bytes · key = bytes[0:32], ct = bytes[32:]
  6. Standard RC4VirtualAlloc(RWX) → Marshal.Copy → VirtualProtect(EXEC) → CreateThread
  7. Donut shellcode (x86-64)Chaskey-CTR decrypt instance → aPLib-decompress module
  8. fBVajj.dllReflectively loaded native x64 C++ DLL · export "likeMe"
  9. likeMeDecrypts its C2 strings at runtime, opens a persistent thread
  10. coolfeedback.comC2 beacon, HTTPS/443, dressed as Microsoft Update traffic

Three more steps, each one verifiable byte by byte. The instance decrypts with Chaskey in counter mode — key at offset 4, nonce at 0x14, encrypted body starting at 0x23C — which immediately yields readable structure: a DLL table (shell32;oleaut32;...) and a decoy name. That readable output is the proof that the Chaskey key is correct; when you get this step right, the encrypted fog turns into strings.

The module metadata says compression is aPLib, length 248,109 bytes, type DLL. Decompressing at offset 0xDC8 produces a 465,408-byte native PE32+ x86-64 DLL with seven sections and exactly one export. Its PDB path inside reads fBVajj.pdb. This is not a .NET agent. It is a compiled MSVC C++ DLL with RTTI, exception handling, and std::bad_exception / type_info symbols sitting in its imports — the unmistakable fingerprint of native C++ code.

Worth noting

fBVajj.dll imports KERNEL32, USER32, ADVAPI32, SHLWAPI and ntdll — but its networking is resolved dynamically via LoadLibraryExW / GetProcAddress. There is no static wininet or ws2_32 import, which is precisely why there is no C2 URL to find statically. Its .data section holds a base64 alphabet, so base64 is in play. And the strings? Encrypted at rest. The word coolfeedback does not appear anywhere in the file — not in plaintext, not in UTF-16, not base64, not under a single-byte XOR, not under a Caesar shift. It exists only in memory, after likeMe runs.

Let the malware decrypt itself

This is the pivot that ends every fight with runtime-decrypted strings: stop trying to invert the decryption and just let the program do it. Load fBVajj.dll, resolve the export, and call likeMe. The thread stays alive afterwards — a persistent implant, not a fire-and-forget dropper — and in that living process, the C2 configuration is sitting in memory in the clear.

A procdump of the process, then a search for the strings that were absent from disk, returns the whole config:

Domain
coolfeedback.com (HTTPS / 443)
Primary URI
https://coolfeedback.com/pNV1LpOnZZP?X-Microsoft-Update-AppId=2f93353a-0f8c-42ed-a926-80434d99ba56&X-Microsoft-Update-Updater=msedgecrx-6LShSE2AsaKCrblec7KS3fTAmMFJeA8M&no-cors=true&x-ms-blob-type=<url-encoded base64 blob>
Secondary
https://coolfeedback.com:443
User-Agent
Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko
Connectivity
https://go.microsoft.com/fwlink/?linkid=2233907 — a legitimate Microsoft link, most likely a connectivity or decoy check
Config UUID
d3ef5678-0538-9510-48ff-a8af9ed5cf7f
Transport
Schannel (TLS) — memory shows Schannel Security Pack, Negotiate Security Package and GSSAPI loaded at runtime

The disguise is the interesting part

Look at that URI again. A random-looking path, then a set of query parameters lifted straight out of the vocabulary Microsoft uses: X-Microsoft-Update-AppId, X-Microsoft-Update-Updater=msedgecrx-<random>, no-cors=true, and x-ms-blob-type — that last one being the real Azure Blob Storage header that Microsoft Edge uses when fetching extension (crx) updates. Add a user agent that claims to be IE11 on Windows 8.1, a platform combination that plenty of enterprise allowlists still tolerate, and the payload rides inside the base64 blob in x-ms-blob-type.

To a network sensor that only looks at hostnames and ports, this is a TLS connection to some random domain. To a deep inspection box that only checks whether the URL contains a known-malicious string, it is a URL full of Microsoft Update parameters. It is a genuinely well-considered piece of traffic camouflage — C2 signalling that is designed to be dismissed as update telemetry by the exact tooling most likely to see it. This is the detail I would hand to a detection engineer first: not the domain, but the shape of the request.

What it does not do

Plenty of write-ups would tell you this sample is packed with anti-analysis. It is worth being precise about what is actually there, because overstating a threat model is its own kind of error. I specifically checked for anti-VM behaviour and found none:

  • No VMware, VirtualBox, QEMU or Hyper-V strings anywhere in the binary.
  • No WMI or registry-based virtualisation checks.
  • No CPUID hypervisor-bit test.
  • No sandbox-process enumeration.

What it does carry is a moderately serious anti-tamper and anti-debug posture:

  • ConfuserEx anti-tamper — the module initializer walks its own PE headers with a watchdog thread, and calls Environment.FailFast(null) if it detects modification.
  • Anti-debugDebugger.IsAttached and Debugger.IsLogging checks.
  • Anti-reflection — constant getters quietly return default() whenever GetCallingAssembly() does not match GetExecutingAssembly(), which specifically punishes the "call the getter to dump the config" trick.
  • Timing jitter — scattered Thread.Sleep calls at 1, 2, 500 and 1000 ms.

The net effect is quite specific: run it in a VM and it behaves perfectly normally; attach a debugger or patch a byte and it dies on you. That is a sample built to punish the analyst at the keyboard, not the sandbox in the cloud — and it explains the clean Zenbox verdict far better than any anti-VM theory would.

Where I was wrong

Two earlier assessments in this investigation were wrong, and they were wrong in instructive ways, so they are worth recording rather than quietly deleting.

Wrong conclusion #2

"The stage is random data." Donut shellcode measures at ~8.0 entropy because it is mostly encrypted payload and compressed module. Entropy alone cannot distinguish encrypted content from meaningless content. The disassembler can: E8 <inst_len> at offset zero is a valid x86-64 call over an embedded structure, and that single instruction was the whole answer. Lesson learned the slow way: when something measures as noise, disassemble it before you declare it random.

Wrong conclusion #3

"It is a Mythic / Apollo agent." The Donut family association and a couple of generic detections pulled the analysis toward the popular .NET C2 framework. It is not. The extracted payload is a native C++ DLL, and the C2 protocol is custom HTTP dressed as Microsoft Update telemetry — not Mythic's JSON callback profile. The label was never confirmed by the payload itself, only inferred from neighbouring context. If a family attribution cannot be demonstrated from the bytes you extracted, treat it as a hypothesis, not a finding.

Indicators

TypeIndicator
Domaincoolfeedback.com
URLhttps://coolfeedback.com/pNV1LpOnZZP?X-Microsoft-Update-AppId=2f93353a-0f8c-42ed-a926-80434d99ba56&X-Microsoft-Update-Updater=msedgecrx-6LShSE2AsaKCrblec7KS3fTAmMFJeA8M&no-cors=true
URLhttps://coolfeedback.com:443
User-AgentMozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko
SHA2568a5731386a0d2a903b718a86a824b65b3d89101dd7be9c257d3d4f50db5f5c58osopprov_TRUE.exe
MD5e010b9dc431fd4dd7105a9edf2004218osopprov_TRUE.exe
SHA256ceed566a6a0f0e744e919e2fcd687c01d070d0c3eaa952f1f5f053262f5aec1cosopprov_restored_2.zip
SHA2561a28b780544d40d5637068bf80587bc4f543f80d49218e41293eb5cfbafd634coriginal_assembly.exe
SHA2564dd608e348f4bdfcf16c4767347fac786c00be695be919549358028da492c3faloader resource
RC4 key2ffed945ab7e1e9f4689122e59b4af8a7feecd8a7fda2d2a5c4d1ab456366514loader[0:32]
Config UUIDd3ef5678-0538-9510-48ff-a8af9ed5cf7f
FilenamefBVajj.dllexport: likeMe

Structural names worth pivoting on if you are hunting: WindowsService1, WindowsService1.Resources.loader.bin, yudVdxYSinTRcfXTqZxMOEzZGAJt.dll, words_dictionary, loader, fBVajj, osopprov.

On the detection side, trojan.msilheracles/donut was the consensus label (HEUR:Trojan.MSIL.Donut.gen from Kaspersky), with Gen:Variant.MSILHeracles and Win64:MalwareX-gen appearing across other vendors. VirusTotal carried the tags 64bits, peexe, assembly and detect-debug-environment — that last tag being the one that lines up with the anti-debug behaviour rather than any anti-VM capability.

Takeaways

  1. Entropy is not a verdict. Donut does not look like shellcode; it looks like nothing. One call instruction at offset zero was worth more than every entropy measurement I took, and I took a lot of them.
  2. When strings are encrypted at rest, run them.likeMe decrypted its own configuration the moment it was called. A memory dump beats an afternoon of guessing at key schedules.
  3. Cleaners can break your extraction path.de4dot renaming a resource link silently severed a 7 MB resource. Always keep the original binary in the loop and compare against it.
  4. Do not inherit family labels from neighbours. "Donut" was true and useful. "Apollo" was neither, and it cost time.
  5. Know what is not there. Establishing the absence of anti-VM behaviour was what made the clean sandbox verdict explicable instead of mysterious.

The most interesting thing about this sample turned out not to be the encryption chain — ConfuserEx into RC4 into Donut is a well-trodden path — but the social layer on top of it. Somebody sat down and carefully built a C2 protocol that looks, to a first and even second glance, like Microsoft Edge checking for a browser extension update. The cryptography was standard the whole way through. The camouflage was bespoke.

Interestingly, a parallel sample named osopprov_FALSE.exe is hinted at in the naming scheme, but no sibling has surfaced in VirusTotal or MalwareBazaar. If one does, it may well carry the cleartext stage that this artifact withheld. If you have seen this family in the wild, I would like to hear about it.


Analysis performed statically, dynamically and in memory, in an isolated environment. All indicators were derived from the sample itself and from a live execution of its own decryptor routines. No live C2 infrastructure was contacted: the implant's strings were recovered from the memory of a local process, not from the network. Handle the artifacts accordingly.