Black Hat议题解读 | 进程注入-基于Windows线程池的隐蔽注入技术剖析

概述

在2023年12月份的Black Hat大会上, 安全研究人员Alon Leviev通过对Windows用户模式线程池内部结构的研究,演示了攻击者如何接管线程池,并将Windows线程池支持的的工作项插入系统中的任意进程,并由此提出了基于Windows线程池的8种变体新进程注入技术,下面就其中涉及的技术点展开介绍.

进程注入

进程注入作为一种在目标进程中执行任意代码的规避技术,通常由下述3个基础原语构成的链式组合实现.

  • 内存分配原语:用于在目标进程内分配可写/可执行的内存区域.
  • 代码写入原语:负责将恶意代码(如ShellCode)写入已分配的内存空间.
  • 执行触发原语:通过线程操控、回调函数劫持等方式,触发恶意代码执行.

最基本的注入是使用VirtualAllocEX进行分配,使用WriteProcessMemory进行写入,使用CreateRemoteThread进行执行.这种技术称之为CreateRemoteThread 注入,非常简单且功能强大,但它有一个缺点:会被所有EDR检测到.

EDR检测方法

经实验验证,针对进程注入攻击中涉及的底层操作原语,EDR的检测机制呈现如下特征:

当前主流EDR产品的检测逻辑高度聚焦于执行阶段的行为特征(执行触发原语),而常规形态的内存分配与代码写入原语未被纳入有效检测范围.

EDR检测方法

基于上述发现,进行下述思考:

  • 如果我们构建仅依赖内存分配与代码写入原语的执行触发机制,会产生何种效果?
  • 如果执行触发原语由合法行为触发(如向无害文件写入数据),又会产生何种影响?

上述两种进程注入攻击模式的演变,将使注入攻击的检测难度显著提升!

Windows线程池架构

线程池主要由以下内容组成:

  1. 3个独立的工作队列(Work Queues),每个队列处理不同类型的工作项.
    • 普通任务队列:通过SubmitThreadpoolWork提交的常规异步任务.
    • 定时器队列:通过CreateThreadpoolTimer管理周期性或延迟任务.
    • I/O完成队列:与I/O完成端口绑定,处理异步I/O操作.
  2. 工作线程(Worker Threads),负责从不同队列中取出工作项并执行.
  3. 工作线程工人工厂(Worker Factory),负责管理工作线程的创建、调度以及生命周期.

线程池架构

攻击工人工厂

工人工厂是一个Windows对象,负责管理线程池中的工作线程.它通过监控活动或阻塞的工作线程来管理工作线程,并根据监控结果创建或终止工作线程.工人工厂不执行任何工作项的调度或执行,它的作用是确保有足够数量的工人线程

工人工厂

内核提供了7个系统调用,用于与工人工厂对象交互:

  • NtCreateWorkerFactory
  • NtShutdownWorkerFactory
  • NtQueryInformationWorkerFactory
  • NtSetInformationWorkerFactory
  • NtWorkerFactoryWorkerReady
  • NtWaitForWorkViaWorkerFactory
  • NtReleaseWorkerFactoryWorker

线程池注入变体1:覆写工人工厂启动例程(StartRoutine)

NtCreateWorkerFactory函数用来创建工人工厂,其声明如下:

NtCreateWorkerFactory

StartRoutine在工人工厂创建时被赋值,是工人线程的入口,通常该例程充当线程池调度器,负责执行和释放工作项.

提示

StartRoutine如果被恶意代码地址覆写,当新的工作线程被创建时,就会执行恶意代码.

那么如何获取目标进程工人工厂中的StartRoutine地址?

  1. 使用DuplicateHandle来访问属于目标进程的工人工厂.(所有进程默认有一个线程池,因此也默认有一个工人工厂)

  2. 调用NtQueryWorkerFactoryInformation来获取工人工厂信息.

    NtQueryWorkerFactoryInformation

    QUERY_WORKERFACTORYINFOCLASS

  3. 工人工厂信息中包含了StartRoutine地址.

    WORKER_FACTORY_BASIC_INFORMATION

有了目标进程工人工厂中的StartRoutine地址,我们就可以使用恶意Shellcode进行覆写.

StartRoutine肯定会在一时刻被执行,但我们如何主动触发?

  1. 获取当前工人工厂线程数并将其加1.

  2. 调用NtSetInformationWorkerFactory进行工人工厂最小线程数设置.

    NtSetInformationWorkerFactory

    WorkerFactoryThreadMinimum

将最小工作线程数设置为当前运行线程数+1,会导致创建一个新的工作线程,这意味着StartRoutine已被执行.

线程执行

攻击线程池

工作项被正确插入到工作队列时,就会被工作线程执行.因此在线程池攻击中,重点关注工作项是如何插入到工作队列中的!

工作项

Windows线程池支持的工作项类型分为3类:

  • 常规工作项: 通过队列API调用立即加入队列.
  • 异步工作项: 在特定操作(如写入文件)完成时,被加入队列,随后由工作线程处理.
  • 定时器工作项: 通过队列API调用立即加入队列,但需等待关联的定时器到期后才会被触发执行.

工作项类型

队列

针对3种不同类型的工作项,线程池中对应存在3种队列:

  • 普通任务队列: 常规工作项会加入其中,该队列位于主线程结构(TP_POOL)种.
  • I/O完成队列: 异步工作项会加入其中,该队列是一个Windows对象.
  • 定时器队列: 定时器工作项会加入其中,该队列同样位于主线程结构(TP_POOL)内.

主线程池结构(如任务队列和定时器队列)位于用户模式下的进程内存地址空间中,可以通过内存写入原语对其队列进行修改.

I/O完成队列是Windows内核对象,其作用是为已完成的I/O操作提供队列支持.当I/O操作完成时,相关通知会被插入该队列.

IO完成队列

可通过下述系统调用与I/O完成队列进行交互.

  • NtCreateIoCompletion
  • NtOpenIoCompletion
  • NtQueryIoCompletion
  • NtQueryIoCompletionEx
  • NtSetIoCompletion
  • NtSetIoCompletionEx
  • NtRemoveIoCompletion
  • NtRemoveIoCompletionEx

注意

微软将I/O完成队列称为I/O完成端口.该对象本质上是一个内核队列,因此为了避免混淆,我们将其称为I/O完成队列.

线程池注入变体2:TP_WORK

常规工作项-TP_WORK的调用代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <windows.h>

VOID CALLBACK ThreadpoolWork(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work) {
	printf("ThreadpoolWork Enter\r\n");
}

int main() 
{
	//创建一个工作项
	PTP_WORK pWork = CreateThreadpoolWork(ThreadpoolWork, nullptr, nullptr);
	//向线程池提交一个请求
	SubmitThreadpoolWork(pWork); 
	//等待完成
	WaitForThreadpoolWorkCallbacks(pWork, FALSE);
	// 释放工作项内存
	CloseThreadpoolWork(pWork); 

	return 0;
}

通过跟进kernel32::CreateThreadpoolWork->ntdll::TpAllocWork函数,可得到TP_WORK结构如下:

IDA_TpAllocWork

TP_WORK

负责提交TP_WORK的API为SubmitThreadpoolWork,通过跟进kernel32::SubmitThreadpoolWork->ntdll::TpPostWork->ntdll::TppWorkPost->ntdll::TpPostTask,可定位常规工作项向任务队列(双向链表)插入的核心代码.

IDA_TpPostTask

TpPostTask

TpPool是目标进程的线程池结构,可通过调用NtQueryInformationWorkerFactory获取目标进程工人工厂信息中的StartParameter得到,StartParameter本质上是一个指向TP_POOL结构体的指针.

 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
typedef struct _WORKER_FACTORY_BASIC_INFORMATION
{
    LARGE_INTEGER Timeout;
    LARGE_INTEGER RetryTimeout;
    LARGE_INTEGER IdleTimeout;
    BOOLEAN Paused;
    BOOLEAN TimerSet;
    BOOLEAN QueuedToExWorker;
    BOOLEAN MayCreate;
    BOOLEAN CreateInProgress;
    BOOLEAN InsertedIntoQueue;
    BOOLEAN Shutdown;
    ULONG BindingCount;
    ULONG ThreadMinimum;
    ULONG ThreadMaximum;
    ULONG PendingWorkerCount;
    ULONG WaitingWorkerCount;
    ULONG TotalWorkerCount;
    ULONG ReleaseCount;
    LONGLONG InfiniteWaitGoal;
    PVOID StartRoutine;
    PVOID StartParameter;
    HANDLE ProcessId;
    SIZE_T StackReserve;
    SIZE_T StackCommit;
    NTSTATUS LastThreadCreationStatus;
} WORKER_FACTORY_BASIC_INFORMATION, * PWORKER_FACTORY_BASIC_INFORMATION;

至此,得到了TP_WORK被插入任务队列的全部信息.

线程池注入变体2:TP_WORK 流程如下:

  1. 调用DuplicateHandle复制目标进程TpWorkerFactory句柄.

  2. 调用VirtualAllocEx以及WriteProcessMemory将Shellcode写入目标进程地址空间.

  3. 创建和Shellcode相关联的TP_WORK结构.

    • 调用NtQueryInformationWorkerFactory获取工人工厂信息.

    • 调用ReadProcessMemory读取工人工厂信息中的StartParameter参数(即目标进程的线程池结构TpPool).

    • 调用CreateThreadpoolWork创建一个和Shellcode相关联的TP_WORK结构.

  4. 修改TP_WORK结构,和目标进程线程池结构进行关联.

  5. 将常规工作项加入到目标线程池-常规任务队列,进而被工作线程执行Shellcode.

    • 调用VirtualAllocEx以及WriteProcessMemory修改后的TP_WORK结构写入目标进程地址空间.
    • 调用WriteProcessMemory修改目标进程线程池-常规任务队列结构入口,指向上述伪造的TP_WORK结构.

线程池注入变体3:TP_IO

异步工作项-TP_IO调用代码如下:

 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
#include <stdio.h>
#include <windows.h>

VOID CALLBACK IoCallback(PTP_CALLBACK_INSTANCE instance, PVOID context, PVOID overlapped, ULONG ioResult, ULONG_PTR numberOfBytesTransferred, PTP_IO io)
{
	switch (ioResult)
	{
	case NO_ERROR:
	{
		printf("文件写入完成\n");
		break;
	}
	default:
		break;
	}
}

int main() 
{
	HANDLE hFile = CreateFile(TEXT("luohun.txt"), GENERIC_WRITE, 0, 0, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, 0);
	if (hFile == INVALID_HANDLE_VALUE) {
		printf("create file failed, errcode:%d\n", GetLastError());
	}
	PTP_IO ptpIo = CreateThreadpoolIo(hFile, IoCallback, NULL, NULL);
	StartThreadpoolIo(ptpIo);

	char buffer[] = "hello world";
	DWORD writeCount = 0;
	OVERLAPPED ol = { 0 };
	WriteFile(hFile, (LPCVOID)buffer, strlen(buffer), &writeCount, &ol);
	CloseHandle(hFile);

	getchar();
	CloseThreadpoolIo(ptpIo);

	return 0;
}

通过跟进kernel32::CreateThreadpoolIo->ntdll::TpAllocIoCompletion函数,可得到TP_IO结构如下:

IDA_TP_IO

TP_IO

继续跟进ntdll::TpAllocIoCompletion->ntdll::TpBindFileToDirect,该函数将文件完成队列设置为线程池的I/O完成队列,并将文件完成键设置为直接结构.

IDA_TpBindFileToDirect

用于启动异步I/O操作的API为StartThreadpoolIo,它告诉线程池监视指定的I/O对象,并在I/O操作完成时调用回调函数.

通过跟进kernel32::StartThreadpoolIo->ntdll::TpStartAsyncIoOperation,可定位启动异步I/O操作的核心代码.

IDA_TpStartAsyncIoOperation

上述函数调用后,对文件进行任何的操作(如调用WriteFile)都会导致完成键进行I/O完成队列.

TpBindFileToDirect_WriteFile

线程池注入变体3:TP_IO 流程如下:

  1. 调用DuplicateHandle复制目标进程IoCompletion句柄.

  2. 调用VirtualAllocEx以及WriteProcessMemory将Shellcode写入目标进程地址空间.

  3. 创建和文件对象以及Shellcode相关联的TP_IO结构.

    • 调用CreateFile创建一个文件对象.

    • 调用CreateThreadpoolIo创建一个和上述文件对象以及Shellcode相关联的TP_IO结构.

  4. 文件对象目标进程I/O完成队列相关联.

    • 修改TP_IO结构字段,模拟StartThreadpoolIo操作.

    • 调用VirtualAllocEx以及WriteProcessMemory修改后的TP_IO结构写入目标进程地址空间.

    • 调用ZwSetInformationFile将上述文件句柄与目标进程I/O完成队列相关联.

  5. 调用WriteFile,触发入队操作,进而被工作线程执行Shellcode.

线程池注入变体4:TP_WAIT

异步工作项-TP_WAIT调用代码如下:

 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
#include <stdio.h>
#include <windows.h>

VOID CALLBACK WaitCallback(PTP_CALLBACK_INSTANCE instance, PVOID context, PTP_WAIT wait, TP_WAIT_RESULT waitResult) 
{
	printf("WaitCallback Enter\r\n");
}

int main() 
{
	HANDLE hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
	//超时时间设置为NULL,表示一直等待下去
	PTP_WAIT pWait = CreateThreadpoolWait(WaitCallback, NULL, NULL);
	//立即执行
	ULARGE_INTEGER ulRelativeStartTime;
	ulRelativeStartTime.QuadPart = 0;
	FILETIME ftRelativeStartTime;
	ftRelativeStartTime.dwHighDateTime = ulRelativeStartTime.HighPart;
	ftRelativeStartTime.dwLowDateTime = ulRelativeStartTime.LowPart;
	SetThreadpoolWait(pWait, hEvent, &ftRelativeStartTime);

	//等候五秒再触发
	//ULARGE_INTEGER ulRelativeStartTime;
	//ulRelativeStartTime.QuadPart = (LONGLONG)-(50000000);
	//FILETIME ftRelativeStartTime;
	//ftRelativeStartTime.dwHighDateTime = ulRelativeStartTime.HighPart;
	//ftRelativeStartTime.dwLowDateTime = ulRelativeStartTime.LowPart;
	//SetThreadpoolWait(pWait, hEvent, &ftRelativeStartTime);

	//超时时间为NULL,表示一直等待下去,直到事件触发
	//SetThreadpoolWait(pWait, hEvent, NULL);

	getchar();
	SetEvent(hEvent);

	getchar();
	CloseThreadpoolWait(pWait);

	return 0;
}

通过跟进kernel32::CreateThreadpoolWait->ntdll::TpAllocWait函数,可得到TP_WAIT结构如下:

IDA_TpAllocWait

通过跟进kernel32::SetThreadpoolWait->ntdll::TpSetWait->ntdll::TpSetWaitEx->ntdll::TppSetupNextWait->ntdll::ZwAssociateWaitCompletionPacket,可定位TP_WAIT结构系统I/O完成队列相关联的核心操作

IDA_TppSetupNextWait

线程池注入变体4:TP_WAIT 流程如下:

  1. 调用DuplicateHandle复制目标进程IoCompletion句柄.

  2. 调用VirtualAllocEx以及WriteProcessMemory将Shellcode写入目标进程地址空间.

  3. 调用CreateThreadpoolWait创建一个和Shellcode相关联的TP_WAIT结构.

  4. 事件句柄目标进程I/O完成队列相关联.

    • 调用VirtualAllocEx以及WriteProcessMemory上述TP_WAIT结构写入目标进程地址空间.

    • 调用VirtualAllocEx以及WriteProcessMemory上述TP_WAIT中的TP_DIRECT结构写入目标进程地址空间.

    • 调用CreateEvent创建一个事件.

    • 调用ZwAssociateWaitCompletionPacket将上述事件句柄与目标进程I/O完成队列相关联.

  5. 调用SetEvent发送信号,触发入队操作,进而被工作线程执行Shellcode.

线程池注入变体5:TP_JOB

IDA_TpAllocJobNotification

线程池注入变体5:TP_JOB 流程如下:

  1. 调用DuplicateHandle复制目标进程IoCompletion句柄.

  2. 调用VirtualAllocEx以及WriteProcessMemory将Shellcode写入目标进程地址空间.

  3. 创建和JOB对象以及Shellcode相关联的TP_JOB结构.

    • 调用CreateJobObject创建一个JOB对象.

    • 调用TpAllocJobNotification创建一个上述JOB对象以及Shellcode相关联的TP_JOB结构.

  4. JOB对象目标进程I/O完成队列相关联.

    • 调用VirtualAllocEx以及WriteProcessMemory上述TP_JOB结构写入目标进程地址空间.

    • 调用SetInformationJobObject,将上述JOB对象与目标进程I/O完成队列相关联.

  5. 调用AssignProcessToJobObject将JOB对象与当前进程相关联,触发入队操作,进而被工作线程执行Shellcode.

线程池注入变体6:TP_ALPC

IDA_TppAllocAlpcCompletion

线程池注入变体6:TP_ALPC 流程如下:

  1. 调用DuplicateHandle复制目标进程IoCompletion句柄.

  2. 调用VirtualAllocEx以及WriteProcessMemory将Shellcode写入目标进程地址空间.

  3. 调用NtAlpcCreatePort以及TpAllocAlpcCompletion创建一个和Shellcode相关联的TP_ALPC结构.

  4. APLC端口目标进程I/O完成队列相关联.

    • 调用NtAlpcCreatePort创建一个APLC端口.

    • 调用VirtualAllocEx以及WriteProcessMemory上述TP_ALPC结构写入目标进程地址空间.

    • 调用NtAlpcSetInformation将上述APLC端口与目标进程I/O完成队列相关联.

  5. 调用NtAlpcConnectPort连接ALPC端口,触发入队操作,进而被工作线程执行Shellcode.

线程池注入变体7:TP_DIRECT

异步工作项排队进入I/O完成队列的核心是其中的TP_DIRECT结构,因此我们可以直接注入恶意TP_DIRECT结构,无需Windows对象进行代理,然后调用系统NtSetIoCompletion完成入队操作.

线程池注入变体7:TP_DIRECT 流程如下:

  1. 调用DuplicateHandle复制目标进程IoCompletion句柄.
  2. 调用VirtualAllocEx以及WriteProcessMemory将Shellcode写入目标进程地址空间.
  3. 创建恶意TP_DIRECT结构与上述Shellcode相关联.
  4. 调用VirtualAllocEx以及WriteProcessMemory上述TP_DIRECT结构写入目标进程地址空间.
  5. 调用ZwSetIoCompletion完成入队,触发Shellcode执行.

线程池注入变体8:TP_TIMER

定时器工作项-TP_TIMER的调用代码如下:

 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
#include <stdio.h>
#include <windows.h>

VOID NTAPI PoolTimerProc(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_TIMER Timer) 
{
	printf("PoolTimerProc Enter\r\n");
}

int main() 
{
	PTP_TIMER pTimer = CreateThreadpoolTimer(PoolTimerProc, NULL, NULL);

	FILETIME dueTime;
	GetSystemTimeAsFileTime(&dueTime);

	//msPeriod 定时器的触发周期,以毫秒为单位. 为0表示只触发一次,单位为毫秒.
	DWORD msPeriod = 0;
	SetThreadpoolTimer(pTimer, &dueTime, msPeriod, 0);

	system("pause");

	CloseThreadpoolTimer(pTimer);

	return 1;
}

通过跟进kernel32::CreateThreadpoolTimer->ntdll::TpAllocTimer函数,可得到TP_TIMER结构如下:

IDA_TpAllocTimer

通过跟进kernel32::SetThreadpoolTimer->ntdll::TpSetTimer->ntdll::TpSetTimerEx->ntdll::TppSetTimer->ntdll::TppEnqueueTimer,可定位到TP_TIMER结构向定时器队列插入的核心代码.

IDA_TppEnqueueTimer

TppEnqueueTimer函数将TP_TIMER的WindowStart链接插入定时器队列的WindowStart字段,将WindowEnd链接插入队列的WindowEnd字段.

通过跟进kernel32::SetThreadpoolTimer->ntdll::TpSetTimer->ntdll::TpSetTimerEx->ntdll::TppSetTimer->ntdll::TppUpdateSubQueueTimer,可定位到对定时器队列中的定时器进行配置代码.

IDA_TppUpdateSubQueueTimer

上述两个操作过后, 一旦定时器过期,就会执行定时器工作项中的回调.

线程池注入变体8:TP_TIMER 流程如下:

  1. 调用DuplicateHandle复制目标进程TpWorkerFactory以及IRTimer句柄.
  2. 调用VirtualAllocEx以及WriteProcessMemory将Shellcode写入目标进程地址空间.
  3. 调用CreateThreadpoolTimer创建和Shellcode相关联的TP_TIMER结构.
  4. 调用VirtualAllocEx以及WriteProcessMemory修改后的TP_TIMER结构写入目标进程地址空间.
  5. 调用WriteProcessMemory修改目标进程定时器队列使其指向上述伪造的TP_TIMER结构.
  6. 调用NtSetTimer2,使队列的定时对象过期,触发入队操作,进而被工作线程执行Shellcode.

总结

PoolParty技术并非传统意义上的漏洞利用,而是对Windows合法组件-用户态线程池的滥用.其本质是通过逆向工程揭示了Windows线程池内部的工作队列(普通任务队列、I/O完成队列、定时器队列)与工人工厂的交互机制,并篡改其核心逻辑:

  • 工人工厂劫持:通过系统调用NtQueryWorkerFactoryInformation,获取了目标进程-工人工厂的启动例程地址并调用WriteProcessMemory进行覆写,将原本用于管理线程的合法代码替换为恶意Shellcode,然后调用NtSetInformationWorkerFactory设置目标进程的工人工厂最小线程数为当前线程数加1,强制新增线程,进而执行恶意Shellcode.
  • 工作项伪造:通过插入伪造的线程池工作项(如TP_WORK、TP_IO、TP_WAIT、…),将恶意Shellcode嵌入到Windows系统预设的异步任务流程,利用线程池自身的调度机制触发Shellcode执行.

上述技术突破了传统进程注入对CreateRemoteThread 显示执行触发原语的依赖,将恶意代码的执行逻辑嫁接到了Windows线程池的底层调度体系中,使攻击行为内化到操作系统自身的线程管理中.

参考链接

PoolParty Github

永生难忘的线程池派对:使用Windows线程池的新进程注入技术

Thread Execution via NtCreateWorkerFactory

Windows 线程池API

Windows 线程池函数调用

Win32编程之线程池


相关内容

0%