Phantom DLL hollowing
Overwrite a signed DLL’s code section with the payload, then restore the original bytes, so the module keeps working after the payload runs.
Metadata
Section titled “Metadata”| ATT&CK | T1574.002 (DLL Side-Loading) |
| Stability | experimental |
| Category | Execution |
| YAML key | dll_hollowing |
| Payload format | shellcode only (see “Payload formats”) |
What it does
Section titled “What it does”Loads a legitimate, signed DLL, saves the first bytes of its code section
(.text), overwrites them with the decrypted payload and calls it. When the
payload returns, the saved bytes are written back so the module’s exports keep
proxying their real implementation.
The difference from module_stomping is the restore: module stomping
overwrites the section and leaves it broken; phantom DLL hollowing keeps a copy
of the overwritten bytes and puts them back once the payload has run, so the
sacrificial module is still a working, proxy-able DLL afterwards.
YAML parameters
Section titled “YAML parameters”| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
payload | string | no | the only declared payload | Name of the payload in build.payloads to run |
module | string | no | wininet.dll | Module to load and hollow: a name the loader resolves or a full path |
The payload’s binary format comes from build.payloads.<name>; see “Payload
formats”. build.output.arch selects the stub’s bitness, and the module is
loaded by the stub’s own loader, so it must be of that bitness too.
Do not name a module another step patches or hooks — patch_amsi (which patches
amsi.dll) is the obvious example, and unhook_ntdll/patch_etw rewrite
ntdll.dll: the restore would silently put back the bytes those steps changed.
YAML example
Section titled “YAML example”build: payloads: implant: source: ./beacon.bin format: shellcoderuntime: - technique: dll_hollowing params: payload: implant module: wininet.dll# Defaults: the only payload, and wininet.dll as the hollowing target.runtime: - technique: dll_hollowingHow it works
Section titled “How it works”flowchart TD
A[LoadLibraryA maps the hollowing target] --> B[walk mapped PE headers to .text]
B --> C{payload fits the section?}
C -- no --> D[fail the step]
C -- yes --> E[nt_alloc_vm: save the first bytes]
E --> F[nt_protect_vm: only the payload pages to RW]
F --> G[memcpy the payload, restore the protection to RX]
G --> H[patch command line, direct call]
H --> I[payload returns]
I --> J[write the saved bytes back, restore the protection]
1. Map and locate the code section
Section titled “1. Map and locate the code section”LoadLibraryAmaps the hollowing target. If it is already loaded the call only bumps its reference count, and the module keeps whatever base it has; the loader (not this technique) does the mapping, the catalog/hash bookkeeping and theDllMain(DLL_PROCESS_ATTACH)call.- The module’s mapped PE headers are copied into a local buffer and walked to
the
.textsection:VirtualAddressgives the section’s address in the mapped image,max(VirtualSize, SizeOfRawData)its capacity. The walk is bounds-checked slice arithmetic; a malformed target fails the load instead of faulting the stub. - The payload’s length is checked against that capacity. A payload that does not fit fails the step — it is never truncated, and it is never written past the section into whatever the loader mapped next.
2. Hollow the section and run the payload
Section titled “2. Hollow the section and run the payload”- The section’s first
payload_lenbytes are saved into ant_alloc_vm(PAGE_READWRITE) buffer. The saved copy lives outside the default heap, so the original module code does not sit in a private heap allocation a scanner can group by module. - Only the pages the payload occupies are made writable (
PAGE_READWRITEthroughnt_protect_vm; the captured old protection is kept for step 6). The rest of the section keeps its protection, and the section is writable and non-executable during the write, so it is never RWX. - The payload is copied over the section’s first bytes with a plain
memcpy(ptr::copy_nonoverlapping), then the captured protection is restored —.textshipsPAGE_EXECUTE_READ, so the section is executable and read-only again before the payload runs. - The PEB command line is repointed at the payload’s configured arguments
(
crate::patch_command_line, shared withreflective_loading), and control is transferred with a direct call to the section’s first byte. The payload’s return value becomes the step’s result, so the technique returns and the runtime chain can continue.
3. Restore the original bytes
Section titled “3. Restore the original bytes”- Once the payload returns, the saved bytes are written back: the section is
made writable again, the original bytes are
memcpyd over the payload and the original protection is restored. The module’s exports now point at real code again, so any code in the process that later calls one of them gets the original implementation.
Decisions
Section titled “Decisions”- Stomp the section’s first byte, like
module_stomping. The classic phantom-DLL variant overwritesAddressOfEntryPoint, which puts the payload in the middle of the section and forces the capacity check to account for the entry offset. The section’s first byte is page-aligned and 16-byte aligned in every real image, and the whole section is the natural size bound. - Save and restore, unlike
module_stomping. The sacrificial module is a proxy after the hollow, not a casualty: restoring the original bytes keeps its exports working, which is the point of the T1574.002 family. The cost is onent_alloc_vmand two extra protection changes. nt_alloc_vm, not a heapVec, for the saved bytes. AVecwould place the original module’s code in the loader’s default heap; a dedicated allocation keeps the saved copy out of the heap and on the syscall layer.memcpy, notnt_write_vm.NtWriteVirtualMemoryon one’s own process is a call an EDR can watch and can fail halfway; the copy needs neither.- Direct call, not a new thread. A thread would make the payload’s start address visible in thread telemetry, and it would break the “execution techniques return” contract used by the runtime chain.
- Restore before executing. Keeps the section out of the RWX state some scanners flag, and keeps the final protection indistinguishable from the module’s normal one.
- No
FlushInstructionCache. The x86/x64 instruction cache is coherent with data writes on the same core; every stub fragment also crosses a kernel boundary (NtProtectVirtualMemory) before the call.
Payload formats
Section titled “Payload formats”The technique runs format: shellcode only: the bytes are placed in the code
section as-is and called with a no-argument contract, exactly like
reflective_loading’s shellcode runner. A pe/dll payload would need a mapper
(relocations, imports, per-section protections) and dotnet a CLR host —
reflective_loading and dotnet_hosting do that. plan::validate rejects
pe/dll/dotnet for this technique at build time, and the fragment also
detects a PE payload at runtime (MZ + PE\0\0) and fails the step with a clear
message instead of jumping into a DOS header.
What it evades
Section titled “What it evades”- The payload never touches disk: it is decrypted in memory and written into a module the image already maps.
- No private executable allocation and no
VirtualAlloc/VirtualAllocExfor the payload, so a “fresh executable memory” heuristic has nothing new to flag. - No child process, no remote target, no new thread.
- The pages the payload runs from belong to a Microsoft-signed module: a memory scan that groups executable regions by module reports the payload under the module’s image, not under an anonymous region.
- After the payload returns, the module is byte-for-byte its original self again (modulo any copy-on-write private pages), so a post-execution integrity check of the module finds nothing out of place.
What detects it
Section titled “What detects it”- Page-protection telemetry.
NtProtectVirtualMemory(ETW-TI, kernel callbacks, userland hooks inmode: none) fires four times per hollow on a signed module’s code pages:RX → RW,RW → RX,RX → RW,RW → RX. A module’s.textbecoming writable, and writable code pages appearing inside a loaded module, are both high-signal. - Image-load telemetry and module integrity checks.
LoadLibraryAon a module the process never uses is itself a signal, and anything that re-validates a module while the payload runs — comparing the mapped section against the file on disk, hashing.text, or checking for executable pages that differ from the module’s on-disk image — sees the payload. The stomped pages are no longer file-backed (copy-on-write private pages inside the module’s address range), which is the residual artifact of this technique family. - The saved-copy allocation. The
nt_alloc_vmbuffer holding the original.textbytes is a privateRWregion containing executable-looking code, which is exactly what a “code held in non-executable memory” heuristic is looking for. It is a new, avoidable artifact thatmodule_stompingdoes not have. - Stack and call-graph inspection. The payload runs from the loader’s own thread, so a stack walk shows a return address inside a module that has no legitimate caller on that thread.
- In-memory scanning / YARA. The payload’s own signatures apply unchanged once its bytes are in the section.
- Import-based heuristics.
LoadLibraryA+GetCurrentProcessin the import table of a stub that does not otherwise use those modules.
Measurements
Section titled “Measurements”Not yet measured. Once the technique is verified end to end, this section records
the static baseline (section entropy, imports, sensitive strings) from
picaro audit and the runtime result (the packed artifact relaying the payload’s
exit code, and the module’s exports still resolving afterwards), mirroring the
module_stomping section in docs/measurements.md.
Implementation notes
Section titled “Implementation notes”- The target is a parameter, not a constant. The default (
wininet.dll) is a Microsoft-signed module present on every SKU with a code section large enough for a staged payload. On a host where it is missing (some Server Core images),LoadLibraryAfails and the step reports it — pick another signed module (dbghelp.dll,xpsservices.dll,iertutil.dll,mshtml.dll, …). - The hollowing target is obfuscated. Its name is rebuilt at runtime by
render_fragment_for(StringsConfig), like every other runtime string in the fragment, and the.textname is a compile-time byte pattern (u64::from_le_bytes([b'.', b't', b'e', b'x', b't', 0, 0, 0])), so no section name reaches.rdataeither. LoadLibraryAstays a Win32 import. Loading a module is a loader entry point, not a kernel call the stub issues itself — the same position asCreateProcessAinprocess_hollowing. Everything kernel-facing (nt_alloc_vm,nt_protect_vm) goes through the syscall layer, somode: indirectremoves theVirtualAlloc/VirtualProtectimports.- Limitations. The payload may not be larger than the target’s
.textand may not self-modify its own first bytes (the section is read-only again when it runs). The restore is best-effort in the same sense as any post-payload code: a payload that callsExitProcess(or never returns) skips step 8 and the module is left hollowed. Hollowing a module the process actually uses — or one a previous step patched — corrupts or silently reverts that state. - Not implemented here (follow-ups). A
pe/dllmapper into the hollowed section (would needresolves_imports: trueand the shared resolver), and a knob for the final protection (pagerwxfor payloads that need writable code).
References
Section titled “References”- ired.team — DLL Hollowing / Phantom DLL Hollowing: https://www.ired.team/offensive-security/code-injection-process-injection/dll-hollowing-and-process-doppelganging (the classic write-up: map a legitimate image, overwrite its entry point with shellcode, and run it from inside the mapped module; this fragment stomps the section’s first bytes and restores them afterwards).
- MITRE ATT&CK T1574.002 — DLL Side-Loading: https://attack.mitre.org/techniques/T1574/002/
- Microsoft Learn — LoadLibraryA: https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-loadlibrarya (reference-counting and “already loaded” semantics the fragment relies on).
- Microsoft Learn — FlushInstructionCache: https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-flushinstructioncache (the documented call for modified code; deliberately skipped here, see “Decisions”).