从概念到实际应用:详细讲解用户级API监控和代码注入检测方法

 

一、前言

在当今,网络犯罪分子致力于开发恶意软件,并用于感染主机以执行特定的活动。考虑到目前反病毒软件能力的不断提升,这些恶意软件也必须保持较强的存活能力,必须要在暗中进行操作,以避免被反病毒软件和系统管理员觉察。在众多方法之中,代码注入是一个保证隐蔽性的较好方法。
本文主要描述恶意软件及其与Windows应用程序编程接口(WinAPI)交互的相关研究成果,详细介绍恶意软件是如何将恶意Payload植入其他进程,以及如何通过监控Windows操作系统的通信来检测此类行为。关于监控API调用的这一概念,在本文中也将会通过对特定函数进行Hook的过程来向大家展现,并且该概念将用于实现代码注入。
由于时间有限,我们以非常快的速度完成了该项目的研究,因此如果本文中有任何疏漏或错误之处,希望大家能够谅解,并期待各位的指正。此外,本文附带的代码可能会存在一些未知的设计缺陷,请各位自行参考。

 

二、相关概念

2.1 Inline Hooking

内联挂钩(Inline Hooking)是通过热补丁(Hotpatching)的方式绕开代码流的行为。在这里,热补丁是指在可执行映像运行期间对代码进行修改。之所以使用内联挂钩的方式,是为了能够捕获程序在何时调用函数的实例,以进行监控或实现调用。以下是一次函数调用正常执行的过程:

Normal Execution of a Function Call

+---------+                                                                       +----------+
| Program | ----------------------- calls function -----------------------------> | Function |  | execution
+---------+                                                                       |    .     |  | of
                                                                                  |    .     |  | function
                                                                                  |    .     |  |
                                                                                  |          |  v
                                                                                  +----------+

与执行一个挂钩后的函数相比:

Execution of a Hooked Function Call

+---------+                       +--------------+                    + ------->  +----------+
| Program | -- calls function --> | Intermediate | | execution        |           | Function |  | execution
+---------+                       |   Function   | | of             calls         |    .     |  | of
                                  |       .      | | intermediate   normal        |    .     |  | function
                                  |       .      | | function      function       |    .     |  |
                                  |       .      | v                  |           |          |  v
                                  +--------------+  ------------------+           +----------+

该过程可以分为三个步骤。我们在这里,以WinAPI函数MessageBox为例,演示此过程。
(1) 对函数进行挂钩
想要挂钩(Hook)函数,我们首先需要一个中间函数,它必须能够复制目标函数的参数。在MSDN中对MessageBox的定义如下:

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函数进行挂钩,我们可以修补代码的前几个字节(请注意,必须要对原始字节进行记录,以便在中间函数完成时能够恢复该函数)。以下是该函数的原始汇编指令,如该函数的相应模块user32.dll中所示:

; 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的小字节序顺序地址(Little-Endian Byte-Order Address)。
(2) 捕获函数调用
当程序调用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参数。
(3) 恢复正常执行
要转发此参数,执行过程需要回到原始的MessageBox,以让操作系统可以显示该对话框。如果现在再次调用MessageBox,会导致无限递归(死循环),因此我们必须要恢复原始字节(如前所述)。

int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
    TCHAR szMyText[] = TEXT("This function has been hooked!");

    // restore the original bytes of MessageBox
    // ...

    // continue to MessageBox with the replaced parameter and return the return value to the program
    return MessageBox(hWnd, szMyText, lpCaption, uType);
}

如果需要拒绝调用MessageBox,方法也非常简单,就像返回一个值一样,但这个值最好是在文档中定义过的。例如,想要从”是/否”对话框中返回”否”选项的值,其中间函数可以是:

int WINAPI HookedMessageBox(HWND hWnd, LPCTSTR lpText, LPCTSTR lpCaption, UINT uType) {
    return IDNO;  // IDNO defined as 7
}

2.2 API监控

在了解函数挂钩以后,我们紧接着讲解API监控的概念。我们有能力获得对函数调用的控制,同时也可以对所有参数进行监控,也就是标题所说的API监控。但是,还存在一个小问题,是由不同级别的API调用可用性导致的,尽管这些调用是唯一的,但在较低级别会使用相同的一组API。这就称为函数包装(Function Wrapping),定义为用于调用次级子程序的子程序。让我们回到MessageBox的示例,其中有两个定义的函数:MessageBoxA用于包含ASCII字符的参数,MessageBoxW用于包含宽字符的参数。实际上,如果我们要对MessageBox进行挂钩,就必须要同时修补MessageBoxA和MessageBoxW。要解决这一问题,我们需要尽可能在函数调用层次(Call Hierarchy)结构的最低公共点位置进行挂钩。

                                                      +---------+
                                                      | Program |
                                                      +---------+
                                                     /           
                                                    |             |
                                            +------------+   +------------+
                                            | Function A |   | Function B |
                                            +------------+   +------------+
                                                    |             |
                                           +-------------------------------+
                                           | user32.dll, kernel32.dll, ... |
                                           +-------------------------------+
       +---------+       +-------- hook -----------------> |
       |   API   | <---- +              +-------------------------------------+
       | Monitor | <-----+              |              ntdll.dll              |
       +---------+       |              +-------------------------------------+
                         +-------- hook -----------------> |                           User mode
                                 -----------------------------------------------------
                                                                                       Kernel mode

以下是MessageBox调用层次结构:

MessageBoxA:
user32!MessageBoxA -> user32!MessageBoxExA -> user32!MessageBoxTimeoutA -> user32!MessageBoxTimeoutW
MessageBoxW:
user32!MessageBoxW -> user32!MessageBoxExW -> user32!MessageBoxTimeoutW

上述的调用层次,都将汇入MessageBoxTimeoutW,这是一个挂钩的绝佳位置。在这里要特别指出,对于具有更深层次结构的函数,由于函数参数的复杂程度有所增加,对较低位置的值进行挂钩可能会产生一些问题。MessageBoxTimeoutW是一个没有出现在文档中的WinAPI函数(Undocumented WinAPI Function),其定义如下:

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();

    // restore the original bytes
    // ...

    // 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;   // return the value of the original function
}

当挂钩被放入MessageBoxTimeoutW中之后,MessageBoxA和MessageBoxW就都可以被捕获了。

2.3 代码注入入门

就本文而言,我们所讲解的”代码注入”是指将可执行代码插入到外部进程中。在WinAPI中,允许了一些功能,从而让代码注入成为了可能。在某些函数的共同作用下,我们可以访问现有进程,向其中写入数据,并且在其上下文中进行远程执行。在本节中,我们将介绍研究中涉及到的相关代码注入技术。

2.3.1 DLL注入

关于代码注入,代码可以是各种形式,其中之一就是动态链接库(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。在这个例子中,当它被加载到进程中时,会通过Demo子例程来显示一个消息框,其标题为”Demo”,文本内容为”This is a demo!”。要正确完成DLL的初始化,它必须返回True,否则就会被卸载。
2.3.1.1 CreateRemoteThread
通过CreateRemoteThread函数的DLL注入会利用此函数在另一个进程的虚拟空间中执行远程线程(Remote Thread)。如上所述,我们要执行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
);

该定义使用了一个参数,即要加载的所需库的路径名称。CreateRemoteThread函数允许将一个参数传递到与LoadLibrary的函数定义完全匹配的线程例程。其目标是在目标进程的虚拟地址空间中分配字符串参数,然后将分配的空间地址传递给CreateRemoteThread的参数中,以便调用LoadLibrary来加载DLL。
(1) 在目标进程中分配虚拟内存
使用VirtualAllocEx可以允许在选定的进程哪分配空间,一旦成功,会返回分配内存的起始地址。

Virtual Address Space of Target Process
                                              +--------------------+
                                              |                    |
                        VirtualAllocEx        +--------------------+
                        Allocated memory ---> |     Empty space    |
                                              +--------------------+
                                              |                    |
                                              +--------------------+
                                              |     Executable     |
                                              |       Image        |
                                              +--------------------+
                                              |                    |
                                              |                    |
                                              +--------------------+
                                              |    kernel32.dll    |
                                              +--------------------+
                                              |                    |
                                              +--------------------+

(2) 将DLL路径写入分配的内存
一旦内存被初始化,DLL的路径可以被注入到VirtualAllocEx使用WriteProcessMemory返回的分配内存中。

Virtual Address Space of Target Process
                                              +--------------------+
                                              |                    |
                        WriteProcessMemory    +--------------------+
                        Inject DLL path ----> | "....myDll.dll"  |
                                              +--------------------+
                                              |                    |
                                              +--------------------+
                                              |     Executable     |
                                              |       Image        |
                                              +--------------------+
                                              |                    |
                                              |                    |
                                              +--------------------+
                                              |    kernel32.dll    |
                                              +--------------------+
                                              |                    |
                                              +--------------------+

(3) 获取LoadLibrary的地址
由于所有系统DLL都映射到所有进程的相同地址空间,因此LoadLibrary的地址不必直接从目标进程中检索,只需调用GetModuleHandle(TEXT(“kernel32.dll”))和GetProcAddress(hModule, “LoadLibraryA”)即可完成这项工作。
(4) 加载DLL
如果要加载DLL,就需要LoadLibrary的地址和DLL的路径。此时使用CreateRemoteThread函数,LoadLibrary会在目标进程的上下文中以DLL路径作为参数执行。

Virtual Address Space of Target Process
                                              +--------------------+
                                              |                    |
                                              +--------------------+
                                   +--------- | "....myDll.dll"  |
                                   |          +--------------------+
                                   |          |                    |
                                   |          +--------------------+ <---+
                                   |          |     myDll.dll      |     |
                                   |          +--------------------+     |
                                   |          |                    |     | LoadLibrary
                                   |          +--------------------+     | loads
                                   |          |     Executable     |     | and
                                   |          |       Image        |     | initialises
                                   |          +--------------------+     | myDll.dll
                                   |          |                    |     |
                                   |          |                    |     |
          CreateRemoteThread       v          +--------------------+     |
          LoadLibraryA("....myDll.dll") --> |    kernel32.dll    | ----+
                                              +--------------------+
                                              |                    |
                                              +--------------------+

2.3.1.2 SetWindowsHookEx
在Windows中,通过SetWindowsHookEx函数为开发人员提供了通过安装钩子来监视某些事件的功能。虽然这一功能经常被用于监控键盘输入并记录,但它其实也能用于注入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);

    // message queue to capture events
    MSG msg;
    while (::GetMessage(&msg, nullptr, 0, 0) > 0) {
        ::TranslateMessage(&msg);
        ::DispatchMessage(&msg);
    }

    return 0;
}

MSDN中定义的SetWindowsHookEx如下:

HHOOK WINAPI SetWindowsHookEx(
    _In_ int       idHook,
    _In_ HOOKPROC  lpfn,
    _In_ HINSTANCE hMod,
    _In_ DWORD     dwThreadId
);

在这里,需要一个HOOKPROC参数,该参数是触发特定挂钩事件时执行的用户定义的回调子例程。在这种情况下,事件是WH_GETMESSAGE,它负责处理消息队列中的消息。该代码最初将DLL加载到其自身的虚拟进程空间中,并且导出的Demo函数的地址将被获取并定义为调用SetWindowsHookEx中的回调函数。为了强制回调函数执行,使用了消息WM_RBUTTONDOWN调用PostThreadMessage,这样就能触发WH_GETMESSAGE钩子,从而显示消息框。
2.3.1.3 QueueUserAPC
使用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能在可报警状态(Alertable State)下运行。由QueueUserAPC进行排队的异步过程仅在线程进入此状态时才进行处理。

2.3.2 Process Hollowing

Process Hollowing,也称RunPE,适用于逃避反病毒检测的一种常用方式。该方式允许将整个可执行文件的注入部分加载到目标进程中,并在其上下文中执行。通常我们可以在加密的应用程序中看到,与Payload相兼容的磁盘上文件会被选为主文件,并创建为进程,其主要可执行模块会被替换。该过程可以分为下述四个阶段。
(1) 创建主进程
为了注入Payload,引导程序必须会首先找到合适的主文件。如果Payload是.NET应用程序,那么主文件也必须是.NET应用程序。如果Payload是定义为使用控制台子系统的本地可执行文件,那么主文件也必须有相同的属性。这一原则在x86和x64程序时都要满足。一旦选择了主文件,随后便会使用CreateProcess(PATH_TO_HOST_EXE, …, CREATE_SUSPENDED, …)将其作为挂起的进程创建。

Executable Image of Host Process
                                        +---  +--------------------+
                                        |     |         PE         |
                                        |     |       Headers      |
                                        |     +--------------------+
                                        |     |       .text        |
                                        |     +--------------------+
                          CreateProcess +     |       .data        |
                                        |     +--------------------+
                                        |     |         ...        |
                                        |     +--------------------+
                                        |     |         ...        |
                                        |     +--------------------+
                                        |     |         ...        |
                                        +---  +--------------------+

(2) 对主进程的Hollowing
为了让Payload能在注入后正常工作,必须要将其映射到虚拟地址空间,该虚拟地址空间要与在Payload PE头部的可选头中找到的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)来完成的。

Executable Image of Host Process
                                        +---  +--------------------+
                                        |     |                    |
                                        |     |                    |
                                        |     |                    |
                                        |     |                    |
                                        |     |                    |
                   NtUnmapViewOfSection +     |                    |
                                        |     |                    |
                                        |     |                    |
                                        |     |                    |
                                        |     |                    |
                                        |     |                    |
                                        |     |                    |
                                        +---  +--------------------+

(3) 注入Payload
要注入Payload,必须手动解析PE文件,以将其从磁盘形式转换为映像形式。在使用VirtualAllocEx分配虚拟内存之后,要将PE头部直接复制到该基地址。

Executable Image of Host Process
                                        +---  +--------------------+
                                        |     |         PE         |
                                        |     |       Headers      |
                                        +---  +--------------------+
                                        |     |                    |
                                        |     |                    |
                     WriteProcessMemory +     |                    |
                                              |                    |
                                              |                    |
                                              |                    |
                                              |                    |
                                              |                    |
                                              |                    |
                                              +--------------------+

要将PE文件转换为映像,必须单独从文件偏移量中读取所有部分,然后使用WriteProcessMemory将其正确放置到相应的虚拟偏移量中。这一点在本文每一节中都有详细的讲解。

typedef struct _IMAGE_SECTION_HEADER {
  BYTE  Name[IMAGE_SIZEOF_SHORT_NAME];
  union {
    DWORD PhysicalAddress;
    DWORD VirtualSize;
  } Misc;
  DWORD VirtualAddress;               // <---- virtual offset
  DWORD SizeOfRawData;
  DWORD PointerToRawData;             // <---- file offset
  DWORD PointerToRelocations;
  DWORD PointerToLinenumbers;
  WORD  NumberOfRelocations;
  WORD  NumberOfLinenumbers;
  DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
Executable Image of Host Process
                                              +--------------------+
                                              |         PE         |
                                              |       Headers      |
                                        +---  +--------------------+
                                        |     |       .text        |
                                        +---  +--------------------+
                     WriteProcessMemory +     |       .data        |
                                        +---  +--------------------+
                                        |     |         ...        |
                                        +---- +--------------------+
                                        |     |         ...        |
                                        +---- +--------------------+
                                        |     |         ...        |
                                        +---- +--------------------+

(4) 执行Payload
最后一步,就是将执行的起始地址指向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执行。

2.3.3 Atom Bombing

Atom Bombing是一种代码注入技术,它利用Windows全局原子表(Windows’s Global Atom Table)进行全局数据存储。全局原子表的数据可以通过所有进程访问,这样就使我们的代码注入成为了可能。存储在表中的数据是以空字符结尾的C字符串类型,使用一个名为atom的16位整数键表示,类似于map数据结构。在MSDN中提供了一个用于添加数据的GlobalAddAtom函数,其定义如下:

ATOM WINAPI GlobalAddAtom(
    _In_ LPCTSTR lpString
);

其中,lpString是要存储的数据。在成功调用时,会返回16位整数的原子。为了检索存储在全局原子表中的数据,MSDN提供了一个GlobalGetAtomName,其定义如下:

UINT WINAPI GlobalGetAtomName(
    _In_  ATOM   nAtom,
    _Out_ LPTSTR lpBuffer,
    _In_  int    nSize
);

其功能是传入从GlobalAddAtom返回的标识原子,会将数据放入lpBuffer并返回不包含空终止符的字符串长度。
Atom Bombing通过迫使目标进程加载并执行放置在全局原子表中的代码来实现,该过程依赖于另一个至关重要的函数NtQueueApcThread,它是QueueUserAPC的最低级用户空间调用。我们之所以使用NtQueueApcThread而不是QueueUserAPC的原因在于,与GlobalGetAtomName相比,QueueUserAPC的APCProc只能接收一个不匹配的参数。

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的文章:https://blog.ensilo.com/atombombing-brand-new-code-injection-for-windows

 

三、UnRunPE

UnRunPE是我们的概念验证(PoC)工具,是为了将API监控的理论应用于实践之中而写的。该工具目的在于选定一个可执行文件,并创建挂起的进程,随后将DLL注入到该进程中,利用Process Hollowing技术来实现特定的功能。

3.1 代码注入检测

读完了我们代码注入的基础知识,Hollowing方法可以用下面的WinAPI调用链来描述:

CreateProcess
NtUnmapViewOfSection
VirtualAllocEx
WriteProcessMemory
GetThreadContext
SetThreadContext
ResumeThread

这些调用过程,不必按照特定顺序进行调用。例如,也可以在VirtualAllocEx之前调用GetThreadContext。然而,一些调用需要依赖之前的API调用,也不能将这些调用完全颠倒先后顺序。例如,SetThreadContext必须要在GetThreadContext或CreateProcess之前调用,否则目标进程就不会有Payload注入。该工具以上述内容为运行的基础,试图检测潜在的Process Hollowing过程。
根据API监控的理论,我们最好对较低的位置进行挂钩,但如果考虑到对恶意软件检测的场景,那么我们最理想的是在最低的位置挂钩。最厉害的恶意软件可能会尝试跳过更高级别的WinAPI函数,直接调用通常在ntdll.dll模块中所找到的调用层次结构中最低的函数。以下WinAPI函数是Process Hollowing的调用层次结构最低的函数:

NtCreateUserProcess
NtUnmapViewOfSection
NtAllocateVirtualMemory
NtWriteVirtualMemory
NtGetContextThread
NtSetContextThread
NtResumeThread

3.2 代码注入转储

当我们对必要的函数进行挂钩之后,就会执行目标进程,并记录每个已挂钩函数的参数,这样能跟踪Process Hollowing以及主进程的当前状态。最重要的两个钩子是NtWriteVirtualMemory和NtResumeThread,前者负责在代码注入后进行应用,后者负责执行。除了记录参数之外,UnRunPE还会尝试转储使用NtWriteVirtualMemory写入的字节,然后当到达NtResumeThread时,会尝试转储已经注入主进程的全部Payload。为了完成上述任务,它使用NtCreateUserProcess中记录的进程和线程句柄参数,以及NtUnmapViewOfSection记录的基地址和大小。在这里,如果使用NtAllocateVirtualMemory提供的参数可能更为合适,但实际过程中,由于某些暂不清楚的原因,如果挂钩该函数会在运行过程中出现一些错误。当Payload从NtResumeThread中转储出来后,它将会终止目标进程及其宿主进程,以防止注入的代码被真正执行。

3.3 UnRunPE示例

为了演示,我使用了之前创建的二进制木马文件,它包含主要可执行文件PEview.exe,同时隐藏了可执行文件PuTTY.exe。

 

四、实战:Dreadnought工具

Dreadnought是基于UnRunPE构建的PoC工具,用于更广泛的代码注入检测,也就是我们在本文2.3中讲解的全部代码注入。我们要实现全面的代码注入检测,就需要对工具进行一些强化。

4.1 代码注入检测方法

考虑到当前有很多种代码注入方法,因此必须对不同的代码注入技术进行区分。第一种方法是识别”触发”API调用,即负责远程执行Payload的API调用。共有四种类型:
段(Section):代码作为段被注入/代码被注入到段中。
进程:代码被注入到进程中。
代码:通用代码注入或Shellcode。
DLL:代码作为DLL被注入。

每一个触发API都列在相应的执行下面。当符合这些API中的任何一个时,Dreadought将会执行相应的代码转储方法,该方法与假设的注入类型相匹配,就类似于UnRunPE中Porcess Hollowing的方式。但是,这还远远不够,因为API调用仍然与可能会被混合,实现如箭头所示的功能。

4.2 启发式检测

为了让Dreadnought更准确地确定代码注入的方法,我们应该使用启发式检测。在开发过程中,我们应用了非常简单的启发式方法。在进程注入后,每次对API进行挂钩时,都会增加一个或多个存储在map数据结构中的相关代码注入类型的权重。由于它会跟踪每一个API调用,因此从一开始就有一个最高可能性的注入类型。一旦触发API被输入,它将识别并比较相关类型的权重,并进行调整。

4.3 Dreadnought实战

4.3.1 进程注入 – Process Hollowing

4.3.2 DLL注入 – SetWindowsHookEx

4.3.3 DLL注入 – QueueUserAPC

4.3.4 代码注入 – Atom Bombing



 

五、结论

本文对代码注入的技术进行了讲解,并帮助大家理解了其与WinAPI的交互过程。恶意软件利用注入的方式来绕过反病毒检测,我们可以使用Dreadnought来有效对这种行为进行检测。
然而,Dreadnought仍然存在一些局限性,它的启发式检测设计尚不完善,目前只能用于理论演示的目的,在实际使用中效果可能并不理想。因此,在操作系统正常运行期间,其可能会出现误报与漏报的情况。

 

六、PoC与相关源代码

https://github.com/NtRaiseHardError/UnRunPE
https://github.com/NtRaiseHardError/Dreadnought

 

七、参考

[1] https://www.blackhat.com/presentations/bh-usa-06/BH-US-06-Sotirov.pdf
[2] https://www.codeproject.com/Articles/7914/MessageBoxTimeout-API
[3] https://blog.ensilo.com/atombombing-brand-new-code-injection-for-windows
[4] http://struppigel.blogspot.com.au/2017/07/process-injection-info-graphic.html
[5] https://www.reactos.org/
[6] https://undocumented.ntinternals.net/
[7] http://ntcoder.com/category/undocumented-winapi/
[8] https://github.com/processhacker/processhacker
[9] https://www.youtube.com/channel/UCVFXrUwuWxNlm6UNZtBLJ-A
[10] https://www.youtube.com/channel/UC–DwaiMV-jtO-6EvmKOnqg

(完)