在PE文件的学习中,我们了解了导入表和动态链接库是如何被PE加载器链接在一起的,这种在运行时才加载代码和数据的机制是Windows系统的核心
但正所谓水能载舟,亦能覆舟,这种强大的机制也诞生了问题 —— DLL注入(DLL Injection)
DLL注入是一种强制让一个正在运行的进程加载并执行我们指定的DLL文件的技术,说白了就是执行任意代码
动态链接库 (DLL)
之前PE主要讲的是exe文件,稍稍提到了dll,现在我们正式来介绍一下这个玩意
什么是动态链接库?
DLL全称Dynamic Link Library,也就是动态链接库,是一个独立于主程序文件之外的程序库文件,它包含可被多个应用程序共享使用的代码、数据和资源
与静态链接lib在编译时将库代码完全复制到exe中不同,动态链接的DLL代码和数据在运行时才被加载器链接到主程序中,加载方式主要有两种:
| 链接方式 | 链接时机 | 实现方式 |
|---|---|---|
| 隐式链接 | 程序启动时 | 由Windows加载器自动扫描PE文件的导入表,并加载所有依赖的DLL |
| 显式链接 | 程序运行时按需调用 | 程序员通过调用LoadLibrary()和GetProcAddress()API来手动加载DLL并获取函数地址 |
这部分内容在之前文章已经讲过,详细的就不再说了 -> 逆向基础4:PE文件
在Windows系统中,有三个核心的DLL,几乎所有图形化程序都离不开它们:
Kernel32.dll:提供操作系统的核心功能,如进程/线程管理、内存管理和文件访问
User32.dll:提供用户界面功能,如窗口管理、消息传递以及键盘/鼠标操作
GDI32.dll:提供图形设备接口,负责在屏幕或打印机上绘制图形和显示文本
文件名里的32并不会随着操作系统的位数而改变,为了向后兼容,它也不可能改名
但是,他们会有不同的版本以服务不同位的程序:
| 功能 | 64位系统中位置 |
|---|---|
| 64位系统DLL | C:\Windows\System32 |
| 32位系统DLL | C:\Windows\SysWOW64 |
在系统内部,微软通过WOW64(Windows-on-Windows 64-bit)子系统来让32位程序也能调用相同的API接口
入口函数:DllMain()
当一个DLL被加载到进程的地址空间时,Windows加载器会查找并执行一个特殊的入口函数 ——DllMain
DllMain是DLL内部的函数,并不会作为API导出
它的存在至关重要,至少对我们这些攻击者来说是重要的,因为我们所有做手脚的代码都是从这里开始执行的
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // DLL实例的句柄,即DLL被映射到的基地址
DWORD fdwReason, // 系统调用 DllMain 的原因
LPVOID lpReserved // 保留参数
)
{
switch(fdwReason)
{
case DLL_PROCESS_ATTACH: // 进程加载 DLL —— 这是我们执行注入代码的时机!!!!!!!!
// DLL 首次被加载到进程时调用,例如程序启动或调用 LoadLibrary() 时
// 只会被调用一次,如果加载失败,返回 FALSE,系统会立即卸载 DLL
// 常用于初始化全局变量、创建所需的资源(如文件、互斥锁、线程池等)、设置钩子、建立与主程序的通信通道
break;
case DLL_THREAD_ATTACH: // 创建新线程
// 进程创建新线程时,系统会调用所有已加载DLL的这个分支
// 常用于为新线程分配线程局部存储(TLS) 、初始化与该线程有关的对象
break;
case DLL_THREAD_DETACH: // 线程退出
// 线程(如调用ExitThread)正常退出时调用
// 常用于释放该线程的局部资源、关闭句柄、写入日志等
break;
case DLL_PROCESS_DETACH: // 卸载 DLL
// DLL 从进程地址空间卸载时(如FreeLibrary)调用
// 常用于清理全局资源,如关闭文件、释放内存、断开连接
break;
}
return TRUE; // 必须返回TRUE表示成功
}
DllMain的强大之处在于,它为我们提供了一个在目标进程上下文中执行任意代码的机会
这可以用来修bug、增加新功能,当然也能被恶意代码利用
如果DLL中找不到DllMain,Windows会从其它运行库中找一个不做任何操作的缺省DllMain函数来启动这个DLL
DLL注入
顾名思义,DLL注入就是向一个正在运行的其他进程中,强制插入一个我们指定的DLL文件
其核心原理是以某种方式让目标进程去调用LoadLibrary()API,以此来加载我们的DLL
一旦LoadLibrary()被调用,目标进程就会自动执行该DLL的DllMain函数,而DllMain的DLL_PROCESS_ATTACH分支中就包含了我们想要执行的payload
Windows系统编程
要实现注入,我们要先知道如何书写dll,如何使用Windows API来操纵进程
常用数据类型
Windows API有自己的一套数据类型定义,这些类型在windows.h头文件中定义:
| 数据类型 | 含义 |
|---|---|
VOID |
作为函数返回类型时,表示函数运行不可能失败 |
BOOL |
int类型,作为函数返回类型时,>0代表TRUE,=0代表FALSE应测试返回值是否为0,不要测试是否为TRUE |
HANDLE |
内核对象的类型,用于标识进程、线程、文件句柄等 失败时通常返回 NULL或INVALID_HANDLE_VALUE(-1) |
PVOID/PCVOID |
无类型的指针(void*)指向一块能存放任意类型值的内存空间 |
LONG/DWORD |
表示有符号/无符号的32位长整型 作为函数返回类型时返回数量值,如果无法正确计数则返回0或-1 |
PDWORD/LPDWORD |
指向DWORD数据的指针类型 |
HMODULE/HINSTANCE |
应用程序加载的模块的线性地址,本质上是HANDLE |
HHOOK |
所安装的钩子的HANDLE |
HWND |
窗口的HANDLE |
HKEY |
注册表键的HANDLE |
LRESULT |
由窗口过程或回调函数所返回的32位值类型 |
WPARAM/LPARAM |
消息参数LPARAM即LONG_PTR,WPARAM即UINT_PTR |
WINAPI |
Windows API的函数调用惯例,相当于_stdcall |
PSECURITY_ATTRIBUTES |
指向SECURITY_ATTRIBUTES结构的指针类型 |
PTHREAD_START_ROUTINE |
函数指针 指向一个线程函数,该函数返回 DWORD并接受一个LPVOID参数 |
HOOKPROC |
钩子过程的地址(回调函数) |
Unicode与ANSI
这是在Windows编程中字符的概念:
-
ANSI
不是严格的标准编码名,而是指基于系统区域设置的单字节或可变字节编码,也称多字节字符集
在这种编码下,
char(1 字节)表示字符(或者字符的一个字节),某些字符需要用多个char才能表示 -
Unicode
Windows内核使用的数据类型,使用
wchar_t(双字节)存储字符关于Unicode详细内容移步:字符集与编码
C运行库(string.h)和Windows API都为这两种类型提供了不同的函数:
| 标准ANSI C函数 | 等价Unicode函数 (string.h) |
|---|---|
char *strcat(...) |
wchar_t *wcscat(...) |
char *strchr(...) |
wchar_t *wcschr(...) |
int strcmp(...) |
int wcscmp(...) |
char *strcpy(...) |
wchar_t *wcscpy(...) |
size_t strlen(...) |
size_t wcslen(...) |
Windows也定义了自己专用的Unicode数据类型:
| 数据类型 | 说明 |
|---|---|
WCHAR |
Unicode字符 (wchar_t) |
LPWSTR |
指向Unicode字符串的指针 |
LPCWSTR |
指向一个常量Unicode字符串的指针 |
为了编写兼容性代码,Windows推荐使用tchar.h头文件中的通用数据类型:
#ifdef _UNICODE
typedef wchar_t TCHAR;
#else
typedef char TCHAR;
#endif
这样,你的代码就可以通过是否定义_UNICODE宏来编译为ANSI或Unicode版本
| 通用类型 | 说明 |
|---|---|
TCHAR |
字符(CHAR或WCHAR) |
LPTSTR |
字符串指针(LPSTR或LPWSTR) |
LPCTSTR |
常量字符串指针(LPCSTR或LPCWSTR) |
定义字符串常量时,应使用_T()/_TEXT():
// 1: 强制 ANSI(char*)
TCHAR *sz1 = "Error";
// 如果 UNICODE 被定义,TCHAR 变成 wchar_t,但这里把 narrow string 赋给 wchar_t* —— 不安全/错误!
// 2: 强制 Unicode(wchar_t*)
TCHAR *sz2 = L"Error";
// 如果 UNICODE 未定义,TCHAR==char,此处把 wchar_t* 赋给 char* —— 不安全/错误!
// 3: 根据 UNICODE 宏自动适配(推荐用于兼容旧代码)
TCHAR *sz3 = _T("Error");
// 如果 UNICODE 未定义 -> TCHAR==char, _T("Error") -> "Error" (OK)
// 如果 UNICODE 定义 -> TCHAR==wchar_t, _T("Error") -> L"Error" (OK)
_UNICODE宏用于C运行时头文件(tchar.h),而UNICODE宏用于Windows头文件(windows.h),在项目中最好同时定义这两个宏以确保一致性
在DLL中,函数通常会提供两个版本,例如CreateWindowEx:
CreateWindowExA:接受ANSI字符串 (A = ANSI)CreateWindowExW:接受Unicode字符串 (W = Wide)
通常,-A版本的函数内部也是通过字符串转换后调用-W版本来实现的
Windows 字符串函数
Windows在windows.h中也提供了一套自己的字符串处理函数(注意函数名前缀是l):
| 函数 | 含义 |
|---|---|
lstrcat |
将一个字符串拼接到另一个字符串的结尾 |
lstrcmp |
对两个字符串进行区分大小写的比较 |
lstrcmpi |
对两个字符串进行不区分大小写的比较 |
lstrcpy |
将一个字符串拷贝到另一个位置 |
lstrlen |
返回字符串的长度(按字符数计量) |
wsprintf |
将一系列字符和数值格式化输出到缓冲区(最大1024字节) |
有时API只接受特定类型的字符串,我们就需要手动转换:
char szA[100];
WCHAR szW[100];
// 将Unicode字符串 L"Unicode Str" 转化为 ANSI字符串
sprintf_s(szA, "%S", L"Unicode Str");
// 将ANSI字符串 "ANSI Str" 转化为 Unicode字符串
swprintf_s(szW, L"%S", "ANSI Str");
关键API列表
进程与线程API
OpenProcess
打开一个已存在的进程对象,返回一个句柄(HANDLE):
HANDLE WINAPI OpenProcess(
_In_ DWORD dwDesiredAccess, // 访问权限
_In_ BOOL bInheritHandle, // 句柄是否可被继承
_In_ DWORD dwProcessId // 目标进程ID
);
_In_并不是C/C++关键字,而是微软的 SAL(Source Annotation Language)注解
它也叫代码注释宏,用于给函数参数、返回值等添加额外的语义信息,帮助静态分析器、IDE和文档生成器更好地理解代码
它不改变运行时行为,通常在预处理时被定义为空或被替换为编译器可识别的属性
常见的SAL注解:
_In_:输入参数,调用方负责初始化,不能为 NULL(默认)_In_opt_:可选输入参数,可以为 NULL_Out_:输出参数,函数负责写入(调用方提供缓冲区)_Out_opt_:可选输出参数_Inout_:输入/输出参数(调用方提供初始值,函数会读写)_Pre_satisfies_/_Post_satisfies_:复杂约束(较少见)_In_reads_bytes_(n)/_Out_writes_bytes_(n):表明缓冲区长度(以字节计),用于检测越界_Null_terminated_:字符串以\0结尾_When_(cond, annotation):条件化注解(复杂场景)
eg:打开PID为1234的进程,并请求所有权限
DWORD targetPID = 1234;
HANDLE hProcess = OpenProcess(
PROCESS_ALL_ACCESS, // 请求所有权限
FALSE, // 不继承句柄
targetPID // 目标进程ID
);
if (hProcess == NULL) {
// 打开失败,处理错误
// 可以调用GetLastError()获取错误代码
}
CreateThread
在调用进程的虚拟地址空间中创建一个线程:
HANDLE WINAPI CreateThread(
_In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes, // 线程安全属性
_In_ SIZE_T dwStackSize, // 初始堆栈大小(0为默认)
_In_ LPTHREAD_START_ROUTINE lpStartAddress, // 线程函数地址
_In_opt_ LPVOID lpParameter, // 传递给线程的参数
_In_ DWORD dwCreationFlags, // 线程创建标志(0为立即运行)
_Out_opt_ LPDWORD lpThreadId // 接收线程ID的指针
);
eg:在当前进程中创建一个新线程
// 1. 定义我们的线程函数
DWORD WINAPI MyThreadFunction(LPVOID lpParam) {
int* pValue = (int*)lpParam;
printf("线程开始,接收到的参数值: %d\n", *pValue);
// ... 执行线程任务 ...
return 0; // 线程退出
}
// 2. 在主函数中创建线程
int myValue = 100;
DWORD threadId;
HANDLE hThread = CreateThread(
NULL, // 默认安全属性
0, // 默认堆栈大小
MyThreadFunction, // 线程函数地址
&myValue, // 传递给线程的参数
0, // 立即运行
&threadId // 接收线程ID
);
if (hThread == NULL) {
// 创建失败
}
CreateRemoteThread
在另一个进程(hProcess)的虚拟地址空间中创建一个线程:
HANDLE WINAPI CreateRemoteThread(
_In_ HANDLE hProcess, // 目标进程句柄
_In_ LPSECURITY_ATTRIBUTES lpThreadAttributes, // 线程安全属性
_In_ SIZE_T dwStackSize, // 初始堆栈大小(0为默认)
_In_ LPTHREAD_START_ROUTINE lpStartAddress, // 远程线程函数地址
_In_ LPVOID lpParameter, // 传递给远程线程的参数
_In_ DWORD dwCreationFlags, // 线程创建标志(0为立即运行)
_Out_ LPDWORD lpThreadId // 接收线程ID的指针
);
eg:在目标进程(hTargetProcess)中创建一个远程线程
// (这是一个概念性示例,假设hTargetProcess已通过OpenProcess获取)
// (并且pRemoteFunction和pRemoteParam已通过VirtualAllocEx和WriteProcessMemory写入)
// HANDLE hTargetProcess = ...
// LPVOID pRemoteFunction = ... (例如LoadLibraryA的地址)
// LPVOID pRemoteParam = ... (例如DLL路径字符串在目标进程中的地址)
HANDLE hRemoteThread = CreateRemoteThread(
hTargetProcess, // 目标进程句柄
NULL, // 默认安全属性
0, // 默认堆栈大小
(LPTHREAD_START_ROUTINE)pRemoteFunction, // 远程函数地址
pRemoteParam, // 远程参数地址
0, // 立即运行
NULL // 不关心线程ID
);
if (hRemoteThread == NULL) {
// 远程线程创建失败
}
内存操作API
VirtualAllocEx
在指定进程(hProcess)的虚拟地址空间中分配内存:
LPVOID WINAPI VirtualAllocEx(
_In_ HANDLE hProcess, // 目标进程句柄
_In_opt_ LPVOID lpAddress, // 期望的起始地址(NULL为自动选择)
_In_ SIZE_T dwSize, // 分配的内存大小(字节)
_In_ DWORD flAllocationType, // 内存分配类型(如MEM_COMMIT)
_In_ DWORD flProtect // 内存保护属性(如PAGE_READWRITE)
);
eg:在hTargetProcess进程中分配1024字节的可读写内存
// HANDLE hTargetProcess = ... (通过OpenProcess获取)
SIZE_T allocSize = 1024; // 1KB
LPVOID pRemoteBuffer = VirtualAllocEx(
hTargetProcess,
NULL, // 系统自动选择地址
allocSize,
MEM_COMMIT | MEM_RESERVE, // 提交并保留内存
PAGE_READWRITE // 内存保护:可读可写
);
if (pRemoteBuffer == NULL) {
// 分配失败
}
WriteProcessMemory
将数据(lpBuffer)写入指定进程(hProcess)的内存区域(lpBaseAddress):
// 函数原型 (结构)
BOOL WINAPI WriteProcessMemory(
_In_ HANDLE hProcess, // 目标进程句柄
_In_ LPVOID lpBaseAddress, // 写入的目标内存基地址
_In_ LPCVOID lpBuffer, // 指向本地数据的指针
_In_ SIZE_T nSize, // 要写入的字节数
_Out_ SIZE_T* lpNumberOfBytesWritten // 接收实际写入字节数的指针
);
eg:将一个本地字符串写入到远程进程已分配的内存中
// HANDLE hTargetProcess = ...
// LPVOID pRemoteBuffer = ... (通过VirtualAllocEx获取)
const char* myData = "Hello, Remote Process!";
SIZE_T dataSize = strlen(myData) + 1; // +1为了null终止符
SIZE_T bytesWritten = 0;
BOOL bResult = WriteProcessMemory(
hTargetProcess,
pRemoteBuffer, // 写入的目标地址
myData, // 本地数据源
dataSize, // 写入大小
&bytesWritten // 接收实际写入的字节数
);
if (bResult == FALSE || bytesWritten != dataSize) {
// 写入失败
}
模块操作API
GetModuleHandle
获取一个已加载模块的句柄:
// 函数原型 (结构)
HMODULE WINAPI GetModuleHandle(
_In_opt_ LPCTSTR lpModuleName // 模块名称(DLL或EXE),NULL为获取主EXE句柄
);
eg:获取当前进程EXE的句柄和kernel32.dll的句柄
// 1. 获取当前进程(EXE)的模块句柄
HMODULE hExe = GetModuleHandle(NULL);
// 2. 获取kernel32.dll的模块句柄(它一定已被加载)
HMODULE hKernel32 = GetModuleHandle(_T("kernel32.dll"));
if (hKernel32 == NULL) {
// 获取失败(极不可能发生)
}
GetModuleFileName
获取包含指定模块(hModule)的文件的完整路径:
// 函数原型 (结构)
DWORD WINAPI GetModuleFileName(
_In_opt_ HMODULE hModule, // 模块句柄(NULL为获取当前EXE路径)
_Out_ LPTSTR lpFilename, // 接收路径的缓冲区
_In_ DWORD nSize // 缓冲区大小(TCHARs)
);
eg:获取当前可执行文件的完整路径
TCHAR exePath[MAX_PATH]; // MAX_PATH是一个预定义常量
DWORD pathLen = GetModuleFileName(
NULL, // 传递NULL来获取当前EXE的路径
exePath, // 接收路径的缓冲区
MAX_PATH // 缓冲区大小
);
if (pathLen == 0) {
// 获取失败
} else {
// exePath现在包含"C:\Path\To\YourApp.exe"
}
GetProcAddress
从指定的DLL模块(hModule)中检索导出函数(lpProcName)的地址:
// 函数原型 (结构)
FARPROC WINAPI GetProcAddress(
_In_ HMODULE hModule, // 模块句柄
_In_ LPCSTR lpProcName // 导出函数名称(ANSI字符串)
);
eg:获取kernel32.dll中的CreateFileA函数的地址
// 1. 定义一个函数指针类型,使其与CreateFileA匹配
typedef HANDLE (WINAPI *PFN_CREATEFILEA)(
LPCSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE
);
// 2. 获取模块句柄
HMODULE hKernel32 = GetModuleHandle(_T("kernel32.dll"));
// 3. 获取函数地址
FARPROC pFunc = GetProcAddress(
hKernel32,
"CreateFileA" // 注意:必须是ANSI字符串
);
if (pFunc == NULL) {
// 获取地址失败
} else {
// 4. 将通用的FARPROC转换为我们定义的类型
PFN_CREATEFILEA MyCreateFile = (PFN_CREATEFILEA)pFunc;
// 5. 现在可以像普通函数一样调用它
// MyCreateFile("test.txt", ...);
}
WaitForSingleObject
阻塞当前线程,直到指定句柄(hHandle)的对象被"signaled"或超时:
// 函数原型 (结构)
DWORD WINAPI WaitForSingleObject(
_In_ HANDLE hHandle, // 要等待的对象句柄(如线程、进程)
_In_ DWORD dwMilliseconds // 超时时间(毫秒),INFINITE为无限等待
);
eg:等待之前创建的hThread线程执行完毕
// HANDLE hThread = ... (通过CreateThread获取)
DWORD waitResult = WaitForSingleObject(
hThread,
INFINITE // 无限期等待,直到线程结束
);
if (waitResult == WAIT_OBJECT_0) {
// 线程已成功结束
printf("线程已退出。\n");
} else {
// 等待失败或超时
}
CloseHandle
关闭一个打开的对象句柄(如进程、线程句柄):
// 函数原型 (结构)
BOOL WINAPI CloseHandle(
_In_ HANDLE hObject // 要关闭的有效对象句柄
);
eg:关闭之前打开或创建的句柄
// HANDLE hProcess = OpenProcess(...);
// HANDLE hThread = CreateThread(...);
// 使用完毕后...
if (hThread != NULL) {
CloseHandle(hThread); // 关闭线程句柄
}
if (hProcess != NULL) {
CloseHandle(hProcess); // 关闭进程句柄
}
消息钩取API
SetWindowsHookEx
将一个用户定义的钩子过程(lpfn)安装到一个钩子链中:
HHOOK WINAPI SetWindowsHookEx(
_In_ int idHook, // 钩子类型(如WH_KEYBOARD_LL)
_In_ HOOKPROC lpfn, // 钩子过程(回调函数)的地址
_In_ HINSTANCE hMod, // 包含钩子过程的DLL句柄
_In_ DWORD dwThreadId // 要钩取的线程ID(0为所有线程)
);
eg:安装一个低级键盘钩子(WH_KEYBOARD_LL)
// 1. 定义一个钩子过程
LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) {
if (nCode == HC_ACTION) {
// ... 处理键盘事件 ...
}
// 必须调用下一个钩子
return CallNextHookEx(NULL, nCode, wParam, lParam);
}
// 2. 在主函数中安装钩子
HHOOK g_hHook = NULL;
g_hHook = SetWindowsHookEx(
WH_KEYBOARD_LL, // 钩子类型:低级键盘
LowLevelKeyboardProc, // 钩子过程回调函数
GetModuleHandle(NULL), // 包含回调函数的模块句柄
0 // 关联到所有线程
);
if (g_hHook == NULL) {
// 安装钩子失败
}
CallNextHookEx
将钩子信息传递给钩子链中的下一个钩子过程:
// 函数原型 (结构)
LRESULT WINAPI CallNextHookEx(
_In_opt_ HHOOK hhk, // 当前钩子的句柄(可忽略,传NULL)
_In_ int nCode, // 钩子代码(从回调函数传入)
_In_ WPARAM wParam, // 消息参数(从回调函数传入)
_In_ LPARAM lParam // 消息参数(从回调函数传入)
);
eg:在钩子过程中调用
// (HHOOK g_hHook; 在SetWindowsHookEx中获取)
LRESULT CALLBACK MyHookProc(int nCode, WPARAM wParam, LPARAM lParam) {
// ... 在这里可以监视或修改 wParam/lParam ...
// 在钩子过程中调用此函数至关重要
// 否则会“吞掉”消息,导致系统或其他程序功能异常
return CallNextHookEx(g_hHook, nCode, wParam, lParam);
}
UnhookWindowsHookEx
移除一个由SetWindowsHookEx安装的钩子过程:
// 函数原型 (结构)
BOOL WINAPI UnhookWindowsHookEx(
_In_ HHOOK hhk // 要移除的钩子句柄
);
eg:在程序退出前,卸载之前安装的G_HHook
// HHOOK g_hHook = ... (通过SetWindowsHookEx获取)
if (g_hHook != NULL) {
BOOL bResult = UnhookWindowsHookEx(g_hHook);
if (bResult) {
// 卸载成功
g_hHook = NULL;
}
}
注入用DLL的准备
既然要学,那肯定要实操,我们就需要一些DLL来验证我们的注入器是否工作
三个示例
以下三个示例DLL的目的都只是为了**“举手示意”**,告诉我们它已经成功在目标进程中运行,除此之外它们不会执行任何其他操作
弹窗(MessageBox)
当DllMain被调用时,它会立刻弹出一个消息框
由于DllMain是在目标进程的上下文中执行的,这个消息框将隶属于目标进程
此DLL唯一的行为就是调用User32.dll中的MessageBoxAPI,不会对系统造成任何更改
#include <windows.h>
#include <tchar.h> // 为了 TCHAR 和 _T()
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // DLL实例句柄
DWORD fdwReason, // 调用原因
LPVOID lpReserved // 保留
)
{
// 我们只关心DLL何时被“附加”到进程上
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// 当DLL被加载时,在目标进程中弹出一个消息框
MessageBox(
NULL, // 父窗口句柄 (NULL 亦可)
_T("DLL 注入成功! (来自 TestDll_MsgBox)"), // 消息内容
_T("注入测试"), // 标题
MB_OK | MB_ICONINFORMATION // 按钮 (OK) 和图标 (信息)
);
break;
case DLL_THREAD_ATTACH:
// 线程创建时不做任何事
break;
case DLL_THREAD_DETACH:
// 线程退出时不做任何事
break;
case DLL_PROCESS_DETACH:
// DLL被卸载时不做任何事
break;
}
return TRUE; // 始终返回 TRUE
}
播放系统提示音(Beep)
有时目标进程可能没有图形界面(例如一个后台服务),或者我们不想用一个弹窗来打断目标进程的运行,在这种情况下,使用声音作为反馈是一个很好的选择
这个DLL在被注入时,会调用MessageBeep函数播放一个系统默认的信息提示音
此DLL唯一的行为是调用User32.dll中的MessageBeepAPI,它只是通过声卡播放一个短暂的提示音,不会执行任何其他操作
#include <windows.h>
BOOL WINAPI DllMain(
HINSTANCE hinstDLL,
DWORD fdwReason,
LPVOID lpReserved
)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// 当DLL被加载时,播放一个“信息”提示音
// MB_ICONINFORMATION 对应一个系统声音
// 你也可以用 Beep(1000, 500); 来发声 (1000Hz, 500毫秒)
MessageBeep(MB_ICONINFORMATION);
break;
}
return TRUE;
}
输出到控制台(AllocConsole)
如果目标是一个GUI程序(如notepad.exe),它默认是没有控制台窗口的,而这个DLL会为目标进程创建一个新的控制台窗口,然后在该窗口中打印一条消息
这非常适合用来确认DLL不仅被加载了,而且可以在目标进程中分配新资源(一个控制台)并执行I/O操作(printf)
此DLL调用AllocConsole创建一个临时窗口,使用printf向其写入文本,然后调用FreeConsole将其关闭,没有触及目标进程的任何原有功能
#include <windows.h>
#include <stdio.h> // 为了 printf 和 FILE*
#include <io.h> // 为了 _open_osfhandle
#include <fcntl.h> // 为了 _O_TEXT
// 我们的控制台线程
// 注意:在DllMain中执行复杂操作(比如创建控制台)是不被推荐的
// 因为 DllMain 运行在加载器锁 (Loader Lock) 内部,容易造成死锁。
// 最安全的方法是像这样,创建一个新线程来执行实际工作。
DWORD WINAPI ConsoleThread(LPVOID lpParam)
{
// 1. 为当前进程分配一个新的控制台窗口
if (AllocConsole())
{
FILE* pFile = NULL;
// 2. 将标准输出 (stdout) 重定向到新的控制台
// freopen_s 是一个安全的方式来重新打开一个文件流
freopen_s(&pFile, "CONOUT$", "w", stdout); // "CONOUT$" 是控制台输出的设备名
// 3. 设置标题
SetConsoleTitle(_T("Injected Console - (TestDll_Console)"));
// 4. 打印我们的消息
printf("--- DLL 注入成功! ---\n");
printf("这个控制台窗口现在属于目标进程。\n");
printf("按回车键关闭此控制台...\n");
// 5. 等待用户输入,以便观察
getchar();
// 6. 清理并释放控制台
if (pFile)
{
fclose(pFile);
}
FreeConsole();
}
return 0;
}
BOOL WINAPI DllMain(
HINSTANCE hinstDLL,
DWORD fdwReason,
LPVOID lpReserved
)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// 创建一个新线程来执行我们的控制台逻辑
// 我们不希望在 DllMain 中直接做这些
HANDLE hThread = CreateThread(NULL, 0, ConsoleThread, NULL, 0, NULL);
if (hThread)
{
CloseHandle(hThread); // 关闭句柄,线程会继续执行
}
break;
}
return TRUE;
}
注入的实现方法
创建远程线程 (CreateRemoteThread)
这是最经典直接的注入方法
原理
之前说过,CreateRemoteThreadAPI允许我们在另一个进程中启动一个新线程
为了方便观看,这里再贴一下它的结构前面的内容其实你根本没看对吧:
HANDLE WINAPI CreateRemoteThread(
_In_ HANDLE hProcess, // 目标进程句柄
_In_ LPSECURITY_ATTRIBUTES lpThreadAttributes, // 线程安全属性
_In_ SIZE_T dwStackSize, // 初始堆栈大小(0为默认)
_In_ LPTHREAD_START_ROUTINE lpStartAddress, // 远程线程函数地址,重要!
_In_ LPVOID lpParameter, // 传递给远程线程的参数,重要!
_In_ DWORD dwCreationFlags, // 线程创建标志(0为立即运行)
_Out_ LPDWORD lpThreadId // 接收线程ID的指针
);
哦,它接受线程函数地址和线程函数参数……那我们岂不是可以用它来加载我们想要的东西?
步骤
首先,我们将lpStartAddress设置为LoadLibrary()函数的地址
然后,我们将lpParameter设置为我们想要注入的DLL文件的路径字符串的地址
这个地址可以通过上面的模块操作API来得到:
GetProcAddress(GetModuleHandle(L"kernel32.dll"), "LoadLibraryW")
于是当CreateRemoteThread执行时,它就相当于在目标进程内部调用了:
LoadLibrary("C:\\path\\to\\myhack.dll")
这样就触发了DllMain,实现了注入
lpStartAddress和lpParameter都必须是目标进程虚拟地址空间中的地址
这里可能有个疑问:为什么我们知道LoadLibrary的地址?难道它是通用的?
还真是!LoadLibrary函数位于Kernel32.dll中,而Windows操作系统为了效率,会将这些核心DLL加载到所有进程中几乎相同的ImageBase
因此,我们在自己进程中用找到的LoadLibrary地址,与它在目标进程中的地址是完全相同的!
这一整个过程下来,其实就是在说:一些为了方便的行为,早晚会变成漏洞(
示例
将MyDll.dll注入到指定PID的进程中
#include <windows.h>
#include <tchar.h>
BOOL InjectDll(DWORD dwPID, LPCTSTR szDllPath) {
HANDLE hProcess = NULL, hThread = NULL;
HMODULE hMod = NULL;
LPVOID pRemoteBuf = NULL;
// 1. 计算DLL路径字符串所需的字节大小 (包括结尾的 \0)
DWORD dwBufSize = (DWORD)(_tcslen(szDllPath) + 1) * sizeof(TCHAR);
LPTHREAD_START_ROUTINE pThreadProc;
// 2. 获取目标进程句柄 (dwPID),需要所有权限
if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)))
return FALSE;
// 3. 在目标进程中分配内存,用于存放DLL路径
pRemoteBuf = VirtualAllocEx(hProcess, NULL, dwBufSize, MEM_COMMIT, PAGE_READWRITE);
if (pRemoteBuf == NULL) {
CloseHandle(hProcess);
return FALSE;
}
// 4. 将DLL路径字符串写入刚刚分配的远程内存中
if (!WriteProcessMemory(hProcess, pRemoteBuf, (LPVOID)szDllPath, dwBufSize, NULL)) {
CloseHandle(hProcess);
return FALSE;
}
// 5. 获取 Kernel32.dll 模块的句柄(在当前进程中)
hMod = GetModuleHandle(_T("kernel32.dll"));
// 6. 获取 LoadLibraryW 函数的地址
// (这里使用 LoadLibraryW 是因为我们使用了 TCHAR,并且很可能定义了 UNICODE)
pThreadProc = (LPTHREAD_START_ROUTINE)GetProcAddress(hMod, "LoadLibraryW");
// 7. 在目标进程中创建远程线程
// 线程函数 = LoadLibraryW 的地址
// 线程参数 = 远程内存中DLL路径的地址
hThread = CreateRemoteThread(hProcess, NULL, 0, pThreadProc, pRemoteBuf, 0, NULL);
if (hThread == NULL) {
CloseHandle(hProcess);
return FALSE;
}
WaitForSingleObject(hThread, INFINITE); // 等待线程执行完毕
// 8. 清理
// VirtualFreeEx(hProcess, pRemoteBuf, 0, MEM_RELEASE); // 理论上应该释放内存
CloseHandle(hThread);
CloseHandle(hProcess);
return TRUE;
}
int _tmain(int argc, TCHAR* argv[]) {
// 用法: InjectDll.exe <PID> <DLL路径>
// 示例: InjectDll.exe 4232 F:\1\MyDll.dll
if (argc != 3) {
_tprintf(_T("Usage: InjectDll.exe <PID> <Full Dll Path>\n"));
return 1;
}
if (InjectDll(_ttoi(argv[1]), argv[2])) {
_tprintf(_T("InjectDll(\"%s\") success!!!\n"), argv[2]);
}
return 0;
}
执行后,使用Process Explorer等工具查看,会发现notepad.exe成功加载了MyDll.dll
使用注册表 (AppInit_DLLs)
这是一种更被动,但影响范围更广的注入方法
原理
还记得之前说的掌管用户事件的User32.dll吗?当它被加载时,会读取一个特殊的注册表项:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows
在这个键下,有一个名为AppInit_DLLs的值,如果这个值里包含了一个或多个(用逗号或空格分隔)DLL的完整路径,User32.dll就会自动调用LoadLibrary()来加载它们
还有一个键LoadAppInit_DLLs,是控制开关,决定是否启用AppInit_DLLs列表中指定的 DLL 的加载
关于注册表,你可以看看这个:注册表
步骤
打开注册表编辑器 (regedit),定位到:
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows
双击AppInit_DLLs,将要注入的DLL的绝对路径填入
之后,找到LoadAppInit_DLLs这个DWORD值,将其数据从0改为1
最后重启电脑
完成上述操作后,你启动的任何GUI程序(如notepad.exe,POWERPNT.EXE,iexplore.exe等)都会被自动注入MyDll2.dll,如果没有改掉,他就会一直影响你,这也是持久化注入的方式
示例
没有示例!
如果你没有虚拟机,去操作本机的注册表是很危险的,尤其是自带的regedit没有撤回机制,很容易就整坏了
并且,从Windows8开始,由于安全启动(Secure Boot)的引入,AppInit_DLLs机制默认被禁用,除非DLL是经过签名的,不然不会被加载的
这个方法很简单,看一下就行了
消息钩取 (SetWindowsHookEx)
这是利用Windows消息机制实现的注入方法
原理
Windows GUI是事件驱动的
每一个的键盘/鼠标操作会产生事件,操作系统会将这些事件包装成消息发送到系统消息队列,应用程序再从自己的消息队列中取出并处理消息,这个过程称为消息循环
消息钩子是允许我们拦截这些消息的机制,我们可以设置一个回调函数(也被称为“钩子过程”),在事件消息从操作系统向应用程序传递的过程中,获取、查看、修改甚至拦截这些消息
如果存在多个钩子,它们会形成一个“钩子链”
步骤
依旧是前文提到过的函数SetWindowsHookEx,它是安装钩子的API
HHOOK WINAPI SetWindowsHookEx(
_In_ int idHook, // 钩子类型(如WH_KEYBOARD_LL)
_In_ HOOKPROC lpfn, // 钩子过程(回调函数)的地址
_In_ HINSTANCE hMod, // 包含钩子过程的DLL句柄
_In_ DWORD dwThreadId // 要钩取的线程ID(0为所有线程)
);
注入的原理就发生在hMod和dwThreadId参数上:
- 我们将钩子过程
lpfn放在准备注入的DLL中(我们称呼它为Hook.dll),hMod是这个Hook.dll的句柄 - 之后,我们让一个程序(我们称呼它为安装程序)加载
Hook.dll,并用SetWindowsHookEx注册钩子 - 如果
dwThreadId被设置为0,SetWindowsHookEx会安装一个全局钩子,也就是说系统会把这个钩子加入到系统钩子链中,它这样系统里所有相关线程产生的消息都可能触发该钩子 - 当被钩取的消息在系统中的任何进程中发生时,操作系统为了能调用钩子过程
lpfn,会强制将包含钩子过程的DLL注入到那个进程的地址空间中
就这样,只要你在notepad.exe里按一下键,Hook.dll就被注入进去了
示例
这个例子包含两部分:HookDll.dll(木马)和TestHook.exe(安装器)
木马:HookDll.cpp
这个DLL包含了真正的钩子过程KeyboardProc,它还导出了两个函数HkStart和HkStop,供安装器调用
#include <windows.h>
#include <tchar.h>
// 必须使用共享变量,以便在不同进程中共享句柄
#pragma data_seg("Shared")
HINSTANCE g_hInstance = NULL; // DLL实例句柄
HHOOK g_hHook = NULL; // 钩子句柄
#pragma data_seg()
#pragma comment(linker, "/SECTION:Shared,RWS") // 设置段属性为读、写、共享
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD dwReason, LPVOID lpvReserved) {
if (dwReason == DLL_PROCESS_ATTACH) {
g_hInstance = hinstDLL; // 保存DLL实例句柄
}
return TRUE;
}
// 钩子过程(回调函数)
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) {
// lParam的第31位:0表示按键,1表示释放键
if (nCode >= 0 && !(lParam & 0x80000000)) {
TCHAR szPath[MAX_PATH] = {0};
GetModuleFileName(NULL, szPath, MAX_PATH);
TCHAR *p = _tcsrchr(szPath, _T('\\'));
// 检查当前进程是否为 notepad.exe
if (p && !lstrcmpi(p + 1, _T("notepad.exe"))) {
return 1; // 是Notepad,返回1,拦截该消息(按键失效)
}
}
// 不是Notepad,或不是按键消息,将消息传递给下一个钩子
return CallNextHookEx(g_hHook, nCode, wParam, lParam);
}
// 导出函数:开始钩取
extern "C" __declspec(dllexport) void HkStart() {
g_hHook = SetWindowsHookEx(
WH_KEYBOARD, // 钩子类型:键盘
KeyboardProc, // 钩子过程地址
g_hInstance, // DLL实例句柄
0 // 全局钩子(所有线程)
);
}
// 导出函数:停止钩取
extern "C" __declspec(dllexport) void HkStop() {
if (g_hHook) {
UnhookWindowsHookEx(g_hHook);
g_hHook = NULL;
}
}
安装器:TestHook.cpp
这个程序负责加载HookDll.dll并调用其导出的HkStart来安装全局钩子
#include <windows.h>
#include <tchar.h>
// 定义导出函数的指针类型
typedef void (*PFN_HOOKSTART)();
typedef void (*PFN_HOOKSTOP)();
int _tmain(int argc, TCHAR* argv[]) {
HMODULE hDll = NULL;
// 1. 加载DLL
if (!(hDll = LoadLibraryA("HookDll.dll"))) // 使用A版本
return 1;
// 2. 获取导出函数地址
PFN_HOOKSTART HookStart = (PFN_HOOKSTART)GetProcAddress(hDll, "HkStart");
PFN_HOOKSTOP HookStop = (PFN_HOOKSTOP)GetProcAddress(hDll, "HkStop");
if (!HookStart || !HookStop) {
FreeLibrary(hDll);
return 1;
}
// 3. 调用导出函数,安装全局钩子
HookStart();
_tprintf(_T("Hook installed. Press 'q' to quit!\n"));
// 4. 阻塞,等待用户退出
while (getchar() != 'q');
// 5. 卸载钩子
HookStop();
// 6. 释放DLL
FreeLibrary(hDll);
return 0;
}
运行TestHook.exe后,打开记事本(notepad.exe),你会发现无法输入任何字符,因为键盘消息被HookDll.dll拦截了
修改PE导入表 (静态注入)
不操作运行中的进程,而是直接修改磁盘上的exe文件
原理
我们直接在目标exe的PE结构中,将其导入表指向的IMAGE_IMPORT_DESCRIPTOR数组进行修改,添加一个指向我们DLL的新条目
这样,当Windows加载器运行这个被修改的exe时,它会像加载kernel32.dll一样,自动加载我们的MyDll3.dll
示例
PE加载器导入DLL的目的是为了调用它的函数,因此,我们的注入DLL必须至少提供一个导出函数,否则无法被隐式链接
我们可以提供一个空的函数(也就是所谓的dummy函数)糊弄它:
#include <windows.h>
#include <tchar.h>
#include <urlmon.h>
#pragma comment(lib, "urlmon.lib")
// 必须提供至少一个导出函数
extern "C" __declspec(dllexport) void dummy() {
return; //什么也不做
}
修改导入表
定位原导入表
使用PEview查看notepad.exe,找到导入表,这个事情我们在之前的课中已经做过
寻找新空间
因为原导入表后面紧跟着其他数据,空间已满,我们无法直接在末尾添加新条目,所以必须把整个导入表移动到文件中的一个空白区域
如何查找文件中的空白区域?例如,.reloc节区末尾可能有几百字节的00填充:
复制、添加并更新PE头
将原IMAGE_IMPORT_DESCRIPTOR数组完整复制到新位置,然后在新位置的末尾追加一个条目,这个条目要在终结描述符之前
更改后,总字节数是(N+1)*0x14
或许你还记得导入表条目的结构:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; //导入名称表 INT 的 RVA
};
DWORD TimeDateStamp; // 通常为 0
DWORD ForwarderChain; // 通常为 -1 或 0
DWORD Name; // DLL 名称的 RVA
DWORD FirstThunk; // 导入地址表 IAT 的 RVA
} IMAGE_IMPORT_DESCRIPTOR;
union是成员共用一块地址,所以一个IMAGE_IMPORT_DESCRIPTOR占用20字节(0x14)
此外,数组必须以一个全0的终结描述符结尾(类似C字符串的 '\0'),这个描述符也是20字节
这时候就能计算新表的RVA了:假设我们就用.reloc节后面的空白区域,用.reloc的RAW和RVA作为基准:
新RVA = 新RAW - 节RAW + 节RVA
这个新RAW我们能在十六进制编辑器中直接看见,就是选定的空白位置
之后修改PE头,将DataDirectory[1](导入表目录)的VirtualAddress和Size更新
构建新的INT、IAT和字符串
IMAGE_IMPORT_DESCRIPTOR结构需要指向INT、IAT和DLL名称
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk; //导入名称表 INT 的 RVA
};
DWORD TimeDateStamp; // 通常为 0
DWORD ForwarderChain; // 通常为 -1 或 0
DWORD Name; // DLL 名称的 RVA
DWORD FirstThunk; // 导入地址表 IAT 的 RVA
} IMAGE_IMPORT_DESCRIPTOR;
我们必须在文件的另一个空白区域手动构建这些东西
为了方便看,先假设是在.text节的RAW: 0xAB10处:
-
RAW: 0xAB10创建INT,写入指向
IMAGE_IMPORT_BY_NAME的RVA -
RAW: 0xAB20写入字符串
"MyDll.dll\0" -
RAW: 0xAB30创建IAT,内容在加载前与INT完全相同,也写入指向
IMAGE_IMPORT_BY_NAME的RVA -
RAW: 0xAB40写入
IMAGE_IMPORT_BY_NAME结构(Hint0x0000+ 函数名"dummy\0")、
间隔0x10字节的原因是这样方便观看,如果空间足够可以随意
计算新构建内容的RVA
可以看见,在IMAGE_IMPORT_DESCRIPTOR中的一切都是RVA,所以我们必须计算所有的RVA
同样,假设.text节的RAW是0x400,RVA是0x1000
INT RVA = 0xAB10 - 0x400 + 0x1000 = 0xB710Name RVA = 0xAB20 - 0x400 + 0x1000 = 0xB720IAT RVA = 0xAB30 - 0x400 + 0x1000 = 0xB730IMAGE_IMPORT_BY_NAME RVA = 0xAB40 - 0x400 + 0x1000 = 0xB740
填充新的IMAGE_IMPORT_DESCRIPTOR**
回到我们移动的导入表末尾,填入我们新的IMAGE_IMPORT_DESCRIPTOR:
OriginalFirstThunk (INT):0xB710TimeDateStamp:0xFFFFFFFFForwarderChain:0xFFFFFFFFName:0xB720FirstThunk (IAT):0xB730
修改节区属性
在上面,我们的IAT被放在了.text节区,而.text节区默认属性是可执行、可读,但不可写
然而,PE加载器在运行时需要向IAT中写入函数的真实地址,怎么办?
因此我们必须修改.text节头的Characteristics字段,为其添加IMAGE_SCN_MEM_WRITE(0x80000000)属性:
0x60000020+0x80000000=0xE0000020
回顾一下节区头:
#define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 节名称,例如 ".text" ".data" ".rdata" ".rsrc"
union {
DWORD PhysicalAddress;
DWORD VirtualSize; // 节区在内存中的大小(RVA对应)
} Misc;
DWORD VirtualAddress; // 节在内存中的 RVA(相对于 ImageBase)
DWORD SizeOfRawData; // 节在文件中的大小(磁盘对齐后的字节数)
DWORD PointerToRawData; // 节在文件中的起始偏移
DWORD PointerToRelocations; // 重定位表指针(如果有重定位信息)
DWORD PointerToLinenumbers; // 行号信息指针(调试用)
WORD NumberOfRelocations; // 重定位表条目数量
WORD NumberOfLinenumbers; // 行号条目数量
DWORD Characteristics; // 节特性标志(可读、可写、可执行等)
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
其中Characteristics字段决定了节的特性,它的机制类似linux的rwx,每一个属性对应一个值:
| 位标志 | 含义 |
|---|---|
| 0x00000020 | 节里有代码 |
| 0x20000000 | 映射后可执行(E) |
| 0x40000000 | 映射后可读(R) |
| 0x80000000 | 映射后可写(W) |
当 Windows 加载器读取 PE 文件时,它会遍历节表:
- 看到节的
Characteristics - 按标志确定应该为这块区域申请怎样的内存页
- 调用内核 API
- 把节的数据映射进去,并把页保护设置为对应的Windows内存保护:
| PE属性组合 | 映射后的页保护 |
|---|---|
| R | PAGE_READONLY |
| R + W | PAGE_READWRITE |
| E + R | PAGE_EXECUTE_READ |
| E + R + W | PAGE_EXECUTE_READWRITE |
默认.text的属性为:
0x60000020
= 0x20000000 (EXECUTE)
+ 0x40000000 (READ)
+ 0x00000020 (CODE)
也就是E+R,加载器映射时会分配为PAGE_EXECUTE_READ,因此.text在内存中可执行、可读,但不可写
为了让他可写,就要加上W属性:
0x60000020+0x80000000=0xE0000020
因此,要把.text节的Characteristics字段改成0xE0000020
清除绑定导入表
PE文件中的DataDirectory[12](绑定导入表)用于加速DLL加载,这个我们之前没有提到
它的存在可能会干扰我们修改过的导入表,最简单的方法是直接将其RVA和Size都清零,禁用该功能
开始注入
完成后,将MyDll.dll放在修改后的notepad_patch.exe同目录下,运行notepad_patch.exe,注入就会自动发生
代码注入
这是DLL注入的变体,我们不注入一个完整的DLL文件,而是只注入一小段独立的机器码
原理
代码注入也常被称为线程注入,我们不在CreateRemoteThread中调用LoadLibrary,而是:
- 在目标进程中分配两块内存。一块用于代码(属性
PAGE_EXECUTE_READWRITE),另一块用于数据/参数(属性PAGE_READWRITE) - 将我们的机器码(一个函数的完整体)写入代码内存
- 将代码所需的数据(例如要调用的API地址、字符串等)打包成一个结构体,写入数据内存
- 调用
CreateRemoteThread,将lpStartAddress指向代码内存的地址,lpParameter指向数据内存的地址
示例
这个例子演示了如何注入一段代码,让notepad.exe弹出一个MessageBoxA
第1步:定义共享结构体
被注入的代码无法访问注入器的内存。我们必须定义一个结构体,将所有需要的信息(API地址、字符串)打包传过去
typedef struct _THREAD_PARAM {
// 我们需要目标进程调用 LoadLibraryA 和 GetProcAddress
// 所以我们提前查好这两个API的地址传进去
FARPROC pLoadLibrary;
FARPROC pGetProcAddress;
// 我们需要的数据(字符串)
char moduleName[128]; // "user32.dll"
char procName[128]; // "MessageBoxA"
} THREAD_PARAM, *PTHREAD_PARAM;
第2步:被注入的函数 (Payload)
ThreadProc就是即将被完整复制到目标进程的函数,它被设计为完全独立,只依赖传入的THREAD_PARAM
// 定义函数指针类型,以便在代码中调用
typedef HMODULE (WINAPI *PFLOADLIBRARYA)(LPCSTR);
typedef FARPROC (WINAPI *PFGETPROCADDRESS)(HMODULE, LPCSTR);
typedef int (WINAPI *PFMESSAGEBOXA)(HWND, LPCSTR, LPCSTR, UINT);
// 这就是我们的“有效载荷”
// 它将在目标进程的内存中执行
BOOL WINAPI ThreadProc(LPVOID lParam) {
PTHREAD_PARAM param = (PTHREAD_PARAM)lParam;
HMODULE hMod = NULL;
FARPROC pFunc = NULL;
// 1. 使用传入的 pLoadLibrary 地址,加载 "user32.dll"
hMod = ((PFLOADLIBRARYA)param->pLoadLibrary)(param->moduleName);
if (!hMod) return FALSE;
// 2. 使用传入的 pGetProcAddress 地址,获取 "MessageBoxA" 地址
pFunc = ((PFGETPROCADDRESS)param->pGetProcAddress)(hMod, param->procName);
if (!pFunc) return FALSE;
// 3. 调用 MessageBoxA
((PFMESSAGEBOXA)pFunc)(NULL, param->moduleName, param->procName, MB_OK);
return TRUE;
}
第3步:注入器 (InjectCode)
InjectCode函数负责填充THREAD_PARAM,分配两块远程内存,写入数据和代码,最后启动远程线程
BOOL InjectCode(DWORD dwPID) {
HANDLE hProcess = NULL, hThread = NULL;
LPVOID pRemoteBuf[2] = {0}; // pRemoteBuf[0] = 参数, pRemoteBuf[1] = 代码
THREAD_PARAM param = {0};
DWORD dwSize = 0;
// 1. 填充参数结构体 (在注入器进程中)
HMODULE hMod = GetModuleHandle(_T("kernel32.dll"));
param.pLoadLibrary = GetProcAddress(hMod, "LoadLibraryA");
param.pGetProcAddress = GetProcAddress(hMod, "GetProcAddress");
strcpy_s(param.moduleName, "user32.dll");
strcpy_s(param.procName, "MessageBoxA");
// ... (OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID) 获取 hProcess) ...
if (!hProcess) return FALSE;
// 2. 注入参数 (pRemoteBuf[0])
dwSize = sizeof(THREAD_PARAM);
pRemoteBuf[0] = VirtualAllocEx(hProcess, NULL, dwSize, MEM_COMMIT, PAGE_READWRITE);
if (!pRemoteBuf[0]) return FALSE;
if (!WriteProcessMemory(hProcess, pRemoteBuf[0], (LPVOID)¶m, dwSize, NULL))
return FALSE;
// 3. 注入代码 (pRemoteBuf[1])
// (InjectCode 和 ThreadProc 必须在同一个文件里)
dwSize = (DWORD)InjectCode - (DWORD)ThreadProc;
if (dwSize <= 0) return FALSE; // 确保大小正确
pRemoteBuf[1] = VirtualAllocEx(hProcess, NULL, dwSize, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (!pRemoteBuf[1]) return FALSE;
if (!WriteProcessMemory(hProcess, pRemoteBuf[1], (LPVOID)ThreadProc, dwSize, NULL))
return FALSE;
// 4. 创建远程线程,执行代码
hThread = CreateRemoteThread(hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)pRemoteBuf[1], // lpStartAddress = 代码地址
pRemoteBuf[0], // lpParameter = 参数地址
0, NULL);
if (!hThread) return FALSE;
// ... (清理句柄) ...
return TRUE;
}
在InjectCode示例中,代码大小是这样计算的:
dwSize = (DWORD)InjectCode - (DWORD)ThreadProc;
为什么呢?
当使用Visual C++的Release模式编译时,源代码中相邻的函数在最终的二进制文件中也物理相邻地存放,因此,ThreadProc函数的二进制码之后紧跟着InjectCode函数的二进制码
使用两个函数起始地址相减,就近似得到了ThreadProc函数的二进制码长度
DLL的卸载 (Dll Eject)
注入的逆过程就是卸载(Eject),其原理是强制目标进程调用FreeLibrary()API
FreeLibrary的函数原型是:
BOOL WINAPI FreeLibrary(HMODULE hLibModule);
它只接受一个参数:要卸载的DLL在目标进程中的模块句柄,也就是基地址
示例
#include <windows.h>
#include <tchar.h>
#include <tlhelp32.h> // 用于CreateToolhelp32Snapshot
// (这里需要一个FindProcessID函数,PDF中省略了,我们假设它已实现)
DWORD FindProcessID(LPCTSTR szProcessName);
BOOL EjectDll(DWORD dwPID, LPCTSTR szDllName) {
HANDLE hSnapshot = NULL, hProcess = NULL, hThread = NULL;
MODULEENTRY32 me = {sizeof(me)}; // 必须初始化dwSize
// 1. 创建工具快照,获取指定PID进程加载的所有模块(DLL)
hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, dwPID);
if (hSnapshot == INVALID_HANDLE_VALUE) return FALSE;
// 2. 遍历模块列表
BOOL bFound = FALSE;
BOOL bMore = Module32First(hSnapshot, &me);
for (; bMore; bMore = Module32Next(hSnapshot, &me)) {
// 比较模块名或路径,找到我们要卸载的DLL
if (!lstrcmpi(me.szModule, szDllName) || !lstrcmpi(me.szExePath, szDllName)) {
bFound = TRUE;
break; // 找到了
}
}
CloseHandle(hSnapshot); // 关闭快照句柄
if (!bFound) return FALSE; // 没找到
// 3. 获取目标进程句柄
if (!(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)))
return FALSE;
// 4. 获取 FreeLibrary 的地址(它也在 kernel32.dll 中)
HMODULE hModule = GetModuleHandle(_T("kernel32.dll"));
LPTHREAD_START_ROUTINE pThreadProc = (LPTHREAD_START_ROUTINE)GetProcAddress(hModule, "FreeLibrary");
// 5. 创建远程线程
// 线程函数 = FreeLibrary
// 线程参数 = 要卸载的DLL的基地址 (me.modBaseAddr)
hThread = CreateRemoteThread(hProcess, NULL, 0, pThreadProc, me.modBaseAddr, 0, NULL);
if (hThread == NULL) {
CloseHandle(hProcess);
return FALSE;
}
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
CloseHandle(hProcess);
return TRUE;
}
int _tmain(int argc, TCHAR* argv[]) {
DWORD dwPID = FindProcessID(_T("notepad.exe"));
if (dwPID) {
EjectDll(dwPID, _T("MyDll.dll"));
}
return 0;
}