本文将探究创建进程的几种方式。
常规api创建进程
通过常用的api来创建进程是常规启动进程的方式,最常用的几个api有WinExec
、ShellExecute
、CreateProcess
,我们一个一个来看一下
WinExec
首先是WinExec
,这个api结构如下,这个api只能够运行exe文件,算是比较局限
1 | UINT WinExec( |
实现代码如下
1 | BOOL winexec(char* szPath, UINT Cmd) |
ShellExecute
ShellExecute的功能是运行一个外部程序(或者是打开一个已注册的文件、打开一个目录、打印一个文件等等),并对外部程序有一定的控制
1 | HINSTANCE ShellExecuteA( |
实现代码如下
1 | BOOL shellexecute(char* szPath, UINT Cmd) |
CreateProcess
最常用的创建进程api,本质是调用了内核函数NtCreateProcess
创建进程并用NtCreateThread
创建一个线程
1 | BOOL CreateProcessA( |
实现代码如下
1 | char szCommandLine[]="notepad"; |
这里着重说一下CreateProcess
的实现过程
在Windows中,进程是不活动的,只是作为线程的容器,现代操作系统将线程作为最小调度单位,进程作为资源分配的最小单位。
所以,CreateProcess
作为一个相对高层的函数,要先通过系统调用``NtCreateProcess()
创建进程(容器),成功以后就立即通过系统调用NtCreateThread()
创建其第一个线程。
第一阶段:打开目标映像文件
对于32位exe映像,CreateProcess
先打开其映像文件,在为其创建一个Section即文件映射区,将文件内容映射进来,前提是目标文件是一个合格的EXE文件(PE文件头部检测);
第二阶段:创建内核中的进程对象
实际上就是创建以EPROCESS
为核心的相关数据结构,这就是系统调用NtCreateProcess()
要做的事情,主要包括:
①分配并设置EPROCESS数据结构;
②其他相关的数据结构的设置,如句柄表等等;
③为目标进程创建初始的地址空间;
④对EPROCESS进行初始化;
⑤将系统Dll映射到目标用户空间,如ntdll.dll等
⑥设置目标进程的PEB;
⑦将其他需要映射到用户空间,如与”当地语言支持“即NLS有关的数据结构;
⑧完成EPROCESS创建,将其挂入进程队列并插入创建者的句柄表
第三阶段:创建初始线程
前面说过,进程只是一个容器,干活儿是里面的线程,所以下一步就是创建目标进程的初始线程
与EPROCESS
对应,线程的数据结构是ETHREAD
,与进程环境块PEB对应,线程也有线程环境块TEB;
PEB在用户空间的位置大致是固定的,在7ffd0000
左右,PEB的下方就是TEB,进程有几个线程就有几个TEB,每个TEB占一个4KB的页面;
这个阶段是通过调用NtCreateThread()
完成的,主要包括:
①创建和设置目标线程的ETHREAD数据结构,并处理好与EPROCESS的关系(例如进程块中的线程计数等等)。
②在目标进程的用户空间创建并设置目标线程的TEB。
③将目标线程在用户空间的起始地址设置成指向Kernel32.dll中的BaseProcessStart()或BaseThreadStart(),前者用于进程中的第一个线程,后者用于随后的线程。用户程序在调用NtCreateThread()时也要提供一个用户级的起始函数(地址), BaseProcessStart()和BaseThreadStart()在完成初始化时会调用这个起始函数。 ETHREAD数据结构中有两个成份,分别用来存放这两个地址。
④调用KeInitThread设置目标线程的KTHREAD数据结构并为其分配堆栈和建立执行环境。 特别地,将其上下文中的断点(返回点)设置成指向内核中的一段程序KiThreadStartup,使得该线程一旦被调度运行时就从这里开始执行。
⑤系统中可能登记了一些每当创建线程时就应加以调用的“通知”函数,调用这些函数。
第四阶段:通知windows子系统
每个进程在创建/退出的时候都要向windows子系统进程csrss.exe进程发出通知,因为它担负着对windows所有进程的管理的责任,
注意,这里发出通知的是CreateProcess的调用者,不是新建出来的进程,因为它还没有开始运行。
至此,CreateProcess的操作已经完成,但子进程中的线程却尚未开始运行,它的运行还要经历下面的第五和第六阶段。
第五阶段:启动初始线程
新创建的线程未必是可以被立即调度运行的,因为用户可能在创建时把标志位CREATE_ SUSPENDED设成了1;
如果那样的话,就需要等待别的进程通过系统调用恢复其运行资格以后才可以被调度运行。否则现在已经可以被调度运行了。至于什么时候才会被调度运行,则就要看优先级等等条件了。
第六阶段:用户空间的初始化和Dll连接
DLL连接由ntdll.dll中的LdrInitializeThunk()
在用户空间完成。在此之前ntdll.dll与应用软件尚未连接,但是已经被映射到了用户空间(第二阶段第⑤步)
函数LdrInitializeThunk()
在映像中的位置是系统初始化时就预先确定并记录在案的,所以在进入这个函数之前也不需要连接。
session0创建进程
Intel的CPU将特权级别分为4个级别:RING0,RING1,RING2,RING3。Windows只使用其中的两个级别RING0和RING3,RING0只给操作系统用,RING3谁都能用。如果普通应用程序企图执行RING0指令,则Windows会显示“非法指令”错误信息。
ring0是指CPU的运行级别,ring0是最高级别,ring1次之,ring2更次之…… 拿Linux+x86来说, 操作系统(内核)的代码运行在最高运行级别ring0上,可以使用特权指令,控制中断、修改页表、访问设备等等。 应用程序的代码运行在最低运行级别上ring3上,不能做受控操作。如果要做,比如要访问磁盘,写文件,那就要通过执行系统调用(函数),执行系统调用的时候,CPU的运行级别会发生从ring3到ring0的切换,并跳转到系统调用对应的内核代码位置执行,这样内核就为你完成了设备访问,完成之后再从ring0返回ring3。这个过程也称作用户态和内核态的切换。
RING设计的初衷是将系统权限与程序分离出来,使之能够让OS更好的管理当前系统资源,也使得系统更加稳定。举个RING权限的最简单的例子:一个停止响应的应用程式,它运行在比RING0更低的指令环上,你不必大费周章的想着如何使系统回复运作,这期间,只需要启动任务管理器便能轻松终止它,因为它运行在比程式更低的RING0指令环中,拥有更高的权限,可以直接影响到RING0以上运行的程序,当然有利就有弊,RING保证了系统稳定运行的同时,也产生了一些十分麻烦的问题。比如一些OS虚拟化技术,在处理RING指令环时便遇到了麻烦,系统是运行在RING0指令环上的,但是虚拟的OS毕竟也是一个系统,也需要与系统相匹配的权限。而RING0不允许出现多个OS同时运行在上面,最早的解决办法便是使用虚拟机,把OS当成一个程序来运行。
我们知道一般用户进程都在3环,而系统进程一般都在0环创建,那么我们可以尝试突破session0的隔离来创建进程
思路
由于SESSION 0会话隔离,使得在系统服务进程内不能通过直接调用CreateProcess
等函数创建进程,而是通过CreateProcessAsUser
函数来创建。这样,创建的进程才会显示UI界面,与用户进行交互。
首先,调用WTSGetActiveConsoleSessionId
函数来获取当前程序的活动会话ID,即Session Id。该函数的调用不需要任何参数,直接返回Session Id。根据Session Id继续调用WTSQueryUserToken
函数来检索用户令牌,并获取对应的用户令牌句柄。
然后,使用DuplicateTokenEx
函数创建一个一个新令牌,并复制上述获取的用户令牌。设置新令牌的访问权限问MAXIMUM_ALLOWED
,表示获取所有令牌权限。新访问令牌的模拟级别为SecurityIdentification
,而且令牌类型为TokenPrimary
,表示新令牌是可以在CreateProcessAsUser
函数中使用的主令牌。
最后,根据新令牌调用CreateEnvironmentBlock
函数创建一个环境块,用来传递给CreateProcessAsUser
使用。在不需要使用进程环境块后,可以通过调用DestroyEnvironmentBlock
函数进行释放。获取环境块之后,就可以调用CreateProcessAsUser
来创建用户桌面进程了。新令牌句柄作为用户主令牌的句柄,指定创建进程的路径,设置优先级和创建标志,设置STARTUPINFO结构信息,获取PROCESS_INFORMATION结构信息。
函数
WTSGetActiveConsoleSessionId
检索Session Id
1 | DWORD WTSGetActiveConsoleSessionId(void); |
WTSQueryUserToken
获取由Session Id指定的登录用户的主访问令牌
1 | BOOL WTSQueryUserToken( |
DuplicateTokenEx
创建一个新的访问令牌,它与现有令牌重复
1 | BOOL WINAPI DuplicateTokenEx( |
CreateEnvironmentBlock
检索指定用户的环境变量
1 | BOOL WINAPI CreateEnvironmentBlock( |
CreateProcessAsUser
创建一个新进程及其主要线程,新进程在由指定令牌表示的用户的安全上下文中运行
1 | BOOL WINAPI CreateProcessAsUser( |
实现
首先使用WTSGetActiveConsoleSessionId
来获取session ID
1 | ::WTSGetActiveConsoleSessionId(); |
使用WTSQueryUserToken
获取当前会话的用户令牌
1 | ::WTSQueryUserToken(dwSessionID, &hToken) |
复制令牌
1 | ::DuplicateTokenEx(hToken, MAXIMUM_ALLOWED, NULL, SecurityIdentification, TokenPrimary, &hDuplicatedToken) |
然后使用CreateEnvironmentBlock
创建用户的session环境
1 | ::CreateEnvironmentBlock(&lpEnvironment,hDuplicatedToken, FALSE) |
再在复制的会话下面执行创建进程的操作
1 | ::CreateProcessAsUser(hDuplicatedToken, lpszFileName, NULL, NULL, NULL, FALSE, NORMAL_PRIORITY_CLASS | CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT, lpEnvironment, NULL, &si, &pi) |
完整代码如下
1 | BOOL CreateUserProcess(char* lpszFileName) |
因为要实现session0隔离,这里需要添加一个系统服务进程,需要调用StartServiceCtrlDispatcher
来设置入口点函数,这里就不展开说了,看一下实现的效果,这里将session0exe.exe
注册到系统服务打开nc.exe
到session1
内存加载运行
将资源加载到内存,然后把DLL文件按照映像对齐大小映射到内存中,切不可直接将DLL文件数据存储到内存中。
因为根据PE结构的基础知识可知,PE文件有两个对齐字段,一个是映像对齐,另一个是文件对齐大。其中,映像对齐大小是PE文件加载到内存中所用的对齐大小,而文件对齐大小是PE文件存储在本地磁盘所用的对齐大小。一般文件对齐大小会比映像对齐大小要小,这样文件会变小,以此节省磁盘空间。
然而,成功映射内存数据之后,在DLL程序中会存在硬编码数据,硬编码都是以默认的加载基址作为基址来计算的。由于DLL可以任意加载到其他进程空间中,所以DLL的加载基址并非固定不变。当改变加载基址的时候,硬编码也要随之改变,这样DLL程序才会计算正确。
如何知道硬编码的位置?答案就藏在PE结构的重定位表中,重定位表记录的就是程序中所有需要修改的硬编码的相对偏移位置。
根据重定位表修改硬编码数据后,这只是完成了一半的工作。DLL作为一个程序,自然也会调用其他库函数,例如MessageBox。
那么DLL如何知道MessageBox函数的地址呢?它只有获取正确的调用函数地址后,方可正确调用函数。PE结构使用导入表来记录PE程序中所有引用的函数及其函数地址。在DLL映射到内存之后,需要根据导入表中的导入模块和函数名称来获取调用函数的地址。若想从导入模块中获取导出函数的地址,最简单的方式是通过GetProcAddress函数来获取。
但是为了避免调用敏感的WIN32 API函数而被杀软拦截检测,采用直接遍历PE结构导出表的方式来获取导出函数地址。
实现
这里有一些其他的函数,例如修复IAT表、修复重定位表的代码就不细说了,这里需要有一定的基础知识才能够实现,主要是说一下在进程中的操作
首先获取大小
1 | DWORD dwSizeOfImage = GetSizeOfImage(lpData); |
使用VirutalAlloc
分配内存
1 | ::VirtualAlloc(NULL, dwSizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); |
然后使用RtlZeroMemory
清空空间数据
1 | RtlZeroMemory(lpBaseAddress, dwSizeOfImage); |
对dll数据进行FileBuffer -> ImageBuffer
的转换
1 | MmMapFile(lpData, lpBaseAddress); |
修复文件的重定位表和IAT表
1 | DoRelocationTable(lpBaseAddress); |
修改函数的ImageBase
1 | SetImageBase(lpBaseAddress); |
调用dll的入口函数
1 | CallDllMain(lpBaseAddress,IsExe) |
完整代码如下
1 | LPVOID LoadLibrary(LPVOID lpData,BOOL IsExe) |
然后新建一个dll,写入成功即弹窗
内存加载运行成功