远线程注入深入分析
在一次项目开发过程中,突然想知道CreateRemoteThread在底层是怎么实现的,于是逆了一下这个函数,然后研究了一些远线程注入的检测方案,顺便写了一篇文章记录一下,零环检测方案未完待续…(等有时间一定来!)
CreateRemoteThread深入分析
三环
CreateRemoteThread
当调用CreateRemoteThread
时,windows会通过导入表找到kernel32.dll
的导出函数
但是由于win32增加到64位时对32位下的部分函数进行了优化,在kernel32.dll
里找不到该函数了
通过一些转发dll,最后会在kernelbase.dll
里实现该函数,实际上也是处理了部分参数,然后转发给了CreateRemoteThreadEx
。
重点分析一下3环下的CreateRemoteThreadEx
做了什么
CreateRemoteThreadEx
- 初始化,先进行参数校验,判断是否又非法标志,并调用
BaseFormatObjectAttributes
和BasepConvertWin32AttributeList
进行线程安全属性格式化,转换为内核可识别的结构
- 句柄合法性判断(不能是
INVALID_HANDLE_VALUE
),调用NtDuplicateObject
复制句柄,确保拥有PROCESS_CREATE_THREAD
和PROCESS_QUERY_INFORMATION
权限。调用NtQueryInformationProcess
再次对进程句柄进行合法性判断,不能是自己进程,镜像也不能无效。这里调用的两个函数均可用于远线程注入检测,具体检测方案见下文。
- 查询上下文,确保新线程使用正确的资源清单。
- 调用
NtCreateThreadEx
创建线程,设置TEB
环境的SubProcessTag
为当前线程的SubProcessTag
。至于SubProcessTag
,每当一个服务注册并且运行的时候,Service Control Manager会分配给服务线程一个id,这个id就是SubProcessTag
,它能够唯一的表示服务,而且这个值是一直都被继承的,也就是说,如果服务线程再创建线程,那么新的线程的SubProcessTag
也会被标记为父线程的id。当然,也有一种例外,那就是如果用了线程池,就不会继承SubProcessTag
了。
- 根据之前查询的上下文,调用
RtlAllocateActivationContextStack
和RtlActivateActivationContextEx
分配内存激活上下文
- 通知
CSRSS
,调用NtResumeThread
激活线程执行。若系统配置需要通知CSRSS
(客户端/服务器运行时子系统),通过GetProcAddressForCaller
获取CsrCreateRemoteThread
函数地址并调用,确保CSRSS
跟踪新线程。
NtCreateThreadEx
NtCreateThreadEx
来自ntdll.dll
,通过系统调用进入零环,调用号为0xC1
三环函数调用:
1 | 参数校验 |
零环
NtCreateThreadEx
- 检查当前线程模式是不是KernelMode ,如果不是的话,校验地址,控制用户层的地址空间在最大的合法地址空间内。
- 调用
PspBuildCreateProcessContext
获取创建线程所需要的上下文,该函数在createProcess过程中也用于进程上下文的创建
- 调用
ObpReferenceObjectByHandleWithTag
获取目标句柄的内核对象KProcess
,根据该对象的某些属性来获取扩展上下文长度,再初始化扩展上下文,并设置用户模式上下文,指定入口点为PspUserThreadStart
。
- 调用
PspCreateThread
来创建线程,PspDeleteCreateProcessContext
回收内存。
PspCreateThread
- 初始化PreviousMode,通过线程的ApcState.Process获取当前进程
Process
,如果给了InitialTeb,设置为父线程的Mode,否则为KernelMode。
- 根据句柄获取目标对象,调用
ObpReferenceObjectByHandleWithTag
获取进程对象,获取成功后,将该对象赋予返回参数a5。
- 检测进程是否为当前进程,判断标志位,调用
PspIsProcessReadyForRemoteThread
来检测目标进程是否支持远程插入线程。检测逻辑实质是通过KiStackAttachProcess
挂靠到目标进程,然后检测a1->Flags3
,和a1->Peb->Ldr
,a1->Pcb.SecureState.SecureHandle
这些值。
- 转换
CreateThreadFlags
标志位, 减少KernelApcDisable
计数,防止内核APC中断,ExAcquireRundownProtection_0
确保进程在操作期间不被终止。
- 调用
PspAllocateThread
,PspInsertThread
来初始化线程对象,并插入到目标进程。
接下来这两个函数就更为底层了,如果用作检测和防护远线程注入的话,应该不能深入到这层来做对抗,这里仅分析一下重要的函数调用,理清函数调用关系。
PspAllocateThread
KeInitThread
里调用KeInitializeApc
,KeInitializeEvent
,KeInitializeTimerEx
,KeAbInitializeThreadState
等来初始化线程的一些属性
KiInitializeContextThread
的核心任务是通过精确的内核栈布局和上下文初始化,为线程的首次执行构建完整的机器状态。其关键点包括:栈帧分层设计
用户/内核线程共享基础浮点保存区,但根据类型分配不同的执行帧(陷阱帧、异常帧、启动帧)。执行流控制
通过设置切换帧的返回地址(KiStartUserThread
或KiStartSystemThread
),确保线程首次调度时跳转到正确的入口函数。状态隔离
用户线程显式初始化浮点状态,内核线程默认忽略,减少不必要的状态切换开销。
PspInsertThread
KeStartThread
通过协调进程属性、处理器亲和性、优先级调度及同步机制,确保线程正确初始化并加入调度队列。其核心在于处理资源分配(CPU、优先级)和线程状态管理,依赖内核锁和IRQL控制保证操作的原子性和一致性。
KeReadyThread
->KiFastReadyThread
->KiCheckForThreadDispatch
零环函数调用
1 | NtCreateThreadEx |
根据CreateRemoteThread
在三环和零环下的一些函数调用,可以提出一些对应的检测方案,通过禁止远线程创建从而实现进程保护。
远线程注入的三环检测方案
远线程注入分为两种情况,一种是通过LoadLibrary
函数实现注入dll,一种是直接注入shellCode,然后远线程执行,以下是一些检测的方法。
模块化注入
对于模块化注入,我这里提出了几种方案,分别是:
- 基于DLL_THREAD_ATTACH通知的入口检测
- 基于Hook的LoadLibraryExW参数合法性校验
- 基于白名单的模块加载列表校验
基于DLL_THREAD_ATTACH通知的入口检测
写一个检测DLL,检测线程起始地址是否是LoadLibrary函数。 当进程创建一个线程的时候,系统会检查当前映射到该进程的地址空间中的所有DLL文件映像,并用DLL_THREAD_ATTACH来调用每个DLL的DllMain函数,调用是在新线程的上下文中进行的。新创建的线程负责执行所有DLL的DllMain函数中的代码,只有当所有DLL都完成了对该通知的处理之后,系统才会让新线程开始执行它的线程函数。检测dll核心代码:
1 | BOOL APIENTRY DllMain( HMODULE hModule, |
基于Hook的LoadLibraryExW参数合法性校验
hook本地进程的LoadLibraryExW(更底层且导出的),甚至可以是LdrLoadDll
函数,校验参数是否合法实现检测。这里演示的基于LoadLibraryExW
的hook检测,核心代码如下:
1 | HMODULE MyLoadLibraryExW(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) |
基于白名单的模块加载列表校验
进程往往都有一个白名单的模块列表,可以在程序加载后利用快照获取立刻获取当前白名单模块列表,可以基于这个白名单创建一个监控线程,持续获取当前所有模块信息,如果有,则有被注入过。核心代码如下。
1 | void detect_4() |
效果如下,检测到注入。
骚姿势还有比如hook LoadLibraryExW底层的一些函数(ZwProtectVirtualMemory)等等,在这里就不继续探讨了。
无模块(ShellCode)注入
对于模块化的注入,往往都会被很简单的直接检测得到,所以使用起来局限性很大,现在恶意程序为了避开安全软件的检测,往往会采用无模块注入的方式。对于这种方式,隐蔽性强,在病毒木马以及游戏作弊中都会被常常用到,也是现在安全软件和反作弊程序重点关注的目标。
无模块注入种类大致有两种,ShellCode注入,PE注入,对于ShellCode注入,实现较为简单,仅需提供字节数组然后远线程运行即可,局限性在于没办法调用导入函数,对于PE注入,实现较为复杂,需要手动写一个DLL加载器函数以及准备需要注入的DLL,然后通过ShellCode注入来调用加载器函数,从而让加载器函数对需要注入的DLL内存展开,修复导入表,重定位,Call EntryPoint等,具体细节可以参考这个比较经典的开源项目https://github.com/suvllian/process-inject。
仅在三环下检测这种注入的方法有限,较为常用的是监测所有线程的栈->实时的创建线程快照,监测所有线程栈(函数调用),如果出现某个线程运行在无模块的内存上,即可判定为无模块注入。·当然也可以用基于DLL_THREAD_ATTACH通知的入口检测这种方式,检测线程起始地址是否在所有模块地址空间内。以下是对第一种方式的实现。
监控线程
1 | void detect_4() |
栈回溯获取函数调用信息,这里有个坑,不能直接用句柄来判断是否是当前线程,因为openThread的句柄随机性以及GetCurrentThread获取的是伪句柄(0xFFFFFFFF),尽管指向的线程是一个对象,但是句柄不同导致当前线程被挂起。
1 | BOOL GetCallStack(DWORD tid, std::vector<PVOID>&callStack,int maxDepth = 20) { |
对所有线程进行栈回溯
1 | std::vector<PVOID> GetAllThreadCallStack() { |
结果展示:
远线程注入的零环检测方案
这里仅对无模块注入方案进行进行延申和补充,就不对模块化注入再进行探讨了。
待续…