远线程注入深入分析

在一次项目开发过程中,突然想知道CreateRemoteThread在底层是怎么实现的,于是逆了一下这个函数,然后研究了一些远线程注入的检测方案,顺便写了一篇文章记录一下,零环检测方案未完待续…(等有时间一定来!)

CreateRemoteThread深入分析

三环

CreateRemoteThread

当调用CreateRemoteThread时,windows会通过导入表找到kernel32.dll的导出函数

image-20250416234215134

但是由于win32增加到64位时对32位下的部分函数进行了优化,在kernel32.dll里找不到该函数了

image-20250416234447017

通过一些转发dll,最后会在kernelbase.dll里实现该函数,实际上也是处理了部分参数,然后转发给了CreateRemoteThreadEx

image-20250416234734615

重点分析一下3环下的CreateRemoteThreadEx做了什么

CreateRemoteThreadEx

  1. 初始化,先进行参数校验,判断是否又非法标志,并调用BaseFormatObjectAttributesBasepConvertWin32AttributeList进行线程安全属性格式化,转换为内核可识别的结构

image-20250417000351606

image-20250417000520727

  1. 句柄合法性判断(不能是INVALID_HANDLE_VALUE),调用NtDuplicateObject复制句柄,确保拥有PROCESS_CREATE_THREADPROCESS_QUERY_INFORMATION 权限。调用NtQueryInformationProcess再次对进程句柄进行合法性判断,不能是自己进程,镜像也不能无效。这里调用的两个函数均可用于远线程注入检测,具体检测方案见下文。

image-20250417000751601

  1. 查询上下文,确保新线程使用正确的资源清单。

image-20250417001315940

  1. 调用NtCreateThreadEx创建线程,设置TEB环境的SubProcessTag为当前线程的SubProcessTag。至于SubProcessTag,每当一个服务注册并且运行的时候,Service Control Manager会分配给服务线程一个id,这个id就是SubProcessTag,它能够唯一的表示服务,而且这个值是一直都被继承的,也就是说,如果服务线程再创建线程,那么新的线程的SubProcessTag也会被标记为父线程的id。当然,也有一种例外,那就是如果用了线程池,就不会继承SubProcessTag了。

image-20250417001837321

  1. 根据之前查询的上下文,调用RtlAllocateActivationContextStackRtlActivateActivationContextEx分配内存激活上下文

image-20250417002204976

  1. 通知 CSRSS调用NtResumeThread激活线程执行。若系统配置需要通知 CSRSS(客户端/服务器运行时子系统),通过 GetProcAddressForCaller 获取 CsrCreateRemoteThread 函数地址并调用,确保 CSRSS 跟踪新线程。

image-20250417002409680

NtCreateThreadEx

NtCreateThreadEx来自ntdll.dll,通过系统调用进入零环,调用号为0xC1

image-20250416235359122

三环函数调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
参数校验

格式化安全属性 (BaseFormatObjectAttributes)

转换线程属性列表 (BasepConvertWin32AttributeList)

复制进程句柄 (NtDuplicateObject)

查询进程信息 (NtQueryInformationProcess)

处理激活上下文 (RtlQuery/Allocate/Activate)

创建线程 (NtCreateThreadEx)

通知 CSRSS (CsrCreateRemoteThread)

恢复线程 (NtResumeThread)

返回句柄或错误

零环

NtCreateThreadEx

  1. 检查当前线程模式是不是KernelMode ,如果不是的话,校验地址,控制用户层的地址空间在最大的合法地址空间内。

image-20250417095708153

  1. 调用PspBuildCreateProcessContext获取创建线程所需要的上下文,该函数在createProcess过程中也用于进程上下文的创建

image-20250417100102429

  1. 调用ObpReferenceObjectByHandleWithTag获取目标句柄的内核对象KProcess,根据该对象的某些属性来获取扩展上下文长度,再初始化扩展上下文,并设置用户模式上下文,指定入口点为PspUserThreadStart

image-20250417100223226

  1. 调用PspCreateThread来创建线程PspDeleteCreateProcessContext回收内存。

image-20250417100425143

PspCreateThread

  1. 初始化PreviousMode,通过线程的ApcState.Process获取当前进程Process,如果给了InitialTeb,设置为父线程的Mode,否则为KernelMode。

image-20250417101111497

  1. 根据句柄获取目标对象,调用ObpReferenceObjectByHandleWithTag获取进程对象,获取成功后,将该对象赋予返回参数a5。

image-20250417101218143

  1. 检测进程是否为当前进程,判断标志位,调用PspIsProcessReadyForRemoteThread来检测目标进程是否支持远程插入线程。检测逻辑实质是通过KiStackAttachProcess挂靠到目标进程,然后检测a1->Flags3,和a1->Peb->Ldra1->Pcb.SecureState.SecureHandle这些值。

image-20250417154000321

  1. 转换CreateThreadFlags标志位, 减少KernelApcDisable计数,防止内核APC中断,ExAcquireRundownProtection_0确保进程在操作期间不被终止。

image-20250417154900044

  1. 调用PspAllocateThreadPspInsertThread来初始化线程对象,并插入到目标进程。

image-20250417155044290

接下来这两个函数就更为底层了,如果用作检测和防护远线程注入的话,应该不能深入到这层来做对抗,这里仅分析一下重要的函数调用,理清函数调用关系。

PspAllocateThread

  1. KeInitThread里调用KeInitializeApcKeInitializeEventKeInitializeTimerExKeAbInitializeThreadState等来初始化线程的一些属性

image-20250417161314797

  1. KiInitializeContextThread 的核心任务是通过精确的内核栈布局和上下文初始化,为线程的首次执行构建完整的机器状态。其关键点包括:

  2. 栈帧分层设计
    用户/内核线程共享基础浮点保存区,但根据类型分配不同的执行帧(陷阱帧、异常帧、启动帧)。

  3. 执行流控制
    通过设置切换帧的返回地址(KiStartUserThreadKiStartSystemThread),确保线程首次调度时跳转到正确的入口函数。

  4. 状态隔离
    用户线程显式初始化浮点状态,内核线程默认忽略,减少不必要的状态切换开销。

image-20250417161157350

PspInsertThread

  1. KeStartThread通过协调进程属性、处理器亲和性、优先级调度及同步机制,确保线程正确初始化并加入调度队列。其核心在于处理资源分配(CPU、优先级)和线程状态管理,依赖内核锁和IRQL控制保证操作的原子性和一致性。

image-20250417162738374

  1. KeReadyThread->KiFastReadyThread->KiCheckForThreadDispatch

零环函数调用

1
2
3
4
5
6
7
8
9
NtCreateThreadEx

PspCreateThread

PspAllocateThread

PspInsertThread

KeStartThread

根据CreateRemoteThread在三环和零环下的一些函数调用,可以提出一些对应的检测方案,通过禁止远线程创建从而实现进程保护。

远线程注入的三环检测方案

远线程注入分为两种情况,一种是通过LoadLibrary函数实现注入dll,一种是直接注入shellCode,然后远线程执行,以下是一些检测的方法。

模块化注入

对于模块化注入,我这里提出了几种方案,分别是:

  1. 基于DLL_THREAD_ATTACH通知的入口检测
  2. 基于Hook的LoadLibraryExW参数合法性校验
  3. 基于白名单的模块加载列表校验

基于DLL_THREAD_ATTACH通知的入口检测

写一个检测DLL,检测线程起始地址是否是LoadLibrary函数。 当进程创建一个线程的时候,系统会检查当前映射到该进程的地址空间中的所有DLL文件映像,并用DLL_THREAD_ATTACH来调用每个DLL的DllMain函数,调用是在新线程的上下文中进行的。新创建的线程负责执行所有DLL的DllMain函数中的代码,只有当所有DLL都完成了对该通知的处理之后,系统才会让新线程开始执行它的线程函数。检测dll核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
DWORD dwStartAddr;
DWORD retLen = 0;
NTSTATUS st = STATUS_UNSUCCESSFUL;
pZwQueryInformationThread ZwQueryInformationThread = nullptr;
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
ZwQueryInformationThread = (pZwQueryInformationThread)GetProcAddress(GetModuleHandleA("ntdll.dll"), "ZwQueryInformationThread");

st = ZwQueryInformationThread(GetCurrentThread(), ThreadQuerySetWin32StartAddress, &dwStartAddr, sizeof(PVOID), &retLen);
if (st == STATUS_SUCCESS)
{
if(dwStartAddr == (DWORD)&LoadLibraryA || dwStartAddr == (DWORD)&LoadLibraryW)
MessageBox(NULL, L"detect remote inject", NULL, NULL);
}

break;
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

image-20250417223342894

基于Hook的LoadLibraryExW参数合法性校验

hook本地进程的LoadLibraryExW(更底层且导出的),甚至可以是LdrLoadDll函数,校验参数是否合法实现检测。这里演示的基于LoadLibraryExW的hook检测,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
HMODULE MyLoadLibraryExW(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags)
{
printf("lpLibFileName:%ls\n", lpLibFileName);
if(lpLibFileName != ...)
{
exit(0);
}
else
{
return pLoadLibrary(lpLibFileName, hFile, dwFlags);
}
}

image-20250418143745565

基于白名单的模块加载列表校验

进程往往都有一个白名单的模块列表,可以在程序加载后利用快照获取立刻获取当前白名单模块列表,可以基于这个白名单创建一个监控线程,持续获取当前所有模块信息,如果有,则有被注入过。核心代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void detect_4()
{
vector<string> moduleList;
while (1)
{
moduleList.clear();
if (GetModuleList(moduleList))
{
for (auto moduleName : moduleList)
{
BOOL isBlack = TRUE;
for (auto whiteModule : WhiteModuleList)
{
if (moduleName == whiteModule) {
isBlack = FALSE;
break;
}
}
if (isBlack)
{
cout << "[+] find black Module " << moduleName << endl;
exit(0);
}
}
}
}
}

效果如下,检测到注入。

image-20250420194645152

骚姿势还有比如hook LoadLibraryExW底层的一些函数(ZwProtectVirtualMemory)等等,在这里就不继续探讨了。

无模块(ShellCode)注入

对于模块化的注入,往往都会被很简单的直接检测得到,所以使用起来局限性很大,现在恶意程序为了避开安全软件的检测,往往会采用无模块注入的方式。对于这种方式,隐蔽性强,在病毒木马以及游戏作弊中都会被常常用到,也是现在安全软件和反作弊程序重点关注的目标。

无模块注入种类大致有两种,ShellCode注入,PE注入,对于ShellCode注入,实现较为简单,仅需提供字节数组然后远线程运行即可,局限性在于没办法调用导入函数,对于PE注入,实现较为复杂,需要手动写一个DLL加载器函数以及准备需要注入的DLL,然后通过ShellCode注入来调用加载器函数,从而让加载器函数对需要注入的DLL内存展开,修复导入表,重定位,Call EntryPoint等,具体细节可以参考这个比较经典的开源项目https://github.com/suvllian/process-inject。

仅在三环下检测这种注入的方法有限,较为常用的是监测所有线程的栈->实时的创建线程快照,监测所有线程栈(函数调用),如果出现某个线程运行在无模块的内存上,即可判定为无模块注入。·当然也可以用基于DLL_THREAD_ATTACH通知的入口检测这种方式,检测线程起始地址是否在所有模块地址空间内。以下是对第一种方式的实现。

监控线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void detect_4()
{
while (1)
{
vector<PVOID> allThreadCallStack = GetAllThreadCallStack();
for (auto addr : allThreadCallStack)
{
string moduleName;
if (GetModuleNameFromAddress(addr, moduleName))
{
bool is = false;

for (auto i : WhiteModuleList)
{
if (moduleName == i)
{
is = true;
break;
}
}
if (!is) cout << "[+] find black moudule " << moduleName << endl;
}
else if (addr != 0)
{
cout << "[+] find None Module " << hex << addr << endl;
}

}
Sleep(1000);
}
}

栈回溯获取函数调用信息,这里有个坑,不能直接用句柄来判断是否是当前线程,因为openThread的句柄随机性以及GetCurrentThread获取的是伪句柄(0xFFFFFFFF),尽管指向的线程是一个对象,但是句柄不同导致当前线程被挂起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
BOOL GetCallStack(DWORD tid, std::vector<PVOID>&callStack,int maxDepth = 20) {
PVOID* mEbp;
BOOL ret = FALSE;

if (tid == GetCurrentThreadId())//threadId判断是否为当前线程,如果是,则不挂起
{
#if defined(_M_IX86)
__asm mov mEbp, ebp
#elif defined(_M_X64)
mEbp = (PVOID*)__readgsqword(0x20);
#endif
for (int i = 0; i < maxDepth && mEbp; ++i) {
PVOID retAddr = *(mEbp + 1); // 返回地址位于基址+1位置
callStack.push_back(retAddr);
mEbp = (PVOID*)(*mEbp); // 跳转到上一帧基址
if (!mEbp || mEbp == (PVOID*)0xFFFFFFFF) break;
}
ret = TRUE;
}
else
{
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, tid);
SuspendThread(hThread);
CONTEXT context = { 0 };
context.ContextFlags = CONTEXT_ALL;
if (GetThreadContext(hThread, &context))
{
mEbp = (PVOID*)context.Ebp;
for (int i = 0; i < maxDepth && mEbp; ++i) {
PVOID retAddr = *(mEbp + 1); // 返回地址位于基址+1位置
callStack.push_back(retAddr);
mEbp = (PVOID*)(*mEbp); // 跳转到上一帧基址
if (!mEbp || mEbp == (PVOID*)0xFFFFFFFF) break;
}
ret = TRUE;
}
ResumeThread(hThread);
CloseHandle(hThread);
}
return ret;
}

对所有线程进行栈回溯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
std::vector<PVOID> GetAllThreadCallStack() {
std::vector<PVOID> ret;
HANDLE hThreadSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hThreadSnapshot != INVALID_HANDLE_VALUE) {
THREADENTRY32 threadEntry;
threadEntry.dwSize = sizeof(THREADENTRY32);
if (Thread32First(hThreadSnapshot, &threadEntry)) {
do {
if (threadEntry.th32OwnerProcessID == GetCurrentProcessId()) {
auto tid = threadEntry.th32ThreadID;
std::vector<PVOID> callStack;
GetCallStack(tid, callStack);
for (auto retAddr : callStack)
{
ret.emplace_back(retAddr);
}
}
} while (Thread32Next(hThreadSnapshot, &threadEntry));
}
CloseHandle(hThreadSnapshot);
}
return ret;
}

结果展示:

image-20250421211410734

远线程注入的零环检测方案

这里仅对无模块注入方案进行进行延申和补充,就不对模块化注入再进行探讨了。

待续…

参考资料

远程线程注入

APC与Early Bird注入