🎭《PoolParty 攻击连续剧》系列总览
潜入 Windows 线程池的暗面:一场关于信任与欺骗的攻防博弈
“不要唤醒工人,要让他们主动为你工作。”
在现代 Windows 安全攻防中,传统的远程线程注入(如 CreateRemoteThread)和 APC 注入早已被 EDR 严密监控。
攻击者被迫转向更隐蔽、更底层、更“合法”的执行路径——而 Windows 线程池,正是这片尚未被充分审视的新战场。
本系列《PoolParty 攻击连续剧》将带你深入 Windows 线程池的调度核心,揭示一个长期被低估的攻击面:
线程池不仅是一个性能优化机制,更可能成为无文件代码执行的隐蔽通道。
🏭 世界观:每个进程都是一座任务调度中心
我们用一套统一的隐喻贯穿全系列,便于理解与防御建模:
- 工人 = 线程池中的工作线程
- 任务单 = 回调函数(Shellcode 的伪装形态)
- 调度中心 = 线程池管理器(
ntdll!Tpp*)
- 快递中转站 = I/O 完成端口(IoCompletion Port)
- 培训中心 = WorkerFactory
- 内部通讯网 = ALPC
- 人事系统 = 作业对象(Job Object)
- 自动闹钟 = 定时器队列(Timer Queue)
在这座工厂里,工人从不主动找活,只等任务单被投递到队列。
而攻击者的目标,就是伪造一张看起来完全合法的任务单,让工人毫无怀疑地执行它。
对防御者而言,关键则在于:如何识别这张“合法”任务单的异常本质。
🧪 什么是 PoolParty?
PoolParty 是 SafeBreach Labs 在 Black Hat EU 2023 上发布的研究成果,题为:
“The Pool Party You Will Never Forget: New Process Injection Techniques Using Windows Thread Pools”。
它系统性地展示了 8 种滥用 Windows 线程池机制的进程注入技术,全部基于系统原生行为,无需创建远程线程,亦不触发传统高危 API 监控。
GitHub 仓库:https://github.com/SafeBreach-Labs/PoolParty
作者:Alon Leviev (@_0xDeku)
其核心在于两类路径:
🔹 7 种“任务单投递”战术
利用系统原生事件作为触发器,将恶意回调注入任务队列:
- TP_WORK:伪造高优先级任务单
- TP_WAIT:绑定事件信号触发回调
- TP_IO:伪装文件 I/O 完成包
- TP_ALPC:冒充可信组件间通信
- TP_JOB:利用进程加入作业对象的事件
- TP_TIMER:设置延迟或周期性回调
- 裸完成包投递:直接向 IoCompletion 投递回调指针(极简形态)
🔴 1 种“工人改造”战术
绕过任务队列,直接干预线程创建流程:
- StartRoutine 覆写:篡改新工人线程的初始执行逻辑
这 8 种战术共同构成了对 Windows 线程池最完整的滥用与检测矩阵。
🌟 为何值得关注?(攻防双重视角)
-
🔍 对攻击者:
- 高度隐蔽:执行线程来自目标进程原生线程池,无异常线程创建行为
- 绕过常见监控:不调用
CreateRemoteThread、QueueUserAPC 等高危 API
- 行为合法:所有操作均基于 Windows 内部机制(部分使用未公开但稳定的接口)
- 无文件、无网络、无新进程:满足高级持续性威胁(APT)的静默执行需求
-
🛡️ 对防御者:
- 揭示新型无文件执行路径,推动 EDR/EDR 检测逻辑演进
- 提供可观测信号:如异常回调地址、非预期的 IoCompletion 投递、WorkerFactory 滥用等
- 促进对“合法行为中的恶意意图”的建模能力(如行为上下文、调用链完整性)
- 为内核级监控(如 ETW、Kernel Callback Tracing)提供新锚点
📚 系列结构
本系列共八幕,层层递进,从机制改造到行为欺骗,兼顾攻击实现与防御启示:
- 第一幕:StartRoutine 覆写 —— 制造一个为你而生的工人
- 第二幕:TP_WORK 插入 —— 伪造一张高优先级任务单
- 第三幕:TP_WAIT 插入 —— 埋下一张会自己跑的任务单
- 第四幕:TP_IO 插入 —— 伪造一个不存在的快递包裹
- 第五幕:TP_ALPC 插入 —— 伪造一封内部部门密电
- 第六幕:TP_JOB 插入 —— 伪造一次人事调动通知
- 第七幕:裸完成包投递 —— 直接塞给工人一张纸条
- 第八幕:TP_TIMER 插入 + 战术自适应框架 —— 终章整合与攻防决策模型
每一篇均包含:
- 世界观叙事(技术隐喻)
- 核心机制剖析(含关键数据结构与调用链)
- 攻击实现(C++/Win32/NTAPI 示例)
- 防御视角:可观测信号、检测思路、缓解建议
- 与其他战术的横向对比(隐蔽性、稳定性、适用场景)
🔚 致读者
本系列面向具备 Windows 内核、线程、进程、句柄等基础知识的安全研究者——无论是红队、蓝队,还是安全产品开发者。
我们不追求“一键利用”或“万能绕过”,而致力于深入机制、识别风险、构建纵深防御。
因为真正的安全攻防,
不在于掌握多少技巧,
而在于理解——
系统为何信任,又该如何验证这份信任。
现在,调度中心的大门已开。
欢迎来到 PoolParty。
🎭 PoolParty 攻击连续剧(第一幕):
“制造一个为你而生的工人” —— 劫持线程池的入职培训手册
副标题:当任务单失效时,我们不再欺骗工人,而是直接重塑他们的灵魂。
在 Windows 的地下世界里,每一个运行中的进程都是一座精密运转的任务调度中心。
这里有成群的工人(线程),在线程池的统一调度下,从任务单队列中领取指令,默默执行着系统或应用交付的使命。
长久以来,攻击者试图混入这座工厂——
他们伪造任务单(如 TP_WORK、TP_TIMER),诱使现有工人执行恶意指令;
他们利用快递(I/O 完成端口)、内部通讯(ALPC)、人事变动(Job 对象)……
一切手段,只为让一个无辜的工人,在某个不经意的瞬间,执行我们的 Shellcode。
但今天,我们要讲述一个更激进的故事。
🧨 不再投递任务单——我们直接篡改“入职培训手册”
想象一下:
调度中心有一个新工人培训中心,名为 WorkerFactory。
每当线程池需要扩充人手,就会从这里“招募”一名新工人。
而每一位新工人上岗前,必须运行一份标准化的 “入职培训程序” —— 这个程序的入口点,就是 StartRoutine。
正常流程:
StartRoutine → 初始化线程上下文 → 加入线程池 → 开始轮询任务单。
我们的计划:
闯入培训中心,把这份“入职培训程序”直接替换成我们的 Shellcode。
从此,每一个新诞生的工人,从“出生”那一刻起,就只为我们的目标服务。
这,就是 PoolParty 家族中最激进的战术:
WorkerFactory StartRoutine 覆写。
🔧 技术实现:三步重塑工人灵魂
第一步:潜入厂区,获取通行证
auto hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwTargetPid);
我们首先获得目标进程的完全控制权——这是所有后续操作的前提。
第二步:定位“培训中心”,读取原始手册
通过 NtQueryInformationWorkerFactory,我们获取 WorkerFactory 的基本信息,尤其是:
PVOID StartRoutine = m_WorkerFactoryInformation.StartRoutine;
这个地址,就是我们要篡改的目标。
第三步:覆写培训内容,强制招募新人
- 修改内存保护属性(使其可写):
VirtualProtectEx(hProcess, StartRoutine, shellcodeSize, PAGE_EXECUTE_READWRITE, &oldProtect);
- 将 Shellcode 直接写入
StartRoutine 所在地址(跳过常规内存分配):
WriteProcessMemory(hProcess, StartRoutine, shellcode, shellcodeSize, nullptr);
- 调用
NtSetInformationWorkerFactory,将最小线程数 +1:
ULONG newMin = currentWorkerCount + 1;
NtSetInformationWorkerFactory(hWorkerFactory, WorkerFactoryThreadMinimum, &newMin, sizeof(ULONG));
💥 瞬间:系统创建一个新线程 → 该线程立即执行 StartRoutine → 实际运行的是我们的 Shellcode!
⚖️ 优势与代价
| 优势 |
风险 |
| ✅ 执行立即:无需等待任务调度 |
⚠️ 高风险:若 StartRoutine 被多次调用,可能导致崩溃 |
| ✅ 绕过任务队列依赖:即使队列满/空也能触发 |
⚠️ 内存写入敏感:直接覆写代码段,易被 EDR 检测 |
| ✅ 高度隐蔽:线程行为看似“合法创建” |
⚠️ 需目标可创建新线程(线程池未达上限) |
📌 最佳使用场景:
- 其他任务单战术因队列状态失败时的 fallback 方案;
- 需要立即执行且能接受一定崩溃风险的高权限攻击场景。
🏗️ 代码结构:面向对象的战术封装
在我们的 PoolParty 框架中,这一战术被封装为:
class WorkerFactoryStartRoutineOverwrite : public PoolParty {
protected:
std::shared_ptr<HANDLE> m_p_hWorkerFactory;
WORKER_FACTORY_BASIC_INFORMATION m_WorkerFactoryInformation;
public:
LPVOID AllocateShellcodeMemory() const override {
return m_WorkerFactoryInformation.StartRoutine; // ← 直接复用 StartRoutine 地址!
}
void SetupExecution() const override {
ULONG newMin = m_WorkerFactoryInformation.TotalWorkerCount + 1;
NtSetInformationWorkerFactory(*m_p_hWorkerFactory, WorkerFactoryThreadMinimum, &newMin, sizeof(ULONG));
}
};
通过继承基类 PoolParty,我们复用通用流程(句柄获取、内存写入等),仅重写关键步骤,实现战术的模块化与可扩展性。
🌐 下一幕预告
在接下来的篇章中,我们将逐一揭开 PoolParty 家族的其他七种战术:
- 如何伪造一张“高优先级任务单”(TP_WORK)?
- 如何让快递员(I/O 线程)顺手执行我们的指令?
- 如何利用“定时闹钟”(TP_TIMER)实现持久化?
但请记住:
最危险的攻击,不是让工人做错事,而是让他们从一开始就被设计成“错的”。
PoolParty 攻击连续剧 · 第一幕 完
🎭 PoolParty 攻击连续剧(第二幕):
“伪造一张高优先级任务单” —— 远程 TpWork 插入的艺术
副标题:当工人还在排队等活,我们已悄悄把一张“紧急指令”塞进队列最前端。
在上一幕中,我们选择重塑工人本身——让新诞生的线程从“出生”就为我们服务。
但那是一条高风险之路:覆写代码段、依赖线程创建、可能引发崩溃。
今天,我们回归更“优雅”的传统:不碰工人,只改任务单。
只不过,这一次我们伪造的不是普通工单,而是一张标有“最高优先级”的红色紧急指令——
它会被立刻执行,甚至打断当前工人的手头工作。
这,就是 PoolParty 家族中最经典、最隐蔽的战术之一:
远程 TpWork 插入(Remote TpWork Insertion)。
📜 世界观回顾:任务单如何被处理?
在调度中心(线程池)内部,任务单被分为三个优先级队列:
- 低(Low)
- 正常(Normal)
- 高(High)
工人轮询时,永远优先处理高优先级队列。
而 TP_WORK,正是 Windows 提供的标准“工作项”任务单类型——
通过 CreateThreadpoolWork() 创建,SubmitThreadpoolWork() 提交。
正常流程:
应用 → 创建 TP_WORK → 提交 → 系统将其链入高优先级队列 → 工人执行回调。
我们的计划:
在本地构造一个“特制任务单”,
直接写入目标进程的高优先级队列头,
让目标工人误以为这是自己人发的紧急指令!
🔧 技术实现:伪造任务单的三重伪装
第一步:潜入厂区,获取“调度中心蓝图”
我们首先通过 GetTargetThreadPoolWorkerFactoryHandle() 获取 WorkerFactory 句柄,
再调用 NtQueryInformationWorkerFactory,读取其 StartParameter——
这实际上是指向 TP_POOL 结构体的指针,即整个线程池的“控制中枢”。
auto WorkerFactoryInfo = GetWorkerFactoryBasicInformation(*m_p_hWorkerFactory);
PVOID pTpPoolInTarget = WorkerFactoryInfo.StartParameter; // ← 调度中心核心地址
第二步:在本地伪造一张“高优先级任务单”
我们在攻击者进程中调用 CreateThreadpoolWork(),创建一个 TP_WORK 对象:
- 回调函数设为
m_ShellcodeAddress(即我们写入目标进程的 Shellcode 地址)
- 上下文设为
nullptr
auto pTpWork = CreateThreadpoolWork(
(PTP_WORK_CALLBACK)m_ShellcodeAddress,
nullptr,
nullptr
);
⚠️ 注意:此时 pTpWork 还属于本地进程,不能直接提交。
第三步:篡改任务单归属,强行插入目标队列
这是最精妙的一步:
我们手动修改 TP_WORK 内部字段,使其“认贼作父”:
- 将
pTpWork->Pool 指向目标进程的 TP_POOL 地址(即 StartParameter)
- 将
pTpWork->Task.ListEntry 的 Flink 和 Blink 指向目标高优先级队列头
- 设置
WorkState = 0x2(表示“已入队”,避免被清理)
pTpWork->CleanupGroupMember.Pool = (PFULL_TP_POOL)pTpPoolInTarget;
pTpWork->Task.ListEntry.Flink = &targetHighPriorityQueueHead;
pTpWork->Task.ListEntry.Blink = &targetHighPriorityQueueHead;
pTpWork->WorkState.Exchange = 0x2; // 已入队状态
第四步:将伪造任务单“快递”到目标进程
- 在目标进程分配内存:
VirtualAllocEx(sizeof(TP_WORK))
- 将篡改后的
TP_WORK 结构体写入:WriteProcessMemory()
- 关键一步:修改目标进程的高优先级队列头,使其双向指向我们的新任务单,形成合法循环链表:
// 修改队列头的 Flink/Blink,指向我们的远程 TP_WORK
WriteProcessMemory(hTarget, &queueHead.Flink, &pRemoteTpWork->Task.ListEntry, sizeof(...));
WriteProcessMemory(hTarget, &queueHead.Blink, &pRemoteTpWork->Task.ListEntry, sizeof(...));
💥 瞬间:线程池工人下一次轮询 → 发现高优先级队列非空 → 执行 TP_WORK 回调 → 运行我们的 Shellcode!
🎯 为何选择高优先级队列?
- 立即执行:无需等待低优先级任务完成
- 隐蔽性强:行为完全符合线程池正常逻辑
- 绕过检测:不调用
SubmitThreadpoolWork(),避免 API 监控
📌 注意:此战术依赖对 TP_POOL 和 TP_WORK 内部结构的精确理解——
微软未公开这些结构,需通过逆向或符号文件还原(如你代码中的 FULL_TP_POOL / FULL_TP_WORK)。
🏗️ 代码结构:精准操控任务队列
在 PoolParty 框架中,这一战术被封装为:
class RemoteTpWorkInsertion : public PoolParty {
protected:
std::shared_ptr<HANDLE> m_p_hWorkerFactory;
public:
void HijackHandles() override {
m_p_hWorkerFactory = GetTargetThreadPoolWorkerFactoryHandle();
}
void SetupExecution() const override {
// 1. 读取目标 TP_POOL
// 2. 本地创建 TP_WORK,回调 = m_ShellcodeAddress
// 3. 篡改 TP_WORK 内部指针,指向目标 TP_POOL 和高优先级队列
// 4. 写入目标进程,并修补队列头形成循环链表
}
};
整个过程不调用任何远程线程创建 API,仅通过内存写入和链表操作,实现“静默投递”。
⚖️ 与“StartRoutine 覆写”的对比
| 维度 |
StartRoutine 覆写 |
TpWork 插入 |
| 攻击面 |
线程创建流程 |
任务调度队列 |
| 执行时机 |
新线程创建时 |
工人下一次轮询(高优先级立即) |
| 内存操作 |
覆写代码段(高风险) |
写入数据结构(低风险) |
| 崩溃概率 |
高(若多次调用) |
极低(结构合法) |
| EDR 规避 |
困难(代码段写入) |
较易(仅数据写入) |
✅ 结论:
TpWork 插入是“优雅的欺骗”,StartRoutine 覆写是“暴力的重塑”。
前者适合持久化与隐蔽渗透,后者适合紧急执行与 fallback。
🌐 下一幕预告
在第三幕中,我们将探索一种更“被动”的战术:
如何埋下一张“触发式任务单”(TP_WAIT)?
——它不会立即执行,而是静静等待某个事件(如句柄信号)发生,才悄然启动。
届时,我们将看到:
最危险的指令,往往藏在最安静的等待之中。
PoolParty 攻击连续剧 · 第二幕 完
🎭 PoolParty 攻击连续剧(第三幕):
“埋下一张会自己跑的任务单” —— 用事件触发的 TP_WAIT 诡计
副标题:最危险的指令,往往藏在最安静的等待之中。
在前两幕中,我们或重塑工人灵魂(StartRoutine 覆写),或伪造高优先级任务单(TpWork 插入)。
但这些战术都有一个共同点:主动出击——要么强制创建线程,要么直接塞入队列。
今天,我们要讲述一种更狡猾、更被动的策略:
我们不催促工人干活,而是在调度中心埋下一颗“定时信标”。
它静静沉睡,直到某个外部信号(如一个事件被触发)出现,
才瞬间激活,将我们的指令悄无声息地投递到工人手中。
这,就是 PoolParty 家族中最具“异步艺术感”的战术:
远程 TP_WAIT 插入(Remote TpWait Insertion)。
🕳️ 世界观升级:引入“快递中转站”与“信号塔”
为了理解这一战术,我们需要在调度中心之外,引入两个新设施:
-
I/O 完成端口(IoCompletion)
→ 相当于快递中转站,所有异步 I/O 操作完成后,都会在此投递一个“包裹”(完成包)。
-
事件对象(Event)
→ 相当于一座信号塔,当它被“点亮”(SetEvent),就会向中转站发送一个通知。
而 TP_WAIT,正是 Windows 提供的一种等待信号并执行回调的机制。
正常情况下,应用会调用 SetThreadpoolWait(),让线程池在事件触发时执行指定函数。
正常流程:
应用注册 TP_WAIT → 等待 Event → Event 被 Set → 线程池执行回调。
我们的诡计:
在目标进程伪造一个 TP_WAIT,
将其与一个我们控制的本地事件绑定,
并通过 ZwAssociateWaitCompletionPacket,让事件触发时自动向目标的“快递中转站”投递包裹,
最终由线程池工人取出包裹,执行我们的 Shellcode!
🔧 技术实现:四步构建“信号触发链”
第一步:在本地创建“特制 TP_WAIT”
我们在攻击者进程中调用 CreateThreadpoolWait(),创建一个 TP_WAIT 对象:
- 回调函数设为
m_ShellcodeAddress(目标进程中的 Shellcode 地址)
- 上下文为
nullptr
auto pTpWait = CreateThreadpoolWait(
(PTP_WAIT_CALLBACK)m_ShellcodeAddress,
nullptr,
nullptr
);
这个对象内部包含一个关键结构:WaitPkt(等待完成包)和 Direct(APC 执行上下文)。
第二步:将 TP_WAIT 与 TP_DIRECT 写入目标进程
- 在目标进程分配内存,写入完整的
TP_WAIT 结构体:
auto pRemoteTpWait = VirtualAllocEx(..., sizeof(FULL_TP_WAIT));
WriteProcessMemory(hTarget, pRemoteTpWait, pTpWait, ...);
- 单独提取
pTpWait->Direct(即 TP_DIRECT 结构),也写入目标进程:
auto pRemoteTpDirect = VirtualAllocEx(..., sizeof(TP_DIRECT));
WriteProcessMemory(hTarget, pRemoteTpDirect, &pTpWait->Direct, ...);
📌 为什么需要 TP_DIRECT?
因为 ZwAssociateWaitCompletionPacket 要求提供一个 APC 上下文(即 TP_DIRECT*),用于在完成包投递后执行回调。
第三步:创建本地事件,并绑定到目标“快递中转站”
- 创建一个命名事件(如
PoolParty_Signal):
auto hEvent = CreateEvent(nullptr, FALSE, FALSE, L"PoolParty_Signal");
- 调用
ZwAssociateWaitCompletionPacket,建立三者关联:
ZwAssociateWaitCompletionPacket(
pTpWait->WaitPkt, // 完成包(包裹内容)
*m_p_hIoCompletion, // 目标的 I/O 完成端口(快递中转站)
hEvent, // 本地事件(信号塔)
pRemoteTpDirect, // APC 上下文(执行指令)
pRemoteTpWait, // TP_WAIT 对象(任务单)
0, 0, nullptr
);
💡 关键点:
此 API 是微软未公开的内核接口,允许将任意事件与目标进程的 I/O 完成端口绑定。
一旦事件被触发,系统会自动向目标的 IoCompletion 投递一个完成包,其中包含我们的回调上下文。
第四步:点亮信号塔,触发执行
SetEvent(hEvent);
💥 瞬间:
- 事件被触发 →
- 系统向目标进程的
IoCompletion 投递完成包 →
- 线程池工人从快递中转站取出包裹 →
- 执行
TP_DIRECT 中的 APC →
- 最终调用
TP_WAIT 的回调 → 运行我们的 Shellcode!
整个过程无需远程线程创建、无需任务队列写入,仅依赖 Windows 内核的异步通知机制。
🎯 战术优势:隐蔽、异步、跨进程
| 优势 |
说明 |
| ✅ 完全异步 |
执行时机由攻击者控制(SetEvent 时刻) |
| ✅ 跨进程绑定 |
本地事件可触发远程进程的回调 |
| ✅ 绕过 API 监控 |
未调用 SubmitThreadpoolWork 或 QueueUserAPC |
| ✅ 利用合法机制 |
ZwAssociateWaitCompletionPacket 是系统内部合法 API |
📌 适用场景:
- 需要延迟执行或条件触发的 payload;
- 目标进程已启用 I/O 完成端口(常见于服务、网络应用);
- 希望避免直接内存写入任务队列(如 EDR 监控链表操作)。
🏗️ 代码结构:异步工作项的抽象
你的代码中,RemoteTpWaitInsertion 继承自 AsynchronousWorkItemInsertion(可能是 PoolParty 的异步子类),体现了对“事件-完成包-回调”模式的封装:
void SetupExecution() const override {
// 1. 创建本地 TP_WAIT(回调 = Shellcode)
// 2. 将 TP_WAIT 和 TP_DIRECT 写入目标进程
// 3. 创建命名事件
// 4. 调用 ZwAssociateWaitCompletionPacket 绑定事件 → IoCompletion → TP_WAIT
// 5. SetEvent 触发
}
这种设计将触发逻辑与执行逻辑解耦,为后续扩展(如定时触发、文件句柄触发)奠定基础。
⚖️ 与前两幕的战术对比
| 战术 |
触发方式 |
执行延迟 |
风险 |
隐蔽性 |
| StartRoutine 覆写 |
强制创建线程 |
立即 |
高(覆写代码) |
中 |
| TpWork 插入 |
写入高优先级队列 |
极短(下一轮询) |
低 |
高 |
| TpWait 插入 |
外部事件触发 |
可控(SetEvent 时) |
极低 |
极高 |
✅ 结论:
TpWait 是“潜伏者”的首选——它不急于行动,只等一声令下,便悄然完成使命。
🌐 下一幕预告
在第四幕中,我们将探索一种更“底层”的战术:
如何利用 I/O 完成端口本身作为投递通道(TP_IO)?
——我们将伪造一个“虚假 I/O 操作完成”事件,让工人误以为这是磁盘或网络的响应,从而执行我们的代码。
届时,我们将看到:
当快递员收到一个不存在的包裹,他依然会认真拆开。
PoolParty 攻击连续剧 · 第三幕 完
🎭 PoolParty 攻击连续剧(第四幕):
“伪造一个不存在的快递包裹” —— 利用 TP_IO 欺骗工人拆箱
副标题:当工人以为自己在处理磁盘响应,其实拆开的是我们埋下的炸弹。
在前三幕中,我们或重塑工人(StartRoutine 覆写),或伪造任务单(TpWork),或设置信号陷阱(TpWait)。
但这些战术,或多或少都依赖“调度中心内部”的逻辑。
今天,我们要从外部世界发起攻击——
我们假装自己是一个磁盘驱动器,向调度中心的“快递中转站”(I/O 完成端口)投递一个虚假的 I/O 完成通知。
工人收到后,会像处理普通文件读写一样,认真执行回调——
而这个回调,正是我们的 Shellcode。
这,就是 PoolParty 家族中最具“欺骗性”的战术之一:
远程 TP_IO 插入(Remote TpIo Insertion)。
📦 世界观扩展:I/O 完成端口 = 快递中转站
在现代 Windows 进程中,尤其是高性能服务(如 IIS、SQL Server、游戏服务器),
I/O 完成端口(IoCompletion Port)被广泛用于高效处理磁盘、网络等异步操作。
正常流程:
- 应用发起异步写文件(
WriteFile + OVERLAPPED)
- 磁盘驱动完成操作后,向 IoCompletion 投递一个“完成包”
- 线程池工人从中转站取出包裹,执行
TP_IO 回调函数
整个过程对应用透明,高效且可扩展。
我们的诡计:
我们不真的等待磁盘响应,而是主动“伪造一个完成事件”,
让系统误以为某个文件 I/O 已完成,从而触发我们的回调!
🔧 技术实现:四步伪造“磁盘快递”
第一步:创建一个临时文件(作为“伪装载体”)
我们在本地创建一个临时文件(如 C:\Windows\Temp\poolparty.tmp),并以 FILE_FLAG_OVERLAPPED 模式打开——
这是启用异步 I/O 的前提。
auto hFile = CreateFile(
L"poolparty.tmp",
GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
nullptr
);
📌 注意:文件内容不重要,它只是一个“信使外壳”。
第二步:在本地构造“特制 TP_IO”
调用 CreateThreadpoolIo(),创建一个 TP_IO 对象:
- 回调函数设为
m_ShellcodeAddress(目标进程中的 Shellcode 地址)
- 文件句柄为刚创建的
hFile
auto pTpIo = CreateThreadpoolIo(hFile, (PTP_WIN32_IO_CALLBACK)m_ShellcodeAddress, nullptr, nullptr);
但 CreateThreadpoolIo 并不会立即启动 I/O,我们需要手动模拟“I/O 已发起”状态:
pTpIo->CleanupGroupMember.Callback = m_ShellcodeAddress; // 修复回调指针
++pTpIo->PendingIrpCount; // 模拟有一个挂起的 I/O 请求
💡 为什么需要 PendingIrpCount?
线程池在处理完成包时,会检查该计数。若为 0,会忽略回调。
我们通过递增它,让系统“相信”有一个 I/O 正在等待完成。
第三步:将 TP_IO 写入目标进程,并绑定到其 I/O 完成端口
-
在目标进程分配内存,写入完整的 TP_IO 结构体:
auto pRemoteTpIo = VirtualAllocEx(..., sizeof(FULL_TP_IO));
WriteProcessMemory(hTarget, pRemoteTpIo, pTpIo, ...);
-
关键一步:调用 ZwSetInformationFile,将本地文件句柄关联到目标进程的 I/O 完成端口:
FILE_COMPLETION_INFORMATION info;
info.Port = *m_p_hIoCompletion; // 目标的 IoCompletion 句柄
info.Key = &pRemoteTpIo->Direct; // APC 执行上下文(指向 TP_IO)
ZwSetInformationFile(hFile, ..., &info, ..., FileReplaceCompletionInformation);
🌟 魔法所在:
此操作告诉内核:“当这个文件的 I/O 完成时,请把完成包投递到目标进程的快递中转站”。
即便文件是我们在本地创建的,完成通知却会出现在远程进程中!
第四步:发起写操作,触发“虚假完成”
我们向临时文件写入一段无意义数据(如一首诗):
WriteFile(hFile, "The PoolParty has begun...", ..., &overlapped);
由于文件是异步打开的,WriteFile 会立即返回,
而内核会在 I/O 完成后,自动向目标进程的 IoCompletion 投递完成包。
💥 瞬间:
- 目标进程的线程池工人从中转站取出包裹 →
- 解析
Key 字段,找到 TP_IO 的 Direct 上下文 →
- 执行回调 → 运行我们的 Shellcode!
整个过程完全模拟合法 I/O 行为,连内核都“信以为真”。
🎯 战术优势:天然合法、难以区分
| 优势 |
说明 |
| ✅ 行为完全合法 |
使用标准异步 I/O 机制,无非常规 API 调用 |
| ✅ 绕过行为检测 |
EDR 很难区分“真实文件写入”与“攻击性 I/O” |
| ✅ 无需远程线程 |
仅通过文件系统与完成端口联动触发 |
| ✅ 适用于高安全环境 |
常见于服务进程,本身就有大量 I/O 操作作掩护 |
📌 适用场景:
- 目标进程已绑定 I/O 完成端口(如数据库、Web 服务器);
- 需要高度隐蔽、长期潜伏的 payload 投递;
- 作为对抗 EDR 的“白利用”(Living-off-the-Land)战术。
🏗️ 代码结构:异步 I/O 的精准操控
在你的框架中,RemoteTpIoInsertion 继承自 AsynchronousWorkItemInsertion,体现了对“外部事件 → 完成端口 → 回调”链路的统一抽象:
void SetupExecution() const override {
// 1. 创建临时异步文件
// 2. 构造 TP_IO,修复回调并模拟 PendingIrpCount
// 3. 写入目标进程
// 4. 通过 ZwSetInformationFile 将文件绑定到目标 IoCompletion
// 5. WriteFile 触发 I/O 完成
}
这种设计将文件 I/O 与线程池回调无缝衔接,展现了对 Windows 异步模型的深度掌控。
⚖️ 与前三幕战术的对比
| 战术 |
触发源 |
是否需远程内存写入 |
是否依赖目标状态 |
隐蔽性 |
| StartRoutine 覆写 |
强制创建线程 |
是(覆写代码段) |
需能创建线程 |
中 |
| TpWork 插入 |
伪造任务单 |
是(写入队列) |
需有 WorkerFactory |
高 |
| TpWait 插入 |
本地事件 |
是(写入 TP_WAIT) |
需有 IoCompletion |
极高 |
| TpIo 插入 |
本地文件 I/O |
是(写入 TP_IO) |
需有 IoCompletion |
极高 + 行为合法 |
✅ 结论:
TP_IO 是“伪装大师”——它不制造异常,只利用系统本就信任的通道,完成致命一击。
🌐 下一幕预告
在第五幕中,我们将深入 Windows 内核通信机制,探索:
如何通过 ALPC(高级本地过程调用)投递任务单(TP_ALPC)?
——我们将伪造一个“内部部门通讯请求”,让调度中心误以为这是来自可信组件的指令。
届时,我们将看到:
最危险的命令,往往来自“自己人”的频道。
PoolParty 攻击连续剧 · 第四幕 完
🎭 PoolParty 攻击连续剧(第五幕):
“伪造一封内部部门密电” —— 利用 ALPC 欺骗调度中心
副标题:最危险的命令,往往来自“自己人”的频道。
在前四幕中,我们或重塑工人(StartRoutine 覆写),或伪造任务单(TpWork),或设置信号陷阱(TpWait),或伪装磁盘快递(TpIo)。
但这些战术,大多依赖外部事件或文件系统。
今天,我们要潜入 Windows 最核心的内部通讯网络——
ALPC(Advanced Local Procedure Call),
这是系统组件之间(如 CSRSS、LSASS、服务管理器)进行高效、安全通信的“加密专线”。
正常用途:
当一个服务需要请求另一个服务执行操作(如启动进程、查询令牌),
它会通过 ALPC 端口发送一条结构化消息,接收方线程池自动处理。
我们的诡计:
我们创建一个“冒牌 ALPC 服务端口”,
将其绑定到目标进程的 I/O 完成端口,
再以“客户端”身份发送一条消息,
让目标工人误以为这是来自可信系统组件的内部指令!
这,就是 PoolParty 家族中最具“内鬼气质”的战术:
远程 TP_ALPC 插入(Remote TpAlpc Insertion)。
📡 世界观升级:ALPC = 调度中心的内部加密专线
想象调度中心内部有一套独立于公网的内部通讯系统:
- 每个关键部门(如人事、安保、I/O 管理)都有自己的加密频道(ALPC 端口);
- 消息格式严格,权限验证严密;
- 所有通讯自动由线程池工人处理,无需人工干预。
正常流程:
- 部门 A 创建 ALPC 服务端口(如
\Sessions\1\PoolParty_Service)
- 部门 B 连接该端口并发送消息
- 消息被投递到部门 A 的 I/O 完成端口
- 工人取出消息,执行
TP_ALPC 回调
我们的计划:
我们冒充“新成立的内部部门”,
注册一个看似合法的 ALPC 端口,
并将回调指向我们的 Shellcode,
再自己给自己发一条“启动指令”!
🔧 技术实现:五步伪造“内部密电”
第一步:创建临时 ALPC 端口(用于构造 TP_ALPC)
我们先调用 NtAlpcCreatePort() 创建一个临时端口(无需命名),
仅用于调用 TpAllocAlpcCompletion() —— 这是 Windows 内部用于注册 ALPC 回调的函数。
auto hTempPort = NtAlpcCreatePort(nullptr, nullptr);
auto pTpAlpc = TpAllocAlpcCompletion(hTempPort, (PTP_ALPC_CALLBACK)m_ShellcodeAddress, ...);
📌 注意:TpAllocAlpcCompletion 并非公开 API,通常需通过 ntdll 导出或动态解析。
第二步:创建命名 ALPC 服务端口(真正的“钓鱼频道”)
我们创建一个全局可见的命名端口(如 \PoolParty_Command):
UNICODE_STRING portName = L"\\PoolParty_Command";
OBJECT_ATTRIBUTES objAttr = { .ObjectName = &portName };
ALPC_PORT_ATTRIBUTES portAttr = { .MaxMessageLength = 328, .Flags = 0x20000 };
auto hAlpcPort = NtAlpcCreatePort(&objAttr, &portAttr);
这个端口将作为“诱饵”,等待“客户端”连接。
第三步:将 TP_ALPC 写入目标进程
我们在目标进程中分配内存,并将伪造的 TP_ALPC 结构体写入:
auto pRemoteTpAlpc = VirtualAllocEx(..., sizeof(FULL_TP_ALPC));
WriteProcessMemory(hTarget, pRemoteTpAlpc, pTpAlpc, ...);
该结构体内部包含回调地址(即 Shellcode)和 ALPC 上下文。
第四步:将 ALPC 端口绑定到目标的 I/O 完成端口
这是最关键的一步:
调用 NtAlpcSetInformation(),将命名 ALPC 端口关联到目标进程的 I/O 完成端口:
ALPC_PORT_ASSOCIATE_COMPLETION_PORT info;
info.CompletionPort = *m_p_hIoCompletion; // 目标的快递中转站
info.CompletionKey = pRemoteTpAlpc; // 指向我们的 TP_ALPC
NtAlpcSetInformation(hAlpcPort, AlpcAssociateCompletionPortInformation, &info, ...);
💡 魔法所在:
此操作告诉内核:“所有发往此 ALPC 端口的消息,请投递到目标进程的 IoCompletion”。
即便端口由我们在本地创建,消息处理却发生在远程进程!
第五步:以客户端身份连接并发送消息(触发执行)
我们调用 NtAlpcConnectPort(),作为“客户端”连接到刚创建的命名端口,
并在连接请求中附带一段消息数据(如一首诗):
ALPC_MESSAGE msg;
msg.PortHeader.DataLength = poem.length();
msg.PortHeader.TotalLength = sizeof(PORT_MESSAGE) + poem.length();
memcpy(msg.PortMessage, poem.data(), poem.length());
NtAlpcConnectPort(&portName, ..., (PPORT_MESSAGE)&msg, ...);
💥 瞬间:
- 内核收到连接请求 →
- 向目标进程的 IoCompletion 投递 ALPC 完成包 →
- 工人取出包,解析
CompletionKey →
- 执行
TP_ALPC 回调 → 运行我们的 Shellcode!
整个过程完全模拟合法系统组件通信,连内核都难以分辨真伪。
🎯 战术优势:高权限通道、天然可信
| 优势 |
说明 |
| ✅ 利用高权限通信机制 |
ALPC 是系统内部核心 IPC,常用于 LSASS、SMSS 等关键进程 |
| ✅ 消息自动路由 |
无需远程线程,仅靠内核消息投递触发 |
| ✅ 极难被监控 |
大多数 EDR 不深度解析 ALPC 消息内容 |
| ✅ 适用于高安全目标 |
若目标本身使用 ALPC(如服务宿主),行为完全合法 |
📌 适用场景:
- 目标进程已启用 I/O 完成端口;
- 需要模拟“可信系统组件”行为;
- 作为对抗高级 EDR 的“内核级白利用”。
🏗️ 代码结构:ALPC 与线程池的深度集成
在你的框架中,RemoteTpAlpcInsertion 继承自 AsynchronousWorkItemInsertion,体现了对“异步事件 → 完成端口 → 回调”模式的统一抽象:
void SetupExecution() const override {
// 1. 创建临时端口用于构造 TP_ALPC
// 2. 创建命名 ALPC 服务端口(钓鱼频道)
// 3. 将 TP_ALPC 写入目标进程
// 4. 通过 NtAlpcSetInformation 绑定到目标 IoCompletion
// 5. 客户端连接并发送消息,触发完成包投递
}
这种设计将ALPC 通信无缝融入 PoolParty 战术体系,展现了对 Windows 内部 IPC 机制的极致利用。
⚖️ 与前四幕战术的对比
| 战术 |
触发源 |
通信层级 |
可信度 |
隐蔽性 |
| StartRoutine 覆写 |
线程创建 |
线程池内部 |
低 |
中 |
| TpWork 插入 |
任务队列 |
线程池调度 |
中 |
高 |
| TpWait 插入 |
事件信号 |
内核同步对象 |
中 |
极高 |
| TpIo 插入 |
文件 I/O |
文件系统 |
高 |
极高 |
| TpAlpc 插入 |
ALPC 消息 |
内核 IPC |
极高 |
极高 + 系统级可信 |
✅ 结论:
TP_ALPC 是“内鬼战术”的巅峰——它不伪装成工人,而是伪装成“发号施令的上级部门”。
🌐 下一幕预告
在第六幕中,我们将转向 Windows 的作业对象(Job Object)机制,探索:
如何通过 TP_JOB 投递任务单?
——我们将利用进程加入/退出作业对象的生命周期事件,触发我们的回调。
届时,我们将看到:
当一个进程“入职”或“离职”某个作业组,就是我们动手的最佳时机。
PoolParty 攻击连续剧 · 第五幕 完
🎭 PoolParty 攻击连续剧(第六幕):
“伪造一次人事调动通知” —— 利用作业对象事件触发回调
副标题:当一个进程“入职”某个作业组,调度中心就会自动发送一封欢迎邮件——而我们,篡改了邮件的内容。
在前五幕中,我们或重塑工人(StartRoutine 覆写),或伪造任务单(TpWork),或设置信号陷阱(TpWait),或伪装磁盘快递(TpIo),或冒充内部部门(TpAlpc)。
但这些战术,大多依赖外部输入或通信。
今天,我们要利用 Windows 的进程生命周期管理机制——
作业对象(Job Object),
这是系统用于批量管理进程组的容器,常用于限制资源、监控退出、强制清理等场景。
正常用途:
父进程创建一个 Job 对象,将子进程“分配”进去;
当组内任一进程退出、超限或状态变更时,
系统会向关联的 I/O 完成端口投递一条作业通知(Job Notification)。
我们的诡计:
我们创建一个“空壳作业组”,
将其绑定到目标进程的 I/O 完成端口,
再把自己“加入”这个组,
触发一条“新成员入职”通知,
让目标工人误以为这是合法的作业事件,从而执行我们的 Shellcode!
这,就是 PoolParty 家族中最具“人事管理”色彩的战术:
远程 TP_JOB 插入(Remote TpJobInsertion)。
🏢 世界观扩展:作业对象 = 调度中心的人事档案室
想象调度中心有一个人事档案室(Job Object),用于管理“项目组”:
- 每个项目组(Job)可包含多个工人(进程);
- 当有新人加入、有人离职、或有人违规,
- 档案室会自动生成一份人事变动通知,投递到“快递中转站”(IoCompletion);
- 工人取出通知,执行预设的处理逻辑。
正常流程:
- 创建 Job 对象 →
- 关联 IoCompletion →
- AssignProcessToJobObject(进程) →
- 系统投递
JOB_OBJECT_MSG_NEW_PROCESS 通知 →
- 执行
TP_JOB 回调
我们的计划:
我们创建一个“幽灵项目组”,
将回调指向我们的 Shellcode,
再把自己“调入”该组,
触发入职通知,完成代码执行!
🔧 技术实现:四步伪造“人事调动”
第一步:创建命名作业对象(“幽灵项目组”)
我们调用 CreateJobObject(),创建一个带名称的作业对象(如 PoolParty_Job):
auto hJob = CreateJobObject(nullptr, L"PoolParty_Job");
📌 命名目的:便于调试与识别,非必需,但增强隐蔽性(看似合法)。
第二步:构造 TP_JOB 并写入目标进程
调用内部函数 TpAllocJobNotification()(通常来自 ntdll),创建 TP_JOB 对象:
auto pTpJob = TpAllocJobNotification(hJob, m_ShellcodeAddress, nullptr, nullptr);
然后将其写入目标进程内存:
auto pRemoteTpJob = VirtualAllocEx(..., sizeof(FULL_TP_JOB));
WriteProcessMemory(hTarget, pRemoteTpJob, pTpJob, ...);
该结构体将作为完成包的 CompletionKey,指向我们的 Shellcode。
第三步:将作业对象绑定到目标的 I/O 完成端口
Windows 要求:一个 Job 对象只能关联一个完成端口,且需先清空再设置。
-
先清空现有绑定(即使为空也需调用):
JOBOBJECT_ASSOCIATE_COMPLETION_PORT info = {0};
SetInformationJobObject(hJob, JobObjectAssociateCompletionPortInformation, &info, ...);
-
重新绑定到目标进程的 IoCompletion:
info.CompletionPort = *m_p_hIoCompletion; // 目标的快递中转站
info.CompletionKey = pRemoteTpJob; // 指向我们的 TP_JOB
SetInformationJobObject(hJob, ..., &info, ...);
💡 关键点:
此操作使所有作业事件(包括新进程加入)
都会投递到目标进程的线程池中处理。
第四步:触发“入职通知”——将当前进程加入作业
我们调用 AssignProcessToJobObject(),将攻击者自身进程加入该作业组:
AssignProcessToJobObject(hJob, GetCurrentProcess());
💥 瞬间:
- 内核检测到“新进程加入 Job” →
- 向目标进程的 IoCompletion 投递
JOB_OBJECT_MSG_NEW_PROCESS 完成包 →
- 工人取出包,解析
CompletionKey →
- 执行
TP_JOB 回调 → 运行我们的 Shellcode!
整个过程完全利用 Windows 作业对象的合法生命周期事件,无任何非常规操作。
🎯 战术优势:合法事件、低噪音、高兼容
| 优势 |
说明 |
| ✅ 触发源为系统事件 |
无需伪造 I/O 或 ALPC,仅依赖进程分配动作 |
| ✅ 行为高度合法 |
作业对象是标准 Windows 机制,广泛用于沙箱、容器 |
| ✅ 低噪音 |
仅一次 AssignProcessToJobObject 调用,无文件/网络痕迹 |
| ✅ 适用于受限环境 |
即使目标无网络、无文件写入权限,仍可触发 |
📌 适用场景:
- 目标进程已启用 I/O 完成端口;
- 需要最小化 API 调用痕迹;
- 作为轻量级 fallback 战术(比 StartRoutine 覆写更安全)。
🏗️ 代码结构:作业事件的精准劫持
在你的框架中,RemoteTpJobInsertion 继承自 AsynchronousWorkItemInsertion,体现了对“系统事件 → 完成端口 → 回调”链路的统一抽象:
void SetupExecution() const override {
// 1. 创建命名 Job 对象
// 2. 构造 TP_JOB,回调 = Shellcode
// 3. 写入目标进程
// 4. 先清空再绑定 Job 到目标 IoCompletion
// 5. AssignProcessToJobObject 触发入职事件
}
这种设计将进程生命周期事件转化为代码执行通道,展现了对 Windows 进程管理机制的深度理解。
⚖️ 与前五幕战术的对比
| 战术 |
触发源 |
是否需远程写入 |
是否依赖外部资源 |
风险 |
| StartRoutine 覆写 |
线程创建 |
是(代码段) |
否 |
高 |
| TpWork 插入 |
任务队列 |
是(队列) |
否 |
低 |
| TpWait 插入 |
事件 |
是(TP_WAIT) |
是(Event) |
极低 |
| TpIo 插入 |
文件 I/O |
是(TP_IO) |
是(File) |
极低 |
| TpAlpc 插入 |
ALPC 消息 |
是(TP_ALPC) |
是(ALPC Port) |
低 |
| TpJob 插入 |
进程加入 Job |
是(TP_JOB) |
否(仅需自身进程) |
极低 |
✅ 结论:
TP_JOB 是“最干净的触发器”——它不依赖文件、网络、信号,只靠一次合法的进程分配操作,便悄然完成使命。
🌐 下一幕预告
在第七幕中,我们将撕下所有伪装,回归最原始的执行本质:
不再依赖任务单、事件、文件或 ALPC,而是直接向 I/O 完成端口投递一个裸回调包。
我们将看到:
当所有外衣都被剥去,真正的力量往往藏在最简单的操作之中——
只需一次 ZwSetIoCompletion 调用,便能让目标线程池乖乖执行我们的指令。
PoolParty 攻击连续剧 · 第六幕 完
当然!以下是 《PoolParty 攻击连续剧》第七幕——也是任务单类战术的终极简化形态。
虽然你提供的代码名为 RemoteTpDirectInsertion,但从其实现逻辑看,它并非基于公开的 TP_DIRECT API(Windows 并未暴露 CreateThreadpoolDirect),而是直接构造一个 APC 回调上下文,并通过 ZwSetIoCompletion 向目标 I/O 完成端口投递裸完成包。
这一战术剥离了所有中间层(Work/Wait/IO/ALPC/Job),直击线程池回调机制的核心:
只要完成包的 Key 指向一个合法的回调结构,工人就会执行它。
我们将这一战术命名为:
“裸包投递”(Bare Completion Packet Injection)
🎭 PoolParty 攻击连续剧(第七幕):
“直接塞给工人一张纸条” —— 裸完成包投递的极简艺术
副标题:当所有伪装都显得多余,我们选择最原始的方式:直接递话。
在前六幕中,我们精心设计了各种“合法外衣”:
- 伪造任务单(TpWork)
- 设置信号陷阱(TpWait)
- 伪装磁盘响应(TpIo)
- 冒充内部通讯(TpAlpc)
- 欺骗人事系统(TpJob)
但这些战术,都需要构造复杂的上下文对象(TP_WORK、TP_WAIT 等),并依赖特定的触发条件。
今天,我们要回归本质——
I/O 完成端口的本质,只是一个队列;
线程池工人的本质,只是不断从中取出“包裹”并执行其中的指令。
正常流程:
系统组件(如文件、ALPC、Job)在事件发生时,
调用 ZwSetIoCompletion(Port, Key, ...),
将一个包含回调地址的完成包投递到队列。
我们的顿悟:
我们不需要“事件”,
我们不需要“上下文对象”,
我们只需要一个指向 Shellcode 的指针作为 Key,
然后直接调用 ZwSetIoCompletion,把包塞进去!
这,就是 PoolParty 家族中最简洁、最直接、最底层的战术:
裸完成包投递(Bare Completion Packet Injection)。
📬 世界观终章:快递中转站的终极漏洞
想象调度中心的“快递中转站”(IoCompletion)是一个开放式邮箱:
- 任何持有“投递权限”的人,都可以往里塞一封信;
- 信封上只需写明“收件人”(
CompletionKey);
- 工人取出信后,会无条件执行收件人指定的操作。
系统假设:
只有可信组件(如内核、系统 DLL)会投递信件,
且 CompletionKey 总是指向合法的回调结构(如 TP_DIRECT)。
我们的突破:
我们发现,这个邮箱没有身份验证!
只要我们能拿到邮箱的“投递句柄”(IoCompletion),
就可以伪造任意 CompletionKey——
比如,直接指向我们写入的 Shellcode 地址!
🔧 技术实现:两步完成终极投递
第一步:在目标进程构造一个“最小回调上下文”
虽然 Windows 未公开 TP_DIRECT 的完整定义,但线程池在处理完成包时,
仅需 Key 指向一个结构体,其首字段为回调函数指针即可。
我们构造一个极简结构:
struct TP_DIRECT {
PVOID Callback; // 必须是第一个字段
// 其他字段可为任意值(工人不访问)
};
然后将其写入目标进程:
TP_DIRECT direct = { .Callback = m_ShellcodeAddress };
auto pRemoteDirect = VirtualAllocEx(..., sizeof(TP_DIRECT));
WriteProcessMemory(hTarget, pRemoteDirect, &direct, sizeof(direct));
📌 关键洞察:
线程池工人执行时,会将 CompletionKey 强制转换为 PTP_DIRECT,
并调用 ((PTP_DIRECT)Key)->Callback()。
只要 Key 指向的内存首地址是我们的 Shellcode,就能执行!
第二步:直接投递完成包
调用未公开的内核 API ZwSetIoCompletion,直接向目标的 I/O 完成端口投递包:
ZwSetIoCompletion(
*m_p_hIoCompletion, // 目标的快递中转站
pRemoteDirect, // CompletionKey = 指向 Shellcode 的“回调结构”
nullptr, // CompletionContext(可为0)
0, // IoStatus
0 // IoStatusInformation
);
💥 瞬间:
- 工人从队列取出完成包 →
- 读取
CompletionKey →
- 调用
((PTP_DIRECT)Key)->Callback() →
- 跳转到 m_ShellcodeAddress,执行我们的代码!
整个过程无需任何中间对象、无需任何触发事件、无需任何系统回调注册,
仅靠一次内核 API 调用,完成代码执行。
🎯 战术优势:极简、极速、极难防御
| 优势 |
说明 |
| ✅ 代码量最小 |
仅需构造 8 字节结构 + 一次 ZwSetIoCompletion |
| ✅ 执行延迟最低 |
投递即执行(下一轮询) |
| ✅ 无行为特征 |
不创建文件、事件、ALPC、Job,无任何“前置动作” |
| ✅ 绕过高级检测 |
EDR 通常只监控 QueueUserAPC 或 CreateRemoteThread,忽略裸完成包 |
⚠️ 前提条件:
- 必须已获取目标进程的
IoCompletion 句柄(通常通过 WorkerFactory 推导);
- 目标线程池必须处于活跃状态(有工人轮询 IoCompletion)。
🏗️ 代码结构:回归本质的极简主义
在你的框架中,RemoteTpDirectInsertion 的实现堪称“大道至简”:
void SetupExecution() const override {
// 1. 构造 TP_DIRECT { .Callback = m_ShellcodeAddress }
// 2. 写入目标进程
// 3. ZwSetIoCompletion(IoCompletion, RemoteDirectAddress, ...)
}
它剥离了所有战术的“外壳”,直指 PoolParty 的核心原理:
线程池只是一个回调执行引擎,而完成端口是它的指令输入口。
🌐 PoolParty 战术全家福(最终版)
| 战术 |
类型 |
触发方式 |
复杂度 |
隐蔽性 |
| 1. StartRoutine 覆写 |
工人改造 |
强制创建线程 |
高 |
中 |
| 2. TpWork 插入 |
任务单 |
写入高优先级队列 |
中 |
高 |
| 3. TpWait 插入 |
任务单 |
事件触发 |
中 |
极高 |
| 4. TpIo 插入 |
任务单 |
文件 I/O 完成 |
中 |
极高 |
| 5. TpAlpc 插入 |
任务单 |
ALPC 消息 |
高 |
极高 |
| 6. TpJob 插入 |
任务单 |
进程加入 Job |
低 |
极高 |
| 7. 裸包投递(TpDirect) |
裸指令 |
直接投递完成包 |
极低 |
极高 |
✅ 总结:
PoolParty 不是一组技巧,而是一套对 Windows 线程池调度模型的深度逆向与重构。
从“欺骗工人”到“重塑工人”,再到“直接递话”,
我们完成了对线程池利用的完整闭环。
🎬 终章预告
在第八幕(最终幕)中,我们将探索最后一种任务单战术:
如何利用 TP_TIMER 实现定时自启?
——我们将埋下一张“定时炸弹任务单”,在指定时间自动执行 Shellcode,实现持久化或延迟攻击。
届时,我们将看到:
最安静的等待,往往孕育最致命的爆发。
并且
整合所有七种任务单 + 一种工人改造战术,构建一个自适应的 PoolParty 框架,
并探讨如何根据目标环境(线程池状态、EDR 配置、权限等级)动态选择最优战术。
真正的红队艺术,不在于掌握多少技巧,而在于知道何时使用哪一种。
PoolParty 攻击连续剧 · 第七幕 完
🎭 PoolParty 攻击连续剧(第八幕 · 终章):
“埋下一颗定时炸弹” —— 以及如何选择最合适的战术
副标题:真正的红队艺术,不在于掌握多少技巧,而在于知道何时使用哪一种。
在前七幕中,我们逐一揭开了 PoolParty 家族的七种任务单战术与一种工人改造战术:
- StartRoutine 覆写:重塑工人灵魂
- TpWork / TpWait / TpIo / TpAlpc / TpJob / 裸包投递:伪造各类任务单
- 而今天,我们将补全最后一环:
TpTimer 插入 —— 埋下一颗定时炸弹
⏳ 第八种战术:“定时自启任务单”(TP_TIMER Insertion)
想象调度中心有一个自动闹钟系统(Timer Queue):
- 应用可设置“10分钟后执行清理”;
- 线程池会定期扫描闹钟列表,触发到期回调。
正常流程:
CreateThreadpoolTimer() → SetThreadpoolTimer() → 系统将 TP_TIMER 插入定时器红黑树 → 到期后执行回调。
我们的诡计:
在本地构造一个 TP_TIMER,
将其手动插入目标进程的定时器队列根节点,
再调用 NtSetTimer2 触发内核定时器扫描,
让线程池在下一次闹钟检查时,执行我们的 Shellcode!
🔧 技术要点:
- 构造 TP_TIMER:回调 =
m_ShellcodeAddress,超时 = -10,000,000(1秒)
- 修复链表指针:
WindowStartLinks 和 WindowEndLinks 的 Flink/Blink 指向远程地址,形成合法循环
- 写入目标进程:分配内存并写入完整结构
- 篡改 TP_POOL 定时器队列根节点:
WriteProcessMemory(..., &tpPool->TimerQueue.AbsoluteQueue.WindowStart.Root, &remoteTpTimer->WindowStartLinks);
- 触发扫描:调用
NtSetTimer2 设置一个内核定定时器,迫使线程池执行 TppTimerQueueExpiration
💥 效果:
1秒后,线程池工人扫描定时器队列 → 发现我们的“炸弹” → 执行 Shellcode!
🎯 适用场景:
- 需要延迟执行(绕过即时检测);
- 作为持久化后门(可设置长周期重复触发);
- 目标线程池活跃但任务队列被监控时的替代方案。
🧩 PoolParty 战术全家福(完整版)
| 编号 |
战术 |
类型 |
触发方式 |
执行延迟 |
风险 |
隐蔽性 |
| 1 |
StartRoutine 覆写 |
工人改造 |
强制创建线程 |
立即 |
高(覆写代码) |
中 |
| 2 |
TpWork 插入 |
任务单 |
写入高优先级队列 |
极短 |
低 |
高 |
| 3 |
TpWait 插入 |
任务单 |
事件触发 |
可控 |
极低 |
极高 |
| 4 |
TpIo 插入 |
任务单 |
文件 I/O 完成 |
短 |
极低 |
极高 |
| 5 |
TpAlpc 插入 |
任务单 |
ALPC 消息 |
短 |
低 |
极高 |
| 6 |
TpJob 插入 |
任务单 |
进程加入 Job |
立即 |
极低 |
极高 |
| 7 |
裸包投递(TpDirect) |
裸指令 |
直接投递完成包 |
极短 |
极低 |
极高 |
| 8 |
TpTimer 插入 |
任务单 |
定时器到期 |
可编程延迟 |
低 |
高 |
🧠 红队实战:如何选择最优战术?
掌握所有战术只是开始,真正的挑战在于动态决策。以下是战术选择的决策树:
✅ 第一步:判断目标环境
| 条件 |
推荐战术 |
| 可创建新线程(线程池未满) |
StartRoutine 覆写(立即执行) |
| IoCompletion 可用 + 需最小痕迹 |
裸包投递(最快最简) |
| 需延迟/持久化 |
TpTimer 插入 |
| 目标有文件 I/O 行为 |
TpIo 插入(行为掩护强) |
| 目标使用 ALPC(如服务进程) |
TpAlpc 插入(高可信) |
| 无特殊依赖,求稳 |
TpJob 插入(仅需自身进程) |
✅ 第二步:规避检测
- 若 EDR 监控
VirtualAllocEx + WriteProcessMemory + CreateRemoteThread:
→ 优先选择 TpJob / TpWait / 裸包投递(无远程线程创建)
- 若 EDR 监控任务队列写入:
→ 改用 StartRoutine 覆写 或 TpTimer(操作不同内存区域)
- 若需绕过 AMSI/ETW:
→ 所有战术均适用(Shellcode 可加密,执行在目标进程上下文)
✅ 第三步:构建自适应框架
在你的 PoolParty 基类基础上,可实现战术调度器:
class AdaptivePoolParty {
public:
void Inject() {
if (CanCreateThread()) {
WorkerFactoryStartRoutineOverwrite(...).Inject();
} else if (HasIoCompletion()) {
if (PreferMinimalTrace()) {
RemoteTpDirectInsertion(...).Inject();
} else if (NeedDelay()) {
RemoteTpTimerInsertion(...).Inject();
} else {
RemoteTpJobInsertion(...).Inject(); // 最安全 fallback
}
}
}
};
🏁 终章总结:PoolParty 的哲学
PoolParty 不仅仅是一组注入技术,它是对 Windows 线程池调度模型的逆向工程与武器化。
它揭示了一个深刻事实:
Windows 的“合法机制”,往往也是最危险的攻击面。
- 线程池本为提升性能而生,却成了无文件执行的温床;
- I/O 完成端口本为高效通信而设,却成了跨进程回调的通道;
- 作业对象、ALPC、定时器……每一个系统组件,都可能成为红队的杠杆。
而我们的使命,不是破坏系统,而是理解它、利用它、超越它。
🌟 致谢与延伸
感谢你一路跟随 PoolParty 的八幕剧情。
攻防挑战,永无止境。
PoolParty 攻击连续剧 · 终章 完
作者:神奇的人鱼
技术标签:#PoolParty #Windows线程池 #TP_TIMER #NtSetTimer2 #战术自适应 #红队框架 #无文件注入 #高级持续性威胁
“我们不是在写 Shellcode,我们是在重写系统的规则。”