0x01 前言
4月份的时候分析一个黑灰产相关性质组织的样本的时候,发现其使用了一个比较新颖的远程进程注入方法,之前没遇到过,现在回过头来记录下(本来预取是5月份找时间更新的到blog的,但是5月的时候辞职了换了工作,跨城市搬家看房租房以及熟悉新环境,相关打点比较消耗精力,就没有写,最近端午放假调整休息的时候补上),当时主要从应用层底层调试以及sysmon行为上都没看到其调用常规的远程进程注入必须使用的关键函数(如:CreatRemoteThread、QueueUserAPC、setWindowsHookEX、suspendthread),但是最后哦却实现了对远程进程的注入。然后仔细分析看代码其实现的时候调用了一堆ALPC相关接口的函数从而实现了注入,于是开展了相关学习;
如下图是,当时对应样本中实现远程注入的核心代码:
简单分析,其实现注入大致使用了5步:
1、利用NtAlpcCreatePort创建ALPC端口对象,主要用于TpAllocAlpcCompletion创建TP_ALPC结构体,注意第三个参数是我们准备的回调参数shellcode;
1
2
3
4
5
6
7
8
9
10
result = ((__int64 (__fastcall *)(__int64 *, _QWORD, _QWORD))NtAlpcCreatePort)(&v35, 0i64, 0i64);
if ( (int)result >= 0 )
{
lpBuffer = 0i64;
result = ((__int64 (__fastcall *)(LPCVOID *, __int64, __int64, _QWORD, _QWORD))TpAllocAlpcCompletion)(
&lpBuffer,
v35,
qword_1800FFBB8,
0i64,
0i64);
2、再次调用NtAlpcCreatePort,创建真正的ALPC端口对象。
1
result = ((__int64 (__fastcall *)(__int64 *, int *, int *))NtAlpcCreatePort)(&v36, &v38, &v47);
3、调用writeProcessMemory,远程将上面创建的(TpAllocAlpcCompletion)TP_ALPC结构写入到目标进程。
1
2
WriteProcessMemory(hProcess, v13, lpBuffer, 0x128ui64, 0i64);
4、通过NtAlpcSetInformation将TP_ALPC结构关联到目标进程中的 IO 完成端口,等待触发执行。
1
((void (__fastcall *)(__int64, __int64, __int64 *, __int64))NtAlpcSetInformation)(v36, 2i64, v37, 16i64);
5、通过NtAlpcConnectPort构造客户端指定消息,连接ALPC端口,从而触发回调(shellcode)。
1
2
3
4
5
6
7
8
9
10
11
12
13
result = ((__int64 (__fastcall *)(char *, int *, __int128 *, int *, int, _QWORD, int *, __int64 *, _QWORD, _QWORD, __int64 *))NtAlpcConnectPort)(
v44,
&v33,
v55,
&v47,
0x20000,
0i64,
&v56,
&v46,
0i64,
0i64,
&v45);
}
原理大致如此,并且我们还可以看到里面出现了一些奇怪的字符串,这种一看就是从哪copy的代码,丢到github上一查,果然,项目如下:
https://github.com/SafeBreach-Labs/PoolParty
23年blackhat大会上提到的一类利用windows线程池管理机制实现的远程进程注入方法。
这里我们对该项目开展原理学习,以及检出测试。
0x02 PoolParty(上)
该项目提供了8种,利用windows的线程池机制实现的远程进程注入方法,如下:
通过描述,我们可以简单看出,不管是哪种方法,其核心都是通过在目标进程 插入对应的TP-xx对象或重写某些对象 ,然后通过一些方式方法触发,从而调用构造的恶意TP_xx中的相关回调、地址。从而实现远程进程注入。
拿上面的样本代码举例,其是通过项目中VariantID为5的技术手段实现的注入。
不难看出要理解这个项目的核心,是要搞懂这些TP_xx对象的工作机制,也就是windows下的线程池管理相关机制以及相关接口。
0x03 windows下的线程池管理
微软对windows的线程池原理框架可以参考如下连接:
https://learn.microsoft.com/en-us/windows/win32/procthread/thread-pools
同时PoolParty项目的作者也讲述了其对windows线程池机制的理解,参考如下连接:
https://www.safebreach.com/blog/process-injection-using-windows-thread-pools?utm_source=social-media&utm_medium=twitter&utm_campaign=2023Q3_SM_Twitter
笔者学习上面两个资料之后,对windows的线程池管理机制了解大致如下:
一、windows 线程池主要结构组成:
-
Worker threads that execute the callback functions 执行回调函数的工作线程
-
Waiter threads that wait on multiple wait handles 等待多个等待句柄的服务员线程
-
different types of work queue
不同类型的工作队列
-
default thread pool for each process 每个进程的默认线程池
-
worker factory that manages the worker threads 管理工作线程的工作程序工厂
借用PoolParty项目作者的图,表示如下:
二、逻辑流程
其对相关结构的关联关系以及相关逻辑描述其实不够清晰,如下是笔者对该机制的理解:
windows线程池支持三种常见类型的task(work_item)的提交,分别是常规工作项、异步工作项和计时器工作项;
常规工作项,直接提到到work queue 然后排队到workthread。
异步工作项,在完成相关动作后调用,例如,在写入文件作完成时。
计时器工作项,由正在排队的API调用立即排队,但在计时器过期时执行。
0x04 PoolParty(下)
我们回到PoolParty项目,一一查看其实现方式。从大方面看,其实就是两种方法。
- 通过劫持Worker Factory的start routine。
- 通过篡改ThreadPool相关属性插入任务。
方式一:WorkerFactory的StartRoutine地址Overwrite
原理:
注入的核心其实就是去接管(目标进程中的)WorkThread,因为其是最后执行回调的地方。 worker Thread种存在一个start routine。start routine 是WorkThead 的入口点。有意思的是 这个start routine 可以在Worker Factory 中查看到。虽然不能将这个start routine地址修改,但是拿到该地址,我们可以修改该地址里面内容,覆盖之前内容写入我们的shellcode即可,前提是拿到目标进程中的Worker Factory对象,该对象可以通过DuplicateHandle() API 获取,只要我们拿到了目标进程的高权限句柄即可。
光有这些还不够,还需要一个触发WorkThread中的startroutine的动作(当然这个不是必须的,虽然这个一定会在未来的某个时刻被调用,但是能够控制这个调用的时机是最好的),项目中利用线程的管理机制,通过一个叫setWorkerFactoryInfoClass的函数去修改WorkerFactory的WorkerFactoryThreadMinimum属性,修改为当前正在运行的线程数 + 1 ,从而导致创建新的Work Thread,从而执行start routine触发我们的shellcode。
代码实现:
方法二
windows线程池支持三种常见类型的task(work_item)的提交,分别是常规工作项、异步工作项和计时器工作项;
常规工作项,直接提到到work queue 然后排队到workthread。
异步工作项,提交到一个异步信号队列(windows 内核中称其为I/O completion queue),等待型号触发执行,例如在写入文件作完成时执行。
计时器工作项,提交到timer Queue,但在计时器过期时执行。
要实现注入核心就是可以掌控目标进程上面的相关队列即可,通过篡改相关属性从而实现劫持。
1、插入TP_Work对象
对于常规task,该项目利用其实现注入的思路是: 首先正常队列的相关地址需要从ThreadPool对象中去获取,该对象其实也在Worker Factory对象中,我们可以通过NtQueryInformationWorkerFactory,拿到WorkerFactory对象的StartParameter,其就是ThreadPool对象,然后利用这个ThreadPool对象获取高优先级队列,接着在本地创建一个task(work_item,常规的任务),利用从目标进程中获取到的ThreadPool来初始化,最后将该work_item对象写到目标进程,并且篡改队列链表将该work_item加入其中。
代码实现如下:
2、(异步任务)插入TP_IO对象
方法大致和上面一致,区别在于这里是异步的任务,异步任务中的IO,需要构造的是一个TP_IO对象,TP_IO结构中用于执行的结构是TP_DIRECT,并且创建一个异步的文件句柄,然后将TP_IO写入到目标进程的 I/O 完成队列(Microsoft 将 I/O 完成队列称为 I/O 完成端口。此对象本质上是一个内核队列 ),然后在目标进程中将io队列设置为文件完成队列,并和创建的TP_IO关联。
最后通过触发文件操作,从而触发回调。
代码实现如下:
3、4、5(异步任务)
然后 ALPC、JOB 和 WAIT和上面的TP_IO基本一致都是异步任务,只不过触发的动作不一样,比如alpc调用(这也是上文提到的样本使用的远程进程注入的手法)等。
6、(异步任务)直接插入TP_DIRECT,2的极简版
7、插入Timer对象
原理:
获取目标进程的 WorkerFactory 信息,拿到ThreadPool对象,用于构造以及初始化创建的TP_Timer对象;然后创建TP_Timer对象;通过获取到的目标进程ThreadPool对象,使TP_Timer对象挂接到TP_Thread的 定时器队列;写入到目标进程,最后将TP_Timer中的定时器,挂载到Thread Pool对象的接口。
代码实现如下:
0x05 测试
简单运行测试,如下通过id为5的技术即上面恶意软件使用的通过ALPC实现的注入。注入的shellcode(shellcode功能:open一个calc)到notepad进程。
0x06 检测
说下 相关检测思路,我们这里直接看上面提到的恶意软件中利用tpalpc实现的注入方式,其实核心就是其本地构造了一个包含shellcode回调的TP_ALPC
结构体,然后远程写到目标进程中,并关联到目标进程的IO 完成端口,最后触发相关调用。
这个过程的关键点:
1、首先需要在目标进程开辟存放shellcode的空间并写入目标进程。
2、然后本地通过TpAllocAlpcCompletion函数构造一个TP_ALPC
的结构体,然后利用写入到目标进程中的shellcode地址作为回调地址。
3、最后将构造好的TP_ALPC
结构体写入到目标进程中,并关联到目标进程的io完成端口。
我们的检出思路显而易见:
1、非白名单进程调用VirtualAllocEx在远程进程开辟空间(严苛点可以是可读可写可执行空间,存在被绕过风险)。
2、监控所有进程调用TpAllocAlpcCompletion创建TP_Alpc
结构体的行为(直接应用层上hook就行),判断其第三个参数:PTP_ALPC_CALLBACK Callback
的地址位置,在调用进程中是否被使用,进一步还能判断是否是可读可写可执行权限(因为这里可能存在误打误撞正好就开辟了对应地址的空间,但是权限也一致的概率就非常小了)。本质其实就是区分正常的windows线程管理调用TpAllocAlpcCompletion创建TP_Alpc
结构体的行为和恶意调用,恶意调用是跨进程的。当然除此之外,还可以结合对应的回调地址位置来判断是否是恶意,如果该地址是开辟的低地址空间,那么不太应该;如果该地址是一些高位dll的代码区间,那么基本是没问题的(当然攻击者也可以通过一些方式去构造这种形式的恶意调用)。(核心的检出逻辑)
3、监控所有进程调用AlpcSetInformation 关联端口和结构体的时候, AlpcPortAssociateCopmletionPort参数中的CompletionKey的值是否是本地地址(和上面一样的逻辑)
例如,如下创建结构体的过程,回调函数地址(0x1C04E460000)不在本进程空间:
本进程里面没有开辟对应地址的相关空间。
进一步,我们想要探究其想要注入的进程的时候,可以看其调用过Openprocess打开过哪个进程的句柄。
如下图,其调用OpenProcess打开的句柄对应的进程pid是:0x238,568;
同时我们在其想要注入的远程进程中可以看到,相关地址空间,并且相关权限是可读可写可执行。如下图。
总结,其实不管是上面的哪种方式,检出点都是围绕实现过程中核心函数的调用参数的检出,大同小异,这里我们就不挨个写了。