A Windows C++ library for planting export forwarders to trigger DLL loading at runtime.
Zero file replacement. Zero proxy DLLs. Just a forwarder string planted in a loaded module's export directory.
中文 | English
Forwarder Phantom manipulates the PE export directory of an already-loaded module (e.g. kernel32.dll) at runtime. It plants a forwarder string — a special RVA that tells the Windows loader to redirect a function call to another DLL — into the export table. When any code calls GetProcAddress on the modified export, the loader automatically follows the forwarder: it loads the payload DLL, resolves its exports, and calls DllMain — all through the standard loader pipeline.
The payload never touches the disk as a replacement for any system file. The original DLL stays intact.
| Concern | Traditional DLL hijacking | Forwarder Phantom |
|---|---|---|
| File on disk | Must replace or proxy a real DLL file | No disk writes to system directories — original DLL untouched |
| Detection surface | File system monitoring + signature scanning | Transient memory modification in export table + VirtualProtect |
| Import resolution | Manual — complex for real payloads | Automatic — the loader resolves imports via the forwarder |
| Persistence | Permanent until file is restored | Unplant() restores the original export table |
| x86 support | Varies | Works on both x64 and x86 |
This technique exploits the Windows loader's forwarder resolution mechanism (LdrpLoadForwardedDll) — the same mechanism used by legitimate OS DLLs like kernel32.dll forwarding to kernelbase.dll.
┌─────────────────────────────────────────────────────────┐
│ Stage 1 — Locate │
│ │
│ Pick a loaded module (e.g. kernel32.dll) │
│ Choose an export function (e.g. AreFileApisANSI) │
│ Binary-search the export name table to find its index │
└───────────────────────┬─────────────────────────────────┘
│
┌───────────────────────▼─────────────────────────────────┐
│ Stage 2 — Plant │
│ │
│ Find a writable gap inside the export DataDirectory │
│ VirtualProtect(PAGE_READWRITE) → export directory │
│ Write forwarder string: │
│ "D:\path\to\payload.ForwardedFunc" │
│ Overwrite AddressOfFunctions[idx] → forwarder RVA │
└───────────────────────┬─────────────────────────────────┘
│
┌───────────────────────▼─────────────────────────────────┐
│ Stage 3 — Trigger │
│ │
│ GetProcAddress(hKernel32, "AreFileApisANSI") │
│ ↓ │
│ Loader sees RVA in export directory range → forwarder │
│ ↓ │
│ LdrpLoadForwardedDll("D:\path\to\payload") │
│ ↓ │
│ LdrpPreprocessDllName detects '\' → absolute path │
│ ↓ │
│ Loader maps payload.dll → DllMain executes │
│ ↓ │
│ Resolves payload!ForwardedFunc → returns address │
└─────────────────────────────────────────────────────────┘
The key insight: Export forwarders are not just for compile-time linking. The Windows loader resolves them dynamically at GetProcAddress time. By planting a forwarder with an absolute path, we bypass the DLL search order entirely and trigger LdrpLoadDllInternal to load our payload directly.
The loader's LdrpPreprocessDllName detects the \ character in the forwarder string and sets the 0x600 flag, treating the string as a full path. Without it, forwarder resolution searches from System32 (the forwarding module's directory) — not the application directory. SetDllDirectory and AddDllDirectory do not affect the forwarder resolution context.
- Visual Studio 2022 (v143 toolset)
- Windows 10/11 (x64 or x86)
- MSVC 14.44+
# 1. Open the solution
start "Forwarder Phantom.sln"
# 2. Build both projects (Release | x64)
MSBuild "Forwarder Phantom.sln" -p:Configuration=Release -p:Platform=x64Output:
compileDirectory/Release/x64/
├── Forwarder Phantom.exe # Demo app
└── PayLoad.dll # Example payload (shows MessageBox)
#include <Forwarder Phantom/Forwarder Phantom.h>
using namespace ForwarderPhantom;
// 1. Pick a loaded module and a target export
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
// 2. Build absolute forwarder path
char szDir[MAX_PATH], szFwdStr[512];
GetModuleFileNameA(NULL, szDir, MAX_PATH);
*strrchr(szDir, '\\') = '\0';
_snprintf_s(szFwdStr, sizeof(szFwdStr), "%s\\payload.a", szDir);
// 3. Plant the forwarder
auto ctx = Plant(hKernel32, "AreFileApisANSI", szFwdStr);
// 4. Trigger — loader follows forwarder → loads payload.dll → DllMain fires
GetProcAddress(hKernel32, "AreFileApisANSI");
// 5. Restore original export table
Unplant(ctx);struct PlantCtx {
HMODULE hModule; // Target module handle
DWORD dwExportIdx; // Export ordinal index
DWORD dwOriginalRva; // Original function RVA (for restoration)
DWORD dwFwdStringRva; // Planted forwarder string RVA
DWORD dwOldProtect; // Original memory protection
DWORD dwExportDirRva; // Export directory RVA
DWORD dwExportDirSize; // Export directory size
DWORD dwFwdStringLen; // Forwarder string length
BYTE backupData[256]; // Backup of overwritten bytes
bool bValid; // Plant was successful
};| Function | Return | Description |
|---|---|---|
Plant( hModule, szExportName, szForwarder ) |
PlantCtx |
Modifies the export table: writes forwarder string, updates AddressOfFunctions. Returns context for restoration. |
Unplant( ctx ) |
bool |
Restores the original export function RVA and forwarder string region. Resets memory protection. |
- Absolute paths are required for the forwarder string. Relative names fail with
MOD_NOT_FOUND(0x7E). - The forwarder string must fit within the export DataDirectory
[VirtualAddress, VirtualAddress+Size). The code searches for gaps between the export sub-tables. Unplant()restores everything — call it as soon as the payload is loaded to minimize detection window.- The forwarder format is
"DLLNAME.ExportName"— the part before the first.becomes the DLL name (or absolute path). - Simultaneous x86 support: the code uses only standard PE parsing — no architecture-specific register manipulation.
- Forwarder strings must not overlap with existing export structures (AddressOfFunctions, AddressOfNames, AddressOfNameOrdinals). The planting logic validates this.
src/
├── Forwarder Phantom/ # Core library
│ ├── Forwarder Phantom.h # Public API declarations
│ └── Forwarder Phantom.cpp # Implementation
├── PayLoad/ # Example payload DLL
│ └── Main.cpp # DllMain with MessageBox
└── App/ # Demo application
└── App.cpp # Minimal usage example
project/
├── Forwarder Phantom/ # Main project (.vcxproj) — Console application
└── PayLoad/ # Payload project (.vcxproj) — DynamicLibrary
namespace ForwarderPhantom {
// Public API
struct PlantCtx { ... };
PlantCtx Plant(HMODULE hModule, const char* szExportName, const char* szForwarder);
bool Unplant(const PlantCtx& ctx);
}A minimal payload is just a DllMain:
#include <Windows.h>
BOOL WINAPI DllMain(HINSTANCE hinst, DWORD reason, LPVOID reserved)
{
if (reason == DLL_PROCESS_ATTACH) {
// Your code here — imports are resolved by the loader!
MessageBoxW(NULL, L"Hello", L"Payload", MB_OK);
}
return TRUE;
}Compile without CRT for minimal dependencies:
cl /LD /GS- /O1 payload.cpp /link /NODEFAULTLIB \
/ENTRY:DllMain /SUBSYSTEM:WINDOWS kernel32.lib user32.libGetProcAddressdetects the RVA falls within the export DataDirectory → treats as forwarderLdrpLoadForwardedDllsplits on the first.:"D:\\path\\to\\payload.ForwardedFunc"→ DLL="D:\\path\\to\\payload", Func="ForwardedFunc"
LdrpPreprocessDllNamedetects\→ sets flag0x600→ treats as full pathLdrpLoadDllInternalloads from the absolute path →DllMainexecutes → forwarder resolves
| Forwarder string format | Loader behavior | Result |
|---|---|---|
payload.ForwardedFunc |
Searched from System32 (forwarding module dir) | ❌ MOD_NOT_FOUND (0x7E) |
D:\path\to\payload.ForwardedFunc |
Detected \ → full path → direct load |
✅ DllMain executes |
SetDllDirectory and AddDllDirectory modify the thread's search path, but forwarder resolution happens in the loader context which does not consult these directories.
The forwarder string must be written entirely within the export DataDirectory bounds:
- Start:
VirtualAddress - End:
VirtualAddress + Size
The planting logic searches for a gap that does not overlap with:
AddressOfFunctionsarrayAddressOfNamesarrayAddressOfNameOrdinalsarray
If no suitable gap exists, Plant() returns an invalid PlantCtx (bValid == false).
Choose an export function that:
- Belongs to a module already loaded in the process
- Is rarely called during normal operation (to avoid accidental triggering)
- Has enough space in the export directory for the forwarder string
Good candidates in kernel32.dll: AreFileApisANSI, SetHandleContext, NeedCurrentDirectoryForExePathW
Unplant() performs a full restoration:
- Restores
AddressOfFunctions[idx]to the original RVA - Restores the forwarder string region from
backupData - Restores the original memory protection
Call it promptly after the payload loads to minimize the detection window.
MIT