The Re-encryption Dance
Assembly.Load needs live bytes. A SAFEARRAY gives the CLR its own copy. That is the window where ghost_loader wipes the original, so by the time the post-load scanner runs, g_ManagedBase holds encrypted data and nothing else.
Where Part 1 stopped
In Blending In: Why “Encrypt Everything” Makes You More Visible, the threat model was the scanner that runs while the implant is idle, the long stretches between check-ins where EDRs are most likely to do a full memory sweep. The answer there was Wraith’s warden thread: during sleep, original DLL bytes are swapped back into the stomped region, so what the scanner sees looks like a legitimately loaded module.
That article ended at the runtime window. But the runtime window is not the only one. There is an earlier gap, shorter and less discussed, that opens the moment ghost_loader finishes decrypting the payload and closes the moment the CLR takes ownership of those bytes. During that window, g_ManagedBase holds a live PE, in a private allocation, with a PE header at offset zero.
This is what ghost_loader does about it.
The exposure
Ghost_loader’s job is to decrypt the payload and hand it to the CLR. Decryption happens in place: the managed agent’s bytes, stored encrypted in the shellcode blob, get decrypted directly into g_ManagedBase, a private allocation ghost_loader made earlier in the bootstrap sequence. At that moment the memory looks like this:
A private allocation with a PE header at offset zero and no backing file is one of the most reliable indicators a memory scanner has. This is not subtle. Ghost_loader needs to get rid of it before anything else happens.
The problem is that it cannot just free the allocation and be done with it. Assembly.Load needs those bytes. The CLR has to read a complete, valid PE to load the assembly. The bytes have to exist somewhere right up until the CLR reads them.
What a SAFEARRAY enables
The COM hosting path for Assembly.Load does not take a raw pointer to bytes. It takes a SAFEARRAY, a COM-standard descriptor type that wraps an array with its dimensions and element type. The specific variant for a byte array is VT_UI1.
Get Qalique’s stories in your inbox
Join Medium for free to get updates from this writer.
The detail that matters here is allocation ownership. SafeArrayCreate allocates the backing buffer itself, inside COM/CLR memory. When ghost_loader calls SafeArrayCreate and copies the payload bytes in, the CLR's memory now holds a copy. Ghost_loader's copy, the one sitting in g_ManagedBase, is no longer the only live version. And once it is no longer the only live version, it can be re-encrypted.
The SAFEARRAY copy is the gap. While two copies exist, one can be wiped. Ghost_loader re-encrypts
g_ManagedBasein that gap, beforeLoad_3fires.
The sequence
The full operation in order:
ghost_loader.c, the re-encryption dance around Load_3
// 1. Allocate the SAFEARRAY with the exact size of the managed agent.
SAFEARRAYBOUND sab = { g_ManagedSize, 0 };
SAFEARRAY* sa = SafeArrayCreate(VT_UI1, 1, &sab);
// 2. Lock the array and copy the decrypted bytes in.
// The CLR now owns a live copy of the PE.
void* pData = NULL;
SafeArrayAccessData(sa, &pData);
memcpy(pData, g_ManagedBase, g_ManagedSize);
SafeArrayUnaccessData(sa);
// 3. Re-encrypt g_ManagedBase.
// The private allocation no longer holds a live PE header.
xor_encrypt(g_ManagedBase, g_ManagedSize, g_EncKey);
// 4. Now call Load_3. The CLR reads from the SAFEARRAY,
// not from g_ManagedBase, which is already clean.
VARIANT vtRaw;
V_VT(&vtRaw) = VT_ARRAY | VT_UI1;
V_ARRAY(&vtRaw) = sa;
VARIANT vtAssembly = { 0 };
pAssembly->lpVtbl->Load_3(pAssembly, vtRaw, &vtAssembly);Step 3 is the entire point. Between step 2 and step 4 there is a brief window where both copies are live, but that window is a few nanoseconds of memcpy, not a window any scanner could observe. What matters is the state that persists: by the time Load_3 returns, g_ManagedBase holds encrypted data and the CLR's SAFEARRAY buffer holds the live bytes inside managed heap memory.
What the scanner sees after Load_3
The private allocation that holds a suspicious PE header, the one no file backs and no module owns, no longer has a PE header. The CLR’s copy is real and live, but it sits inside the CLR heap alongside every other managed allocation the runtime has made. There is no isolated, unexplained region holding a .NET PE anymore.
The handoff to lethargic_sleep
Ghost_loader’s role ends when Load_3 returns and the managed agent's entry method is invoked. At that point the two techniques from this post and Part 1 divide responsibilities cleanly.
Ghost_loader owned the load-time window: from decrypt to Assembly.Load. The re-encryption dance covered it. Once the managed agent is running, lethargic_sleep takes over the runtime window: the long idle periods between check-ins where a scanner is most likely to run. That is the window Part 1 covered with module stomping and the warden thread.
The -1 sentinel is the handoff signal. Ghost_loader writes it to a known offset before invoking the entry method, telling the managed agent that the loader has finished its cleanup and it is safe to start the lethargic_sleep cycle. The managed agent does not start sleep masking until it sees that sentinel, because until ghost_loader signals completion there is no guarantee the load-time cleanup has run.
Together the two cover the full lifecycle. Ghost_loader cleans up the decryption window. Lethargic_sleep owns everything after that. Neither technique knows about the other’s mechanism, they just agree on a sentinel and stay in their lane.
Lessons learned
The load-time window is easy to overlook because it is short. The decryption happens, Load_3 fires, and everything looks fine in testing. What you do not see in testing is the post-load memory scan that some EDRs trigger precisely at that moment, when a new module has just appeared in the process. That scan sees whatever is in memory right after load, not during execution and not during sleep.
The SAFEARRAY is not usually thought of as an evasion primitive. It is a COM data container. But its copy semantics are exactly what ghost_loader needs: a way to give the CLR a live copy of the bytes while retaining the ability to wipe the original. The evasion is a consequence of how COM arrays work, not a special trick bolted on top.
The broader lesson is the same one Part 1 ended with: evasion is not one problem. It is a sequence of separate problems, one per phase of the implant’s lifecycle, and they require separate answers. Runtime sleep masking does not help with the load-time window. The load-time cleanup does not help with sleep. Getting both right means reasoning about each phase independently and making sure the handoff between them is clean.








