I mostly used this for local experiments with old games, test processes and my own DLLs while learning game rendering, process memory and PE loading. It was not meant for malicious use or for injecting into third-party software without permission.

At first the DLL itself was the interesting part and the annoying part was the workflow around testing it. Every time I changed and compiled the DLL, I needed a simple way to pick a target process, pick the right DLL, check that the architecture matched and then run the same injection step again without rebuilding some temporary test code.

The most common first version of this is the standard LoadLibraryW method:

open target process
-> allocate memory for the DLL path
-> write the DLL path
-> start a remote thread at LoadLibraryW
-> wait for the thread result

That is a good baseline because it uses the standard Windows loader path. It was also a good learning point because then you have to deal with the “boring” parts: process handles, remote memory, address resolution, timeouts and error messages.

But I did not want the project to stay as a single hardcoded CreateRemoteThread + LoadLibraryW sample. I wanted a small tool where the UI could stay simple, while the backend could be improved as the target changes, using different loading modes and different ways of starting the remote routine.

The high level flow ended up like this:

Win32 GUI
-> select process
-> add one or more DLL files
-> choose injection mode
-> choose launch method
-> validate process and DLL
-> run backend injector
-> show status in the log

The entry point is intentionally small:

int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int showCommand)
{
    return winject::RunApplication(instance, showCommand);
}

The GUI is kept in one simple layer and the injector logic in another layer. The main dispatch function just looks at the selected mode and calls the matching backend:

InjectionResult InjectDll(const InjectionRequest& request)
{
    switch (request.mode)
    {
    case InjectionMode::LoadLibraryW:
        return InjectWithLoadLibrary(request);

    case InjectionMode::ManualMap:
        return InjectWithManualMap(request);

    default:
        return {
            false,
            0,
            ERROR_NOT_SUPPORTED,
            L"Selected injection mode is not implemented yet."
        };
    }
}

This is not a big abstraction but it made the rest of the code easier to work with. The UI only needs to build an InjectionRequest and then the backend decides how to run the request.

The baseline loader path

The normal loader path writes the DLL path into the target process and asks the target process to call LoadLibraryW on that path.

One detail that can be missed easily is that a local function pointer is not enough for this. The injector process has its own address space, so the code resolves the module that owns LoadLibraryW, finds the same module in the target process and then rebuilds the remote address using the offset inside that module:

HMODULE ownerModule = GetModuleFromAddress(loadLibrary);
const std::wstring ownerName = FileNameFromPath(ownerPath);
const uintptr_t remoteBase = GetRemoteModuleBase(pid, ownerName);

const auto localBase = reinterpret_cast<uintptr_t>(ownerModule);
const auto localProc = reinterpret_cast<uintptr_t>(loadLibrary);

outRoutine = reinterpret_cast<LPTHREAD_START_ROUTINE>(
    remoteBase + (localProc - localBase)
);

In most normal cases this resolves to kernel32.dll or some forwarded export like KernelBase.dll but in this case the specific owner module is not the important part, but the address calculation. The local address is only used to calculate an offset inside the module that owns the resolved function and that offset is then applied to the matching module base in the target process.

After that the actual baseline injection is the classic remote memory pattern:

void* remotePath = VirtualAllocEx(
    process,
    nullptr,
    bytes,
    MEM_COMMIT | MEM_RESERVE,
    PAGE_READWRITE
);

WriteProcessMemory(
    process,
    remotePath,
    request.dllPath.c_str(),
    bytes,
    &written
);

Then the backend starts the chosen routine and checks the result:

const RemoteRoutineResult launchResult = RunRemoteRoutine(
    process,
    remoteLoadLibrary,
    remotePath,
    request.launchMethod,
    request.timeoutMs,
    L"LoadLibraryW"
);

If LoadLibraryW returns NULL inside the target process the injector treats that as a failed load:

if (!launchResult.exitCode)
{
    return {
        false,
        0,
        ERROR_DLL_INIT_FAILED,
        L"LoadLibraryW returned NULL in the target process"
    };
}

At this point I only know that the launch method ran. The actual load can still fail inside the target process if the loader cannot resolve the module, for example a dependency could fail to load or the DLL rejects the process attach by returning FALSE from DllMain.

For that reason the LoadLibraryW result is treated as a load-status “signal” only and not as a reliable full module handle. On x64, GetExitCodeThread only gives the injector a DWORD, while HMODULE is pointer-sized. If I needed the real remote module handle, I would write it into a pointer-sized result block in the target process and read that block back instead.

Architecture checks

A lot of early injector examples skip validation and then often fail with confusing errors. I wanted the tool to be harder to misuse, because when you are testing DLLs quickly, small mistakes waste a lot of time.

The UI shows the target process architecture and the DLL architecture. The backend rejects target and injector mismatches before trying to run the selected loader path:

const std::wstring targetArch = QueryProcessArch(request.processId);
const std::wstring injectorArch = CurrentProcessArch();

if (targetArch != L"?" && targetArch != injectorArch)
{
    return {
        false,
        0,
        ERROR_BAD_EXE_FORMAT,
        L"Architecture mismatch: injector is " + injectorArch +
            L", target is " + targetArch
    };
}

For the process list, the code uses IsWow64Process2 when it is available and falls back to IsWow64Process:

if (isWow64Process2(process, &processMachine, &nativeMachine))
{
    CloseHandle(process);

    return processMachine == IMAGE_FILE_MACHINE_UNKNOWN
        ? MachineToArch(nativeMachine)
        : MachineToArch(processMachine);
}

For the DLL file, the code reads the PE header and checks the machine type:

IMAGE_DOS_HEADER dos{};
file.read(reinterpret_cast<char*>(&dos), sizeof(dos));

if (!file || dos.e_magic != IMAGE_DOS_SIGNATURE)
{
    return L"?";
}

file.seekg(dos.e_lfanew, std::ios::beg);

// Read NT headers here...

return MachineToArch(header.Machine);

The manual-map path also validates the DLL PE machine type before mapping, because this version does not try to cross the 32-bit/64-bit boundary.

This is one of those features that is not exciting in a screenshot, but it makes the tool much nicer to use. If the x64 injector is pointed at an x86 target, or an x86 DLL is queued for an x64 process, the tool can say that directly instead of failing later in the loader path with a vague error.

That matters for my use case because many of the older applications I test against are still 32-bit.

Launching the remote routine

The first launch method is CreateRemoteThread, because it is the simplest one and it is useful for controlled test targets:

thread = CreateRemoteThread(
    process,
    nullptr,
    0,
    routine,
    parameter,
    0,
    nullptr
);

if (!thread)
{
    const DWORD errorCode = GetLastError();

    return {
        false,
        0,
        errorCode,
        std::wstring(L"CreateRemoteThread for ") +
            routineDescription +
            L" failed: " +
            GetLastErrorText(errorCode)
    };
}

But the backend does not hardcode that one method. It also supports NtCreateThreadEx and a baseline thread hijack path:

switch (method)
{
case LaunchMethod::CreateRemoteThread:
    createResult = CreateThreadWithWin32(
        process,
        routine,
        parameter,
        thread,
        routineDescription
    );
    break;

case LaunchMethod::NtCreateThreadEx:
    createResult = CreateThreadWithNt(
        process,
        routine,
        parameter,
        thread,
        routineDescription
    );
    break;

case LaunchMethod::HijackThread:
    return HijackThreadRoutine(
        process,
        routine,
        parameter,
        timeoutMs,
        routineDescription
    );

default:
    return {
        false,
        0,
        ERROR_NOT_SUPPORTED,
        L"Selected launch method is not implemented yet."
    };
}

For NtCreateThreadEx, the code resolves the function dynamically from ntdll.dll:

auto ntCreateThreadEx = ntdll
    ? reinterpret_cast<NtCreateThreadExFn>(
        GetProcAddress(ntdll, "NtCreateThreadEx")
    )
    : nullptr;

This does not make the injector invisible or advanced, it just gives the backend another execution path that is closer to the native API. The useful part for me was being able to test the same loader mode with different routine launch methods, without changing the rest of the injector.

The thread hijack path is more involved. It finds a thread owned by the target process, suspends it, saves the register state, redirects execution to a small wrapper, waits for the wrapper to report that it finished and then lets the original thread continue.

The rough idea is:

find target thread
-> suspend thread
-> read context
-> write wrapper data and wrapper code
-> set instruction pointer to wrapper
-> resume thread
-> wait for wrapper state = Finished
-> restore original state

This was useful to learn because it forces you to think about the target thread state instead of only creating a new thread and forgetting about it. It is also the kind of feature that needs careful testing, because a bad context restore can crash the target process.

The selected thread may be in a bad state for hijacking, a timeout can leave the wrapper still running and x64 stack alignment mistakes are enough to destabilize the target. For that reason, I treat this as a controlled-test launch method, not as the default path.

Manual mapping

The next step after the normal loader path was a baseline manual mapper.

With LoadLibraryW, the Windows loader does most of the hard work. With manual mapping the injector reads the DLL as a PE image and maps it into the target process itself. That means the code needs to handle the pieces the Windows loader normally handles for us.

The flow is roughly:

read DLL bytes
-> validate PE headers
-> allocate SizeOfImage in target
-> write headers
-> write each section
-> make the mapped image temporarily executable/writable
-> write manual-map data
-> write shellcode
-> run shellcode in target
-> read result back
-> apply final section protections

The PE validation starts with the normal DOS and NT signatures, then checks that the selected file is actually a DLL and that the machine type matches the current injector build:

auto* dos = reinterpret_cast<IMAGE_DOS_HEADER*>(image);
if (dos->e_magic != IMAGE_DOS_SIGNATURE)
{
    error = L"Invalid DLL file: missing MZ header";
    return false;
}

ntHeaders = reinterpret_cast<IMAGE_NT_HEADERS*>(image + dos->e_lfanew);
if (ntHeaders->Signature != IMAGE_NT_SIGNATURE)
{
    error = L"Invalid DLL file: missing PE signature";
    return false;
}

if (!(ntHeaders->FileHeader.Characteristics & IMAGE_FILE_DLL))
{
    error = L"Invalid DLL file: PE image is not marked as a DLL";
    return false;
}

if (ntHeaders->FileHeader.Machine != kCurrentMachine)
{
    error =
        L"Architecture mismatch: manual map requires the DLL, injector, "
        L"and target to use the same architecture";

    return false;
}

Then the injector allocates the full image size in the target process:

BYTE* remoteImage = reinterpret_cast<BYTE*>(VirtualAllocEx(
    process,
    nullptr,
    ntHeaders->OptionalHeader.SizeOfImage,
    MEM_COMMIT | MEM_RESERVE,
    PAGE_READWRITE
));

The headers are written first, then each section is copied to its virtual address inside the allocated image:

if (!WriteProcessMemory(process, remoteImage, image.data(), headerBytes, nullptr))
{
    // Return a useful backend error here.
}

auto* section = IMAGE_FIRST_SECTION(ntHeaders);

for (UINT i = 0; i < ntHeaders->FileHeader.NumberOfSections; ++i, ++section)
{
    if (!section->SizeOfRawData)
    {
        continue;
    }

    WriteProcessMemory(
        process,
        remoteImage + section->VirtualAddress,
        image.data() + section->PointerToRawData,
        section->SizeOfRawData,
        nullptr
    );
}

Before the remote mapper routine runs, the mapped image also needs executable permissions. The image was first allocated as writable so the injector could copy headers and sections into it. After that, the mapper can temporarily make the image executable while the remote setup code runs:

DWORD oldProtect = 0;

VirtualProtectEx(
    process,
    remoteImage,
    ntHeaders->OptionalHeader.SizeOfImage,
    PAGE_EXECUTE_READWRITE,
    &oldProtect
);

The final section protections are applied later, after the shellcode has done the loader work. That way the image does not have to stay writable and executable after mapping is complete.

At this point the DLL bytes are in the target process, but it is not ready to run yet. If the image did not land at its preferred base, relocations need to be applied. Imports need to be resolved and TLS callbacks may need to run. On x64, the exception and unwind information may need to be registered. Then DllMain can be called.

That work happens in the manual-map shellcode. The code passes a small data block into the shellcode with the target-side addresses of LoadLibraryA, GetProcAddress, the image base and the selected options:

struct ManualMapData
{
    LoadLibraryAFn loadLibraryA = nullptr;
    GetProcAddressFn getProcAddress = nullptr;

#if defined(_M_X64)
    RtlAddFunctionTableFn rtlAddFunctionTable = nullptr;
#endif

    BYTE* imageBase = nullptr;
    HINSTANCE moduleHandle = nullptr;

    DWORD reason = DLL_PROCESS_ATTACH;
    LPVOID reserved = nullptr;

    ManualMapOptions options;
    ManualMapError error = ManualMapError::None;

    BOOL completed = FALSE;
};

The relocation step is a good example of why manual mapping is more than just copying bytes:

const auto locationDelta =
    reinterpret_cast<UINT_PTR>(base) -
    static_cast<UINT_PTR>(opt->ImageBase);

if (locationDelta)
{
    auto relocDirectory =
        opt->DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC];

    if (!relocDirectory.Size)
    {
        data->error = ManualMapError::RelocationMissing;
        return;
    }

    auto* relocation = reinterpret_cast<IMAGE_BASE_RELOCATION*>(
        base + relocDirectory.VirtualAddress
    );

    // Walk relocation blocks here...
}

If the DLL was built assuming one image base and we load it somewhere else, the absolute addresses inside the image need to be fixed. Without that, the mapped image may crash as soon as it touches one of those addresses.

Imports are similar. The mapped DLL may reference functions from other DLLs, so the shellcode walks the import descriptors, loads each imported module and fills the import address table:

char* moduleName = reinterpret_cast<char*>(base + importDescriptor->Name);

HINSTANCE module = loadLibraryA(moduleName);
if (!module)
{
    data->error = ManualMapError::ImportLoadFailed;
    return;
}

FARPROC proc = getProcAddress(module, importByName->Name);
if (!proc)
{
    data->error = ManualMapError::ImportResolveFailed;
    return;
}

*firstThunk = reinterpret_cast<ULONG_PTR>(proc);

That code snippet shows the readable import-by-name path. The mapper handles both import-by-name and ordinal imports because not every import is stored with a function name.

The mapper also has baseline support for delay imports, TLS callbacks, security cookie initialization, DllMain, x64 exception and unwind registration and final section protections. The options are visible in the UI as read-only indicators:

struct ManualMapOptions
{
    bool runDllMain = true;
    bool resolveImports = true;
    bool resolveDelayImports = true;
    bool executeTls = true;
    bool setPageProtections = true;
    bool enableExceptions = true;
    bool initializeSecurityCookie = true;

    bool lockLoaderLock = false;
    bool cleanDataDirectories = false;
    bool shiftModuleBase = false;
    bool linkToPeb = false;
    bool loadFromMemory = false;
};

The important part is that only the implemented baseline options are enabled in the backend. Things like header wiping, PEB unlinking, data directory cleanup and other cloaking-style features are still out of scope for this version and I will be implementing those later. I wanted the first version to be simple, usable and testable before adding more complicated behavior.

There is also a difference between calling the DLL entry point manually and the Windows loader calling it. The mapper can call DllMain but that does not make the manual-map path identical to a real loader path. Loader-lock behavior, loader notifications, activation contexts, static TLS edge cases and PEB loader bookkeeping are separate pieces, and they are not the point of this version.

After the shellcode finishes, the injector reads the result block back:

ManualMapData checkedData{};

if (!ReadProcessMemory(
        process,
        remoteData,
        &checkedData,
        sizeof(checkedData),
        nullptr))
{
    // Return a useful backend error here.
}

const bool mapped =
    checkedData.completed &&
    (
        checkedData.error == ManualMapError::None ||
        checkedData.error == ManualMapError::ExceptionSupportFailed
    );

I still report the mapping as completed if exception registration fails, because the image may run fine until something needs to unwind through mapped code. The warning is kept in the result so the UI can still show that the mapper did not fully reproduce the normal loader behavior.

This gives the UI a useful error instead of just saying that something failed. For manual mapping, that matters a lot because the failure could be missing relocations, import load failure, a failed TLS step, DllMain returning FALSE or a problem applying final section protections.

Simple UI

The UI is intentionally thin and is just there so I can repeat the workflow quickly:

process list
DLL queue
mode dropdown
launch method dropdown
manual-map option indicators
log pane

The actual injection button builds the request from the selected UI state:

InjectionRequest request{};

request.processId = pid;
request.dllPath = g_dlls[i].path;
request.mode = SelectedInjectionMode();
request.launchMethod = SelectedLaunchMethod();
request.timeoutMs = timeoutMs;
request.manualMapOptions = CurrentManualMapOptions();

const InjectionResult result = InjectDll(request);

Then it logs either the remote thread exit code/result signal or the backend error:

if (result.success)
{
    std::wstringstream stream;

    stream << L"Success. Remote routine returned non-zero result signal: 0x"
           << std::hex << result.threadResult;

    AppendLog(stream.str());
}
else
{
    std::wstring message = L"Injection failed: " + result.message;

    // Include backend error code text here when available.

    AppendLog(message);
}

That is enough for the current version. The tool is not trying to be the best or most comprehensive. It is a small injector that makes the common testing path faster and keeps the backend separated enough that new methods can be added without rewriting the UI.

Some learnings

The normal loader path teaches remote memory, remote function addresses and thread result handling. NtCreateThreadEx and thread hijacking teach that “start code in another process” can be separated from “how the DLL is loaded”. Manual mapping teaches what the Windows loader normally does for us: sections, relocations, imports, delay imports, TLS, exception and unwind data, security cookies and entry point calls.

Also that the uninteresting validation code is what makes the tool usable. Architecture checks, PE header checks, timeouts, cleanup and readable errors matter more than another checkbox in the UI.

Current version is not a complete stealth injector and it does not implement the more “aggressive” features that are sometimes associated with manual mapping.

That is fine for this stage to use the tool in reverse-engineering and game-dev related experiments without repeating the same setup every time.