loading...
Featured image of post 逆向基础6:DLL注入

逆向基础6:DLL注入

以彼之矛

在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:提供图形设备接口,负责在屏幕或打印机上绘制图形和显示文本

📝 Note

文件名里的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、增加新功能,当然也能被恶意代码利用

📝 Note

如果DLL中找不到DllMain,Windows会从其它运行库中找一个不做任何操作的缺省DllMain函数来启动这个DLL

DLL注入

顾名思义,DLL注入就是向一个正在运行的其他进程中,强制插入一个我们指定的DLL文件

其核心原理是以某种方式让目标进程去调用LoadLibrary()API,以此来加载我们的DLL

一旦LoadLibrary()被调用,目标进程就会自动执行该DLL的DllMain函数,而DllMainDLL_PROCESS_ATTACH分支中就包含了我们想要执行的payload

image-20251029225118284

Windows系统编程

要实现注入,我们要先知道如何书写dll,如何使用Windows API来操纵进程

常用数据类型

Windows API有自己的一套数据类型定义,这些类型在windows.h头文件中定义:

数据类型 含义
VOID 作为函数返回类型时,表示函数运行不可能失败
BOOL int类型,作为函数返回类型时,>0代表TRUE,=0代表FALSE
应测试返回值是否为0,不要测试是否为TRUE
HANDLE 内核对象的类型,用于标识进程、线程、文件句柄等
失败时通常返回NULLINVALID_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 消息参数
LPARAMLONG_PTRWPARAMUINT_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 字符(CHARWCHAR
LPTSTR 字符串指针(LPSTRLPWSTR
LPCTSTR 常量字符串指针(LPCSTRLPCWSTR

定义字符串常量时,应使用_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
);
💡 Tip

_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,实现了注入

⚠️ Warning

lpStartAddresslpParameter都必须是目标进程虚拟地址空间中的地址

这里可能有个疑问:为什么我们知道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为所有线程)
);

注入的原理就发生在hModdwThreadId参数上:

  1. 我们将钩子过程lpfn放在准备注入的DLL中(我们称呼它为Hook.dll),hMod是这个Hook.dll的句柄
  2. 之后,我们让一个程序(我们称呼它为安装程序)加载Hook.dll,并用SetWindowsHookEx注册钩子
  3. 如果dwThreadId被设置为0SetWindowsHookEx会安装一个全局钩子,也就是说系统会把这个钩子加入到系统钩子链中,它这样系统里所有相关线程产生的消息都可能触发该钩子
  4. 当被钩取的消息在系统中的任何进程中发生时,操作系统为了能调用钩子过程lpfn,会强制将包含钩子过程的DLL注入到那个进程的地址空间中

就这样,只要你在notepad.exe里按一下键,Hook.dll就被注入进去了

示例

这个例子包含两部分:HookDll.dll(木马)和TestHook.exe(安装器)

木马:HookDll.cpp

这个DLL包含了真正的钩子过程KeyboardProc,它还导出了两个函数HkStartHkStop,供安装器调用

#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填充:

image-20251102163528866

复制、添加并更新PE头

将原IMAGE_IMPORT_DESCRIPTOR数组完整复制到新位置,然后在新位置的末尾追加一个条目,这个条目要在终结描述符之前

更改后,总字节数是(N+1)*0x14

💡 Tip

或许你还记得导入表条目的结构:

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节后面的空白区域,用.relocRAWRVA作为基准:

新RVA = 新RAW - 节RAW + 节RVA

这个新RAW我们能在十六进制编辑器中直接看见,就是选定的空白位置

之后修改PE头,将DataDirectory[1](导入表目录)的VirtualAddressSize更新

构建新的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字节的原因是这样方便观看,如果空间足够可以随意

image-20251102174301399

image-20251102174314034

计算新构建内容的RVA

可以看见,在IMAGE_IMPORT_DESCRIPTOR中的一切都是RVA,所以我们必须计算所有的RVA

同样,假设.text节的RAW0x400RVA0x1000

  • INT RVA = 0xAB10 - 0x400 + 0x1000 = 0xB710
  • Name RVA = 0xAB20 - 0x400 + 0x1000 = 0xB720
  • IAT RVA = 0xAB30 - 0x400 + 0x1000 = 0xB730
  • IMAGE_IMPORT_BY_NAME RVA = 0xAB40 - 0x400 + 0x1000 = 0xB740

填充新的IMAGE_IMPORT_DESCRIPTOR**

回到我们移动的导入表末尾,填入我们新的IMAGE_IMPORT_DESCRIPTOR

  • OriginalFirstThunk (INT)0xB710
  • TimeDateStamp0xFFFFFFFF
  • ForwarderChain0xFFFFFFFF
  • Name0xB720
  • FirstThunk (IAT)0xB730

修改节区属性

在上面,我们的IAT被放在了.text节区,而.text节区默认属性是可执行、可读,但不可写

然而,PE加载器在运行时需要向IAT中写入函数的真实地址,怎么办?

因此我们必须修改.text节头的Characteristics字段,为其添加IMAGE_SCN_MEM_WRITE0x80000000)属性:

0x60000020+0x80000000=0xE0000020
💡 Tip

回顾一下节区头:

#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 文件时,它会遍历节表:

  1. 看到节的Characteristics
  2. 按标志确定应该为这块区域申请怎样的内存页
  3. 调用内核 API
  4. 把节的数据映射进去,并把页保护设置为对应的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,而是:

  1. 在目标进程中分配两块内存。一块用于代码(属性PAGE_EXECUTE_READWRITE),另一块用于数据/参数(属性PAGE_READWRITE
  2. 将我们的机器码(一个函数的完整体)写入代码内存
  3. 将代码所需的数据(例如要调用的API地址、字符串等)打包成一个结构体,写入数据内存
  4. 调用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)&param, 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;
}
最后更新于 2025-11-02
距离小站第一行代码被置下已经过去
使用 Hugo 构建
主题 StackJimmy 设计
...当然还有kakahuote🤓👆