转眼来到科锐学习已经超过一年的时间了,眼看三阶段已经进入尾声,内核的学习也快要结束,记录一下笔记和心得,也给刚接触的朋友做一个参考。当然,学习新知识最好的办法就是带着目的来学习,所以在文章后半部分,介绍如何自己实现了一个WindowsAPI。
本篇文章主要涉及两个部分:
- 介绍内核基础知识
- 自己实现ReadProcessMemory
环境介绍:真机64位Windows10,虚拟机:32位XP_sp3
学习内核的本质其实是学习操作系统的原理,而学习的过程应该是从CPU架构出发,操作系统作为使用CPU提供功能的例子。
内核可以分成两类:
- 宏内核:追求性能,大部分系统代码放在0环,代表:Linux
- 微内核:追求维护性,大部分系统代码在3环,代表:Minix(Linux作者老师的作品),其中著名的设计:动态链接库,在Windows中也使用
Windows算是微内核和宏内核特点都具有的操作系统。
80x86处理器的工作模式
8086处理器有三种工作模式,分别是:实模式,保护模式,虚拟86模式,其中关系为:
16位汇编中
iret
可以进入保护模式。
分段式内存管理
如何保证操作内存的动作是否合法?
GDT、LDT
操作系统通电后进入实模式,做了一系列初始化的动作后进入到保护模式,在保护模式中,CPU执行所有和内存有关的操作都会通过查表来确定操作是否合法,这个表就是GDT和LDT表,表的格式由CPU厂商决定,所以为了能兼容多款CPU,操作系统代码里多用条件宏来实现。
地址的转换
- 逻辑地址:在程序调试中见到的地址,实际上是:段+偏移的形式
- 线性地址:逻辑地址转物理地址的中间层,逻辑地址是段中的偏移地址然后加上基地址就是线性地址。
- 物理地址:物理内存条上的真实地址
逻辑地址如何转换到物理地址?
首先通过逻辑地址的偏移查第一次表得到线性地址,再查第二次表得到物理地址。为什么要查第二次表呢?因为第二张表实际上是为了实现虚拟内存,那么就是说这段内存可能是在磁盘上的,访问的时候会先查表,然后从磁盘上调到内存中,有些情况下(关闭了虚拟内存)查第一次表的结果等价于物理地址。
为什么要叫线性地址?
从逻辑地址转换到线性地址,是一块平坦且连续的地址,实际上对应到物理地址上,并不是连续的。
如何计算线性地址的范围?
例如:
- 设段A的基地址等于00012345H,段界限等于5678H,并且段界限以字节为单位(G=0),那么段A对应线性地址空间中从00012345H-000179BDH的区域。
- 如果段界限以4K字节为单位 (G=1),那么段A对应线性地址空间中从00012345H-0568B344H(=00012345H+5678000H+0FFFH) 的区域。
如何从逻辑地址 ===查表===>> 线性地址?
这个表也叫做分段表,结构如下图
这种奇葩的做法来源于为了兼容286的历史遗留问题。
描述符
用于表示上述定义段的三个参数的数据结构称为描述符。每个描述符长8个字节。在保护方式下,每一个段都有一个相应的描述符来描述。
存储段描述符
存储段是存放可由程序直接进行访问的代码和数据的段。存储段描述符描述存储段,所以存储段描述符也被称为代码和数据段描述符。
描述符是一个8个字节的结构,具体结构如下:
- Limit 0:15项和Limit 16:19项一起构成20位的段界限。20位的段界限最大值为0xFFFFF,单位是字节或者分页(有Flags项Gr位来确定)。在分页机状态下最大可以表达4G的内存空间。
- Base0:23项和Base24:31一起构成32位的段基址,是线性还是物理地址也取绝于分页机制是否开启。
整个的解析结构如下图:
Access Byte
- Pr:存在位,对于一个有效的内存分段此值必定为1。
Privl(2bit):优先级位,取值从0-3,对应Ring0-Ring3级别。
Ex:可执行位,为1时表示此描述符对应是代码段,为0时为数据段。
- DC:
- 对于数据段,表于数据段的增长方向,0表示向上。1表示向下,也就是偏移大于段基址。
- 对于代码段,表示是否遵循一致原则。当此位为1时,也就是遵循一致原则,不同优先级代码跳转时,优先级同目标代码所在段一致。为0时,刚跳转时优先级不变。
- R/W:读写位
- 对于数据段,为1时表示可写,为0时表示不可写。数据段总是可读。
- 对于代码段,为1时表示可读,为0时表示不可读。代码段总是不可写。
- Ac:保留位,设置为0,当被访问过时系统将其改写为1。
Flags
- Gr:表示段界限的单位也叫做粒度,为1时表示单位为4KB(一个页面),为0时表示单位为1字节。
- Sz:区分是16位保护模式,还是32位保护模式。可以同时有两种类型描述符在同一个GDT中。
全局和局部描述符表
每个任务的局部描述符表LDT含有该任务自己的代码段、数据段和堆栈段的描述符,也包含该任务所使用的一些门描述符,如任务门和调用门描述符等。随着任务的切换,系统当前的局部描述符表LDT也随之切换。
全局描述符表GDT含有每一个任务都可能或可以访问的段的描述符,通常包含描述操作系统所使用的代码段、数据段和堆栈段的描述符,也包含多种特殊数据段描述符,如各个用于描述任务LDT的特殊数据段等。在任务切换时,并不切换GDT。
通过LDT可以使各个任务私有的各个段与其它任务相隔离,从而达到受保护的目的。通过GDT可以使各任务都需要使用的段能够被共享。
GDT存储在GDTR寄存器, 通过汇编指令LGDT载入。它的操作码是一个结构的地址,这个结构描述GDT的大小和地址。共6个字节,如下:
- Size项(2个字节)是GDT的字节数减1(这也意味着GDT大小不可能为0)。2个字节对应最大值是65535,也就是说一个GDT最大也就是65536字节(8192个内存分段)。
- Offset项(4个字节)指向GDT的线性地址(未开启分页机制则是物理地址)。
LDT存在LDTR寄存器中,存有局部进程的描述符表,LDTR中的内容根据线程的切换不停切换,表中的内容由操作系统来修改,若我们拿到0环权限,自己修改LDTR,改到目标进程,那么修改自己的内存就相当于修改了目标进程的内存,这是内核修改的一个经典招式
通过段选择子确定逻辑地址到物理地址的转化(未开启分页机制)
段选择子
在保护方式下,虚拟地址空间(相当于逻辑地址空间)中存储单元的地址由段选择子和段内偏移两部分组成。段选择子长16位,在32位程序下,CPU的段寄存器中保存的就是选择子,其格式如下表所示:
- 段选择子的高13位是描述符索引(Index):所谓描述符索引是指描述符在描述符表中的序号。
- 段选择子的第2位是引用描述符表指示位,标记为TI(Table Indicator),TI=0指示从全局描述符表GDT中读取描述符;TI=1指示从局部描述符表LDT中读取描述符。
(windows不使用这种CPU的做法,而Linux使用)
- RPL:特权级描述符,CPU比较这一项个描述符的特权级判断访问操作是否能进行下去
具体通过逻辑地址查找线性地址的例子:
现有逻辑地址:23:13ac34b,假如段寄存器中的的选择子Index为:0000000000100,RPL:11,先比对,是三环程序,继续操作,去LDT表中的第4项拿到段首地址,加上偏移13ac34b,得到线性地址。
值得一提的是Windows中并没有使用LDT,而Linux是使用了LDT的,但是有意思的是在阅读Windows源码时发现微软也留下了LDT的接口,难道微软想什么时候顺便兼容一下Linux?
分页管理
80386开始支持存储器分页管理机制。分页机制是存储器管理机制的第二部分。上述的段管理机制实现虚拟地址(由段和偏移构成的逻辑地址)到线性地址的转换,分页管理机制实现线性地址到物理地址的转换。
线性地址到物理地址的转换
线性地址到物理地址的转换方式受很多变量的影响,我们先以一个其中最具代表性的方式来讲解基本概念和转换流程,再来总结所有的转换方式。
流程如图所示采用了二级表的结构
页目录表(PDE)
一级表称为页目录表(Page Directory Entry),共有1024(1k)个表项,每个表项的大小是4bit,总大小为4k,表项内容包括了页表的指针和指向页表的属性。
页表(PTE)
二级表称为页表(Page Table Entry),每张页表里有1024(1k)个表项,每个表项的大小是4bit,总大小为4k,最多有1024(1k)张页表,最大占用空间为4M,而操作系统一般是动态申请页表,大小大概在1M左右。表项内容包括了物理地址的指针和属性。
CR3
控制寄存器CR3的高20位存放了指向页目录表的指针(这里存的是物理地址,如果这里存虚拟地址就会产生悖论),每个进程都会有一张PDE,切换进程其实就是CPU在切换CR3的值,这一点非常重要,是我们自己实现ReadProcessMemory的基础!
表项
PDE和PTE的表项结构基本相似,略有差别,如下图所示:
-
页目录表表项
-
页表表项
结构基本类似,高20位存指向目标首地址,低12位表示指向目标的属性
P:Present,存在标志,该标志标明当前表项所指向的页或页表是否存在于内存中。当标志位置位表示该页在内存中,当标志位清零表示该页不在内存中,若CPU试图访问则会产生一个缺页异常,值得一提的是CPU并不会主动操作该标志位,而是让操作系统来维护。
R/W:Read or Write,读写标志位,当标志位置位,所指页表或页是可读可写的,清零表示所指页表或页是只读的。
U/S:User or Surpervisor,用户权限标志,置位时表示普通用户权限也就是我们常说的3环权限,清零则表示超级用户权限也就是0环权限。
PWT:Page Write Through,页直写标记,控制页或页表的直写或回写缓存策略。
PCD:Page Cache Disabled,页层次的缓存禁用,控制页或页表的的缓存,置位时缓存被禁止,清零时表示可以缓存。
A:Accessed,访问标志,指明这个页或页表是否曾经被访问过,当指向的页或页表第一次载入内存,会清零标志位,当页或页表第一被访问,改标志位置位
D :Dirty,脏位(在PDE的表项中,该位是0,不使用此标志位),指明该页是否曾经被写入过,,当指向的页第一次载入内存,会清零标志位,当页第一次写操作完成,改标志位置位
PAT:Page Table Attribute Index(PTE表项中的第7位)页属性索引。
PS :Page Size(PDE表项中的第7位),该位指明指向的页表尺寸,当改标志清零,页尺寸为4k。当改标志被置位,页的尺寸为32位寻址的4M(物理地址拓展启用,页尺寸为2M)
G:Global,全局标志。
Avl:保留位。
线性地址:
- 线性地址的最高10位(即位22至位31)作为页目录表的索引
- 线性地址的中间10位(即位12至位21)作为所指定的页目录表中的页表项的索引
- 线性地址的低12位作为32位物理地址的低12位。
转换实例:
如何搭建双机调试环境,请自行谷歌
我们以GDT地址作为例子:
dd :查看虚拟内存
现在我们有线性地址:0x8003f000,CR3:39000。
- 拆分线性地址:1000000000 0000111111 000000000000
- PDE Index:0x200
- PTE Index :0x3f
- Offset :0x0
- 页目录表首地址为CR3的高20位,找到对应页目录表项:
!dd命令表示查看物理内存, 0x200 * 4是因为表项是4个字节
-
表项 0x0003b163的前20位指向页表的首地址,也就是0x0003b000,页表的Index为0x3f,于是:
- 表项0x0003f163的前20位指向物理地址页,也就是0x0003f000,加上Offset,最后得到物理地址:0x0003f000。
-
检验:
所有转换方式(查表方式):
以上我们所阐述的线性地址转物理地址的方法适用于没有物理地址拓展,且页表大小为4k的情况。
如何决定适用哪种查表方式?
查表方式根据页表大小来决定,而页表大小根据以下标志决定:
- PG:分页标志,CR0的31位
- PSE : 页尺寸拓展标志,CR4的第4位
- PAE : 物理地址拓展标志,CR4的第5位
- PS :页表尺寸,PDE表项中的第7位
未开启PAE,页表大小4k
以下图片均来自Intel手册,详细解释请参考手册
未开启PAE,页表大小4M
当符合页表大小是4M的情况下,只需要查一次PDE表再加上偏移就能得出物理地址。
开启PAE分页机制的36位物理寻址,
开启PAE时,寻址的方式有所不同,CR3里保存的不再是PDE的首地址,而是一个保存了PDE指针的表的首地址,这张表我们称作页目录指针表(PDPT),对于线性地址的拆分也有所不同,高两位作为了PDPT的索引。
页表大小4K
当开启PAE且页表大小是4k的情况,需要查三次表,线性地址的21-29位作为PDE表的索引,12-20位作为PTE表的索引
页表大小2M
说了这么多关于表的格式和查表的方法,在实践中我们该如何利用呢?一个用处是我们经常需要把虚拟地址转换成物理地址,明白其转换原理利于分析问题,另外上面说了,每个进程都会有一套自己的分页机制,切换进程实际上是切换CR3中的内容,那么如何实现我们自己的ReadProcessMemory呢?
问题变成了以下4步:
- 拿到指定进程保存在CR3中的内容
- 切换当前的CR3
- 读取指定内存的内容
- 还原CR3
第一步,如何拿到指定进程保存在CR3中的内容?
我们知道在3环程序里,fs[0]保存的是线程环境块TEB,在0环,保存的则是处理器控制区(_KPCR),其结构如下图:
从_EPROCESS中的进程链表我们可以遍历所有进程,当匹配到目标进程时,拿出
目标进程DirectoryTableBase中保存的地址。
NTSTATUS GetProcessDirBase(IN DWORD dwPID, OUT PDWORD pDirBase)
{
PEPROCESS Process;
PEPROCESS CurProcess;
CHAR *pszImageName;
DWORD dwCurPID;
DWORD i;
__try
{
__asm
{
//ETHREAD
mov eax, fs:[124h]
//Current EPROCESS
mov eax, [eax + 44h]
mov Process, eax
}
CurProcess = Process;
i = 0;
//traversing EPROCESS
do
{
pszImageName = (char*)CurProcess + 0x174;
dwCurPID = (*(DWORD*)((char*)CurProcess + 0x084));
if (dwCurPID == dwPID)
{
*pDirBase = (*(DWORD*)((char*)CurProcess + 0x018));
return STATUS_SUCCESS;
}
//Next
CurProcess = (*(DWORD*)((char*)CurProcess + 0x088)) - 0x88;
} while (CurProcess != Process);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
dprintf("[MyReadProcessMemory] GetProcessDirBase __except \r\n");
}
return STATUS_INVALID_DEVICE_REQUEST;
}
第二步,切换当前的CR3
切换CR3的值之前,我们需要屏蔽调当前CPU核心的中断,以防线程切换,如果是多核的CPU,每个核心都需要屏蔽掉中断。同时,为了预防内存属性不可写,暂时改掉CR0中表示所有内存属性的标志,让所有内存暂时都可写。
__asm
{
//Shielding interrupt
cli
//close memory protect
mov eax, cr0
and eax, not 10000h
mov cr0, eax
mov eax, cr3
mov dwOldDirBase, eax
//swap CR3
mov eax, dwDirBase
mov cr3, eax
}
第三步,读取指定内存的内容
申请一段空间,暂存一下读取的数据,记得要检查目标内存地址是否有效
//Alloc ring0 Buff
char* szRing0Buf = (char*)MmAllocateNonCachedMemory(dwBufSize);
//check address invalid
if (MmIsAddressValid(dwTargetAdddress))
{
RtlCopyMemory(szRing0Buf, dwTargetAdddress, dwBufSize);
bIsRead = TRUE;
}
第四步,还原CR3
恢复内存属性,恢复中断
__asm
{
mov eax, dwOldDirBase
mov cr3, eax
//Reset memory protect
mov eax, cr0
or eax, 10000h
mov cr0, eax
//Restore interrupt
sti
}
总结
内核的学习也开始进入尾声,温故而知新,整理知识本身也是一种学习的过程。衷心感谢一年多以来钱老师,张老师,姚老师,戚老师,王老师,唐老师的指导。