在 Windows 环境中,线程池通过复用工作线程执行短生命周期、异步或事件驱动的任务,减少线程创建、销毁和调度的开销。程序只需向线程池提交任务或注册回调,无需手动创建线程。
Windows 线程池支持工作项、定时器、等待对象和异步 I/O 等多种任务类型,并负责在适当时机调度工作线程执行相应的 Callback。恶意软件可以利用这一机制执行恶意代码,避免直接调用 CreateRemoteThread(),从而降低被传统线程创建行为检测发现的可能性。
2023年12月,SafeBreach 研究人员在 Black Hat Europe 公开了 PoolParty 系列进程注入技术。与传统的远程线程注入不同,这类技术利用 Windows 线程池的内部机制影响目标进程中的代码执行路径,可用于执行恶意代码。PoolParty 介绍了 8 种变体注入,涉及工作线程工厂、工作项、I/O 完成队列、定时器等不同对象。
本文介绍 PoolParty 的第一种变体注入方式,即利用 WorkerFactory 相实现代码注入。
(原项目 PoolParty 是用 C++代码写的,代码非常优雅整洁。国内已经有些文章介绍了PoolParty,为了加深学习印象,我用 C 语言重新实现了一遍,在此写下学习记录,各位师傅不要嫌弃代码混乱;原项目链接已放在文章末尾)
Windows 为应用程序提供默认线程池,也允许应用程序通过 CreateThreadpool() 创建自定义线程池。两者的主要区别在于管理方式和配置能力。默认线程池由系统管理,应用程序通常直接向其提交工作;自定义线程池允许开发者设置线程数量相关参数,并通过回调环境将特定任务关联到指定线程池。
以下是通过CreateThreadpool() 创建自定义的线程池的一个示例代码
TrySubmitThreadpoolCallback() 函数的第三个参数为 NULL 时,意味着任务提交给了默认的线程池;
测试代码中的 TrySubmitThreadpoolCallback(FastTask, "CustomPool", &cbe),任务提交给了自定义的线程池。
程序运行结果如下图所示

自定义线程池 CustomPool 的快速任务在提交后毫秒级立即响应。
默认线程池 DefaultPool 的快速任务可能需要等待系统调度新的默认线程池线程(通常有几百毫秒甚至更长的延迟),或者直到某一个耗时任务结束才开始执行。
默认线程池全进程共享,若某个任务死锁或长时间阻塞,会影响默认池里的其他任务,没有限制数量,进程池的生命周期与进程共存亡
自定义线程池拥有独立的任务队列和专用工作线程,互不影响,可通过函数 SetThreadpoolThreadMaximum/Minimum 精确控制线程数量,可随时创建、清空、销毁 (CloseThreadpool)

这段测试代码展示了 TP_WORK 工作对象的基本用法:应用程序创建工作对象,通过 SubmitThreadpoolWork() 提交任务,再由线程池中的工作线程执行回调。WaitForThreadpoolWorkCallbacks() 用于等待相关回调完成,避免在任务仍然运行时就进入清理流程。
需要注意的是程序提交的是工作项 TP_WORK,而不是为每个工作项单独创建一个线程。
WorkerFactory 是用于管理线程池的工作线程创建及相关运行状态的内核对象。当线程池需要更多工作线程,且当前状态与线程数量配置允许扩展时,WorkerFactory 可以在目标进程中创建新的工作线程;对于空闲时间超过相应超时条件的工作线程,系统也可以将其终止,以回收资源。
用户态的程序不能直接访问 WorkerFactory,只能通过句柄读取 WorkerFactory 相关信息。WorkerFactory 的相关信息存在 WORKER_FACTORY_BASIC_INFORMATION 结构体中
每个线程池(TP_POOL)通常对应一个 WorkerFactory 句柄。当任务量增加且现有线程被占满时,WorkerFactory 负责在目标进程中派生新的工作线程。
线程池创建新的 Worker Thread 时,WorkerFactory 会使用其记录的 StartRoutine 作为新线程刚启动时默认执行的函数指针。如果攻击者将 StartRoutine 修改为恶意 Shellcode 的内存地址,并增加了 WorkerFactory 最少的工作线程 Worker 数量(+1),那么 WorkerFactory 不得不新创建一个 Worker Thread ,此时线程仍会按照正常的线程创建机制从 StartRoutine 这个入口地址开始执行,但实际执行的已经是攻击者植入的恶意 Shellcode,从而实现代码注入。
以下是用 C 语言展示的PoolParty 的第一种变体注入方式,流程如下
NtQueryInformationProcess() 主要用于获取指定进程的各种详细信息,在接受 ProcessHandleInformation 这个参数后,会返回目标进程的所有句柄的快照(Snapshot)。
NtQueryObject() 主要用于获取特定内核对象(如文件、注册表项、线程、进程、事件等句柄)的属性和类型信息
将之前获取的 WorkerFactory 句柄传入函数 NtQueryInformationWorkerFactory() 中,返回结构体中包含该WorkerFactory的基本数据,例如起始例程地址(StartRoutine)、挂起的工作项数量以及当前工作线程的数量
NtQueryInformationWorkerFactory() 是 ntdll.dll 的未公开 API,用于查询 WorkerFactory 对象的内部信息。
获取 WorkerFactory 的基本信息后,就可以得知 WORKER_FACTORY_BASIC_INFORMATION 结构体的成员 StartRoutine。前面已经提到 StartRoutine 是一个地址值,是线程池里工作线程 worker 执行时的起点。
在获取目标进程的 WorkerFactory 并查询到 StartRoutine 的具体地址后,将该地址覆盖为预先准备好的 shellcode。接着,通过调用函数 SetWorkerFactoryMinimumThreads() 调整 WorkerFactory 的最小 Worker 数量,迫使其创建新的 Worker Thread,触发执行 shellcode。