查看原文
其他

通过对PsSetCreateProcessNotifyRoutineEx的逆向分析得出的结果来实现反进程监控

1900 看雪学苑 2022-07-01


本文为看雪论坛优秀文章
看雪论坛作者ID:1900



1


前言


关于如何使用PsSetCreateProcessNotifyRoutineEx来实现进程监控,请看这篇文章:
通过PsSetCreateProcessNotifyRoutineEx和PsSetCreateThreadNotifyRoutine实现进程与线程监控(https://bbs.pediy.com/thread-269312.htm)

这篇主要是讲解通过逆向PsSetCreateProcessNotifyRoutineEx来查看这个API如何实现的进程监控并以此来实现反进程监控。

这次的实验是在Win7 x86系统上进行,所以如果是其他版本的Windows系统,里面的具体实现会有不同。


2


逆向分析PsSetCreateProcessNotifyRoutine


首先看下PsSetCreateProcessNotifyRoutine的反汇编结果。
PAGE:005947DE ; NTSTATUS __stdcall PsSetCreateProcessNotifyRoutine(PCREATE_PROCESS_NOTIFY_ROUTINE NotifyRoutine, BOOLEAN Remove)PAGE:005947DE public PsSetCreateProcessNotifyRoutinePAGE:005947DE PsSetCreateProcessNotifyRoutine proc nearPAGE:005947DE ; CODE XREF: sub_6FC83F+2B↓pPAGE:005947DE ; sub_798D02+22↓pPAGE:005947DEPAGE:005947DE NotifyRoutine = dword ptr 8PAGE:005947DE Remove = byte ptr 0ChPAGE:005947DEPAGE:005947DE mov edi, ediPAGE:005947E0 push ebpPAGE:005947E1 mov ebp, espPAGE:005947E3 push 0 ; 将0压入栈中PAGE:005947E5 push dword ptr [ebp+Remove] ; 将Remove压入栈中PAGE:005947E8 push [ebp+NotifyRoutine] ; 将Routine压入栈中PAGE:005947EB call PspSetCreateProcessNotifyRoutinePAGE:005947F0 pop ebpPAGE:005947F1 retn 8PAGE:005947F1 PsSetCreateProcessNotifyRoutine endp

可以看到PsSetCreateProcessNotifyRoutine就干了一件事,就是调用PspSetCreateProcessNotifyRoutine。在对它的调用时候,压入的两个参数除了NotifyRoutine和Remove以外,还压入了一个0。事实上PsSetCreateProcessNotifyRoutine和PsSetCreateProcessNotifyRoutineEx的唯一区别就是,在压入第三个参数的时候,PsSetCreateProcessNotifyRoutine压入的是0,而PsSetCreateProcessNotifyRoutineEx压入的是1。

接着跟进PspSetCreateProcessNotifyRoutine。
PspSetCreateProcessNotifyRoutine proc nearPAGE:005947F9 ; CODE XREF: PsSetCreateProcessNotifyRoutineEx+D↑pPAGE:005947F9 ; PsSetCreateProcessNotifyRoutine+D↑pPAGE:005947F9PAGE:005947F9 arg_Routine = dword ptr 8PAGE:005947F9 arg_Remove = dword ptr 0ChPAGE:005947F9 arg_Zero = byte ptr 10hPAGE:005947F9PAGE:005947F9 mov edi, ediPAGE:005947FB push ebpPAGE:005947FC mov ebp, espPAGE:005947FE cmp byte ptr [ebp+arg_Remove], 0 ; 判断Remove是否为0PAGE:00594802 push ebxPAGE:00594803 push esiPAGE:00594804 push ediPAGE:00594805 jz loc_594905 ; 为0则跳转,当要增加回调的时候,会传入FALSE,所以这里会跳转

函数首先判断压入的Remove参数是否为0,是的话则跳转。由于在增加回调的时候,Remove参数会使用FALSE,所以这个时候这个跳转会成立。接着跟进loc_594905看看。
PAGE:00594905 loc_594905: ; CODE XREF: PspSetCreateProcessNotifyRoutine+C↑jPAGE:00594905 cmp [ebp+arg_Zero], 0 ; 判断第三个参数是否为0PAGE:00594909 jz short loc_59491E PAGE:0059490B push [ebp+arg_Routine]PAGE:0059490E call sub_56F869PAGE:00594913 test eax, eaxPAGE:00594915 jnz short loc_59491E PAGE:00594917 mov eax, STATUS_ACCESS_DENIEDPAGE:0059491C jmp short loc_59496B

在loc_594905中,函数会判断传进的第三个参数是否为0。上面提到过,这个参数其实是区别你调用的是PsSetCreateProcessNotifyRoutine还是PsSetCreateProcessNotifyRoutineEx。如果是后者,这个参数就是1,前者这个参数是0。而两者的区别只是后者会调用sub_56F869。但由于这个函数的作用对今天的主题而言没什么意义,所以这里不分析,直接接着看loc_59491E的内容。
PAGE:0059491E loc_59491E: ; CODE XREF: PspSetCreateProcessNotifyRoutine+110↑jPAGE:0059491E ; PspSetCreateProcessNotifyRoutine+11C↑jPAGE:0059491E xor eax, eax ; 将eax清0PAGE:00594920 cmp [ebp+arg_Zero], al ; 将传入的第三个参数与al做比较PAGE:00594923 setnz al ; 将zf位取反然后赋值给alPAGE:00594926 push eax ; 将eax入栈PAGE:00594927 push [ebp+arg_Routine] ; 将要设置的回调函数地址入栈PAGE:0059492A call AllocateAssignPAGE:0059492F mov ebx, eax ; 将分配的内存的地址赋给ebxPAGE:00594931 test ebx, ebx ; 判断ebx是否为0PAGE:00594931 ; 为0则为eax赋值内存不够的返回值并跳到函数结束PAGE:00594933 jnz short loc_59493CPAGE:00594935 mov eax, STATUS_INSUFFICIENT_RESOURCESPAGE:0059493A jmp short loc_59496B

函数将要设置的回调函数的地址作为第一个参数,根据调用是否带有Ex的PsSetCreateProcessNotifyRoutine来传入第二个参数。

这里主要是调用AllocateAssign这个函数,当然这个函数名是我重命名以后的函数名。因为这个函数就是为保存回调函数地址申请一块空间,如果申请到了那么会跳转继续执行,如果没申请到就会设置返回值为没有足够的资源并且跳到函数结束执行,接下来看看AllocateAssign的反汇编结果。
PAGE:005946D7 AllocateAssign proc near ; CODE XREF: PsSetLoadImageNotifyRoutine+E↑pPAGE:005946D7 ; DbgkLkmdRegisterCallback+53↑p ...PAGE:005946D7PAGE:005946D7 arg_NotifyRoutine= dword ptr 8PAGE:005946D7 arg_Zero = dword ptr 0ChPAGE:005946D7PAGE:005946D7 mov edi, ediPAGE:005946D9 push ebpPAGE:005946DA mov ebp, espPAGE:005946DC push 'brbC' ; TagPAGE:005946E1 push 0Ch ; NumberOfBytesPAGE:005946E3 push PagedPool ; PoolTypePAGE:005946E5 call ExAllocatePoolWithTagPAGE:005946EA test eax, eaxPAGE:005946EC jz short loc_5946FDPAGE:005946EE mov ecx, [ebp+arg_NotifyRoutine]PAGE:005946F1 and dword ptr [eax], 0 ; 分配到的内存低4位清0PAGE:005946F4 mov [eax+4], ecx ; 分配到的内存的中间4位赋值为函数地址PAGE:005946F7 mov ecx, [ebp+arg_Zero]PAGE:005946FA mov [eax+8], ecx ; 分配到的内存的最高4位赋值为传入的参数,此时为0PAGE:005946FDPAGE:005946FD loc_5946FD: ; CODE XREF: AllocateAssign+15↑jPAGE:005946FD pop ebpPAGE:005946FE retn 8PAGE:005946FE AllocateAssign endp

可以看到这个函数中,首先是申请0xC大小的内存空间,然后将低4为清0,中间4位赋值为要设置的回调函数的地址,最高4位根据传入的参数来设置。

由此我们可以知道,在PsSetCreateProcessNotifyRoutine函数中,系统会分配0xC大小的内存,而内存的中间4位保存的就是要设置的回调函数的地址。根据调用的是PsSetCreateProcessNotifyRoutine还是PsSetCreateProcessNotifyRoutineEx,系统会设置最高的4位的值,如果是前者会设置位0,后者设置位1。

接着看内存看内存分配并且赋值成功以后,会执行的代码,也就是loc_59493C的代码。
PAGE:0059493C loc_59493C: ; CODE XREF: PspSetCreateProcessNotifyRoutine+13A↑jPAGE:0059493C mov esi, offset ProcessFuncArray ; 将进程数组地址赋给esiPAGE:00594941 xor edi, edi ; 为edi清0PAGE:00594943PAGE:00594943 loc_594943: ; CODE XREF: PspSetCreateProcessNotifyRoutine+165↓jPAGE:00594943 push 0 ; 将0压入栈中PAGE:00594945 mov ecx, ebx ; 分配到的内存的地址赋值给ecxPAGE:00594947 mov eax, esi ; 数组地址赋值给eaxPAGE:00594949 call SetArrayPAGE:0059494E test al, alPAGE:00594950 jnz short loc_594972 ; 函数返回值不为0,说明设置成功PAGE:00594952 add edi, 4 ; 将edi加4PAGE:00594955 add esi, 4 ; 数组的地址加4,也就是获得下一个元素的地址PAGE:00594958 cmp edi, 100h ; 判断是否小于0x100,小于的话。使用新地址来调用上面的函数继续设置PAGE:0059495E jb short loc_594943 ; 这里可以得出结论,这个数组一共0x100 % 4 = 0x40PAGE:0059495E ; 也就是说,最多可以设置64个进程创建的回调函数PAGE:00594960 push ebx ; BufferPAGE:00594961 call FreeAllocatePAGE:00594966 mov eax, STATUS_INVALID_PARAMETER ; 如果执行到这里说明赋值失败PAGE:00594966 ; 接下来就释放内存,返回值设为失败PAGE:0059496BPAGE:0059496B loc_59496B: ; CODE XREF: PspSetCreateProcessNotifyRoutine+A8↑jPAGE:0059496B ; PspSetCreateProcessNotifyRoutine+10A↑j ...PAGE:0059496B pop ediPAGE:0059496C pop esiPAGE:0059496D pop ebxPAGE:0059496E pop ebpPAGE:0059496F retn 0Ch

可以看到,函数首先将一个数组地址赋给esi,然后将0压入栈中并且调用SetArray这个函数,在调用这个函数之前还把esi的地址,也就是这个数组的地址赋给了eax,把ebx的值赋给ecx,这个ebx的值就是在上面调用完AllocateAssign函数以后得到的分配和赋值的内存地址。因为在调用AllocateAssign函数后的返回值eax赋值给了ebx。

调用完SetArray函数以后,会判断返回值是否为0,不为0那就是调用成功,也就是回调函数设置成功,就会跳转到函数调用成功的退出代码执行。如果为0说明并没有设置完成,随后会对edi和esi进行加4的操作并判断edi是否小于0x100,如果小于就跳上去继续执行SetArray函数。由于此时esi指向的是ProcessFuncArray数组的地址,所以加4就是指向数组中下一个元素,edi就是为了避免数组溢出而设置,根据值的大小可以猜测这个数组一共0x40个大小。

分析到这里就可以得出结论,PsSetCreateProcessNotifyRoutine函数设置进程监控的回调函数的办法就是首先申请一块内存来保存要设置的回调函数的地址,然后将这个申请到的内存的地址赋到ProcessFuncArray数组中的某个元素。

那么它是怎么判断要赋值到哪个元素呢,就要继续跟进SetArray函数。
PAGE:00594706 SetArray proc near ; CODE XREF: IoRegisterPriorityCallback+5B↑pPAGE:00594706 ; IoUnregisterPriorityCallback+4C↑p ...PAGE:00594706PAGE:00594706 var_8 = dword ptr -8PAGE:00594706 var_4 = dword ptr -4PAGE:00594706 arg_Zero = dword ptr 8PAGE:00594706PAGE:00594706 mov edi, ediPAGE:00594708 push ebpPAGE:00594709 mov ebp, espPAGE:0059470B push ecx ; 此时ecx是分配的地址,eax是数组的地址PAGE:0059470C push ecxPAGE:0059470D push ebxPAGE:0059470E push esiPAGE:0059470F push ediPAGE:00594710 mov esi, eax ; 将数组地址赋给esiPAGE:00594712 test ecx, ecx ; 判断分配地址是否为0PAGE:00594714 jz short loc_594726 PAGE:00594716 push 8PAGE:00594718 pop edxPAGE:00594719 call ExAcquireRundownProtectionEx ; 为了让调用者安全访问对象,这里不是重点,过PAGE:0059471E test al, alPAGE:00594720 jz loc_5947D0 ; 返回为0就代表函数运行失败,会退出函数PAGE:00594726PAGE:00594726 loc_594726: ; CODE XREF: SetArray+E↑jPAGE:00594726 mov ebx, [esi] ; 将数组中的内容取出赋给ebxPAGE:00594728 mov eax, ebx ; 将内容赋给eaxPAGE:0059472A jmp short loc_594749

函数将数组中的对应的内容赋给eax以后跳转到loc_594759执行,接着看loc_594749处的代码。
PAGE:00594749 loc_594749: ; CODE XREF: SetArray+24↑jPAGE:00594749 xor eax, [ebp+arg_Zero] ; 将得到的值与0进行异或,与7做比较,小于7则跳转.PAGE:0059474C cmp eax, 7 ; 所以这两条合起来看的话,就是除了低3位,其他都为0,否则就跳转PAGE:0059474F jbe short loc_59472C ; 可以得出结论,数组中元素高29位用来判断是否还未使用。PAGE:0059474F ; 如果为0,说明还未使用

在这里程序将得到的数组中对应的元素内容和传入的参数进行异或,然后判断是否小于7,如果小于7就跳转到对数组进行赋值的代码进行执行。而0x7对应的二进制是高29位是0,低3位是1,在跟进得到的数组内容要和0进行异或可以知道,这里是判断得到的数组中的内容高29位是不是0。如果高29位中有1个是1,那么异或以后得到的值就会大于7。

接着看loc_59472C中的代码,也就是对数组进行赋值的代码。
PAGE:0059472C loc_59472C: ; CODE XREF: SetArray+49↓jPAGE:0059472C test ecx, ecx ; ecx是申请的地址,判断是否为0,为0则跳转PAGE:0059472E jz short loc_594737PAGE:00594730 mov eax, ecx ; 将ecx赋给eaxPAGE:00594732 or eax, 7 ; 将低三位置1,这里要注意,在将申请到的内存地址复制到数组之前,会将地址的低3位置1PAGE:00594735 jmp short loc_594739 PAGE:00594737 ; ---------------------------------------------------------------------------PAGE:00594737PAGE:00594737 loc_594737: ; CODE XREF: SetArray+28↑jPAGE:00594737 xor eax, eax ; 如果ecx是0,那就会跳转到这里执行,也就是eax会被变成0PAGE:00594739PAGE:00594739 loc_594739: ; CODE XREF: SetArray+2F↑jPAGE:00594739 mov edx, eax ; 将或运算得到的值赋给edxPAGE:0059473B mov edi, esi ; 取出数组对应元素地址给ediPAGE:0059473D mov eax, ebx ; ebx保存的是数组中的内容,赋给eaxPAGE:0059473F lock cmpxchg [edi], edx ; edi是数组地址,而eax是数组元素,所以eax==[edi]PAGE:0059473F ; 那么此时,edx,也就是计算以后的地址会赋值给[edi],也就是赋值到数组中PAGE:00594743 cmp eax, ebx ; 由于上面的赋值操作,这里会成立跳转PAGE:00594745 jz short loc_594751 PAGE:00594747 mov ebx, eax

这里的ecx的值就是前面用来保存回调函数地址所调用的AllocateAssign函数得到的分配的内存地址。因为上面申请到内存以后,将内存地址先赋给eax在赋给ecx,随后的代码中都没对ecx进行改变。

将这个地址赋给eax并且与7异或,那就是将低3位置1,接着就跳转到loc_594739处执行。执行的时候,这个esi就是要赋值的数组的对应元素的地址,因为在进入SetArray函数前,将数组的对应元素的地址赋给了esi后就没在改变了。而根据cmpxchg的功能可以知道,这里就是将AllocateAssign分配到的内存地址和7或运算以后的地址复制到数组中,随后跳转到loc_594751执行。那么继续看loc_594751执行的代码。
PAGE:00594751 loc_594751: ; CODE XREF: SetArray+3F↑jPAGE:00594751 mov edi, ebx ; ebx中是数组中原来的内容,将他赋值给ediPAGE:00594753 and edi, 0FFFFFFF8h ; 将低3位清0PAGE:00594756 cmp edi, [ebp+arg_Zero] ; 判断是否为0,不为0则跳转退出函数,函数执行失败,返回值为0PAGE:00594759 jnz short loc_5947C4 ; 这里就是判断原来的高29位是不是0,不是的话,函数执行失败PAGE:0059475B test edi, edi ;判断edi是否位0PAGE:0059475D jz short loc_5947C0 ; 为0则跳转,函数执行成功,返回值eax=1PAGE:0059475F mov esi, large fs:124h ; 接下来的内容和APC相关,这里不讨论PAGE:00594766 dec word ptr [esi+86h]PAGE:0059476D xchg eax, [ebp+var_4]PAGE:00594770 mov ecx, dword_53C7D8PAGE:00594776 and ecx, 1PAGE:00594779 xchg eax, [ebp+var_8]PAGE:0059477C test ecx, ecxPAGE:0059477E jz short loc_594794PAGE:00594780 mov ecx, offset dword_53C7D8PAGE:00594785 call ExfAcquirePushLockExclusivePAGE:0059478A mov ecx, offset dword_53C7D8PAGE:0059478F call ExfReleasePushLockExclusive

在这里,程序首先将原来数组中保存的内容的低三位清0,接着判断得到的数据是否为0。也就是判断原来数组中的内容的高29位是不是0,如果是0说明上面的赋值成功了,程序就会跳转到函数执行成功的地方,也就是loc_5947C0处继续执行,在loc_5947C0,程序会将eax赋值为1,也就是说返回值是1。如果高29位不是0,那上面的赋值就会失败,程序就会跳转到loc_5947C4的地址继续执行,在这个地址中,程序会将eax赋值为0,也就是说返回值是0。

综上,可以得出以下的结论:
  • 程序会申请一块0xC大小的内存,内存中低4为是0,中间4为是要设置的回调函数的地址,高4为根据是否是Ex函数来设置为是0还是1。

  • 从整型数组ProcessArray中按顺序依次查看对应的数组的元素的位置是不是可以用来保存分配的内存地址。

  • 而这个位置能不能存储这个地址,取决于这个位置中保存的内容的高29位是不是0,如果是0就用来保存申请到的地址。



3


反进程监控的实现


根据上面的分析,可以得出结论,设置这些回调函数的时候,操作系统首先会申请一个0xC大小的内存,然后将内存地址的中间4位设置为回调函数的地址。随后就会在ArrayProcess数组中存放这个分配的内存地址,当要注意这个内存地址的低3位在复制到数组之前会被置1。

那也就是说当有进程的状态发生变化的时候,操作系统就会去ProcessArray数组中遍历,如果发现对应的元素的高29位不是0就把内容取出并且将低3位置0(因为在复制到数组之中去的时候,分配的内存的低3位会被置为1)。置0以后得到的地址在+4的地方保存的就是回调函数的地址,系统就会根据这个地址在调用相应的回调函数。

所以要进行反进程监控,只需要找到这个数组,并且将这个数组中的回调函数的地址取出然后通过PsSetCreateProcessNotifyRoutine函数删除这个回调函数就可以了。

要找到这个数组首先需要找到PspSetCreateProcessNotifyRoutine函数地址,因为在这个函数中才有对这个数组进行使用,才可以找到数组地址。但因为这个函数并没有导出,所以需要通过PsSetCreateProcessNotifyRoutine函数来找到它,因为这个函数是导出函数,可以直接获取到地址。

在PsSetCreateProcessNotifyRoutine中对PspSetCreateProcessNotifyRoutine的调用如下:


可以看到是调用地址处偏移为9的地方就是PspSetCreateProcessNotifyRoutine函数的地址,所以可以将0xE8作为特征码来查找函数地址。

得到PspSetCreateProcessNotifyRoutine函数地址以后,可以在对esi赋值为函数地址的地方找到ProcessFuncArray数组的地址如下:


所以得到PspSetCreateProcessNotifyRoutine函数地址以后,可以根据0xEB,0x2F和0xBE这三个特征码来定位进程数组的地址(当然这样有点太少,可以弄的多一点,这里就偷了个懒)。
根据以上内容就可以写出获取ProcessFuncArray地址的代码如下:
PUINT32 SearchProcessArray(){ UNICODE_STRING uStrFuncName; PUCHAR pPsSetCreateProessNotifyRoutine = NULL; PUCHAR pPspCreateProcessNotifyRoutine = NULL; PUINT32 pProcessArray = NULL; //获取PsSetCreateProcessNotifyRoutine函数地址 RtlInitUnicodeString(&uStrFuncName, L"PsSetCreateProcessNotifyRoutine"); pPsSetCreateProessNotifyRoutine = (PUCHAR)MmGetSystemRoutineAddress(&uStrFuncName); //获取PspCreateProcessNotifyRoutine函数的地址,由于程序执行到最后的ret语句会使用0xC2,所以这里用它作为结束 while (pPsSetCreateProessNotifyRoutine && *pPsSetCreateProessNotifyRoutine != 0xC2) { //根据0xE8来获取函数地址 if (*pPsSetCreateProessNotifyRoutine == 0xE8) { pPspCreateProcessNotifyRoutine = pPsSetCreateProessNotifyRoutine + 5 + *(PUINT32)(pPsSetCreateProessNotifyRoutine + 1); break; } pPsSetCreateProessNotifyRoutine++; } //获取回调函数数组的地址,由于程序执行到最后的ret语句会使用0xC2,所以这里用它作为结束 while (pPspCreateProcessNotifyRoutine && *pPspCreateProcessNotifyRoutine != 0xC2) { //根据特征码来获取数组的地址 if (*pPspCreateProcessNotifyRoutine == 0xEB && *(pPspCreateProcessNotifyRoutine + 1) == 0x2F && *(pPspCreateProcessNotifyRoutine + 2) == 0xBE) { pProcessArray = (PUINT32)*(PUINT32)(pPspCreateProcessNotifyRoutine + 3); break; } pPspCreateProcessNotifyRoutine++; } return pProcessArray;}

获取到函数地址以后,只需要遍历数组并查看相应的高29位是不是0,如果不是0说明里面保存了地址,取出这个地址将低3位置0以后在加4就得到了保存回调函数地址的地址,将这个回调函数地址取出就可以调用PsSetCreateProcessNotifyRoutine函数来进行回调函数的删除,代码如下:
NTSTATUS status = STATUS_SUCCESS;PUINT32 pProcessArray = NULL, pFuncAddr = NULL;UINT32 i = 0; pProcessArray = SearchProcessArray();if (pProcessArray != NULL){ for (i = 0; i < 0x40; i++) { if (pProcessArray[i] & ~0x7) { pFuncAddr = (PUINT32)(pProcessArray[i] & ~0x7 + 4); status = PsSetCreateProcessNotifyRoutineEx((PCREATE_PROCESS_NOTIFY_ROUTINE_EX)*pFuncAddr, TRUE); if (NT_SUCCESS(status)) { DbgPrint("成功删除进程的回调函数, 函数地址0x%X\r\n", *pFuncAddr); } } }}else DbgPrint("没有找到进程数组\r\n");



4


运行结果


首先是开启进程监控以后,可以看到所有进程的创建都被监控到了,而且demo.exe进程的创建也被拦截了。


当运行程序卸载掉回调函数以后,在创建进程就没有被监控到,并且demo.exe进程也可以成功创建。




 


看雪ID:1900

https://bbs.pediy.com/user-home-835440.htm

*本文由看雪论坛 1900 原创,转载请注明来自看雪社区





# 往期推荐

1.分享一个基本不可能被检测到的hook方案

2.由2021ByteCTF引出的intent重定向浅析

3.全网最详细CVE-2014-0502 Adobe Flash Player双重释放漏洞分析

4.基于linker实现so加壳补充-------从dex中加载so

5.一题house_of_storm的简单利用

6.BCTF2018-houseofatum-Writeup题解



公众号ID:ikanxue
官方微博:看雪安全
商务合作:wsc@kanxue.com



球分享

球点赞

球在看



点击“阅读原文”,了解更多!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存