转眼来到科锐学习已经超过一年的时间了,眼看三阶段已经进入尾声,内核的学习也快要结束,记录一下笔记和心得,也给刚接触的朋友做一个参考。当然,学习新知识最好的办法就是带着目的来学习,所以在文章后半部分,介绍如何自己实现了Windows的一个API:ReadProcessMemory,为什么要选这个API呢?首先在软件保护上,任何开发者都不会希望别人可以随意查看自己的内存内容,于是多数人会选择在这个函数上挂钩,监视并保护内存,那么我们可以绕过他的保护,用自己的函数来查看进程内存。
环境介绍:真机64位Windows10,虚拟机:32位XP_sp3
Windows算是微内核和宏内核特点都具有的操作系统。
8086处理器有三种工作模式,分别是:实模式,保护模式,虚拟86模式,其中关系为:
16位汇编中 iret
可以进入保护模式。
如何保证操作内存的动作是否合法?
操作系统通电后进入实模式,做了一系列初始化的动作后进入到保护模式,在保护模式中,CPU执行所有和内存有关的操作都会通过查表来确定操作是否合法,这个表就是GDT和LDT表,表的格式由CPU厂商决定,所以为了能兼容多款CPU,操作系统代码里多用条件宏来实现。
首先通过逻辑地址的偏移查第一次表得到线性地址,再查第二次表得到物理地址。为什么要查第二次表呢?因为第二张表实际上是为了实现虚拟内存,那么就是说这段内存可能是在磁盘上的,访问的时候会先查表,然后从磁盘上调到内存中,有些情况下(关闭了虚拟内存)查第一次表的结果等价于物理地址。
从逻辑地址转换到线性地址,是一块平坦且连续的地址,实际上对应到物理地址上,并不是连续的。
例如:
这个表也叫做分段表,结构如下图:
这种奇葩的做法来源于为了兼容286的历史遗留问题。
用于表示上述定义段的三个参数的数据结构称为描述符。每个描述符长8个字节。在保护方式下,每一个段都有一个相应的描述符来描述。
存储段是存放可由程序直接进行访问的代码和数据的段。存储段描述符描述存储段,所以存储段描述符也被称为代码和数据段描述符。
描述符是一个8个字节的结构,具体结构如下:
整个的解析结构如下图:
Privl(2bit):优先级位,取值从0-3,对应Ring0-Ring3级别。
Ex:可执行位,为1时表示此描述符对应是代码段,为0时为数据段。
每个任务的局部描述符表LDT含有该任务自己的代码段、数据段和堆栈段的描述符,也包含该任务所使用的一些门描述符,如任务门和调用门描述符等。随着任务的切换,系统当前的局部描述符表LDT也随之切换。
全局描述符表GDT含有每一个任务都可能或可以访问的段的描述符,通常包含描述操作系统所使用的代码段、数据段和堆栈段的描述符,也包含多种特殊数据段描述符,如各个用于描述任务LDT的特殊数据段等。在任务切换时,并不切换GDT。
通过LDT可以使各个任务私有的各个段与其它任务相隔离,从而达到受保护的目的。通过GDT可以使各任务都需要使用的段能够被共享。
GDT存储在GDTR寄存器, 通过汇编指令LGDT载入。它的操作码是一个结构的地址,这个结构描述GDT的大小和地址。共6个字节,如下:
LDT存在LDTR寄存器中,存有局部进程的描述符表,LDTR中的内容根据线程的切换不停切换,表中的内容由操作系统来修改,若我们拿到0环权限,自己修改LDTR,改到目标进程,那么修改自己的内存就相当于修改了目标进程的内存,这是内核修改的一个经典招式。
在保护方式下,虚拟地址空间(相当于逻辑地址空间)中存储单元的地址由段选择子和段内偏移两部分组成。段选择子长16位,在32位程序下,CPU的段寄存器中保存的就是选择子,其格式如下表所示:
现有逻辑地址:23:13ac34b,假如段寄存器中的的选择子Index为:0000000000100,RPL:11,先比对,是三环程序,继续操作,去LDT表中的第4项拿到段首地址,加上偏移13ac34b,得到线性地址。
值得一提的是Windows中并没有使用LDT,而Linux是使用了LDT的,但是有意思的是在阅读Windows源码时发现微软也留下了LDT的接口,难道微软想什么时候顺便兼容一下Linux?
80386开始支持存储器分页管理机制。分页机制是存储器管理机制的第二部分。上述的段管理机制实现虚拟地址(由段和偏移构成的逻辑地址)到线性地址的转换,分页管理机制实现线性地址到物理地址的转换。
线性地址到物理地址的转换方式受很多变量的影响,我们先以一个其中最具代表性的方式来讲解基本概念和转换流程,再来总结所有的转换方式。
流程如图所示采用了二级表的结构:
一级表称为页目录表(Page Directory Entry),共有1024(1k)个表项,每个表项的大小是4bit,总大小为4k,表项内容包括了页表的指针和指向页表的属性。
二级表称为页表(Page Table Entry),每张页表里有1024(1k)个表项,每个表项的大小是4bit,总大小为4k,最多有1024(1k)张页表,最大占用空间为4M,而操作系统一般是动态申请页表,大小大概在1M左右。表项内容包括了物理地址的指针和属性。
控制寄存器CR3的高20位存放了指向页目录表的指针(这里存的是物理地址,如果这里存虚拟地址就会产生悖论),每个进程都会有一张PDE,切换进程其实就是CPU在切换CR3的值,这一点非常重要,是我们自己实现ReadProcessMemory的基础!
PDE和PTE的表项结构基本相似,略有差别,如下图所示:
结构基本类似,高20位存指向目标首地址,低12位表示指向目标的属性
如何搭建双机调试环境,请自行谷歌
我们以GDT地址作为例子:
寄存器环境:
gdt内容:
dd :查看虚拟内存地址
现在我们有线性地址:0x8003f000,CR3:39000
!dd:查看物理内存地址, 0x200 * 4是因为表项是4个字节
以上我们所阐述的线性地址转物理地址的方法适用于没有物理地址拓展,且页表大小为4k的情况。
查表方式根据页表大小来决定,而页表大小根据以下标志决定:
PS :页表尺寸,PDE表项中的第7位
以下图片均来自Intel手册,详细解释请参考手册
当符合页表大小是4M的情况下,只需要查一次PDE表再加上偏移就能得出物理地址。
开启PAE时,寻址的方式有所不同,CR3里保存的不再是PDE的首地址,而是一个保存了PDE指针的表的首地址,这张表我们称作页目录指针表(PDPT),对于线性地址的拆分也有所不同,高两位作为了PDPT的索引。
当开启PAE且页表大小是4k的情况,需要查三次表,线性地址的21-29位作为PDE表的索引,12-20位作为PTE表的索引
说了这么多关于表的格式和查表的方法,在实践中我们该如何利用呢?一个用处是我们经常需要把虚拟地址转换成物理地址,明白其转换原理利于分析问题,另外上面说了,每个进程都会有一套自己的分页机制,切换进程实际上是切换CR3中的内容,那么如何实现我们自己的ReadProcessMemory呢?
问题变成了以下4步:
我们知道在3环程序里,fs[0]保存的是线程环境块TEB,在0环,保存的则是处理器控制区(_KPCR),部分内核数据结构如下图:
从_EPROCESS中的进程链表我们可以遍历所有进程,当匹配到目标进程时,拿出
目标进程DirectoryTableBase中保存的地址。
切换CR3的值之前,我们需要屏蔽调当前CPU核心的中断,以防线程切换,如果是多核的CPU,每个核心都需要屏蔽掉中断。同时,为了预防内存属性不可写,暂时改掉CR0中表示所有内存属性的标志,让所有内存暂时都可写。
申请一段空间,暂存一下读取的数据,记得要检查目标内存地址是否有效
恢复内存属性,恢复中断
内核的学习也开始进入尾声,温故而知新,整理知识本身也是一种学习的过程。衷心感谢一年多以来钱老师,张老师,姚老师,戚老师,王老师,唐老师的指导。要毕业了,帮科锐宣传下,科锐30期正在招生中
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;
}
__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;
}
__asm
{
mov eax, dwOldDirBase
mov cr3, eax
//Reset memory protect
mov eax, cr0
or eax, 10000h
mov cr0, eax
//Restore interrupt
sti
}
环境介绍:真机64位Windows10,虚拟机:32位XP_sp3
16位汇编中 iret
可以进入保护模式。
这种奇葩的做法来源于为了兼容286的历史遗留问题。
值得一提的是Windows中并没有使用LDT,而Linux是使用了LDT的,但是有意思的是在阅读Windows源码时发现微软也留下了LDT的接口,难道微软想什么时候顺便兼容一下Linux?
如何搭建双机调试环境,请自行谷歌
以下图片均来自Intel手册,详细解释请参考手册
- 介绍内核基础知识
- 自己实现ReadProcessMemory
- 宏内核:追求性能,大部分系统代码放在0环,代表:Linux
- 微内核:追求维护性,大部分系统代码在3环,代表:Minix(Linux作者老师的作品),其中著名的设计:动态链接库,在Windows中也使用
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!