第二章进程和线程
2.1任务
在操作系统层面,任务常常时代表进程的,比如windows是典型的多任务操作系统,指系统中可以同时运行多个进程。
在CPU手册中,很多时候是使用"任务"来代之线程的,比如著名的多任务状态段(Task State Segment TSS).就是用来记录每个线程的状态。
CPU一级的任务很多时候相当于进程中的一个线程,操作系统中的任务是指系统中运行着的各个进程,操作系统的每个任务对应于CPU的一个或多个任务。
2.2进程资源
在Windows操作系统中,每个进程都拥有如下资源。
-
一个虚拟的地址空间,一般称为进程空间。
-
全局唯一的进程ID(又称客户ID,ClientID),简称PID
-
一个可执行映像(image),也就是该进程的程序文件(可执行文件)在内存中的表示。
-
一个或多个线程。
-
一个位于内核空间中的名为EPROCESS(executive process block,进程执行块)的数据结构,用以记录该进程的关键信息,包括进程的创建时间、映像文件名称等。
-
一个位于内核空间中的对象句柄表,用以记录和索引该进程所创建/打开的内核对象。操作系统根据该表格将用户模式下的句柄翻译为指向内核对象的指针。
-
一个用于描述内存目录表起始位置的基地址,简称页目录基地址(DirBase),当CPU切换到该进程/任务时,会将该地址加载到页表基地址寄存器(x86之CR3,ARM之TTBR),这样当前进程的虚拟地址才能被翻译为正确的物理地址(卷1 2.7节和2.9节)。
-
一个位于用户空间中的进程环境块(ProcessEnvironment Block PEB)
-
一个访问令牌(access token),用于表示该进程的用户、安全组以及优先级。
!process 0 0 命令的第一个参数用来指定要显示的进程ID,0代表所有进程。第二各参数用来指定要显示的进程属性,0代表只显示最基本的进程属性。
可以在上面的命令后面加上程序文件名作为过滤条件,比如,下面的命令只显示wermgr
进程的属性
在以上命令结果中,第一行显示的是进程的EPROCESS结构的地址,接下来的三行显示的是进程的关键属性: -
SessionId是指该进程所在的Windows会话(session)的ID号。当有多个用户同时登录时Windows会为每个登录用户建立一个会话,每个会话有自己的WorkStation和Desktop。这样大家变可以工作在不同"会话"中共用一个Windows系统。对于典型的XP系统,当只有一个用户登录时,用户启动的程序和系统服务都运行在Session 0.当切换到另一个用户账号(Switch User,不是Log off)时,系统会建立Session 1,以此类推。为了提高系统服务的安全性,从WindowsVista开始,只有允许系统服务运行在Session 0,系统启动后便会自动创建.当用户登录后,会创建另一个会话,一般会Session 1。因此,当用户登录到系统中后,总是会看到至少有两个会话,有两个Windows子系统进程(CSRSS)在运行。系统启动早期创建的几个特殊进程不属于任何会话,因此它们的Sessioned为空(none),例如系统(System)进程便是如此。
Cid即进程ID,又叫CilentID(客户ID)。进程ID是标识进程的一个整数,很多用户态的函数用它作为标识进程的参数。在内核空间的代码里,主要使用EPROCESS指针来标识一个进程。
ParentCid是父进程的进程ID,即创建该进程的那个进程的进程ID。
DirBase描述的是该进程顶级页表的位置,即当CPU切换到该进程执行时,CR3寄存器(对于x86CPU)的内容。页目录基地址是将虚拟地址转换为物理地址的必须参数在x86 CPU经典的32位分页模式中,顶级的页表也叫页目录表,所以这个字段的名字就叫页目录基地址。对于64位分页模式,顶级页表有新的名字(页映射表)。
DirBase字段的位定义与当前使用的分页模式有关。在经典的32位分页模式中,DirBase的低12位总是0,高20位是该进程的页目录的页帧编号(Page Frame Number,PFN)。例如,如果DirBase的值是0x1f350000,那么它的PFN便是0x1f350。
在IA32e分页模式中,CR3的低12位的含义因CR4的PCIDE位(第17位)而不同。PCIDE是Process-Context Identifiers Enable的缩写,其为1,CPU会缓冲多个进程的页表信息,CR3的低12位是进程上下文ID号。在2018年后的Windows10版本中,为了应对CPU的熔断(Meltdown)和幽灵(Spectry)漏洞,NT内核引入了名为KVA影子(Kernel Virtual Address Shadowing)的安全补丁,在这个补丁中会使用CPU的PCID功能。
在前面"格物"实验中使用的转储文件,已经启用了这个安全功能。
uploading-image-565117.pngKVA影子有两种工作模式,在该转储文件中,使用的模式1
模式1要求CPU具有PCID功能支持,从一下内核变量和cr4寄存器的位17都可以看出。
PFN代表着物理内存页的编号,加上低12位便是物理地址。WinDBG的一些内存命令是使用PFN作为参数的,例如使用!ptov扩展命令加上PFN,便可以列出对应进程中所有物理地址到虚拟地址空间的映射,如!ptov 1f350(输出结果非常长)
ObjectTable的含义是该进程的内核对象和句柄表格。Windows系统使用这个表格将句柄翻译为指向内核对象的指针。使用!handle命令可以查看句柄和对象信息。
在内核调试对话中,该命令的格式如下。
比如!handle 0 0 86a7d030会显示出8647d030进程的所有句柄情况。
在用户调试对话中,命令格式如下。
使用!object命令可以进一步查看内核对象的信息。
HandleCount即该进程所使用的句柄个数,也就是ObjectTable所包含的表项数。
2.3进程空间
为了保证系统每个任务或进程的安全,Windows为不同的进程分配了独立的进程地址空间(process address space)常常称为进程空间。进程空间是操作系统分配给每个进程的虚拟地址空间(virtual address space),每个进程运行在这个受操作系统保护的虚拟空间之中,它的地址指针指向的都是这个空间中的虚拟地址,根本无法指到另一个进程空间中,这样便保证了一个进程的数据和代码不会轻易受到其他进程的侵害,一个进程内的错误也不会波及同一系统内运行着的其他进程。或者说每个进程都在操作系统分配给它的虚拟空间中运行,它无法直接访问其他进程的空间,也不必担心自己的空间会被其他进程所侵占。
2.3.1
在不同系统中,进程空间的大小可能不同。对于32位的Windows系统,每个进程的进程空间是4GB,即地址0x00000000到地址0xFFFFFFFF。为了高效地调用和执行操作系统的各种服务,Windows会把操作系统的内核数据和代码映射到所有进程的进程空间中。因此4GB的进程空间总是被划分为两个区域:用户空间和内核空间,内核空间又被称为系统空间。
在32位系统中,用户空间和内核空间的默认大小各为2GB,低2GB为用户空间,高2GB为内核空间。Windows2000AdvanceServer、Windows2000DatacenterServer、WindowsXP和Windows2003支持"3GB"
启动选项使用用户空间为3GB,以便满足数据库等某些特殊应用程序的需要。要使用该功能,除了要在启动配置文件(boot.ini)中设置"3GB"启动选项,还需要在可执行映像的头信息中设置大用户空间标志(IMAGE_FILE_LARGE_ADDRESS_AWARE flag)WindowsXP和Windows Server2003还支持/USERVA选项,该选项可以设定一个介于2GB~3GB(以MB为单位)的值用于定义用户空间的大小。由于对于大多数32位系统和大多数进程,用户空间大小是2GB,以后讨论的是用户空间为2GB的情况,也就是0x00000000到地址0x7FFFFFFF为用户空间,地址0x80000000到0xFFFFFFFF为内核空间。
用户空间是给应用程序的模块使用的,内核空间是给操作系统内核使用的。因为所有应用程序都需要使用内核提供的服务,所以内核空间是统一的,只有一个,会映射到所有应用程序的进程空间中。可以知道,用户空间是独立的,每个进程都有自己的一个空间,而内核空间是共享的,所有进程共享一个空间。
2.3.2 64位进程空间
64位系统下,主要特点用户空间和内核空间增大了很多。
对于X64CPU,在早期的64位Windows系统中使用的是44位线性地址,进程地址空间的总大小为16TB,用户空间的范围是0x00x7FF`FFFFFFFF大小为8192GB(8TB),内核空间的范围是0XFFFFF800`000000000XFFFFFFFF`FFFFFFFF大小也是8TB
2.4EPROCESS结构
从数据结构的角度来看,NT内核使用一个名为EPROCESS的庞大结构来描述进程。每个进程都会有一个EPROCESS结构。
在前面的!process 0 0,每个进程有3行信息,第一行信息中,PROCESS后面的地址指向的便是EPROCESS结构,使用dt命令(显示类型结构命令)可以观察该结构的各个字段和取值。
WindowsXP内核中EPROCESS
EPROCESS结构几乎包括了所有进程的所有关键信息比如:Debug和ExceptionPort,指向进程的虚拟地址描述符(VAD)二叉树根节点的VadRoot(使用!vad命令可以列出这些描述符),以及指向进程内所有线程列表表头的ThreadListHead,进程环境块地址等。
在WinDBG中,可以用!process命令加上EPROCESS结构的地址来显示该进程的关键信息。
EPROCESS结构中的Token字段记录这个进程的TOKEN结构的地址,进程的很多与安全的有关信息都是记录在Token结构中的。使用!Token命令便可以查看Token的详细信息。
2.5PEB
PEB(Process Environment Block)的全称是进程环境块,它包含了进程的大多数用户信息。与EPROCESS结构位于内核空间中不同,PEB是在内核模式建立后映射到用户空间的。
一个系统中,多个进程的PEB地址可能是同一个值。
使用dt_PEB命令可以显示出PEB结构的字段及其当前值。因为PEB的地址位于用户空间,所以既可以在内核调试会话也可以在用户调试会话中观察PEB。在内核调试会话中观察PEB时,应该先用.process
命令设置当前进程
也可以使用!peb扩展命令来观察进程环境块,例如使用!peb 7ffdf000命令便可以显示位于0x7ffdf000处的PEB结构。
在调试应用程序时,常常通过观察PEB来了解PEB进程的很多全局信息,比如进程是否被调试,进程的默认堆,进程中的模块列表,进程的命令行等。
2.6内核模式和用户模式
NT内核会把操作系统的代码和数据映射到系统中所有进程的内核空间中。每个进程内的应用程序代码便可以方便地调用内核空间中的系统服务。
第一层含义:内核代码和用户代码在一个地址空间中,应用程序调用系统服务时不需要切换地址空间。
第二层含义:整个系统中内核空间的地址是统一的,编写内核空间的代码时会简单很多。但是,如此设计也带来一个很大的问题,那就是用户空间中的程序指针可以指向内核空间中的数据和代码,因此必须防止用户代码破坏内核空间中的操作系统,怎么做呢?答案:利用权限控制实现对内核空间的保护。
2.6.1
Windows定义了两种访问模式(access mode)--用户模式(user mode也称用户态)和内核模式(kernel node 也称为内核态)。应用程序(代码)运行在用户模式下,操作系统代码运行在内核模式下。内核模式对应于处理器的最高权限级别(不考虑虚拟机),在内核模式下执行的代码可以访问所有系统资源并具有使用所有的特权指令的权利。相对而言,用户模式对应于较低的处理器优先级,在用户模式下执行的代码只可以访问系统允许其访问的内存空间,并且没有使用特权指令的权利。
IA-32处理器定义了4种特权(privilege level)或者为环(ring),分为0、1、2、3,优先级0(环0)的 特权级别最高。处理器在硬件一级保证高优先级的数据和代码不会被优先级的代码破坏。Windows系统使用IA-32处理器所定义的4种优先级种的两种,优先级3(环3)用户用户模式,优先级0用于内核模式。之所以只使用了其中的两种,主要时因为有些处理器只支持两种优先级,比如CompaqAlpha处理器。值得说明的是,对于x86处理器来说,并没有任何寄存器表明处理器当前处于何种模式(或优先级)下,优先级只是代码或数据所在的内存段或页的一个属性。
因为内核模式下的数据和代码具有较高的优先级,所以用户模式下的代码不可以直接访问内核空间种的数据,也不可以直接调用内核空间中的任何函数或例程。任何这样的尝试都会导致保护性错误。也就是,即使用户空间中的代码指针正确指向了要访问的数据或代码,但一旦访问发生,那么处理器会检测到该访问时违法的,会停止该访问并产生保护性异常(#GP)。*
虽然不可以直接访问,但是用户程序可以通过调用系统服务来间接访问内核空间中的数据或间接调用、执行内核空间中的代码。当调用系统服务时,主调线程会从用户模式切换到内核模式,调用结束后返回到用户模式,也就是所谓的模式切换。在线程的KTHREAD结构中定义了UserTime和KernelTime两个字段,分别用来记录这个线程在用户模式中和内核模式的运行时间(以时钟中断次数为单位)。模式切换时通过软件中断或专门的快速系统调用(fast system call)指令来实现的。
2.6.2使用INT 2E切换到内核模式
下图展示了Windows2000中通过INT2E从应用程序调用ReadFile()API的过程。因为ReadFile()API是从Kernel32.dll导出的,所以我们看到该调用首先转到Kernel32.dll中的ReadFile()函数,ReadFile()函数在对参数进行简单检查后便调用Nt
通过反汇编可以看到,NtDll.dll中的NtReadFile()函数非常简短,首先将ReadFile()对应的系统服务号(0xa1,与版本有关)放入EAX寄存器中,将参数指针放入EDX寄存器中,然后便通过INT n 指令发出调用。这里要说明的一点是,虽然每个系统服务都具有唯一的号码,但微软公司并没有公开这些服务号,也不保证这些号码在不同的Windows版本中会保持一致。
在WinDBG下通过!idt 2e命令可以看到2e向量对应的服务例程KiSystemService().KiSystemService()是内核态中专门用来分发系统调用的例程。
Windows将2e号向量专门用于系统调用,在启动早期初始化中断描述符表(Interrupt Descriptor Table IDT)时(见第11章)便注册好了合适的服务例程。因此当NTDLL.DLL中的NtReadFile()发出INT2E指令后,CPU便会通过IDT找到KiSystemService()函数。因为KiSystemService()函数是位于内核空间的,所以CPU在把执行权交给KiSystemService()函数前,会做好从用户模式切换到内核模式的各种工作,包括:
- 权限检查,即检查源位置和目标位置所在的代码段权限,核实是否可以转移;
- 准备内核模式使用的栈,为了保证内核安全,所有线程在内核态执行时都必须使用位于内核空间的内核栈(kernel stack),内核栈的大小一般为8KB或12KB。
KiSystemService()会根据服务ID从系统服务分发表(System Service Dispatch Table)中查找到要调用的服务函数地址和参数描述,然后将参数从用户态栈复制到该线程的内核栈中,最后KiSystemService()调用内核中真正的NtReadFile()函数,执行读文件的操作,操作结束后会返回到KiSystemService(),KiSystemService()会将操作结果复制回该线程用户态栈,最后通过IRET指令将执行权交回给NtDLL.dll中的NtReadFile()函数(继续执行INT 2E后面的那条指令)。
通过INT 2E进行系统调用时,CPU必须从内存中分别加载门描述符和段描述符才能得到KiSystemService的地址,即使门描述符和段描述符已经在高速缓存中,CPU也需要通过"内存读(memory read)"操作从高速缓存中读出这些数据,然后进行权限检查。
2.6.3 快速系统调用
因为系统调用的非常频繁的操作,所以如果能减少这些开销还是非常有意义的。可以从两个方面降低开销:
- 一是把系统调用服务例程的地址放到寄存器中以避免读IDT这样的内存操作,因为读寄存器的速度比读内存的速度要快很多;
- 二是避免权限检查,也就是使用特殊的指令让CPU省去那些对系统服务调用来说根本不需要的权限检查。奔腾II处理器引入的SYSENTER/SYSEXIT指令正是按这一思路设计的。AMD K7引入的SYSCALL/SYSRETURN指令也是为了这一目的而设计的。相对于INT2E,使用这些指令可以加快系统调用的速度,因此利用这些指令进行的系统调用称为快速系统调用。
Windows系统是如何利用IA-32处理器SYSENTER/SYSEXIT指令(从奔腾II开始)实现快速系统调用的。首先,Windows2000或之前的Windows系统不支持快速系统调用,它们只能使用前面介绍的INT2E方式进行系统调用。WindowsXP和WindowsServer2003或更新的版本在启动过程中会通过CPUID指令检测CPU是否支持快速系统调用指令(EDX寄存器的SEP标志位)。如果CPU不支持这些指令,那么仍使用INT2E方式。如果CPU支持这些指令,那么Windows系统便会决定使用新的方式进行系统调用,并做好如下准备工作。- 在全局描述符表(GDT)中建立4个段描述符,分别用来描述供SYSENTER指令进入内核模式时使用的代码段(CS)和栈段(SS),以及SYSEXIT指令从内核模式返回用户模式时使用的代码段和栈段。这4个段描述符在GDT中的排列应该严格按照以上顺序,只要指定一个段描述符的位置便能计算出其他的。
- 设置中专门用于系统调用的MSR,SYSENTER_EIP_MSR用于指定新的程序指针,也就是SYSENTER指令要跳转到目标例程地址。Windows系统会将其设置为KiFastCallEntry的地址,因为KiFastCallEntry例程是Windows内核中专门用来受理快速系统调用的。SYSENTER_CS_MSR 用来指定新的代码段,也就是KiFastCallEntry所在的代码段。SYSENTER_ESP_MSR用于指定新的栈指针(ESP)。新的栈段是由SYSENTER_CS_MSR的值加8得来的。
- 将一小段名为SystemCallStub的代码复制到SharedUserData内存区,该内存区会被映射到每个Win32进程的进程空间中。这样当应用程序每次进行系统调用时,NTDLL.DLL中的残根(stub)函数便调用这段SystemCallStub代码。SystemCallStub的内容因系统硬件的不同而不同,对于IA-32处理器,该代码使用SYSENTER指令,对于AMD处理器,该代码使用SYSCALL指令。
例如在配有Pentium M CPU的WindowsXP系统上,以上3个寄存器的值分别为:
其中SYSENTER_CS_MSR的值为8,这是Windows系统的内核代码段的选择子,即常量KGDT_R0_CODE的值。WinDBG帮助文件中关于dg命令的说明中列出了这个常量。SYSENTER_EIP_MST的值是8053cad0,检查nt内核中KiFastCallEntry函数的地址。
可见,Windows把快速系统调用的目标指向内核代码中的KiFastCallEntry函数。通过反汇编WindowsXP下NTDLL.DLL中的NtReadFile()函数,可以看到SystemCallStub被映射到进程的0x7ffe0300位置。与前面Windows 2000下的版本相比,容易看到该服务的系统服务号码在这两个版本间是不同的。
。
观察本段下面反汇编SystemCallStub的结果,它只包含3条指令,分别用于将栈指针(ESP寄存器)放入EDX寄存器中、执行sysenter指令和返回。第一条指令有两个用途:一是向内核空间传递参数;二是指定从内核模式返回时的栈地址。因为笔者使用的英特尔奔腾M处理器,所以此处是sysenter指令,对于AMD处理器,此处应该是syscall指令。
下面让我们看一下KiFastCallEntry例程,其清单如下所示。
显而易见,KiFastCallEntry在做了些简单操作后,便下落(fall through)到KiSystemService函数了,也就是说,快速系统调用和使用INT2E进行的系统调用在内核中的处理绝大部分是一样的。另外,请注意ecx寄存器,mov ecx,0x7ff0304将其值设为0x7ffe0304,也就是SharedUserData内存区里SystemCallStub例程ret指令的地址(参见上文的SystemCallStub代码)。在进入nt!KiSystemService之前,ecx连同其他一些参数被压入栈中。事实上,ecx用来指定SYSEXIT返回用户模式时的目标地址。当使用INT 2E进行系统调用时,由于INT n指令会自动将中断发生时的CS和EIP寄存器压入栈中,当中断处理例程通过执行iretd返回时,iretd指令会使用栈中保存的CS和EIP值返回合适的位置。因为sysenter指令不会向栈中压入要返回的位置,所以sysexit指令必须通过其他基址知道要返回的位置。这便是压入ECX寄存器的原因。通过反汇编KiSystemCallExit2例程,我们可以看到在执行sysexit指令之前,ecx寄存器的值又从栈中恢复出来了。
以上代码中包含了3个系统调用返回的例程,即KiSystemCallExit、KiSystemCallExit2和KiSystemCallExit3,它们分别对应于INT2E、sysenter和syscall发起的系统调用。
下图使用sysenter/sysexit指令对进行系统调用的完整过程(以及调用ReadFile服务为例)
下面通过一个小的实验来加深大家对系统调用的理解。首先启动WinDBG程序选择File--->OpenCrashDump,然后选择本书实验文件中的dumps\w732cf4文件。在调试会话建立后,先执行.symfix c:\symbols和.reload加载模块与符号,再执行K命令,便得到清单2-4所示的完美栈回溯。
仔细观察清单2-4中的地址部分,很容易看出用户空间和内核空间的分界,也就是在栈帧04和栈帧05之间。栈帧05中的KiFastSystemCallRet函数属于ntdll模块,位于用户空间。栈帧04中的KiFastCallEntry函数属于nt模块,位于内核空间。栈帧04的基地址是9796fc24,属于内核空间;栈帧05的基地址是001df4dc,属于用户空间它们分别来自这个线程的内核态栈和用户态栈。WinDBG的k命令穿越两个空间,遍历两个栈,显示出线程在用户空间和内核空间执行的完整过程,能产生如此完美的栈回溯展示了WinDBG的强大。
2.6.4
前文介绍了从用户模式进入内核模式的两种方法,通过这两种方法,用户模式的代码可以"调用"位于内核模式的系统服务。那么内核模式的代码是否可以主动调用用户模式的代码呢?答案是肯定的,这种调用通常称为逆向调用(reverse call)。
简单来说,逆向调用的过程是这样的。首先内核代码使用内核函数KiCallUserMode发起调用。接下来的执行过程与从系统调用返回(KiServiceExit)类似,不过进入用户模式时执行的是NTDLL.DLL中的KiUserCallbackDispatcher。而后KiUserCallbackDispatcher会调用内核希望调用的用户态函数。当用户模式的工作完成后,执行返回动作的函数会执行INT2B指令,也就是触发一个0x2B异常。这个异常的处理函数是内核模式的KiCallbackReturn函数。于是,通过INT2B异常,CPU又跳回内核模式继续执行了。
2.6.5实例分析
下面通过一个实际例子来进一步展示系统调用和逆向调用的执行过程。清单2-5显示了使用WinDBG的内核调试会话捕捉到的记事本进程发起系统调用进入内核和内核函数执行逆向调用的全过程。(栈回溯)。
根据执行的先后顺序,最下面一行(帧#12)对应的是进程的启动函数BaseProcessStart,而后是编译器生成的进程启动函数WinMainCRTStartup,以及记事本程序自己的入口函数WinMain。帧#0f表示记事本程序在调用GetMessageAPI进入消息循环。接下来GetMessageAPI调用Windows子系统服务的残根函数NtUserGetMessage。从第2列的栈帧基地址都小于0x80000000可以看出,帧#12~#0d都是在用户模式执行的。帧#0d执行我们前面分析过的SystemCallStub,而后(帧#0c)便进入了内核模式的KiSystemService。KiSystemService根据系统服务号码,将调用分发给Windows子系统内核模块win32k的NtUserGetMessage函数。
帧#0a#05表示内核模式的窗口消息在工作。帧#07#05表示要把一个窗口消息发送到用户态。帧#04的SfnDWORD表示在将消息组织好后调用KeUserModeCallback函数,发起逆向调用。帧#02表明在执行KiCallUserMode函数,帧#01表明已经在用户模式下执行,这两行之间的部分过程没有显示出来。同样,帧#01和帧#00之间执行用户模式函数的过程没有完全体现出来。XyCallbackReturn函数是用于返回内核模式的,它的代码很简单,只有如下几条命令。
第一行把用户模式函数的执行结果赋给EAX寄存器,第2行执行INT2B指令。执行INT2B后,CPU便转去执行异常处理程序KiCallbackReturn,回到内核模式。
2.7线程
如果把进程比作一栋大楼,那么线程便是这栋楼里的声明。可以说,进程是线程生活的空间,线程是进程中的生命。通常,一个进程内有一个或者多个线程。但是在某些特殊情况下,比如进程创建初期或者进程退出和销毁的过程中,进程内也可能没有任何线程。
2.7.1ETHREAD
与使用EPROCESS结构来描述进程类似,NT内核使用ETHREAD结构来描述线程。在内核代码中,大多数是哦那个ETHREAD结构的地址来索引线程。在调试时,也常常这样使用。比如在内核调试会话中,执行.thread命令,便会显示出当前线程的ETHREAD结构地址。
.thread命令
然后执行如下命令便可以观察ETHREAD结构的内容。
ETHREAD结构也很庞大,包含着线程的各种属性。特别值得说明的是,ETHREAD开头的512字节是一个KTHREAD结构,也称为线程控制块(TCB),里面的字段主要是供内核调度线程时使用的。
只要把上面命令中的第一个E字符改为K便可以观察KTHREAD结构了。
即_KTHREAD 地址
其中的Header字段是DISPATCHER_HEADER类型,DISPATCHER是NT内核的线程调度器的别名,代表CPU时间片的意思。
因为EHREAD结构字段众多,而且缺少详细的文档miaos,所以在调试时,一般不直接观察结构,而使用WinDBGd的扩展命令!thread,让这个扩展命令以比较友好的方式显示线程属性,如图2-6所示。
在内核调试会话中,可以直接观察State字段的值,比如对于前面观察过的崩溃线程,可以看到它的状态为2,代表它正处于运行状态,与图2-6所示的信息一致。
执行!ready命令可以显示所有处于就绪状态的线程。
观察上面各个线程的State字段,值为1,代表就绪。
NT内核为每个CPU定义了一个名为处理器控制块(Processor Control Block,PRCB)的庞大结构,在这个结构中有一个名为DispatcherReadyListHead的数组,包含32个元素,代表线程的32个优先级,每个元素是个LIST_ENTRY结构,起链表头的作用,用来挂接对应优先级的就绪线程。
上面的!ready命令便是从这个链表数组中读取信息,然后显示出各个优先级的就绪线程的。
下图显示了各个线程状态之间的切换关系,以及部分切换条件。
如果线程处于等待状态,那么!thread命令会显示出等待原因,这对于调试线程死锁问题是非常有价值的。KTHREAD结构中有一个名为WaitReason的字段,用来记录线程的等待原因,它的长度只有1字节,是枚举类型,名为KWAIT_REASON,在公开的PDB符号文件中,包含了这个枚举类型的定义,因此,很容易在WinDBG中观察它,比如:
WaitReason字段的值为6,代表用户代码主动请求等待(UserRequest)。
因为一些公开的内核函数的参数中也是用了KWAIT_REASON,比如KeWaitForSingleObject等,所以在驱动开发包(DDK/WDK)的头文件(wdm.h)中也包含这个枚举的定义,但是没有描述每个值得含义。表2-4列出了KWAIT_REASON的所有可能值,并且对每种原因做了说明。
值得说明一下,表2-4列出的枚举常量主要是为软件调试服务的。当线程因为进入等待状态而不执行时,可以通过这些常量查找进入等待状态的原因,搜索发起等待的代码,对于NT内核中的等待函数来说,并不关心KWAIT_REASON参数的内容。
2.7.2TEB
与描述进程用户空间信息的PEB类似,NT内核定义了线程环境块(ThreadEnvironmentBlock,TEB)来描述线程的用户空间信息,包括用户态栈、异常处理、错误码、线程局部存储等。
在通过WinDBG调用应用程序时,可以使用!teb显示当前线程的TEB结构位置,比如:
NT内核会使用CPU的硬件机制来快速定位当前线程的TEB。也因为此,内核在创建线程时,就会分配专门的内存页用作TEB,将其地址记录在KTHREAD中,所以TEB的地址总是按页对齐的(低12位为0)。
也可以使用dt命令直接观察TEB结构。
2.8 WoW进程
今天的大多数Windows系统是64位的,运行在支持64位的CPU上,比如笔者现在写作使用的便是64位的Windows10。在64位的Windows系统中,内核空间的代码都是64位的,而用户空间的代码却不一定如此。为了兼容老的32位应用程序,64位的Windows系统上可以运行32位的应用程序,这样运行在64内核上的32位进程有一个专门的名字,叫做WoW64(Windows 32 on Windows 64)进程,常常称为WoW进程。
2.8.1 架构
图2-8展示了WoW进程的架构和工作原理图。图中上面是32位的可执行文件和32位的动态链接库(DLL),下面是64位的内核。32位的代码时不能直接与64位的内核交互的,中间的转接层就是为了解决这个问题而设计的。转接层本身是64位的模块,它给32位的应用程序营造一个32位的环境。这个环境有点像虚拟机,但没有虚拟技术那么复杂,它的工作简单很多,主要负责指针长度的转换和解决API兼容等问题。
因为32位的程序需要使用老的32位Win 32API和一些库函数,所以在64位Windows系统的目录里,总是有一个名为SysWoW64的子目录,里面放着32位版本的程序文件和动态库。与SysWoW64并列的还有一个System32目录,里面放着内核和64位的各种程序文件。简而言之,SysWoW64中放的是32位的内容,System32中放的是64的内容,目录名字有些误导的,大家不要上当。一种说法是为了兼容应用程序,保持系统程序主目录的System32之名不变,而SysWow64中的64来自Windows32 on Windows 64。
2.8.2工作过程
既可以使用32位版本的WinDBG调试WoW进程,也可以使用64位版本的WinDBG来调试。前者的好处是比较简单的,仿佛调试普通32位程序一样;后者的好处是既可以调试32位代码,也可以调式64位的转接层。可以使用.effmach命令在两种代码间切换。
在64位的Windows系统中,使用资源管理器浏览Windows\SysWoW64文件夹,找到notepad.exe(32位版本),通过双击执行它。
启动64位的WinDBG,选择File--->Attach to process,附加到刚刚启动的notepad进程中。执行如下命令先切换到0号线程,在切换到64位模式,然后观察栈回溯。
阅读上面的栈回溯,可以看到64位转接层的执行过程,其中的wow64和wow64win都是转接层的核心模块。
执行如下命令切换到32位模式并观察32位代码的执行情况:
值得说明的是,在WoW进程中,总是有两个ntdll模块,一个是64位的,另一个是32位的。因为二者的名字相同,为了区别它们,WinDBG会给后加载进进进程的32位版本的模块名加上基地址,即像ntdll_77700000这样。
仔细观察上面k命令的结果,容易看出两个结果中的栈地址相差悬殊,其实它们来自两个栈。进一步说,WoW进程中,很多东西是双份的,每个进程有两个PEB,每个线程有两个TEB,有两个栈。WinDBG的wow64exts扩展模块专门是为调试WoW进程而设计的,它的info命令可以显示WoW进程的双份资产。
上面的结果中,使用了虚拟机的术语,Guest指的是32位代码,Native指的是64位代码。
2.8.3 注册表重定向
在调试与WoW进程有关的问题时,如果需要查看或者修改注册表,那么需要特别注意。出于多种原因,64位Windows系统会对WoW进程的注册表访问实施重定向。比如,如果程序中访问的路径为HKEY_LOCAL_MACHINE\Software,那么会被重定向到HKEY_LOCAL_MACHINE\Software\Wow6432Node。
使用注册表编辑器时,如果想要查看WoW进程的设置,那么也应该查看Wow6432Node表键下的。
有了这个重定向机制,可以认为,很多注册表表键有两份,一份供WoW进程使用,另一份供64位程序使用。不过,一部分表键时两类程序共享的,HKEY_LOCAL_MACHINE-\SOFTWARE\Policies表键便是如此。而且有些表键的情况是因Windows版本不同而不同的,比如HEKEY_LOCAL_MACHINE\SOFTWARE\Classes表键,在Windows7或者更高版本中是共享的,在老版本中是重定向的。概而言之,注册表早因为臃肿杂乱而称为Windows系统的一个负担,有了WoW后,问题变得更加复杂。
2.8.4注册表反射
考虑到有些COO组件既有32位版本也有64位版本,为了让用户在一个版本中所做的设置在另一个版本中也有效,Windows实现了一种名为注册表发射(registry reflection)的机制,对于某些与COM组件有关的表键,来自一边修改会自动更新到另一边。这样的表键主要有以下几个:
- HKLM\Software\Classes;
- HKLM\Software\Ole;
- HKLM\Software\Rpc;
- HKLM\Software\Com3;
- HKLM\Software\EventSystem;
- HKLM\Software\CLSID(只用于进程外组件)。
2.8.5文件系统重定向
如上文所讲,在WoW进程中,有两个NTDLL.dll,一个是64位的,另一个是32位的。64位版本的NTDLL.DLL位于%windir%\System32目录中,32位版本的NTDLL.DLL位于%windir%\SysWoW64目录中。
在Windows on ARMx系统中,还有一个%windir%\SysArm32目录,里面放的是32位的ARM版本系统文件。
为了让不同类型的程序可以取到自己需要的系统文件,64位Windows系统设计了名为文件系统重定向的机制,当32位的WoW进程访问系统文件目录时,会被自动重定向到SysWow64或SysArm32目录。
2.9创建进程
无论我们使用哪种方式打开一个新的程序,大多数时候,Windows操作系统使用一套标准的流程来创建一个新进程。创建新进程的过程比较复杂,一般分为如下6个阶段。
-
1.在父进程的用户空间中打开要执行的映像文件,确定其名称、类型和系统对它的设置选项。
-
2.进入父进程的内核空间,为新进程创建EPROCESS结构、进程地址空间、KPROCESS结构和PEB。
-
3.创建初始线程,但是创建时指定了挂起(suspend)标志,它并不会立刻开始运行。
-
- 通知子系统服务程序。对于Windows程序,通知Windows子系统服务进程,即CSRSS。
-
5.初始线程开始在内核空间执行。
-
6.通过APC机制(第5章),在新进程自己的用户空间中执行初始化工作。这一步最重要的工作就是通过NTDLL.DLL中的加载器,加载进程所依赖的DLL文件。
在上面6个阶段中,前4个都是在父进程或者子系统服务进程中完成的。这样做的原因是新进程创建之初,进程内的设施还不完善,执行某些任务可能有困难或者不可行。
Windows Internals 一书很详细地描述了上面每个阶段所做的工作。
2.10最小进程和Pico进程
为了满足虚拟化、容器和适用于Linux系统的Windows子系统(Windows Subsystem for Linux,WSL)等需求,除了用以运行Windows程序的普通进程(称为NT进程),今天的NT内核还支持两种特殊类型的进程------最小进程(minimal process)和Pico进程。
如前面各节所讲,对于普通的NT进程,NT内核会自动创建些设施,并将这些设施映射到进程的用户空间中,比如描述进程属性的进程环境块(PEB),描述线程属性的线程环境块(TEB)等。另外,考虑到NTDLL.DLL的特殊性,NT内核也会自动将NTDLL.DLL映射到普通进程的用户模式空间中。但对于某些特殊情况,这邪恶动作不但是不必要的,而且是多余和有副作用的,最小进程和Pico进程就是为了解决这个问题而设计的。
2.10.1
所谓最小进程,就是在创建进程时,指定一个特殊的标志,告诉NT内核,只创建进程的空间,不要自动向进程空间中添加内容。
目前,微软没有对外公开用于创建最小进程的接口,只供内部使用。根据有限的资料,Windows10的内存压缩技术和基于虚拟化的安全(VBS)功能都使用了最小进程。
下面以内存压缩进程为例来加深一下大家对最小进程的理解。在Windows10系统中,以管理员身份启动一个PowerShell窗口,执行Enable-MMAgent-mc,启用内存压缩功能,重启后,在内核调试会话中执行!process 0 0,列出所有进程,然后找到MemCompression进程。
可以看到,这个进程的Peb值为0,这是区别于普通NT进程的一个显著标志。
继续执行如下的dt命令观察进程结构中的Flags字段:
其中,Flags3为1,意味着代表最小进程的Minimal标志位(Bit0)为1。
如果执行!process ffffbd039cacd040继续观察这个进程的详细信息,可以看到它有很多线程,但都是系统线程。简单理解,这个进程的进程空间也就是一个内存仓库(Store),用于存放原本应该交换到磁盘上的内存。为了减少这个内存仓库所占用的物理内存,这个进程的系统线程会压缩仓库里的内存页。
从2019年年底发布的17063版本开始,Windows10引入了一个新的最小进程,名叫Registry,我们将其称为注册表进程。与内存压缩进程用于缓存内存数据类似,注册表进程的主要作用是缓存注册表数据,以便提高访问注册表数据的效率,降低与注册表有关的内存开销。
WinDBG的内核调式会话中,可以使用!process 0 0 Registry命令找到注册表进程:
。
然后,可以使用EPROCESS结构的地址作为参数观察它的详细信息。
。
一般Registry进程有3个线程,其中两个线程的入口函数是CmpLazyWriteWorker,该函数应该是用于把修改过的注册表数据成批写回磁盘的工作线程。另一个线程名叫CmpDummyThreadRoutine。这个线程的代码看起来有些古怪,线程启动后就一直在等待一个名为CmpDummyThreadEvent的事件,如果等待成功,便调用KeBugCheekEx函数触发蓝屏崩溃。其真实用途是"占位",始终等待一个内核空间的事件对象,让内存管理器不要把它的内存页交换出去。
关于在任务管理器中是否显示最小进程,目前的Windows10版本似乎有些混乱。根据笔者的观察,内存压缩进程总是不显示,而注册表则有时显示有时不显示。后文介绍任务管理器使用的图2-11包含了注册表进程(其进程ID似乎总为120)。
2.10.2 Pico进程
Pico进程是"最小进程"的一个子类。在英文中,Pico一般用作前缀,代表(10的-12),有"微小"之意。
与普通的"最小进程"相比,Pico进程的特点是它通过所谓的Pico提供器与NT内核协作。简单理解,"最小进程"是一个不希望内核干预太多的容器,而Pico进程则可以通过一组接口与NT内核交互。
NT内核为Pico提供新增了一个名为PsRegisterPicoProvider的接口,供注册使用。例如,在启用WSL的Windows10内和启动早期,WSL的子系统核心驱动LXCORE就会调用PsRegisterPicoProvider函数注册Pico提供器,其过程如下:
PsRegiterPicoProvide函数有两个参数,都是结构指针,结构的第一个字段表示大小,后面是函数指针。例如,下面是LXCORE注册时第一个参数的内容。
中间部分便是LXCORE提供给内核的回调函数,例如,当Pice进程执行系统调用时,NT内核便会调用PicoSystemCallDispatch,转交给LXCORE继续分发和处理。
当Pico进程内发生异常时,NT内核的异常分发函数KiDipatchException会调用PicoDisPatch-Exception,例如:
注册成功后,NT内核也会返回一个类似的结构,包含一组函数提供Pico提供器调用。
在启用WSL后,LINUX子系统中的每个Linux进程都是Pico进程,比如下面是top进程的EPROCESS结构的地址和概要信息。
注意,其中Peb值为00000000,可执行文件名显示为"System Process"。
Pico进程的EPROCESS结构还有两个特征:首先,Flags2的PicoCreated标志位(第10位)为1;其次,PicoContext字段是一个指针,指向的是Pico提供器使用Pico上下文结构。
2.11任务管理器
观察进程的一个更简单的方法就是使用Windows操作系统自带的任务管理器(图2-10)。有3种方法可以启动任务管理器:按Ctrl+Shift+Esc组合键;在任务栏上右击,然后选择任务管理器;按Ctrl+Alt+Del组合键,然后选择任务管理器。
图2-10的任务管理器是本书第1版中的截图,基于WindowsXP版本。在Windows版本中,微软对任务管理器做了一次较大的重构。图2-11是Windows10版本的任务管理器。
任务管理器窗口中有多个选项卡,我们重点介绍调试时常用的"详细信息"(老版本为"进程")选项卡。这个选项卡的核心内容是进程列表,列表的每一行描述一个进程(系统中断行除外),每一列描述进程的一个属性。列的内容是可定制的,默认显示的是常用的进程属性。定制列的方法都是通过图2-12所示的"选择列"对话框实现的。在老版本中,弹出这个对话框的方法与在新版本不同,老版本是通过View菜单中SelectColumns弹出的,新版本中是通过右击表格的列标题,激活右键菜单弹出的。
无论对于调试高手还是初学者,任务管理器都是一个非常好的帮手。
系统空闲(IDLE)进程的进程ID总是为0,它的线程数就等于系统中的总CPU个数。例如,在笔者现在使用的系统中,一共有8个逻辑CPU(四核,启用了超线程),与图2-11中第2行第3列中的"8"刚好一致。
"CPU时间"列显示出的是CPU的净时间,也就是CPU在该进程上运行的总时间。观察该列,可以知道CPU的时间都花在哪里了。一般来说,一个进程的累计CPU时间达到分钟级别就算比较多了,如果达到小时级别,就代表比较中的进程了。当没有任务执行时,CPU就会执行空闲进程,所以空闲进程的总时间大于等于系统的总开机时间乘以CPU个数。或者说空闲进程的总时间除以CPU个数约等于系统的总开机时间。
当分析高CPU占用率的问题时,"CPU"列显示的是上1s的CPU占用率,是针对系统中所有CPU计算的百分比。这意味着,对于笔者使用的8个CPU的系统,如果"CPU"列的数值始终在12(单位是秒)左右,就代表对应进程中的可能有一个线程陷入死循环了。
当分析磁盘有关的问题时,可以选择以I/O开头的多个列,比如"I/O读取""I/O写入"代表的是I/O次数,如果希望知道访问的 字节数,则可以选择"I/O读取字节"、"I/O写入字节列"。
如果分析内存有关的问题,可以通过"工作集(内存)""峰值工作集(内存)"和"工作集增量(内存)"来了解物理内存的使用情况,通过"提交大小"了解虚拟内存的使用情况,通过"页面错误"和"页面错误增量"来了解触发页面错误的情况。