挂钩原生 API 并全局控制进程创建






4.92/5 (92投票s)
如何全局挂钩原生 API 并控制进程创建。
引言
最近我偶然看到一款名为 Sanctuary 的相当有趣的安全产品的描述。该产品可防止执行任何未出现在特定计算机上允许运行的软件列表中的程序。因此,PC 用户可以免受各种附加间谍软件、蠕虫和木马的侵害——即使恶意软件设法进入了他的/她的计算机,它也没有机会被执行,因此也无法对计算机造成任何损害。当然,我发现这个功能很有趣,经过一番思考,我提出了自己的实现方式。因此,本文将介绍如何通过挂钩原生 API 来以编程方式监视和控制系统范围内的进程创建。
本文“大胆”地假设目标进程是由用户模式代码创建的(shell 函数、CreateProcess()
、手动创建进程作为一系列原生 API 调用等)。尽管理论上,进程可以由内核模式代码启动,但出于实际目的,这种情况微乎其微,因此我们不必担心它。为什么?试着逻辑性地思考——为了从内核模式启动进程,必须加载一个驱动程序,而这反过来又首先需要执行一些用户模式代码。因此,为了阻止未经授权的程序执行,我们可以安全地将自己限制在全局控制用户模式代码创建进程。
定义我们的策略
首先,让我们确定需要做什么才能全局监视和控制进程创建。
进程创建是一件相当复杂的事情,涉及到大量工作(如果您不相信我,可以反汇编 CreateProcess()
,您就会亲眼看到)。为了启动一个进程,必须执行以下步骤:
- 必须以
FILE_EXECUTE
访问权限打开可执行文件。 - 可执行映像必须加载到 RAM 中。
- 必须设置进程执行对象(
EPROCESS
、KPROCESS
和PEB
结构)。 - 必须为新创建的进程分配地址空间。
- 必须为进程的主线程设置线程执行对象(
ETHREAD
、KTHREAD
和TEB
结构)。 - 必须为进程的主线程分配堆栈。
- 必须设置进程主线程的执行上下文。
- 必须通知 Win32 子系统有关新进程的信息。
为了使这些步骤中的任何一个成功,所有先前的步骤都必须成功完成(没有可执行节的句柄,您无法设置执行进程对象;没有文件句柄,您无法映射可执行节,等等)。因此,如果我们决定中止这些步骤中的任何一个,所有后续步骤都将失败,进程创建也将被中止。可以理解的是,所有上述步骤都是通过调用某些原生 API 函数来完成的。因此,为了监视和控制进程创建,我们所要做的就是挂钩那些即将启动新进程的代码无法绕过的 API 函数。
我们应该挂钩哪些原生 API 函数?虽然 NtCreateProcess()
似乎是这个问题的最明显答案,但这个答案是错误的——不调用此函数也可以创建进程。例如,CreateProcess()
在不调用 NtCreateProcess()
的情况下设置与进程相关的内核模式结构。因此,挂钩 NtCreateProcess()
对我们没有帮助。
为了监视进程创建,我们必须挂钩 NtCreateFile()
和 NtOpenFile()
,或者 NtCreateSection()
——绝对没有办法在不进行这些 API 调用的情况下运行任何可执行文件。如果我们决定监视对 NtCreateFile()
和 NtOpenFile()
的调用,我们必须区分进程创建和常规文件 IO 操作。这项任务并不总是容易的。例如,当某个可执行文件以 FILE_ALL_ACCESS
权限打开时,我们该怎么办?它只是一个 IO 操作,还是进程创建的一部分?此刻很难做出判断——我们需要查看调用线程接下来要做什么。因此,挂钩 NtCreateFile()
和 NtOpenFile()
不是最好的选择。
挂钩 NtCreateSection()
是一个更合理的方法——如果我们拦截对 NtCreateSection()
的调用,该调用请求将可执行文件映射为映像(SEC_IMAGE
属性),并结合请求允许执行的页面保护,我们就可以确定进程即将启动。此时,我们可以做出决定,如果不想创建进程,就让 NtCreateSection()
返回 STATUS_ACCESS_DENIED
。因此,为了获得对目标计算机上进程创建的完全控制,我们所要做的就是全局挂钩 NtCreateSection()
。
像 ntdll.dll 中的任何其他存根一样,NtCreateSection()
会将 EAX
加载为服务索引,将 EDX
指向函数参数,并将执行转移到 KiDispatchService()
内核模式例程(在 Windows NT/2000 上通过 INT 0x2E
指令完成,在 Windows XP 上通过 SYSENTER
指令完成)。在验证函数参数后,KiDispatchService()
将执行转移到服务的实际实现,该实现的地址可从服务描述符表获得(指向该表的指针由 ntoskrnl.exe 作为 KeServiceDescriptorTable
变量导出,因此对内核模式驱动程序可用)。服务描述符表由以下结构描述:
struct SYS_SERVICE_TABLE { void **ServiceTable; unsigned long CounterTable; unsigned long ServiceLimit; void **ArgumentsTable; };
该结构的 ServiceTable
字段指向一个数组,该数组保存着所有实现系统服务的函数的地址。因此,为了全局挂钩任何原生 API 函数,我们所要做的就是将我们的代理函数的地址写入 KeServiceDescriptorTable
的 ServiceTable
字段指向的数组的第 i 个条目(i 是服务索引)。
看起来现在我们知道了全局监视和控制进程创建所需的一切。让我们继续实际工作。
控制进程创建
我们的解决方案包括一个内核模式驱动程序和一个用户模式应用程序。为了开始监视进程创建,我们的应用程序会将 NtCreateSection()
对应的服务索引以及交换缓冲区地址传递给我们的驱动程序。这通过以下代码完成:
//open device device=CreateFile("\\\\.\\PROTECTOR",GENERIC_READ|GENERIC_WRITE, 0,0,OPEN_EXISTING, FILE_ATTRIBUTE_SYSTEM,0); // get index of NtCreateSection, and pass it to the driver, along with the //address of output buffer DWORD * addr=(DWORD *) (1+(DWORD)GetProcAddress(GetModuleHandle("ntdll.dll"), "NtCreateSection")); ZeroMemory(outputbuff,256); controlbuff[0]=addr[0]; controlbuff[1]=(DWORD)&outputbuff[0]; DeviceIoControl(device,1000,controlbuff,256,controlbuff,256,&dw,0);
代码几乎不言自明——唯一值得关注的是我们获取服务索引的方式。ntdll.dll 中的所有存根都以 MOV EAX, ServiceIndex
一行开头,这适用于任何版本和版本的 Windows NT。这是一个 5 字节的指令,其中 MOV EAX
操作码作为第一个字节,服务索引作为其余 4 个字节。因此,为了获得对应于某个特定原生 API 函数的服务索引,您所要做的就是从位于存根开头 1 字节偏移量的地址处读取 4 个字节。
现在让我们看看我们的驱动程序在收到应用程序的 IOCTL 时会做什么:
NTSTATUS DrvDispatch(IN PDEVICE_OBJECT device,IN PIRP Irp) { UCHAR*buff=0; ULONG a,base; PIO_STACK_LOCATION loc=IoGetCurrentIrpStackLocation(Irp); if(loc->Parameters.DeviceIoControl.IoControlCode==1000) { buff=(UCHAR*)Irp->AssociatedIrp.SystemBuffer; // hook service dispatch table memmove(&Index,buff,4); a=4*Index+(ULONG)KeServiceDescriptorTable->ServiceTable; base=(ULONG)MmMapIoSpace(MmGetPhysicalAddress((void*)a),4,0); a=(ULONG)&Proxy; _asm { mov eax,base mov ebx,dword ptr[eax] mov RealCallee,ebx mov ebx,a mov dword ptr[eax],ebx } MmUnmapIoSpace(base,4); memmove(&a,&buff[4],4); output=(char*)MmMapIoSpace(MmGetPhysicalAddress((void*)a),256,0); } Irp->IoStatus.Status=0; IoCompleteRequest(Irp,IO_NO_INCREMENT); return 0; }
正如您所见,这里也没有什么特别之处——我们只是通过 MmMapIoSpace()
将交换缓冲区映射到内核地址空间,并加上将我们的代理函数的地址写入服务表(当然,我们在保存实际服务实现的地址到 RealCallee
全局变量后才这样做)。为了覆盖服务表的相应条目,我们使用 MmMapIoSpace()
映射目标地址。我们为什么要这样做?毕竟,我们已经可以访问服务表了,不是吗?问题是服务表可能位于只读内存中。因此,我们必须检查我们是否对目标页面具有写入权限,如果没有,我们必须更改页面保护才能覆盖服务表。这工作量太大了,您不觉得吗?因此,我们只是使用 MmMapIoSpace()
映射我们的目标地址,这样我们就无需再担心页面保护了——从现在起,我们可以认为对目标页面具有写入访问权限。现在让我们看看我们的代理函数:
//this function decides whether we should //allow NtCreateSection() call to be successfull ULONG __stdcall check(PULONG arg) { HANDLE hand=0;PFILE_OBJECT file=0; POBJECT_HANDLE_INFORMATION info;ULONG a;char*buff; ANSI_STRING str; LARGE_INTEGER li;li.QuadPart=-10000; //check the flags. If PAGE_EXECUTE access to the section is not requested, //it does not make sense to be bothered about it if((arg[4]&0xf0)==0)return 1; if((arg[5]&0x01000000)==0)return 1; //get the file name via the file handle hand=(HANDLE)arg[6]; ObReferenceObjectByHandle(hand,0,0,KernelMode,&file,&info); if(!file)return 1; RtlUnicodeStringToAnsiString(&str,&file->FileName,1); a=str.Length;buff=str.Buffer; while(1) { if(buff[a]=='.'){a++;break;} a--; } ObDereferenceObject(file); //if it is not executable, it does not make sense to be bothered about it //return 1 if(_stricmp(&buff[a],"exe")){RtlFreeAnsiString(&str);return 1;} //now we are going to ask user's opinion. //Write file name to the buffer, and wait until //the user indicates the response //(1 as a first DWORD means we can proceed) //synchronize access to the buffer KeWaitForSingleObject(&event,Executive,KernelMode,0,0); // set first 2 DWORD of a buffer to zero, // copy the string into the buffer, and loop // until the user sets first DWORD to 1. // The value of the second DWORD indicates user's //response strcpy(&output[8],buff); RtlFreeAnsiString(&str); a=1; memmove(&output[0],&a,4); while(1) { KeDelayExecutionThread(KernelMode,0,&li); memmove(&a,&output[0],4); if(!a)break; } memmove(&a,&output[4],4); KeSetEvent(&event,0,0); return a; } //just saves execution contect and calls check() _declspec(naked) Proxy() { _asm{ //save execution contect and calls check() //-the rest depends upon the value check() returns // if it is 1, proceed to the actual callee. //Otherwise,return STATUS_ACCESS_DENIED pushfd pushad mov ebx,esp add ebx,40 push ebx call check cmp eax,1 jne block //proceed to the actual callee popad popfd jmp RealCallee //return STATUS_ACCESS_DENIED block:popad mov ebx, dword ptr[esp+8] mov dword ptr[ebx],0 mov eax,0xC0000022L popfd ret 32 } }
Proxy()
保存寄存器和标志,将服务参数的指针推送到堆栈上,并调用 check()
。其余取决于 check()
返回的值。如果 check()
返回 TRUE
(即我们希望继续处理请求),Proxy()
将恢复寄存器和标志,并将控制转移到服务实现。否则,Proxy()
将 STATUS_ACCESS_DENIED
写入 EAX
,恢复 ESP
并返回——从调用者的角度来看,这看起来就像 NtCreateSection()
调用以 STATUS_ACCESS_DENIED
错误状态失败了。
check()
如何做出决定?一旦收到服务参数的指针作为参数,它就可以检查这些参数。首先,它会检查标志和属性——如果请求的节不是映射为可执行映像,或者请求的页面保护不允许执行,我们就可以确定 NtCreateSection()
调用与进程创建无关。在这种情况下,check()
会直接返回 TRUE
。否则,它会检查底层文件的扩展名——毕竟,SEC_IMAGE
属性和允许执行的页面保护可能被请求用于映射某个 DLL 文件。如果底层文件不是 .exe 文件,check()
则返回 TRUE
。否则,它会给用户模式代码一个做出决定的机会。因此,它只是将文件名和路径写入交换缓冲区,并轮询该缓冲区直到获得响应。
在打开我们的驱动程序之前,我们的应用程序会创建一个运行以下函数的线程:
void thread() { DWORD a,x; char msgbuff[512]; while(1) { memmove(&a,&outputbuff[0],4); //if nothing is there, Sleep() 10 ms and check again if(!a){Sleep(10);continue;} // looks like our permission is asked. If the file // in question is already in the white list, // give a positive response char*name=(char*)&outputbuff[8]; for(x=0;x<stringcount;x++) { if(!stricmp(name,strings[x])){a=1;goto skip;} } // ask user's permission to run the program strcpy(msgbuff, "Do you want to run "); strcat(msgbuff,&outputbuff[8]); // if user's reply is positive, add the program to the white list if(IDYES==MessageBox(0, msgbuff,"WARNING", MB_YESNO|MB_ICONQUESTION|0x00200000L)) {a=1; strings[stringcount]=_strdup(name);stringcount++;} else a=0; // write response to the buffer, and driver will get it skip:memmove(&outputbuff[4],&a,4); //tell the driver to go ahead a=0; memmove(&outputbuff[0],&a,4); } }
这段代码不言自明——我们的线程每 10 毫秒轮询一次交换缓冲区。如果它发现我们的驱动程序已将请求发布到缓冲区,它会根据允许在该计算机上运行的程序列表检查文件名和路径。如果找到匹配项,它会立即给出 OK 响应。否则,它会显示一个消息框,询问用户是否允许执行所讨论的程序。如果响应是肯定的,我们将所讨论的程序添加到允许在该计算机上运行的软件列表中。最后,我们将用户响应写入缓冲区,即将其传递给我们的驱动程序。因此,用户可以完全控制他/她 PC 上的进程创建——只要我们的程序在运行,就绝对不可能在 PC 上启动任何程序而不征求用户许可。
正如您所见,我们让内核模式代码等待用户响应。这真的明智吗?要回答这个问题,您必须问自己是否在阻塞任何关键系统资源——一切都取决于具体情况。在我们的案例中,所有操作都在 IRQL PASSIVE_LEVEL
下进行,不涉及 IRP 处理,并且需要等待用户响应的线程并不属于关键重要性。因此,在我们的案例中一切正常。但是,此示例仅用于演示目的。为了使其真正应用于实际,最好将我们的应用程序重写为一个自动启动的服务。在这种情况下,我建议我们为 LocalSystem 帐户设置一个例外,并且如果 NtCreateSection()
是在具有 LocalSystem 帐户特权的线程上下文中调用的,则继续进行实际的服务实现而不执行任何检查——毕竟,LocalSystem 帐户只运行在注册表中指定的那些可执行文件。因此,这种例外不会损害我们的安全性。
结论
总而言之,我必须说,挂钩原生 API 绝对是曾经存在的最强大的编程技术之一。本文为您提供了通过挂钩原生 API 可以实现的一个示例——正如您所见,我们通过挂钩**单个**(!!!) 原生 API 函数成功阻止了未经授权的程序执行。您可以进一步扩展这种方法,并获得对硬件设备、文件 IO 操作、网络流量等的完全控制。然而,我们目前的解决方案对内核模式 API 调用者不起作用——一旦允许内核模式代码直接调用 ntoskrnl.exe 的导出函数,这些调用就不需要通过系统服务分派器。因此,在我的下一篇文章中,我们将挂钩 ntoskrnl.exe 本身。
此示例已在多台运行 Windows XP SP2 的计算机上成功测试。尽管我尚未在任何其他环境中进行测试,但我相信它应该在那里都能正常工作——毕竟,它不使用任何可能特定于系统的结构。要运行此示例,您所要做的就是将 protector.exe 和 protector.sys 放在同一个目录中,然后运行 protector.exe。只要 protector.exe 的应用程序窗口未关闭,每次尝试运行任何可执行文件时都会弹出提示。
如果您发送电子邮件与您的评论和建议,我将不胜感激。