Black Hat议题解读 | 进程注入-基于Windows线程池的隐蔽注入技术剖析
概述
在2023年12月份的Black Hat大会上, 安全研究人员Alon Leviev通过对Windows用户模式线程池内部结构的研究,演示了攻击者如何接管线程池,并将Windows线程池支持的的工作项插入系统中的任意进程,并由此提出了基于Windows线程池的8种变体新进程注入技术,下面就其中涉及的技术点展开介绍.
进程注入
进程注入
作为一种在目标进程中执行任意代码的规避技术,通常由下述3个基础原语构成的链式组合
实现.
- 内存分配原语:用于在目标进程内分配可写/可执行的内存区域.
- 代码写入原语:负责将恶意代码(如ShellCode)写入已分配的内存空间.
- 执行触发原语:通过线程操控、回调函数劫持等方式,触发恶意代码执行.
最基本的注入是使用VirtualAllocEX
进行分配,使用WriteProcessMemory
进行写入,使用CreateRemoteThread
进行执行.这种技术称之为CreateRemoteThread 注入
,非常简单且功能强大,但它有一个缺点:会被所有EDR检测到.
EDR检测方法
经实验验证,针对进程注入攻击中涉及的底层操作原语,EDR的检测机制呈现如下特征:
当前主流EDR产品的检测逻辑高度聚焦于执行阶段的行为特征(执行触发原语)
,而常规形态的内存分配与代码写入原语未被纳入有效检测范围
.
基于上述发现,进行下述思考:
- 如果我们构建
仅依赖内存分配与代码写入原语
的执行触发机制,会产生何种效果? - 如果
执行触发原语
由合法行为触发(如向无害文件写入数据),又会产生何种影响?
上述两种进程注入攻击模式的演变,将使注入攻击的检测难度显著提升!
Windows线程池架构
线程池主要由以下内容组成:
- 3个独立的工作队列(Work Queues),每个队列处理不同类型的工作项.
- 普通任务队列:通过
SubmitThreadpoolWork
提交的常规异步任务. - 定时器队列:通过
CreateThreadpoolTimer
管理周期性或延迟任务. - I/O完成队列:与I/O完成端口绑定,处理异步I/O操作.
- 普通任务队列:通过
- 工作线程(Worker Threads),负责从不同队列中取出工作项并执行.
- 工作线程工人工厂(Worker Factory),负责管理工作线程的创建、调度以及生命周期.
攻击工人工厂
工人工厂是一个Windows对象,负责管理线程池中的工作线程.它通过监控活动或阻塞的工作线程来管理工作线程,并根据监控结果创建或终止工作线程.工人工厂不执行任何工作项的调度或执行
,它的作用是确保有足够数量的工人线程
。
内核提供了7个系统调用,用于与工人工厂对象交互:
- NtCreateWorkerFactory
- NtShutdownWorkerFactory
- NtQueryInformationWorkerFactory
- NtSetInformationWorkerFactory
- NtWorkerFactoryWorkerReady
- NtWaitForWorkViaWorkerFactory
- NtReleaseWorkerFactoryWorker
线程池注入变体1:覆写工人工厂启动例程(StartRoutine)
NtCreateWorkerFactory函数用来创建工人工厂,其声明如下:
StartRoutine
在工人工厂创建时被赋值,是工人线程的入口,通常该例程充当线程池调度器,负责执行和释放工作项.
提示
StartRoutine
如果被恶意代码地址覆写,当新的工作线程被创建时,就会执行恶意代码.
那么如何获取目标进程工人工厂中的StartRoutine地址?
-
使用
DuplicateHandle
来访问属于目标进程的工人工厂.(所有进程默认有一个线程池,因此也默认有一个工人工厂) -
调用
NtQueryWorkerFactoryInformation
来获取工人工厂信息. -
工人工厂信息中包含了
StartRoutine
地址.
有了目标进程工人工厂中的StartRoutine
地址,我们就可以使用恶意Shellcode进行覆写.
StartRoutine
肯定会在一时刻被执行,但我们如何主动触发?
将最小工作线程数设置为当前运行线程数+1,会导致创建一个新的工作线程,这意味着StartRoutine
已被执行.
攻击线程池
当工作项
被正确插入到工作队列
时,就会被工作线程
执行.因此在线程池攻击中,重点关注工作项是如何插入到工作队列
中的!
工作项
Windows线程池支持的工作项
类型分为3类:
常规工作项
: 通过队列API调用立即加入队列.异步工作项
: 在特定操作(如写入文件)完成时,被加入队列,随后由工作线程处理.定时器工作项
: 通过队列API调用立即加入队列,但需等待关联的定时器到期后才会被触发执行.
队列
针对3种不同类型的工作项,线程池中对应存在3种队列:
普通任务队列
: 常规工作项会加入其中,该队列位于主线程结构(TP_POOL
)种.I/O完成队列
: 异步工作项会加入其中,该队列是一个Windows对象.定时器队列
: 定时器工作项会加入其中,该队列同样位于主线程结构(TP_POOL
)内.
主线程池结构(如任务队列和定时器队列)位于用户模式下的进程内存地址空间中,可以通过内存写入原语对其队列进行修改.
I/O完成队列是Windows内核对象,其作用是为已完成的I/O操作提供队列支持.当I/O操作完成时,相关通知会被插入该队列.
可通过下述系统调用与I/O完成队列进行交互.
- NtCreateIoCompletion
- NtOpenIoCompletion
- NtQueryIoCompletion
- NtQueryIoCompletionEx
- NtSetIoCompletion
- NtSetIoCompletionEx
- NtRemoveIoCompletion
- NtRemoveIoCompletionEx
注意
微软将I/O完成队列称为I/O完成端口.该对象本质上是一个内核队列,因此为了避免混淆,我们将其称为I/O完成队列.
线程池注入变体2:TP_WORK
常规工作项-TP_WORK
的调用代码如下:
|
|
通过跟进kernel32::CreateThreadpoolWork->ntdll::TpAllocWork
函数,可得到TP_WORK
结构如下:
负责提交TP_WORK
的API为SubmitThreadpoolWork
,通过跟进kernel32::SubmitThreadpoolWork->ntdll::TpPostWork->ntdll::TppWorkPost->ntdll::TpPostTask
,可定位常规工作项向任务队列(双向链表)插入的核心代码.
TpPool是目标进程的线程池结构,可通过调用NtQueryInformationWorkerFactory
获取目标进程工人工厂信息中的StartParameter
得到,StartParameter
本质上是一个指向TP_POOL结构体的指针
.
|
|
至此,得到了TP_WORK
被插入任务队列的全部信息.
线程池注入变体2:TP_WORK
流程如下:
-
调用
DuplicateHandle
复制目标进程TpWorkerFactory
句柄. -
调用
VirtualAllocEx
以及WriteProcessMemory
将Shellcode写入目标进程地址空间. -
创建和Shellcode相关联的TP_WORK结构.
-
调用
NtQueryInformationWorkerFactory
获取工人工厂信息. -
调用
ReadProcessMemory
读取工人工厂信息中的StartParameter参数(即目标进程的线程池结构TpPool). -
调用
CreateThreadpoolWork
创建一个和Shellcode相关联的TP_WORK结构.
-
-
修改TP_WORK结构,和目标进程线程池结构进行关联.
-
将常规工作项加入到目标线程池-常规任务队列,进而被工作线程执行Shellcode.
- 调用
VirtualAllocEx
以及WriteProcessMemory
将修改后的TP_WORK结构
写入目标进程地址空间. - 调用
WriteProcessMemory
修改目标进程线程池-常规任务队列结构入口,指向上述伪造的TP_WORK结构.
- 调用
线程池注入变体3:TP_IO
异步工作项-TP_IO
调用代码如下:
|
|
通过跟进kernel32::CreateThreadpoolIo->ntdll::TpAllocIoCompletion
函数,可得到TP_IO
结构如下:
继续跟进ntdll::TpAllocIoCompletion->ntdll::TpBindFileToDirect
,该函数将文件完成队列设置为线程池的I/O完成队列,并将文件完成键设置为直接结构.
用于启动异步I/O操作的API为StartThreadpoolIo
,它告诉线程池监视指定的I/O对象,并在I/O操作完成时调用回调函数.
通过跟进kernel32::StartThreadpoolIo->ntdll::TpStartAsyncIoOperation
,可定位启动异步I/O操作的核心代码.
上述函数调用后,对文件进行任何的操作(如调用WriteFile)都会导致完成键进行I/O完成队列.
线程池注入变体3:TP_IO
流程如下:
-
调用
DuplicateHandle
复制目标进程IoCompletion
句柄. -
调用
VirtualAllocEx
以及WriteProcessMemory
将Shellcode写入目标进程地址空间. -
创建和
文件对象
以及Shellcode
相关联的TP_IO结构
.-
调用
CreateFile
创建一个文件对象. -
调用
CreateThreadpoolIo
创建一个和上述文件对象以及Shellcode相关联的TP_IO结构.
-
-
将
文件对象
与目标进程I/O完成队列
相关联.-
修改
TP_IO结构字段
,模拟StartThreadpoolIo
操作. -
调用
VirtualAllocEx
以及WriteProcessMemory
将修改后的TP_IO结构
写入目标进程地址空间. -
调用
ZwSetInformationFile
将上述文件句柄与目标进程I/O完成队列相关联.
-
-
调用
WriteFile
,触发入队操作,进而被工作线程执行Shellcode.
线程池注入变体4:TP_WAIT
异步工作项-TP_WAIT
调用代码如下:
|
|
通过跟进kernel32::CreateThreadpoolWait->ntdll::TpAllocWait
函数,可得到TP_WAIT
结构如下:
通过跟进kernel32::SetThreadpoolWait->ntdll::TpSetWait->ntdll::TpSetWaitEx->ntdll::TppSetupNextWait->ntdll::ZwAssociateWaitCompletionPacket
,可定位TP_WAIT结构
与系统I/O完成队列
相关联的核心操作
线程池注入变体4:TP_WAIT
流程如下:
-
调用
DuplicateHandle
复制目标进程IoCompletion
句柄. -
调用
VirtualAllocEx
以及WriteProcessMemory
将Shellcode写入目标进程地址空间. -
调用
CreateThreadpoolWait
创建一个和Shellcode相关联的TP_WAIT结构. -
将
事件句柄
与目标进程I/O完成队列
相关联.-
调用
VirtualAllocEx
以及WriteProcessMemory
将上述TP_WAIT结构
写入目标进程地址空间. -
调用
VirtualAllocEx
以及WriteProcessMemory
将上述TP_WAIT中的TP_DIRECT结构
写入目标进程地址空间. -
调用
CreateEvent
创建一个事件. -
调用
ZwAssociateWaitCompletionPacket
将上述事件句柄与目标进程I/O完成队列相关联.
-
-
调用
SetEvent
发送信号,触发入队操作,进而被工作线程执行Shellcode.
线程池注入变体5:TP_JOB
线程池注入变体5:TP_JOB
流程如下:
-
调用
DuplicateHandle
复制目标进程IoCompletion
句柄. -
调用
VirtualAllocEx
以及WriteProcessMemory
将Shellcode写入目标进程地址空间. -
创建和
JOB对象
以及Shellcode
相关联的TP_JOB结构
.-
调用
CreateJobObject
创建一个JOB对象. -
调用
TpAllocJobNotification
创建一个上述JOB对象以及Shellcode相关联的TP_JOB结构.
-
-
将
JOB对象
与目标进程I/O完成队列
相关联.-
调用
VirtualAllocEx
以及WriteProcessMemory
将上述TP_JOB结构
写入目标进程地址空间. -
调用
SetInformationJobObject
,将上述JOB对象与目标进程I/O完成队列相关联.
-
-
调用
AssignProcessToJobObject
将JOB对象与当前进程相关联,触发入队操作,进而被工作线程执行Shellcode.
线程池注入变体6:TP_ALPC
线程池注入变体6:TP_ALPC
流程如下:
-
调用
DuplicateHandle
复制目标进程IoCompletion
句柄. -
调用
VirtualAllocEx
以及WriteProcessMemory
将Shellcode写入目标进程地址空间. -
调用
NtAlpcCreatePort
以及TpAllocAlpcCompletion
创建一个和Shellcode相关联的TP_ALPC结构. -
将
APLC端口
与目标进程I/O完成队列
相关联.-
调用
NtAlpcCreatePort
创建一个APLC端口. -
调用
VirtualAllocEx
以及WriteProcessMemory
将上述TP_ALPC结构
写入目标进程地址空间. -
调用
NtAlpcSetInformation
将上述APLC端口与目标进程I/O完成队列相关联.
-
-
调用
NtAlpcConnectPort
连接ALPC端口,触发入队操作,进而被工作线程执行Shellcode.
线程池注入变体7:TP_DIRECT
异步工作项
排队进入I/O完成队列
的核心是其中的TP_DIRECT结构
,因此我们可以直接注入恶意TP_DIRECT结构
,无需Windows对象进行代理,然后调用系统NtSetIoCompletion
完成入队操作.
线程池注入变体7:TP_DIRECT
流程如下:
- 调用
DuplicateHandle
复制目标进程IoCompletion
句柄. - 调用
VirtualAllocEx
以及WriteProcessMemory
将Shellcode写入目标进程地址空间. - 创建
恶意TP_DIRECT结构
与上述Shellcode相关联. - 调用
VirtualAllocEx
以及WriteProcessMemory
将上述TP_DIRECT结构
写入目标进程地址空间. - 调用
ZwSetIoCompletion
完成入队,触发Shellcode执行.
线程池注入变体8:TP_TIMER
定时器工作项-TP_TIMER
的调用代码如下:
|
|
通过跟进kernel32::CreateThreadpoolTimer->ntdll::TpAllocTimer
函数,可得到TP_TIMER
结构如下:
通过跟进kernel32::SetThreadpoolTimer->ntdll::TpSetTimer->ntdll::TpSetTimerEx->ntdll::TppSetTimer->ntdll::TppEnqueueTimer
,可定位到TP_TIMER结构向定时器队列插入的核心代码.
TppEnqueueTimer函数将TP_TIMER的WindowStart链接
插入定时器队列的WindowStart字段
,将WindowEnd链接
插入队列的WindowEnd字段
.
通过跟进kernel32::SetThreadpoolTimer->ntdll::TpSetTimer->ntdll::TpSetTimerEx->ntdll::TppSetTimer->ntdll::TppUpdateSubQueueTimer
,可定位到对定时器队列中的定时器进行配置代码.
上述两个操作过后, 一旦定时器过期,就会执行定时器工作项中的回调.
线程池注入变体8:TP_TIMER
流程如下:
- 调用
DuplicateHandle
复制目标进程TpWorkerFactory以及IRTimer
句柄. - 调用
VirtualAllocEx
以及WriteProcessMemory
将Shellcode写入目标进程地址空间. - 调用
CreateThreadpoolTimer
创建和Shellcode相关联的TP_TIMER结构. - 调用
VirtualAllocEx
以及WriteProcessMemory
将修改后的TP_TIMER结构
写入目标进程地址空间. - 调用
WriteProcessMemory
修改目标进程定时器队列使其指向上述伪造的TP_TIMER结构. - 调用
NtSetTimer2
,使队列的定时对象过期,触发入队操作,进而被工作线程执行Shellcode.
总结
PoolParty技术并非传统意义上的漏洞利用,而是对Windows合法组件-用户态线程池的滥用.其本质是通过逆向工程揭示了Windows线程池内部的工作队列(普通任务队列、I/O完成队列、定时器队列)与工人工厂的交互机制,并篡改其核心逻辑:
- 工人工厂劫持:通过系统调用
NtQueryWorkerFactoryInformation
,获取了目标进程-工人工厂的启动例程地址并调用WriteProcessMemory
进行覆写,将原本用于管理线程的合法代码替换为恶意Shellcode,然后调用NtSetInformationWorkerFactory
设置目标进程的工人工厂最小线程数为当前线程数加1,强制新增线程,进而执行恶意Shellcode. - 工作项伪造:通过插入伪造的线程池工作项(如TP_WORK、TP_IO、TP_WAIT、…),将恶意Shellcode嵌入到Windows系统预设的异步任务流程,利用线程池自身的调度机制触发Shellcode执行.
上述技术突破了传统进程注入对CreateRemoteThread
等显示执行触发原语
的依赖,将恶意代码的执行逻辑嫁接到了Windows线程池的底层调度体系中,使攻击行为内化到操作系统自身的线程管理中.
参考链接
永生难忘的线程池派对:使用Windows线程池的新进程注入技术