找回密码
立即注册
搜索
发回帖 发新帖

4772

积分

0

好友

608

主题
发表于 昨天 23:04 | 查看: 5| 回复: 0

一、老办法为什么越来越不好使

教科书式的 DLL 注入是四件套:

OpenProcess → VirtualAllocEx → WriteProcessMemory → CreateRemoteThread(LoadLibraryW, 路径)

在目标进程里申请一块内存,把 DLL 路径字符串写进去,再创建一个远程线程,线程入口直接指向 LoadLibraryW,参数就是那个路径。DLL 一被载入,DllMain 自动执行,代码就算落地了。

问题在于,这套动作里的每一个 API 都已经被安全产品“记住了”。SafeBreach Labs 在研究中把进程注入拆成三个原语——分配(allocation)、写入(writing)、执行(execution)——然后用实验得出一个关键结论:

EDR 的检测重心几乎全压在「执行原语」上,而最基础形态的分配与写入原语,基本不被检测。

于是思路就变了:能不能构造一种只依赖“分配 + 写入”的执行原语? 更进一步——如果执行的触发来自一个完全合法的系统行为,而不是攻击者显式发起的调用,会怎样?

Windows 用户态线程池,恰好就是这个问题的答案。


二、线程池:一个被低估的执行原语工厂

Windows线程池三层结构与可被滥用的注入面示意图

现代 Windows 用户态进程都能访问 ntdll 的线程池实现。多数 GUI / 服务 / shell 进程(notepad.exe、explorer.exe、各类 svchost)一旦有线程池 API 或依赖子系统触发初始化,进程内就会存在可用的默认线程池状态。池一旦活跃,就是三层协作结构:

层 · 关键对象 作用
用户态句柄层 · PTP_POOL 池的句柄,由 CreateThreadpoolWork 等返回
用户态工作项层 · _TP_WORK 等 装着回调指针 + 上下文 + 清理组链接的结构体
内核态调度层 · Worker Factory 真正取任务、跑回调的那些线程

补充:前两层都位于调用方堆上;第三层由 NtCreateWorkerFactory 建立,位于系统地址空间。工作项类型除 _TP_WORK 外,还有 _TP_TIMER、_TP_WAIT、_TP_IO、_TP_DIRECT、_TP_ALPC、_TP_JOB。

对注入者来说,这个组件堪称完美目标,原因有四:

  1. 所有 Windows 进程默认都有线程池—— 意味着技术适用性覆盖全进程;
  2. 工作项和池都由结构体表示—— 天然适合用“分配 + 写入”原语去构造执行原语;
  3. 支持多种工作项类型—— 队列类型多,机会就多;
  4. 组件横跨内核态与用户态,复杂度高 —— 攻击面自然被放大。

需要注意:线程池对用户代码是故意不透明的。_TP_WORK 的字段偏移不属于任何你可以 #include 的头文件,只有函数指针的原型是公开的。SafeBreach 的贡献正是逆向出这些结构,并证明——*只要能在目标进程地址空间里放一个语法合法的 `TP` 结构,并向该进程的 worker factory “宣告”它的存在,线程池就会替你在目标进程里执行你的回调,而不需要你创建任何线程。**

一次“正常”的提交长什么样

ntdll!TpAllocWork
  → RtlAllocateHeap                                  // 在本地堆上分配一个 TP_WORK
  → TP_WORK.CleanupGroupMember.Pool = 进程默认池
  → TP_WORK.Task.Callback          = 回调
  → TP_WORK.Task.Context           = 上下文

ntdll!TpPostWork(或 SubmitThreadpoolWork)
  → 把 TP_WORK 插入 Pool->WorkQueue(无锁链表)
  → 置 WorkState.Exchange = 2                        // 标记为"可取"
  → NtSetIoCompletion(Pool->IoCompletion, 1)         // 唤醒一个 worker 线程

[worker 线程在 NtRemoveIoCompletion 上醒来]
  → 取 WorkQueue 队首
  → 调用 TP_WORK.Task.Callback(TP_WORK.Task.Context)

两点值得留意:

  • 回调在操作系统已经拥有的 worker 线程里执行,用户代码没有创建任何新线程;
  • 整条“插入 + 派发”路径都经过一个进程内的 I/O 完成端口(Pool->IoCompletion),靠 NtSetIoCompletion 发信号唤醒。这个句柄,正是各个变体最终以不同方式去攻击的东西。

三、PoolParty 的三步原语

正常提交与注入路径对比:同一套调度两种写入者

所有 PoolParty 变体都收敛为同一个三步形状,差别只在第三步:

1. OPEN    —— OpenProcess(目标, PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_DUP_HANDLE)
2. WRITE   —— VirtualAllocEx(目标, RW) + WriteProcessMemory(伪造的 _TP_* 结构)
              · 其 Task.Callback 指向要执行的代码
              · 其 CleanupGroupMember.Pool 指向目标的默认池
                (通过 NtQueryInformationProcess + ReadProcessMemory 读出来)
3. ANNOUNCE—— 怎么告诉目标的 worker factory:"醒醒,你队列里有新活了,地址在这儿"

SafeBreach 在 Black Hat Europe 2023 上给出了 8 个变体,可归为两类战术:

七种“任务单投递”战术(利用系统原生事件作触发器,把恶意回调塞进任务队列):
TP_WORK 伪造高优先级任务单、TP_WAIT 绑定事件信号、TP_IO 伪装文件 I/O 完成包、TP_ALPC 冒充可信组件间通信、TP_JOB 利用进程加入作业对象事件、TP_TIMER 设置延迟/周期回调、以及直接向 IoCompletion 投递回调指针的裸完成包形态。

一种“工人改造”战术:绕过任务队列,直接篡改 Worker Factory 的 StartRoutine,让新生的工人线程一开始就跑攻击者的逻辑。

在当年的测试中,这套技术对五家主流 EDR(Palo Alto Cortex、SentinelOne、CrowdStrike Falcon、Microsoft Defender for Endpoint、Cybereason)达成了完全绕过。

重要澄清(本文增补):这类“100% 绕过”的结论是2023 年时间点的测试快照,不等于今天仍然成立。Trustwave 后续的 C# 实现 SharpParty 就观察到:微软在 2025 年 3 月收到报告并实现检测后,相关检出明显上升。把这类结论当成永恒真相,是运维侧最容易踩的坑。


四、“无 shellcode”到底难在哪:回调签名与参数位错位

无Shellcode难点:回调签名与参数位错位

这是整件事真正的技术内核,也是最容易被含糊带过的地方。

经典 PoolParty 是把 shellcode 写进目标(往往是一块 RWX 内存),然后让 Task.Callback 指向它。所谓 shellcodeless,就是不往目标里写任何自定义代码,而是让回调直接指向一个目标进程里已经存在的合法函数——最自然的选择就是 LoadLibraryW。

听起来很直接,但一动手就会撞墙。公开资料里的实证把这个问题讲得很清楚:

PTP_WORK_CALLBACK 的类型展开是——

VOID CALLBACK WorkCallback(
    PTP_CALLBACK_INSTANCE Instance,   // 参数一
    PVOID                 Context,    // 第二个参数
    PTP_WORK              Work        // 第三个参数
);

而 TpAllocWork 的 PVOID OptionalArg 会被当作 Context(参数二) 传递给回调。可 LoadLibraryA/W 只接受一个参数:

HMODULE LoadLibraryW(LPCWSTR lpFileName);

在 x64 调用约定下,回调被调用时是 RCX = Instance、RDX = Context、R8 = Work。而 LoadLibraryW 只读 RCX。于是:

朴素地把 LoadLibraryA 塞给 TpAllocWork 当回调,DLL 路径(wininet.dll)会落到 RDX,而 RCX 拿到的是 ntdll 内部 TppWorkPost 动态生成的 TP_CALLBACK_INSTANCE 结构指针——并不是路径字符串。结果要么加载失败,要么加载到错误内容。

实证者还试过一个折中:写一个桩回调,里面调 LoadLibraryA(Context)。这确实能跑通,但桩函数本身落在私有 RX 内存里,调用栈变成 LoadLibraryA → RX 区内的桩 → RtlUserThreadStart,又回到了“被执行栈回溯盯上”的老问题——绕了一圈,等于没绕。

所以“shellcodeless”的成立条件,本质就是一件事:让回调拿到的首参所指向的内存,直接就是 DLL 路径字符串。一旦这件事成立,LoadLibraryW 就成了“天然可用”的回调,目标进程里只多出一个路径串 + 一个伪造结构,没有任何自定义代码、没有 RX/RWX 区段、没有新建线程。

⚠️ 未核实标注:具体到 DLLParty 采用哪一种手段来达成首参指向路径(例如如何安排伪造工作项中“回调实例 / 上下文”区域的内存布局、或是否借助其他工作项类型绕过签名差异),因原文未能抓取,本文不作断言。上述“参数位错位”问题及其后果,来自可核实的公开实证;而 DLLParty 的具体解法,请以原文为准。


五、完整流程(逻辑骨架)

需要强调的是,下面只是逻辑骨架,不包含可用于直接攻击的完整实现。

① 打开目标
OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_DUP_HANDLE | PROCESS_QUERY_INFORMATION)

② 定位目标的线程池
   · 经 Worker Factory 句柄 → NtQueryInformationWorkerFactory 取 StartParameter
     (该参数实质上就是指向 TP_POOL 结构的指针)
   · 或走 NtQueryInformationProcess + ReadProcessMemory 读出默认池

③ 写入负载数据
VirtualAllocEx(目标, RW) + WriteProcessMemory:
     · DLL 路径字符串(宽字符)
     · 伪造的 _TP_WORK(或 _TP_TIMER / _TP_WAIT / _TP_IO …)
       ├ Task.Callback  = LoadLibraryW 在目标进程中的地址
       ├ Task.Context   = 路径串地址
       └ CleanupGroupMember.Pool = 目标的默认池

④ 宣告并触发(变体差异点)
   · 常规工作项:把伪造结构挂进目标任务队列链表
   · 异步工作项:投递到 I/O 完成队列,靠后续 I/O 事件触发
   · 定时器工作项:挂进 timer queue,到期触发
   · 或直接 NtSetIoCompletion 唤醒 worker

⑤ 落地
   目标进程自己的 worker 线程(起始于 ntdll!TppWorkerThread)取出任务,
   调用 LoadLibraryW(路径) → DLL 载入 → DllMain 以目标进程身份执行

与传统的对比:

① 是否新建线程

  • 传统:是,CreateRemoteThread 属高危强信号
  • 线程池:否,复用系统已有的 worker

② 是否需要 shellcode

  • 传统:不需要,但必须建远程线程
  • 线程池:不需要,也不需要桩代码

③ 目标内新增内存属性

  • 传统:通常是 RWX,强信号
  • 线程池:仅 RW 数据,只有路径串与伪造结构

④ 执行触发者

  • 传统:攻击者显式调用
  • 线程池:线程池的合法调度

⑤ 检测抓手

  • 传统:CreateRemoteThread、RWX 区段、远程线程起始地址
  • 线程池:线程池结构被跨进程改写、模块加载调用栈异常

六、范围、限制与脆弱性(本文增补)

这类技术并非“放之四海皆准”,实际约束不少:

  1. 目标线程池必须已初始化。若进程尚未建立可用线程池状态,工作项无处可挂。
  2. 需要相当高的进程句柄权限。PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_DUP_HANDLE 本身就该是监控重点。
  3. 结构偏移随系统版本漂移。_TP_WORK 等结构不属于公开 ABI,字段布局靠逆向获得;Windows 更新可能导致失效甚至把目标进程搞崩。这是该族技术最本质的脆弱性。
  4. 依赖目标进程内 LoadLibraryW 的地址。需要解析目标进程的模块导出表,并考虑 ASLR 与架构(x86/x64)差异。
  5. 依赖“参数位”这一层约定。签名与调用约定的任何变化都会让“直接拿 LoadLibrary 当回调”失效。
  6. DllMain 内的 loader lock 限制。在 DLL 加载锁内可调用的 API 受限;若在 DllMain 里再去做复杂初始化(如 C2 拉起、再加载其他 DLL),容易死锁或卡住——实践中更稳妥的做法是只在 DllMain 做最小动作。
  7. 检测态势是动态的。如前所述,2023 年的“完全绕过”结论在 2025 年后已被部分厂商追上。

七、检测与防御

线程池注入检测与防御多维度策略

防御侧的好消息是:线程池内部结构虽然不透明,但也正因为“正常形态”高度固定,异常反而显眼。

  • 线程起始地址基线:正常 worker 线程的起始地址恒为 ntdll!TppWorkerThread。一个自称 worker、却起始于私有 / 无映像支持内存的线程,值得二次审视。
  • 结构完整性:校验 TP_POOL 任务队列链表与工作项字段的一致性;识别那些不属于本进程分配器的伪造工作项。
  • 句柄监控:监视对 TpWorkerFactory 对象的跨进程句柄获取,以及 NtSetIoCompletion / NtQueryInformationWorkerFactory 等调用的异常组合。
  • 行为遥测(最关键):紧盯模块加载事件——进程加载了非常规来源的 DLL,且 LoadLibrary 的调用栈中,返回地址落在线程池调度路径内(而非正常的业务调用链)。这是“shellcodeless”绕不开的落脚点:DLL 终究要被加载,加载就会留下遥测。
  • 权限收敛:减少可被跨进程写入的进程句柄暴露面;对敏感进程启用受保护进程(PPL)等机制。
  • 检测理念调整:不要把 CreateRemoteThread 当成进程注入的同义词。应把“线程池结构被跨进程改写”本身当作一等信号来采集。

参考地址

  1. DLLParty: Abusing Thread Pool Internals for Shellcodeless DLL Injection
  2. SafeBreach Labs 原始研究
  3. SafeBreach Labs PoC 仓库
  4. 线程池内部机制与 PoolParty 变体分析
  5. 回调签名与参数位错位实证(0xdarkvortex)
  6. Windows 线程池安全相关性梳理
  7. 线程池注入 PoC(变体对照)
  8. SharpParty(C# 实现与检测态势变化)
  9. 事件报道



上一篇:别让 AI 盲人摸象:Ontexus 把企业知识变成知识图谱
下一篇:CyberStrikeAI 全自动红队渗透实战:多代理编排与 100+ 安全工具集成
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-4 02:59 , Processed in 0.078208 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表