Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Forwarder Phantom

DLL Loading via Export Forwarder Manipulation

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.

Language Platform MSVC License

中文 | English


What is Forwarder Phantom?

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.

Why this approach?

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.

How it works

┌─────────────────────────────────────────────────────────┐
│  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.

Why absolute path?

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.

Quick start

Prerequisites

  • Visual Studio 2022 (v143 toolset)
  • Windows 10/11 (x64 or x86)
  • MSVC 14.44+

Build

# 1. Open the solution
start "Forwarder Phantom.sln"

# 2. Build both projects (Release | x64)
MSBuild "Forwarder Phantom.sln" -p:Configuration=Release -p:Platform=x64

Output:

compileDirectory/Release/x64/
├── Forwarder Phantom.exe      # Demo app
└── PayLoad.dll                # Example payload (shows MessageBox)

Usage

#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);

API Reference

PlantCtx

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
};

Functions

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.

⚠️ Important notes

  • 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.

Architecture

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 structure

namespace ForwarderPhantom {
    // Public API
    struct PlantCtx { ... };
    PlantCtx Plant(HMODULE hModule, const char* szExportName, const char* szForwarder);
    bool     Unplant(const PlantCtx& ctx);
}

Writing your own payload

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.lib

Technical notes

Forwarder string resolution internals

  1. GetProcAddress detects the RVA falls within the export DataDirectory → treats as forwarder
  2. LdrpLoadForwardedDll splits on the first .:
    • "D:\\path\\to\\payload.ForwardedFunc" → DLL="D:\\path\\to\\payload", Func="ForwardedFunc"
  3. LdrpPreprocessDllName detects \ → sets flag 0x600 → treats as full path
  4. LdrpLoadDllInternal loads from the absolute path → DllMain executes → forwarder resolves

Why absolute paths are non-negotiable

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.

Export directory region constraints

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:

  • AddressOfFunctions array
  • AddressOfNames array
  • AddressOfNameOrdinals array

If no suitable gap exists, Plant() returns an invalid PlantCtx (bValid == false).

Target export selection

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 safety

Unplant() performs a full restoration:

  1. Restores AddressOfFunctions[idx] to the original RVA
  2. Restores the forwarder string region from backupData
  3. Restores the original memory protection

Call it promptly after the payload loads to minimize the detection window.

License

MIT

About

Using in-memory export table patching and forwarder strings to hijack API resolution and stealthily load arbitrary DLLs without LoadLibrary.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages