- 原文地址:Userland API Monitoring and Code Injection Detection
- 原文做者:dtm
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:Xekin-FE
- 校对者:Starrier,sunhaokk
本文实属做者对恶意程式(或者病毒)是如何与 Windows 应用程序编程接口(WinAPI)进行交互的研究成果。当中详细赘述了恶意程式如何可以将 Payload [译注:Payload 指一种对设备形成伤害的程序]植入到其余进程中的基本概念,以及如何经过监控与 Windows 操做系统的通讯来检测此类功能。而且经过函数钩子钩住某些函数的方式来介绍观察 API 调用的过程,而这些函数正被用来实现代码注入功能。html
阅前声明:因为时间方面的缘由,这是一个相对来讲比较短促的项目。因此各位在阅读时如若发现了可能相关的错误信息,我先在此表示十分抱歉,还请尽快地通知我以便及时修正。除此以外,文章随附的代码部分在项目延展性上有必定的设计缺陷,也可能会由于版本落后而没法成功在当下执行。前端
在当下,恶意软件是由网络罪犯开发并针对在网络上那些容易泄露信息的计算机,经过在这些计算机系统上执行恶意任务以谋取利益。在大多数恶意软件入侵事件中,这些恶意程式都生存于人们的视野以外,由于它们的行动必须保持隐蔽才能不让管理员发现同时阻止系统杀毒软件检测。所以,经过代码注入让自身“隐形”成为了经常使用的入侵手段。react
内联挂钩是经过热补丁修复过程来绕过代码流的一种行为。热补丁修复被定义为一种能够经过在程式运行时修改二进制代码来改变应用行为的方法[1]。其主要的目的就是为了可以捕捉程序调用函数的时段,从而实现对程序进行监控和调用。下面是模拟内联挂钩在程序正常工做时的过程:android
正常调用函数时的程序
+---------+ +----------+
| Program | ----------------------- calls function -----------------------------> | Function | | execution
+---------+ | . | | of
| . | | function
| . | |
| | v
+----------+
复制代码
与执行了一个钩子函数后的程序相比:ios
程序中调用钩子函数
+---------+ +--------------+ + -------> +----------+
| Program | -- calls function --> | Intermediate | | execution | | Function | | execution
+---------+ | Function | | of calls | . | | of
| . | | intermediate normal | . | | function
| . | | function function | . | |
| . | v | | | v
+--------------+ ------------------+ +----------+
复制代码
此过程能够分红三个执行步骤。在这里咱们能够以 WinAPI 方法 MessageBox 来演示整个过程。git
若是咱们要想在函数中挂钩,咱们首先须要一个必须能复制目标函数参数的中间函数。 MessageBox
方法在微软开发者网络(MSDN)中是这样定义的:github
int WINAPI MessageBox(
_In_opt_ HWND hWnd,
_In_opt_ LPCTSTR lpText,
_In_opt_ LPCTSTR lpCaption,
_In_ UINT uType
);
复制代码
因此咱们的中间函数也能够像这样:算法
int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
// our code in here
}
复制代码
一旦触发中间函数,代码执行流将被重定向至某个特定位置。要想在 MessageBox
方法中进行挂钩,咱们能够补充代码的前几个字节(请记住,咱们必须备份本来的字节,以便于在中间函数执行后恢复原始函数)。如下是在MsgBox方法中相应模块 user32.dll
中的原始编码指令:shell
; MessageBox
8B FF mov edi, edi
55 push ebp
8B EC mov ebp, esp
复制代码
与挂钩后的函数相比:编程
; MessageBox
68 xx xx xx xx push <HookedMessageBox> ; our intermediate function
C3 ret
复制代码
基于以往的经验以及对隐蔽性可靠程度的考虑,这里我会选择使用 push-ret
指令组合而不是一个绝对的 jmp
语句。xx xx xx xx
表示 HookedMessageBox
中的低字节序顺序地址。
当程序调用 MessageBox
方法时,它将会执行 push-ret
相关指令并立刻插入 HookedMessageBox
函数中,如若执行成功,就能够调用该函数来彻底控制程序参数和调用自己。例如若是要替换即将在消息对话框中显示的文本内容,能够在 HookedMessageBox
中声明如下内容:
int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
TCHAR szMyText[] = TEXT("This function has been hooked!");
}
复制代码
其中 szMyText
能够用来替换 MessageBox
中的 LPCTSTR lpText
参数。
要想将替换后的参数转发,须要让代码执行流中的 MessageBox
方法回退到原始状态,才能让操做系统显示对话框。因为继续调用 MessageBox
方法只会致使无限递归,因此咱们必需要恢复原始字节(正如前面所提到的)。
int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
TCHAR szMyText[] = TEXT("This function has been hooked!");
// 还原 MessageBox 中的原始字节
// ...
// 使用已替换参数的 MessageBox 方法,并将值返回给程序
return MessageBox(hWnd, szMyText, lpCaption, uType);
}
复制代码
若是须要拒绝调用 MessageBox
方法,那就跟返回一个值同样简单,最好这个值曾在文档中被定义过。例如要在一个“确认/取消”对话框中返回“取消”选项,在中间函数中就能够这样声明:
int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
return IDNO; // IDNO defined as 7
}
复制代码
基于函数挂钩的方法机制,咱们彻底能够控制函数调用的过程,同时也能够控制程序里的全部参数,这也就是咱们实现文档中标题里也提到过的 API 监控的概念原理。然而,这里仍有一个小问题,那就是因为不一样的深层 API 实用性也不尽相同,致使这些 API 的调用将是独一无二的,只不过在浅层调用中它们可能都使用同一组 API,这被称为函数嵌套,被定义为在子程序中调用次级子程序。回到 MessageBox
的例子中,在方法里,咱们声明了两个函数 MessageBoxA
和 MessageBoxW
,前者用来包含 ASCII 字符的参数,后者用来包含宽字符的参数。在实际应用中,若是咱们在 MessageBox
方法中挂钩,就须要对 MessageBoxA
和 MessageBoxW
的前几个字节都进行补充。而其实遇到这样的问题时,咱们只须要在函数调用等级最低的公共点进行挂钩就能够了。
+---------+
| Program |
+---------+
/ \
| |
+------------+ +------------+
| Function A | | Function B |
+------------+ +------------+
| |
+-------------------------------+
| user32.dll, kernel32.dll, ... |
+-------------------------------+
+---------+ +-------- hook -----------------> |
| API | <---- + +-------------------------------------+
| Monitor | <-----+ | ntdll.dll |
+---------+ | +-------------------------------------+
+-------- hook -----------------> | User mode
-----------------------------------------------------
Kernel mode
复制代码
下面是模拟调用 Message 方法的层级顺序:
在 MessageBoxA
中:
user32!MessageBoxA -> user32!MessageBoxExA -> user32!MessageBoxTimeoutA -> user32!MessageBoxTimeoutW
复制代码
在 MessageBoxW
中:
user32!MessageBoxW -> user32!MessageBoxExW -> user32!MessageBoxTimeoutW
复制代码
上面方法中的层层调用最后都会合并到 MessageBoxTimeoutW
函数中,这会是个合适的挂钩点。对于处在过深层次的函数,伴随着函数参数的复杂化,对在任何底层的点进行挂钩都只会带来不必的麻烦。MessageBoxTimeoutW
是一个没有在 WinAPI 文档中说明的一个函数,它的定义以下:
int WINAPI MessageBoxTimeoutW(
HWND hWnd,
LPCWSTR lpText,
LPCWSTR lpCaption,
UINT uType,
WORD wLanguageId,
DWORD dwMilliseconds
);
复制代码
用法:
int WINAPI MessageBoxTimeoutW(HWND hWnd, LPCWSTR lpText, LPCWSTR lpCaption, UINT uType, WORD wLanguageId, DWORD dwMilliseconds) {
std::wofstream logfile; // declare wide stream because of wide parameters
logfile.open(L"log.txt", std::ios::out | std::ios::app);
logfile << L"Caption: " << lpCaption << L"\n";
logfile << L"Text: " << lpText << L"\n";
logfile << L"Type: " << uType << :"\n";
logfile.close();
// 恢复原始字节
// ...
// pass execution to the normal function and save the return value
int ret = MessageBoxTimeoutW(hWnd, lpText, lpCaption, uType, wLanguageId, dwMilliseconds);
// rehook the function for next calls
// ...
return ret; // 返回原始函数的值
}
复制代码
只要在 MessageBoxTimeoutW
挂钩成功,MessageBoxA
和 MessageBoxW
的行为就均可以被咱们捕获了。
就本文而言,咱们将代码注入技术定义为一种嵌入行为,它能够将程序内部可执行代码在外部甚至是远程进行调用修改。在 WinAPI 自己就拥有一些可让咱们实现嵌入的功能。当其中某些函数方法被组合封装在一块儿时,就可能实现访问现有进程,篡改写入数据而后隐藏在代码流中远程执行。在本节中,做者将会介绍在研究中涉及到的代码注入的相关技术。
在计算机中,代码能够存在于多种形式的文件下,其中之一就是 Dynamic Link Library (动态连接库 DLL)。DLL 文件又被称为应用程序拓展库,顾名思义,它就是经过导出应用子程序后用来给其余程序进行拓展。本文其他部分将都以此 DLL 文件示例:
extern "C" void __declspec(dllexport) Demo() {
::MessageBox(nullptr, TEXT("This is a demo!"), TEXT("Demo"), MB_OK);
}
bool APIENTRY DllMain(HINSTANCE hInstDll, DWORD fdwReason, LPVOID lpvReserved) {
if (fdwReason == DLL_PROCESS_ATTACH)
::CreateThread(nullptr, 0, (LPTHREAD_START_ROUTINE)Demo, nullptr, 0, nullptr);
return true;
}
复制代码
当一个 DLL 文件在程序中加载并初始化后,加载程序将会调用 DllMain
这个方法并判断 fdwReason
参数是否设置为 DLL_PROCESS_ATTACH
。在这个例子中,当在进程中加载 DLL 文件时,它将经过 Demo
这个方法显示一个带有 Demo
标题和 This is a demo!
文本内容的消息框。要想正确地完成对 DLL 文件地初始化,消息框必须返回 true
值,不然文件就会被拒绝执行。
CreateRemoteThread 是实现 DLL 注入的方法之一,它能够被使用在某个进程的虚拟空间中执行远程线程。正如以前所提到过的,咱们所作的一切都是为了经过注入 DLL 文件使其进程强制执行 LoadLibrary
函数。经过如下代码咱们将实现这点:
void injectDll(const HANDLE hProcess, const std::string dllPath) {
LPVOID lpBaseAddress = ::VirtualAllocEx(hProcess, nullptr, dllPath.length(), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
::WriteProcessMemory(hProcess, lpBaseAddress, dllPath.c_str(), dllPath.length(), &dwWritten);
HMODULE hModule = ::GetModuleHandle(TEXT("kernel32.dll"));
LPVOID lpStartAddress = ::GetProcAddress(hModule, "LoadLibraryA"); // LoadLibraryA for ASCII string
::CreateRemoteThread(hProcess, nullptr, 0, (LPTHREAD_START_ROUTINE)lpStartAddress, lpBaseAddress, 0, nullptr);
}
复制代码
MSDN 对 LoadLibrary 是这样定义的:
HMODULE WINAPI LoadLibrary(
_In_ LPCTSTR lpFileName
);
复制代码
使用上面这个函数时,咱们须要传入一个参数那就是加载库的路径。而在 LoadLibrary
例程中声明的这个参数将会被传递给 CreateRemoteThread
方法中相匹配的路径参数。这种行为的目的是为了能在目标进程的虚拟地址空间中传递字符串参数,而后将 CreateRemoteThread
方法的自变量参数分配给空间地址以便调用 LoadLibrary
来加载 DLL。
使用 VirtualAllocEx
函数能够指定进程的虚拟空间保留或提交内存区域,执行完毕后函数将返回分配内存的首地址。
目标进程的虚拟地址空间:
+--------------------+
| |
VirtualAllocEx +--------------------+
Allocated memory ---> | Empty space |
+--------------------+
| |
+--------------------+
| Executable |
| Image |
+--------------------+
| |
| |
+--------------------+
| kernel32.dll |
+--------------------+
| |
+--------------------+
复制代码
只要内存初始化成功, DLL 的路径就能够被注入到 VirtualAllocEx
使用 WriteProcessMemory
返回的分配内存里。
目标进程的虚拟地址空间
+--------------------+
| |
WriteProcessMemory +--------------------+
Inject DLL path ----> | "..\..\myDll.dll" |
+--------------------+
| |
+--------------------+
| Executable |
| Image |
+--------------------+
| |
| |
+--------------------+
| kernel32.dll |
+--------------------+
| |
+--------------------+
复制代码
LoadLibrary
地址因为全部的系统 DLL 文件都会被映射到全部进程的相同地址空间,因此 LoadLibrary
的地址不须要到目标进程中检索。只需调用 GetModuleHandle(TEXT("kernel32.dll"))
和 GetProcAddress(hModule, "LoadLibraryA")
就能够了。
若是咱们须要加载 DLL 文件,LoadLibrary
地址以及 DLL 文件路径是咱们必须知道的两个主要参数。在使用 CreateRemoteThread
函数时,LoadLibrary
将会以 DLL 文件路径做为参数在目标进程的代码流中被执行。
目标进程的虚拟地址空间
+--------------------+
| |
+--------------------+
+--------- | "..\..\myDll.dll" |
| +--------------------+
| | |
| +--------------------+ <---+
| | myDll.dll | |
| +--------------------+ |
| | | | LoadLibrary
| +--------------------+ | loads
| | Executable | | and
| | Image | | initialises
| +--------------------+ | myDll.dll
| | | |
| | | |
CreateRemoteThread v +--------------------+ |
LoadLibraryA("..\..\myDll.dll") --> | kernel32.dll | ----+
+--------------------+
| |
+--------------------+
复制代码
SetWindowsHookEx 函数是 Windows 提供给程序开发人员的一个 API,经过对某一事件流程挂钩实现对消息拦截的功能,虽然这个函数常常被使用来监视键盘按键输入和记录,但其实也能够被用于 DLL 注入。如下代码将演示如何将 DLL 注入事件自己。
int main() {
HMODULE hMod = ::LoadLibrary(DLL_PATH);
HOOKPROC lpfn = (HOOKPROC)::GetProcAddress(hMod, "Demo");
HHOOK hHook = ::SetWindowsHookEx(WH_GETMESSAGE, lpfn, hMod, ::GetCurrentThreadId());
::PostThreadMessageW(::GetCurrentThreadId(), WM_RBUTTONDOWN, (WPARAM)0, (LPARAM)0);
// 捕捉事件的消息队列
MSG msg;
while (::GetMessage(&msg, nullptr, 0, 0) > 0) {
::TranslateMessage(&msg);
::DispatchMessage(&msg);
}
return 0;
}
复制代码
SetWindowsHookEx
在 MSDN 中是这样定义的:
HHOOK WINAPI SetWindowsHookEx(
_In_ int idHook,
_In_ HOOKPROC lpfn,
_In_ HINSTANCE hMod,
_In_ DWORD dwThreadId
);
复制代码
在上面的定义中, HOOKPROC
是由用户声明的钩子函数,当特定的挂钩事件被触发时它就会被执行。在咱们的示例中,这一事件指的是 WH_GETMESSAGE
钩子,它主要负责处理进队消息的工做[译注:Windows 中消息分为进队消息和不进队消息]。这段代码是一个回调函数,它会先将 DLL 文件加载到它本身的虚拟进程空间中,再得到以前导出的 Demo
函数地址,最后在 SetWindowsHookEx
函数中声明并调用。要想强制执行这个钩子函数,咱们只需调用 PostThreadMessage
函数并将消息赋值为 WM_RBUTTONDOWN
就能够触发 WH_GETMESSAGE
钩子以后就能显示以前所说的消息框了。
使用 QueueUserAPC 接口方法的 DLL 注入和 CreateRemoteThread
相似,都是在分配和注入 DLL 地址到目标进程的虚拟地址空间中后在代码流中强制调用 LoadLibrary
函数。
int injectDll(const std::string dllPath, const DWORD dwProcessId, const DWORD dwThreadId) {
HANDLE hProcess = ::OpenProcess(PROCESS_ALL_ACCESS, false, dwProcessId);
HANDLE hThread = ::OpenThread(THREAD_ALL_ACCESS, false, dwThreadId);
LPVOID lpLoadLibraryParam = ::VirtualAllocEx(hProcess, nullptr, dllPath.length(), MEM_COMMIT, PAGE_READWRITE);
::WriteProcessMemory(hProcess, lpLoadLibraryParam, dllPath.data(), dllPath.length(), &dwWritten);
::QueueUserAPC((PAPCFUNC)::GetProcAddress(::GetModuleHandle(TEXT("kernel32.dll")), "LoadLibraryA"), hThread, (ULONG_PTR)lpLoadLibraryParam);
return 0;
}
复制代码
这个方法和 CreateRemoteThread
有一个主要区别,QueueUserAPC
是只能在警告状态下执行调用的。也就是说在 QueueUserAPC
队列中的异步程序只有在当在线程处于警告状态时才能调用 APC 函数。
Process hollowing(傀儡进程),又称为 RunPE,这是一个常见的用于躲避反病毒检测的方法。它能够作到把整个可执行文件注入到目标进程中并在其代码流中执行。一般咱们会在加密的应用程序中看到,存在 Payload 的磁盘上的某个文件会被选举为 host 而且被做为进程建立,而这个文件的主要执行模块都被挖空而且替换掉了。这样一个过程能够分解为四步来执行。
为了将 Payload 注入,首先引导程序必须找到适合引导的主文件。若是 Payload 是一个 .NET 应用程序,那么主文件也必须是 .NET 应用程序。若是 Payload 是一个能够调用控制台子系统的本地可执行程序,则主文件也要具备与其相同的属性。无论是32位仍是64位的程序都必需要知足这一条件。一旦主文件找到了以后,系统函数 CreateProcess(PATH_TO_HOST_EXE, ..., CREATE_SUSPENDED, ...)
即可建立一个挂起状态的进程。
主进程中的可执行映像
+--- +--------------------+
| | PE |
| | Headers |
| +--------------------+
| | .text |
| +--------------------+
CreateProcess + | .data |
| +--------------------+
| | ... |
| +--------------------+
| | ... |
| +--------------------+
| | ... |
+--- +--------------------+
复制代码
为了使注入后的 Paylaod 正常工做,咱们必须将其映射到与 PE 映像头的 optional header 的 ImageBase
值相同的虚拟地址空间。
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint; // <---- this is required later
DWORD BaseOfCode;
DWORD BaseOfData;
DWORD ImageBase; // <----
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage; // <---- size of the PE file as an image
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER, *PIMAGE_OPTIONAL_HEADER;
复制代码
这一点很是重要,由于绝对地址颇有可能会涉及彻底依赖其内存位置的代码。为了安全地映射该可执行映像,必须从描述的 ImageBase
值开始的虚拟内存空间卸载映射。因为许多可执行文件共享通用的基地址(一般为 0x400000
),所以主进程自己的可执行映像未映射的状况并不罕见。卸载这一操做能够经过 NtUnmapViewOfSection(IMAGE_BASE, SIZE_OF_IMAGE)
来完成。
主进程中的可执行映像
+--- +--------------------+
| | |
| | |
| | |
| | |
| | |
NtUnmapViewOfSection + | |
| | |
| | |
| | |
| | |
| | |
| | |
+--- +--------------------+
复制代码
要将 Payload 注入,咱们必须手动去解析 PE 文件将其从磁盘格式转换为映像格式。在使用 VirtualAllocEx
分配完虚拟内存后,PE 映像头将直接被复制到基地址中。
主进程中的可执行映像
+--- +--------------------+
| | PE |
| | Headers |
+--- +--------------------+
| | |
| | |
WriteProcessMemory + | |
| |
| |
| |
| |
| |
| |
+--------------------+
复制代码
而若是要将 PE 文件转换成映像,全部的区块(节)都必须从文件偏移量里逐个读取,而后经过使用 WriteProcessMemory
将其放置到正确的虚拟偏移量中。在这篇 MSDN 文档中每一个章节的 section header. 都有介绍。
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress; // <---- 虚拟偏移量
DWORD SizeOfRawData;
DWORD PointerToRawData; // <---- 文件偏移量
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
复制代码
主进程中的可执行映像
+--------------------+
| PE |
| Headers |
+--- +--------------------+
| | .text |
+--- +--------------------+
WriteProcessMemory + | .data |
+--- +--------------------+
| | ... |
+---- +--------------------+
| | ... |
+---- +--------------------+
| | ... |
+---- +--------------------+
复制代码
最后一步就是将执行的首地址指向上面有提到过的(建立主进程)Payload 的 AddressOfEntryPoint
。因为进程的主线程已经被挂起,因此可使用 GetThreadContext
方法来检索相关信息。其代码结构能够如如下声明:
typedef struct _CONTEXT
{
ULONG ContextFlags;
ULONG Dr0;
ULONG Dr1;
ULONG Dr2;
ULONG Dr3;
ULONG Dr6;
ULONG Dr7;
FLOATING_SAVE_AREA FloatSave;
ULONG SegGs;
ULONG SegFs;
ULONG SegEs;
ULONG SegDs;
ULONG Edi;
ULONG Esi;
ULONG Ebx;
ULONG Edx;
ULONG Ecx;
ULONG Eax; // <----
ULONG Ebp;
ULONG Eip;
ULONG SegCs;
ULONG EFlags;
ULONG Esp;
ULONG SegSs;
UCHAR ExtendedRegisters[512];
} CONTEXT, *PCONTEXT;
复制代码
若是要修改首地址,咱们必须将上面的 Eax
数据成员更改成 Payload 的 AddressOfEntryPoint
的虚拟地址。简单表示,context.Eax = ImageBase + AddressOfEntryPoint
。调用 SetThreadContext
方法,并传入修改的 CONTEXT
结构,咱们就能够更改应用到进程线程。以后如今咱们只需调用 ResumeThread
,Payload 应该就能够开始执行了。
Atom Bombing 是一种代码注入技术,它利用了 Windows 的全局原子表来实现全局数据存储。全局原子表中的数据能够跨全部进程进行访问,这也正是咱们能实现代码注入的缘由。表中的数据是以空字符结尾的 C-string 类型,用 16-bit 的整数表示,咱们称之为原子(Atom),它相似于 map 数据结构。在 MSDN 中提供了 GlobalAddAtom 方法用于向其添加数据,以下声明:
ATOM WINAPI GlobalAddAtom(
_In_ LPCTSTR lpString
);
复制代码
其中 lpString
是要存储的数据,当方法调用成功时将会返回一个 16-bit 的整数原子。咱们能够经过 GlobalGetAtomName 来检索存储在全局原子表里面的数据,以下声明:
UINT WINAPI GlobalGetAtomName(
_In_ ATOM nAtom,
_Out_ LPTSTR lpBuffer,
_In_ int nSize
);
复制代码
经过 GlobalAddAtom
添加方法返回的标识原子将会被放入 lpBuffer
中并返回该字符串的长度(不包含空终止符)。
Atom bombing 是经过强制让目标进程加载并执行存储在全局原子表里的代码,这依赖于另外一个关键函数,NtQueueApcThread
,一个 QueueUserAPC
接口在用户领域的调用方法。之因此使用 NtQueueApcThread
而不是 QueueUserAPC
其余方法的缘由,正如前面所看到的,QueueUserAPC
的 APCProc 方法只能接收一个参数,而 GlobalGetAtomName
须要三个参数[3]。
VOID CALLBACK APCProc( UINT WINAPI GlobalGetAtomName(
_In_ ATOM nAtom,
_In_ ULONG_PTR dwParam -> _Out_ LPTSTR lpBuffer,
_In_ int nSize
); );
复制代码
然而在 NtQueueApcThread
的底层会容许咱们能够传入三个潜在的参数:
NTSTATUS NTAPI NtQueueApcThread( UINT WINAPI GlobalGetAtomName(
_In_ HANDLE ThreadHandle, // target process's thread _In_ PIO_APC_ROUTINE ApcRoutine, // APCProc (GlobalGetAtomName) _In_opt_ PVOID ApcRoutineContext, -> _In_ ATOM nAtom, _In_opt_ PIO_STATUS_BLOCK ApcStatusBlock, _Out_ LPTSTR lpBuffer, _In_opt_ ULONG ApcReserved _In_ int nSize ); ); 复制代码
下面是咱们用图形模拟代码注入的过程:
Atom bombing code injection
+--------------------+
| |
+--------------------+
| lpBuffer | <-+
| | |
+--------------------+ |
+---------+ | | | Calls
| Atom | +--------------------+ | GlobalGetAtomName
| Bombing | | Executable | | specifying
| Process | | Image | | arbitrary
+---------+ +--------------------+ | address space
| | | | and loads shellcode
| | | |
| NtQueueApcThread +--------------------+ |
+---------- GlobalGetAtomName ----> | ntdll.dll | --+
+--------------------+
| |
+--------------------+
复制代码
这是 Atom bombing 的一种很是简化的概述,但对于本文的其他部分来讲已经足够了。若是余姚了解更多关于 Atom bombing 的技术信息,请参阅 enSilo 的 AtomBombing: Brand New Code Injection for Windows。
UnRunPE 是一个概念验证(Proof of concept,简称 PoC)工具,是为了将 API 监控的理论概念应用到实际操做而编写的。该工具的目的是将选定的可执行文件做为进程建立并挂起,随后将带有钩子函数的 DLL 经过傀儡进程技术(process hollowing)注入到进程中。
了解了相关的代码注入的基础知识以后,能够经过下面的 WinAPI 函数调用链来实现傀儡进程技术的注入手段:
CreateProcess
NtUnmapViewOfSection
VirtualAllocEx
WriteProcessMemory
GetThreadContext
SetThreadContext
ResumeThread
其实当中有一些并不必定要按这样的顺序执行,例如,GetThreadContext
能够在 VirtualAllocEx
以前就调用。不过因为一些方法须要依赖前面调用的 API,例如 SetThreadContext
必须要在 GetThreadContext
或者 CreateProcess
调用以前调用,不然就没法将 Payload 注入到目标进程。该工具将假定上述的调用顺序做为参考,尝试检测是否有潜在的傀儡进程。
遵循 API 监控的理论,咱们最好是在函数调用等级最低的公共点进行挂钩,但当被恶意软件入侵时,咱们最理想的应该是将其可访问的可能性降到最低。假定在最坏的状况下,入侵者可能会尝试绕太高层的 WinAPI 函数,而直接调用最低层的函数,这些函数一般在 ntdll.dll
模块中能够找到。下列是傀儡进程当中常常调用的达到上述要求的 WinAPI 函数:
NtCreateUserProcess
NtUnmapViewOfSection
NtAllocateVirtualMemory
NtWriteVirtualMemory
NtGetContextThread
NtSetContextThread
NtResumeThread
一旦咱们在须要的函数中挂钩成功,目标进程就会被执行而且记录每一个挂钩函数的参数,这样咱们就能跟踪傀儡进程以及主进程的当前进度。最值得注意的是 NtWriteVirtualMemory
和 NtResumeThread
这两个钩子函数,由于前者参与应用了代码注入,然后者执行了它。除了记录参数之外,UnRunPE 还会尝试转储使用 NtWriteVirtualMemory
写入的字节而且当执行 NtResumeThread
时,它将尝试转储整个被注入到主进程的 Payload。要作到这点,函数将须要利用经过 NtCreateUserProcess
记录的进程和线程句柄参数以及经过 NtUnmapViewOfSection
记录的基地址及其大小。在这里,若是使用 NtAllocateVirtualMemory
的参数可能会更合适,但实际应用中出于某些不明缘由,对函数进行挂钩的过程当中会出现错误。当经过 NtResumeThread
将 Payload 成功转储后,它将终止目标进程及其宿主进程,同时也阻止了注入后的代码执行。
为了演示这点,我选择了使用以前建立的二进制木马文件来作实验。文件中包含了 PEview.exe
以及 PuTTY.exe
做为隐藏的可执行文件。
Dreadnought 是基于 UnRunPE 构建的 PoC 工具,它提供了更多样的代码注入检测,也就是咱们前面代码注入入门的所有内容。为了让应用程序更全面的检测代码注入,强化工具功能也在所必然。
实现代码注入能够有不少种方法,因此咱们必需要了解不一样的技术之间的区别。第一种检测代码注入的方法就是经过识别调用 API 的“触发器”,也就是负责 Payload 远程执行的 API 调用者。经过识别咱们能够肯定代码注入的完成过程以及某种程度上肯定了代码注入的类型。其类型共分为如下四种:
由 Karsten Hahn 制做的代码注入图形化过程[4]。
如上图所示(图片若加载失败请前往 Github 仓库查看原文),每个 API 触发器都列在了 Execute 这一栏下,当其中任何一个触发器被执行,Dreadnought 工具会当即将代码转储,以后将识别代码并匹配在此前假定的注入类型,这种方式和 UnRunPE 工具中处理傀儡进程的方式相似,但仅有这点是不够的,由于每个触发 API 的行为均可能混淆了各类底层调用方法,最后仍旧能够实现上图中箭头所指向的功能。
启发式的逻辑算法将可以使咱们的 Dreadnought 工具更加精准地肯定代码注入方法。所以在实际开发中,咱们使用了一种很是简单的启发式逻辑。从咱们的进程注入信息图表上看,每一次当任何一个 API 被挂钩时,该算法将会增长一个或者多个相关的代码注入类型的权重并存储在一个 map 数据结构里。在它跟踪每一个 API 的调用链时,它会尝试偏向某一种注入类型。一旦 API 触发器被触发,它将会识别并把每个有关联的注入类型的权重对比以后采起适应的措施。
本文旨在让读者对代码注入及其与 WinAPI 的交互具备必定程度的技术理解。此外,在用户领域监控 API 调用的概念也曾被恶意地利用来绕过反病毒检测。下面是本文中有关 Dreadnought 工具在实际的使用状况说明。
目前在理论上,Dreadnought 工具的这套检测设计方式和启发式算法确实足够让咱们向读者演示并讲述相关的原理知识,但在实际开发中却不可能这么理想。由于在咱们操做系统的常规操做中,有很是大的可能性存在那些被用来挂钩的 API 的替代品。而这些能够替代它们的行为或者调用,咱们没法分辨其是否为恶意的,也就没法检测到它们是否参与了代码注入。
由此看来,Dreadnought 工具以及它为用户领域提供的相关操做,在对抗过于复杂的恶意程序时并不理想,特别是能直接侵入到系统内核并与其进行交互的又或者是具备可以避开通常钩子能力的恶意程序等等。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。