一个更完整的 DLL 注入解决方案,使用 CreateRemoteThread






4.83/5 (45投票s)
2007年8月18日
14分钟阅读

280180

13129
本文探索了一种使用 CreateRemoteThread 方法更完整地将 DLL 注入到另一个进程中的方法。
目录
引言
我知道现在有些人会因为又一篇 DLL 注入的文章而翻白眼。CodeProject 已经够多了,不是吗?在您停止阅读并点击返回按钮之前,请花点时间阅读一下。您可能会发现一些您以前从未见过的内容。如果您没有学到新东西,也许您可以留下评论或反馈,以便我获得一些反馈。我们都在这里学习。
本文的目的是扩展 CreateRemoteThread
DLL 注入方法,以消除一些缺陷并添加一些急需的功能。实现的许多核心概念都相同,但我会将实现推进一步,以获得更“完整”的解决方案。“完整”解决方案的定义是:
- 实现一个符合 Microsoft“创建 DLL 的最佳实践”文章的 DLL
- 可以注入 DLL 并通过释放其分配的内存进行清理,以及在完成后选择性地卸载 DLL
- 提供错误处理,以便在 DLL 未能注入时让最终用户知道出了什么问题
- 当然,使用
CreateRemoteThread
API 函数进行注入
背景信息
如引言所述,我将扩展 CreateRemoteThread
DLL 注入方法。如果您需要回顾这项技术,请参阅这篇 CodeProject 文章:将代码注入另一个进程的三种方法。CodeProject 上还有许多其他处理此方法的教程,因此请随时参考它们。
下一组重要信息可以在 Microsoft 文档中找到:创建 DLL 的最佳实践。这篇方便的小文档介绍了创建 DLL 的一些重要(但经常被滥用)的实践。虽然我注意到您不必遵守这些准则就可以创建可用的 DLL,但我设计了我的代码和要注入的 DLL 来遵循这些准则,以获得“完整性”。我需要强调的是,我并不是说最佳实践文章就是唯一的做事方式;我只是说信息给我们的原因。让我们假设我们必须遵循那一套准则。话虽如此,请考虑以下来自文档的摘录:
理想的
DllMain
应该只是一个空存根。然而,鉴于许多应用程序的复杂性,这通常过于严格。DllMain
的一个好经验法则是尽可能推迟初始化。延迟初始化提高了应用程序的健壮性,因为在加载器锁被持有期间不会执行此初始化。此外,延迟初始化使您能够安全地使用更多的 Windows API。
在几乎所有的 DLL 注入文章或教程中,DllMain
都不是一个空存根。我理解它不必是,但让我们考虑一下为什么几乎没有人拥有一个“理想”的 DllMain
函数。在大多数文章中,您会发现从 DllMain
函数调用 CreateThread
或 LoadLibrary
。我之所以提到这一点,是因为根据最佳实践:
您永远不应该在
DllMain
中执行以下任务:
* 调用LoadLibrary
或LoadLibraryEx
(直接或间接)。
* ...
* 调用CreateProcess
。创建进程可能会加载另一个 DLL。
* ...
再次强调,我不是在提倡最佳实践文章是唯一的做事方式。让我们假设在本教程中,我们必须遵循那一套准则。如果我们消除了在 DllMain
函数中调用 LoadLibrary
和 CreateThread
,那么使用 CreateRemoteThread
DLL 注入方法,我们的 DLL 将变得毫无用处。这是因为使用此技术无法从加载器执行 DLL 中的任何其他函数。那么,我们现在该怎么办?
让我们停下来思考一下,我们首先是如何将 DLL 注入到进程中的。在获取目标进程后,我们首先在进程中分配一块内存。然后,我们将要注入的 DLL 的名称写入该进程。最后,我们执行一个使用 kernel32
函数和我们分配的内存指针的线程。虽然这可行,但它与最佳实践准则不兼容,并且需要调用 CreateThread
或 LoadLibrary
来初始化 DLL。
考虑到我们已经在向进程写入数据,为什么不将 DLL 加载器写入我们分配的空间呢?与其在 LoadLibrary
的地址上启动远程线程,不如在用户代码上启动它,让进程自己加载注入的 DLL。我的意思是,让我们用汇编编写一个小型过程,该过程将加载注入的 DLL,获取导出函数的地址,并调用它。通过这样做,我们将为 DllMain
提供一个空存根,任何初始化代码都将在我们加载和运行的导出函数中安全地调用,并且我们可以在最后清理自己。这种方法有点棘手,需要一些额外的代码和工作,但我们不是程序员,因为我们喜欢轻松的事情。让我们开始吧!
附加背景信息
在我开始讲解实际代码之前,我需要解释一些我采用的技术和概念。
- “工作区”。我选择在本地构建我们要注入到进程中的代码,而不是为代码的每一行直接写入应用程序的内存。通过这样做,我可以快速构建加载器本身的代码,并一次性将其写入进程本身。我这样做的原因是使代码的构建更容易。编写汇编代码本身就很困难,但逐字节编写虚拟汇编代码则更加困难。在代码中,您会看到诸如
workspace[workspaceIndex++] = 0xCC;
此表示法仅仅是将预期的十六进制字符写入虚拟工作区。变量
workspace
是作为缓冲区分配的本地内存。变量workspaceIndex
是写入的索引。后缀使用++
运算符允许将当前字节写入缓冲区,然后递增索引。这使得代码更整洁,因为替代方案看起来会是workspace[workspaceIndex] = 0xCC; workspaceIndex++;
- “假设 user32.dll 未加载”。在大多数应用程序中,user32.dll 会与 kernel32.dll 一起加载,但这并不总是如此。如果可执行文件打包了 UPX、ASProtect 等,那么很有可能 user32.dll 在注入 DLL 时不会在启动时加载。因此,添加了代码来先加载此 DLL 并获取
MessageBoxA
函数的地址。通过这样做,此解决方案保留了所追求的“完整性”。 - “让用户知道是否有问题”。大量代码是错误检查和
MessageBox
调用,以便让用户知道出了什么问题。我注意到许多注入器的一个大缺陷是,您无法立即知道 DLL 是否真的被注入了。您需要向注入的 DLL 添加代码,或者将调试器附加到进程中来验证 DLL 是否确实被注入了。在此解决方案中,如果出现任何问题,用户将通过MessageBox
得知。此外,进程将被终止。如果“什么也没发生”,并且程序正常加载,那么可以假设 DLL 注入成功,不考虑注入 DLL 中可能导致程序崩溃的错误。如果您确实想要最小、最简洁的代码,可以删除错误报告详细信息,但为了最终用户,最好将其保留。 - “像没明天一样注释代码”。多年来,我积累了不少知识。当我回顾我写的旧代码进行评估时,注释很少,有时我不知道我在做什么。当您阅读我的代码时,就像阅读另一篇文章一样,我使用了大量的注释来解释正在发生的事情。有些注释比其他注释更有价值,有些似乎是多余的,但我会尽我最大的努力为阅读我代码的任何人保留文档。我知道代码注释这个话题是一个有争议的问题,所以作为警告,我更喜欢在我的代码中添加大量注释。
- “利用
lpStartAddress
参数”。为了在汇编中使用字符串,我们必须将它们写入内存中的某个位置。如果您以前从未这样做过,您可能会先写出所有代码并留出占位符,然后写出字符串数据,最后更新占位符地址以显示正确的值。这可行,而且还可以,但它不是最好的方法。相反,您可以先写出字符串数据,存储这些地址,然后编写代码并逐步填充地址。这样就无需更新字符串的地址占位符,因为您已经知道了地址。由于我们可以通过CreateRemoteThread
中的lpStartAddress
指定起始地址,因此我们可以利用这一点。如果这一点不清楚,请不要担心。一旦您通读了代码并重新阅读了这一点,它可能会更有意义。
使用代码
终于完成了所有背景信息,现在是时候讲解代码了。注入函数很大,因为它包含了注释和使事情尽可能直观的设计。错误代码被复制在两个部分,但将它们写两次比创建另一个函数来减少行数要有效得多。代码的编写是为了生成完成任务所需的基本汇编代码量。没有必要尝试优化或减小其大小,因为它每个需要注入的 DLL 只会被调用一次。首先,我将展示函数文档。
Function:
Inject
Parameters:
hProcess - The handle to the process to inject the DLL into.
const char* dllname - The name of the DLL to inject into the process.
char* funcname - The name of the function to call once the DLL has
been injected.
Description:
This function will inject a DLL into a process and execute an exported
function the DLL to "initialize" it. The function should be in the format
shown below, no parameters and no return type. Do not forget to prefix
extern "C" if you are in C++.
__declspec(dllexport) void FunctionName(void)
The function that is called in the injected DLL -MUST- return, the
loader waits for the thread to terminate before removing the allocated
space and returning control to the Loader. This method of DLL injection
also adds error handling, so the end user knows if something went wrong.
这段代码在 VS6、VS7、VS8 和 Dev-CPP 上编译只需要两个头文件。它们是:
#include <windows.h>
#include <stdio.h>
接下来是代码的主要标记部分,并附有额外的注释。代码太多,无法完全粘贴到文章中。
函数变量
本节定义了此函数中使用的变量。由于代码是为了在 C 或 C++ 中编译而创建的,因此它们位于顶部。变量本身应该通过其名称一目了然,主要关注的是 DWORD xxxAddr = 0;
设置。这些变量是我们写入进程顶部数据的占位符。有关更多详细信息,请参阅“数据和字符串写入”部分。
变量初始化
本节首先获取用户计算机上 kernel32.dll 的句柄,然后加载一组我们的代码将调用的函数。之后,它将构建将写入进程的文本字符串。最后,它将分配本地工作区内存并在目标进程中分配内存。
数据和字符串写入
本节将写入我们注入到进程所需的所有数据和字符串。其中包括 user32.dll、MessageBoxA
函数地址,以及所使用的 DLL、函数和错误消息的名称的占位符。在写入每个字符串之前,地址将如上面“函数变量”部分末尾所述存储在占位符中。在本节末尾,将写入几个 INT 3
,然后保存开始执行的最终地址。请注意,我添加了一个已注释掉的部分,可用于调试。
// For debugging - infinite loop, attach onto process and step over
//workspace[workspaceIndex++] = 0xEB;
//workspace[workspaceIndex++] = 0xFE;
当程序执行到这里时,程序将进入一个无限循环。此时,您可以附加调试器,逐步执行此部分,然后逐步跟踪以下代码以逐行观察。请注意,您可能需要提前将进程优先级设置为“空闲”或“低于平均水平”,以免它占用过多 CPU。
User32.dll 加载
本节将加载 user32.dll,然后加载 MessageBoxA
函数。我选择不添加任何错误检查,因为它不应该失败。如果失败了,我不太确定它为什么会失败,只有在发生错误并且调用 MessageBoxA
函数时,程序才会崩溃。DLL 和函数的句柄都保存在数据区域中以供以后使用。
注入的 DLL 加载
本节是函数的核心。此代码的高级实现可以看作如下:
// Load the injected DLL into this process
HMODULE h = LoadLibrary("mydll.dll");
if(!h)
{
MessageBox(0, "Could not load the dll", "Error", MB_ICONERROR);
ExitProcess(0);
}
// Get the address of the export function
FARPROC p = GetProcAddress(h, "Initialize");
if(!p)
{
MessageBox(0, "Could not load the function", "Error", MB_ICONERROR);
ExitProcess(0);
}
// So we do not need a function pointer interface
__asm call p
// Exit the thread so the loader continues
ExitThread(0);
这看起来像一些 C/C++ 代码,但由于错误处理,它会转换为大量的汇编代码。不过,不要因此气馁或感到不知所措!按照源代码中的汇编注释进行操作,您应该就能理解。
从注入的 DLL 退出
本节为用户提供了两种选择,以决定 DLL 如何退出。第一种选择是简单地退出线程,让 DLL 驻留在内存中。这样,DLL 本身可以在需要时卸载,或者进程会在正常退出时卸载它。第二种选择是同时释放库并退出线程。通过这样做,DLL 将执行然后从进程中删除。要控制使用哪种方式,只需更改预处理器定义,以便编译您想要执行的代码。默认情况下,编译第一种选择。
// Call ExitThread to leave the DLL loaded
#if 1
...
#endif
// Call FreeLibraryAndExitThread to unload DLL
#if 0
...
#endif
代码注入和清理
最后一节涉及最终写入构建的代码,然后进行清理。在将代码写入我们在进程中分配的内存之前,我们首先调用 VirtualProtect
来确保我们拥有正确的权限。在写入内存并恢复之前的页面访问权限后,我们必须调用 FlushInstructionCache
以确保更改立即生效。此时,代码已写入进程,因此我们可以通过调用 HeapFree
来释放本地工作区。最后剩下的是执行我们的线程,然后解除其曾经使用的内存。现在,DLL 已注入到进程中,并且我们已经完成了清理工作!
结论
我们的劳动成果可以在文章顶部的截图中看到(取自 OllyDbg)。如果一切顺利,我们现在有了一种使用 CreateRemoteThread
DLL 注入方法的方法,该方法使用的是根据 Microsoft 的最佳实践文章设计的 DLL。归根结底,如果您看看我们所做的,并问自己“是否值得?”,您可能没有明确的答案。如果唯一的目标是创建一个“有效”的 DLL 注入器,那么通常的方法就可以了。但是,如果您对更健壮的解决方案感兴趣,那么这种方法可能非常适合您。无论如何,感谢您的阅读,希望您喜欢。
这是一个简单的 WinMain
,它充当注入 DLL 的进程的加载器。在此示例中,我加载了 Pinball 游戏并向其中注入了一个 DLL。请注意,所有内容都已硬编码,仅用于此示例。理想情况下,您会将要注入的 DLL 与进程和加载器放在同一文件夹中。这样生活会更轻松一些。
// Program entry point
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
LPTSTR lpCmdLine, int nCmdShow)
{
// Structures for creating the process
STARTUPINFO si = {0};
PROCESS_INFORMATION pi = {0};
BOOL result = FALSE;
// Hardcoded just for a demo, you will need to use a game/program of
// your own to test. It is not a good idea to use stuff in system32
// due to the DEP enabled on those apps.
char* exeString =
"\"C:\\Program Files\\Windows NT\\Pinball\\PINBALL.EXE\" -quick";
char* workingDir = "C:\\Program Files\\Windows NT\\Pinball";
// Holds where the DLL should be
char dllPath[MAX_PATH + 1] = {0};
// Set the static path of where the Inject DLL is, hardcoded for a demo
_snprintf(dllPath, MAX_PATH, "InjectDLL.dll");
// Need to set this for the structure
si.cb = sizeof(STARTUPINFO);
// Try to load our process
result = CreateProcess(NULL, exeString, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, workingDir, &si, &pi);
if(!result)
{
MessageBox(0, "Process could not be loaded!", "Error", MB_ICONERROR);
return -1;
}
// Inject the DLL, the export function is named 'Initialize'
Inject(pi.hProcess, dllPath, "Initialize");
// Resume process execution
ResumeThread(pi.hThread);
// Standard return
return 0;
}
我还包含了一个要注入到进程中进行测试的示例 DLL。但我没有为它创建一个项目,所以您需要自己创建要注入的 DLL。如果您想自己创建一个,以下是它的源代码。
#include <windows.h>
// Define the DLL's main function
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ulReason, LPVOID lpReserved)
{
// Get rid of compiler warnings since we do not use this parameter
UNREFERENCED_PARAMETER(lpReserved);
// If we are attaching to a process
if(ulReason == DLL_PROCESS_ATTACH)
{
// Do not need the thread based attach/detach messages in this DLL
DisableThreadLibraryCalls(hModule);
}
// Signal for Loading/Unloading
return (TRUE);
}
extern "C" __declspec(dllexport) void Initialize()
{
MessageBox(0, "Locked and Loaded.", "DLL Injection Successful!", 0);
}
您不必将函数命名为 Initialize
,但如果更改了它,请确保在调用 Inject
时更新加载器以使用正确的函数名称。还要确保使用诸如 Dependency Walker 之类的程序来验证编译器是否优化掉了您的导出函数。另外,请不要忘记对 C++ 导出函数使用 extern "C"
,这样函数名就不会被混淆。
历史
- 2007.08.18 - 版本 1.0