不用驱动进入内核模式

不用驱动进入内核模式并且利用APIC得到中断的信息
说明:原文地址 http://www.codeproject.com/system/soviet_kernel_hack.asp
转载请说明译者:WH QQ: 19552451, QQ Group: 5497193
介绍:
尽管使user-mode的应用程序进入kernel绝对是一件令人激动的体验,但是这已经不是什么新鲜事了/还有很多事情我们从未听说过。Matt Pietrek是第一个完成这件事的人(许多年前他在windows95上做过了)。他的技术后来被Prasad Dabak, Sandeep Phadke和 MilindBorate修改了一下用到了windows NT上。为了从一个应用程序进入kernel,其中一个方法是去调用全局描述表(Global Descriptor Table(GDT))中的门调用描述符(call gate descriptor),所以一个应用程序能够通过门调用进入kernel。然而一旦 user-mode代码不允许进入GDT,上面作者使用的kernel-mode 驱动就是为了去调用一个描述门。那么,有个很合乎逻辑的问题会被提出,如果你始终需要用一个驱动来起作用那么离开驱动来实现这个功能其最关键的是什么?毕竟,用驱动的方法仅仅是达到了这个目的,难道你不认为吗?
这篇文章描述了如何使user-mode的应用程序能进入kernel 的地址空间,并且在GDT中调用一个门调用描述符,同时不需要使用驱动。文章解释了如何在32位的处理器中进行虚拟地址到物理地址的转换,同时又描述了user-mode的应用程序如何能够找出其被分配的虚拟地址表示形式在物理地址中的位置。 解决这个任务的“方法学”100%我自己设计的----你不会在任何地方找到和这个相似的文章。这篇文章也彻底地解释了Windows NT的kernel地址空间的保护是如何执行的,在基于x86的系统中从非特权方式到特权模式是如何过渡的和应用程序是如何进入kernel而不需要利用驱动的方式。
除了上面所介绍的之外,这篇文章也为读者介绍了高级可编程序中断控制器Advanced Programmable Interrupt Controller(APIC),同时也解释了如何利用APIC的方法去获得中断的信息。仅管APIC在Mark Russinovich和David Solomon合写的书《Windows Internals, Fourth Edition》中有简短的提及,但是这个主题似乎在windows的社区中很少有人了解。然而,这本书也没有告诉我们如何去对APIC进行编程。同时,我也从未在任何windows的核心文章中浏览过关于APIC的编程。我不得不亲自从Intel的手册中去找寻相关资料,因此,我相信这些信息对于windows开发者来说必定是相当的感兴趣的。
总结下来,如果你想去了解更多的关于系统内核的信息,这篇文章必定很适合你
进入内核地址空间
让我们假设我们想进入一些内核的地址空间,然后在运行user-mode的时候去做这些事情,可能会发生什么?是或不是。如果我们尝试直接从虚拟地址空间进入内核地址空间,我们就会得到一个地址越界的异常。然而,这里有一个工作区―――物理RAM能够被打开,使用一组写成 ”\\Device\\PhysicalMemory” 样式的字符串然后用NtOpenSection()函数打开,接着用NtMapViewOfSection()本地的API来映射。利用这种技巧我们能够进入RAM的任何一页(page)。如果我们知道我们程序目标地址所表示的是哪一个物理地址,我们的任务就很容易了,所有我们必须做的事情就是通过NtMapViewOfSection()的指针映射到物理地址空间中,这个函数的返回值,引用相同的物理地址作为我们在内核地址空间中的目标地址,但是在数字上是低于0x80000000,也就是说,我们能够从user-mode进入内核模式。如果我们对它需要有写的权利,我们在映射之前几乎很少要做额外的事情。问题是非系统的进程,也就是指,这些不是由SYSTEM用户启动的进程,没有权去写”\\Device\\PhysicalMemoy”。因此,你必须保证你有权去写”\\Device\\PhysicalMemoy”。代码如下方:

EXPLICIT_ACCESS Access;
PACL OldDacl=NULL, NewDacl=NULL;
PVOID security;
HANDLE Section; INIT_UNICODE_STRING(name, L"\\Device\\PhysicalMemory");
OBJECT_ATTRIBUTES oa ={sizeof(oa),0,&name,0,0,0};

memset(&Access, 0, sizeof(EXPLICIT_ACCESS));
NtOpenSection(&Section, WRITE_DAC | READ_CONTROL, &oa);
GetSecurityInfo(Section, SE_KERNEL_OBJECT,
DACL_SECURITY_INFORMATION, NULL, NULL, &OldDacl,
NULL, &security);

Access.grfAccessPermissions = SECTION_ALL_ACCESS;
Access.grfAccessMode = GRANT_ACCESS;
Access.grfInheritance = NO_INHERITANCE;
Access.Trustee.MultipleTrusteeOperation = NO_MULTIPLE_TRUSTEE;
Access.Trustee.TrusteeForm = TRUSTEE_IS_NAME;
Access.Trustee.TrusteeType = TRUSTEE_IS_USER;
Access.Trustee.ptstrName = "CURRENT_USER";

SetEntriesInAcl(1, &Access, OldDacl, &NewDacl);
SetSecurityInfo(Section, SE_KERNEL_OBJECT,
DACL_SECURITY_INFORMATION, NULL, NULL, NewDacl,
NULL);

CloseHandle(Section);

为了保证这些代码能运行,你需要用Administrator的权限来登陆。就我个人的经验来看,受限制的用户不能使用 "\\Device\\PhysicalMemory" ------ 即使你只要求只读的权利NtOpenSection()还是会一直返回一个错误代码。
因此只要我们有管理员的权限,我们就可以在user-mode时获得进入RAM任意页的权利,即使这些页可能正在和其他内核地址空间中的地址通信。另一方面,我们能获得间接的进入内核地址空间的权利。难道你没有发现这个很令人激动吗!然而,为了使这些假设实际产生效果,我们必须去发现我们的目标地址在内核地址空间中的物理地址的表示。内核模式的驱动能够调用MmGetPhysicalAddress(),但是user-mode是不会有机会去调用这样的内核模式的API的,所以,这里没有能够起到帮助作用的user-mode的API,因此,我们必须靠我们自己去探索。这就是为什么,我们首先必须要学习如何在32位的处理器中进行虚拟到物理的地址转换。
在x86系统中每一页的大小不是4KB就是4MB。如果这页的大小是4KB,则32位的虚拟地址包含了3块CPU所需要的信息,这些信息是为了得到这些地址表示的实际物理位置。虚拟地址从0..12位代表了一个物理内存页(physical memory page)的偏移量,12…21位在一张页表中起索引的作用,这张页表中有1024个条目,描绘了物理的页。每一个进程理论上可以有1024张页表,所以,1024×1024×4096 = 4GB的空间。一个进程的所有页表的地址都被存在一张页目录(Page Directory)中。虚拟地址空间中的22…31位被用来在页目录中定位合适的页表。每一进程页都有自己独立的页目录。当前正在运行的进程的页目录的物理地址被保存在CR3寄存器中。这个寄存器被期望在任务切换(task switch)时可以被修改,所以任何人没有权限进入这个寄存器,除了系统之外。在任务切换时,系统会加载一个不同的页目录到CR3。所以,先前引用物理页X的虚拟地址现在可以引用相同的页X,也有可能是其他页Y,或者不引用任何页。这就是为什么任意一个在进程A中有效地址空间中的虚拟地址,对于进程B来说是没有任何意义的,除非它们引用相同的地址空间。例如,驱动被装载进入RAM只有一次,同时映射到所有进程的相同的虚拟地址空间,所以改变CR3的内容也不会产生影响。

每一个页目录的二进制设计和页表条目的32位结构描述:
struct PageDirectoryOrTableEntry
{
DWORD Present:1;
DWORD ReadWrite:1;
DWORD UserSupervisor:1;
DWORD WriteThrough:1;
DWORD CacheDisabled:;
DWORD Accessed:1;
DWORD Reserved:1;
DWORD Size:1;
DWORD Global:1;
DWORD Unused:3;
DWORD PhysicalAddress:20
};
如果页的大小是4KB,那么地址转换的工作如下:
1.CPU从当前运行进程的CR3寄存器中得到页目录。在这个目录中,虚拟地址的高10位代表了一个索引值index(i),,这个索引值是为了让CPU定位页表的地址的,这个地址与被给予的虚拟地址相对应。如果目录中的i项条目的当前位没有被设置的话,CPU就会产生一个缺页中断(Page Fault exception, INT 0xE),缺页中断的产生有多种原因,例如:无效的地址,对只读的内存进行写操作等等。因此,系统要首先检查缺页中断的原因。如果这个中断产生只是因为当前位的状态,则系统会结束有问题的页表和硬盘的之间的交换操作。因此,CPU装载页表进入RAM,设置对应的页目录中条目的当前位,和CPU再一次尝试进入虚拟地址,所有这些行为对于客户端的编程来说都是透明的。
2.当定位好了页表之后,在这张表中,CPU根据相应的虚拟地址进入下一步定位页。虚拟地址的12…21表示一个索引值index(i),这个索引值CPU将会用来定位在页表中的目标页的地址。如果页表中第i项的当前位没有设置的话,CPU产生一个缺页中断,处理缺页中断的过程我们上面已经看到了。
3.最后,当定位好了页之后,CPU使用虚拟地址的低12位作为页的偏移量。

64位处理器使用更先进的地址转换方法。例如,Itanium在硬件层次上允许所谓的Data Execution Prevention(DEP)。这里有个错误的观念认为DEP是windows xp sp2的新的特色,其实不是而是CPU的新的特色,windows xp sp2只是利用了这个特色而已。如果windows xp sp2运行在不支持DEP的CPU上时,DEP将不起作用---- 离开了DEP的特色这里就没有方法在处理器上预防执行硬件机器指令,好了,我们将继续讨论32位处理器和4KB页。
在windows NT中,当前正在运行的页目录会映射到虚拟地址0xC0300000。这个信息和我们已经了解的虚拟地址到物理地址的转换机制相结合的话,我们可以总结出两点结论
1.在windows NT中,一个页目录的第0x300项保存了这个页目录自身的物理地址。
2.页表,及相应的虚拟地址,用 0xC0000000+((address>>10)&0x3FF000) 是可以得到的。例如这个起解释作用的页表,对应的虚拟地址0xC0300000,它自身的地址就是0xC0300000。换句话说,页目录也只是一张对应于虚拟地址页目录自身的一张页表而已


现在,让我们尝试做一些实际的练习。想象一下如下的内核模式的代码:
_asm
{
mov ebx, 0xfec00000
or ebx, 0x13
mov eax, 0xc0000000
mov dword ptr[eax], ebx
}
//what are we doing??? Are we insane???
PULONG array=NULL;
array[0]=1;

如果运行这些代码会发生什么呢?答案似乎很明显,但是这是错的。我们不会崩溃的。我们对0xfec00013(高20位指出其物理页是0xfec00,低12位指出Present,ReadWrite, CacheDisabled这几个标志位被设置了)进行写操作,作为一个第一次进入页表的条目,其地址被存在页目录中。现在考虑一下CPU如何去转换虚拟地址0,同时你也会了解,由于我们的修改,CPU将转换0到物理地址0xfec00000。从现在开始,运行这些代码的进程,其虚拟地址的范围从0—0x1000将成为进程地址空间中有效的虚拟地址范围!!!空指针将会有用,同时可以引用物理地址0xfec00000!!!你现在应该不会觉得很枯燥了吧。稍后你们将看到这些小把戏在某些地方会十分的有用。
但是,让我们回到我们的任务中,你们也许还记得哦,我们当前的任务是去探索哪一个物理地址代表了我们的目标虚拟地址。更重要的是,这个工作必须是在user-mode下的程序来完成。如果我们知道我们进程的页目录在物理RAM中的地址,那我们的任务就很简单了。让我们估计我们目标程序的虚拟地址是V。我们将通过函数NtMapViewOfSection()映射页目录到虚拟地址D,并且将D想象为一个1024的DWORD数组。物理页是一个页表,对应的虚拟地址V,该物理页被(V>>22)位后在D(页目录)中的项(entry)的高20位所描绘(很绕口希望大家看懂)。因此,我们将映射这一页到虚拟地址T,同时继续把T想象成一个1024 DWORD的数组,T中第 ((V>>12)&0x3FF) 项的高20位是虚拟地址V所代表的一个物理页。这里只有一个问题---当我们进程的页目录是可用的时候,CR3寄存器在user-mode下是不能访问的。所以,首先我们必须找到我们进程的页目录物理地址。
我们要做的事情就是去扫描一下内存,将RAM中的每一页都映射到我们进程的地址空间中。所以迟早我们可以在这些页中发现页目录。那么如何辨认出页目录呢?让我们假设一下我们检查的物理页是P,并且P被映射进入虚拟地址V中,利用函数NtMapViewOfSection()。我们把V想象成一个1024 DWORD的数组。如果P是我们进程中含有页目录的一个物理页的话,我们按如下做法:
1.V的第0x300项的高20位肯定等于P,因为一个页目录的第0x300项必定存有这个页目录自身的物理地址。
2.V中第0x300项的最低位肯定被设置了,因为它表明了此页存在于RAM中。
3.如果P是一个页目录,那么V中的第(V>>22)项表示是一个页表--对应于虚拟地址V它的本身。同时这个页表毫无疑问的被装载进入RAM中。因此,V中第(V>>22)项的最低位肯定被设置了。
如果上面所说的任意一条不成立的话,我们能得出结论,这个P肯定不是我们的页目录。所以我们可以处理下一页了。另外,我们映射对应于虚拟地址V的物理页,这个页被V中第(V>>22)项的高20位所表示(我们称这个结果为虚拟地址T),同时估计T是一个页表。如果我们的估计是正确的,那么:
1.T中第((V>>12)&0x3FF)项的高20位必定等于P。
2.T中第((V>>12)&0x3FF)项的低位必定被设置。
如果如上的条件都匹配的话,我们能得出结论,P是一个正真的页目录。所以可以从虚拟地址V中访问P。
这些东西我们该如何去想?再一次阅读关于虚拟地址到物理地址的转换,思考虚拟地址V转换到物理地址P,同时我希望,我所讲的这些东西你能清楚了解。看看下面的代码吧:
//check how much RAM we've got
MEMORYSTATUS meminfo;GlobalMemoryStatus(&meminfo);

//get handle to RAM
status = NtOpenSection(&Section,SECTION_MAP_READ|SECTION_MAP_WRITE,&oa);

DWORD found=0,MappedSize,x;LARGE_INTEGER phys;DWORD* entry;
PVOID DirectoryMappedAddress,TableMappedAddress;
DWORD DirectoryOffset,TableOffset;
for(x=0;x<meminfo.dwTotalPhys;x+=0x1000)
{
//map current page in RAM
MappedSize=4096; phys.QuadPart=x; DirectoryMappedAddress=0;
status = NtMapViewOfSection(Section, (HANDLE) -1, &DirectoryMappedAddress, 0L,MappedSize,
&phys, &MappedSize, ViewShare,0, PAGE_READONLY);
if(status)continue;
entry=(DWORD*)DirectoryMappedAddress;

//get offsets
DirectoryOffset=(DWORD)DirectoryMappedAddress;TableOffset=(DWORD)DirectoryMappedAddress;
DirectoryOffset>>=22;TableOffset=(TableOffset>>12)&0x3ff;

//let's check if this page can be a page directory - 20 upper bits of 0x300-th entry
//must be //equal to P, and Present bit must be set in 0x300-th and V>>22-th entries.
//If not,proceed to next page
if((entry[0x300]&0xfffff000)!=x ||(entry[0x300]&1)!=1 || (entry[DirectoryOffset]&1)!=1)
{NtUnmapViewOfSection((HANDLE) -1, DirectoryMappedAddress);continue;}

//seems to be OK for the time being. Now let's try to map a possible page table
MappedSize=4096; phys.QuadPart=(entry[DirectoryOffset]&0xfffff000); TableMappedAddress=0;
status = NtMapViewOfSection(Section, (HANDLE) -1, &TableMappedAddress, 0L,MappedSize,
&phys, &MappedSize, ViewShare,0, PAGE_READONLY);

if(status){NtUnmapViewOfSection((HANDLE) -1, DirectoryMappedAddress);continue;}

//now let's check if this is really a page table If yes, 20 upper bits of (V>>12)&0x3ff-th
//entry must be equal to P, and Present bit must be set in this entry.
//If the above is true, P is really a page directory
entry=(DWORD*)TableMappedAddress;
if((entry[TableOffset]&1)==1 && (entry[TableOffset]&0xfffff000)==x)found++;

NtUnmapViewOfSection((HANDLE) -1, TableMappedAddress);

//directory is found - no need to proceed further
if(found)break;
NtUnmapViewOfSection((HANDLE) -1, DirectoryMappedAddress);
}

这些代码如何可信呢?我们可能在RAM中对于我们的页目录犯一些错误吗?对于这样的错误的产生是需要任意的42位的一模一样的。因此这些错误发生的概率是1/2^42,也就是说在实践过程中可以忽略不计了。我们可能某些时候在我们的进程中丢失页目录吗?我们连续的扫描已经保证可以扫描到它了,除非当我们的代码运行的时候,页目录在RAM中不停的游走。理论上,内存管理可以在RAM中移动任何页,包括页目录,但是这种事情的发生只有在,某些时候,页和硬盘交互时出现问题然后重新装载进入RAM时才会发生。一次频繁的页和硬盘的交互会降低系统的性能,所以系统不会频繁的存取这些页。一个进程在页目录和硬盘进行交互之前会保持不活动状体很长一段时间----只要代码运行,这个进程的页目录就要读取每一条指令来执行,所以页目录不能是页面调度中的候选页。因此,我们能安全地假设我们的页目录将会保存在一些固定的RAM地址中直到我们的程序执行完。换句话说,在实践阶段,是相当的可靠的。这些代码我使用了许多次都没有出现这唯一一个错误。
现在,我们已经知道我们的页目录的物理地址了,我们可以很容易的获得相应的任意一个我们感兴趣的虚拟地址的物理地址----解决这些任务的“方法论”在上面已经被描述出来了。让我们从页的物理地址中得到Global Descriptor Table(GDT)吧。

//get base address of gdt

BYTE gdtr[8]; DWORD gdtbase,physgdtbase;
_asm
{
sgdt gdtr
lea eax,gdtr
mov ebx,dword ptr[eax+2]
mov gdtbase,ebx
}

//get directory and table offsets
DirectoryOffset=gdtbase;TableOffset=gdtbase;
DirectoryOffset>>=22;TableOffset=(TableOffset>>12)&0x3ff;

entry=(DWORD*)DirectoryMappedAddress;

//map page table - phys. address of it is 20 upper bits of (V-22)-th entry of page directory
MappedSize=4096; phys.QuadPart=(entry[DirectoryOffset]&0xfffff000); TableMappedAddress=0;
status = NtMapViewOfSection(Section, (HANDLE) -1, &TableMappedAddress, 0L,MappedSize,
&phys, &MappedSize, ViewShare,0, PAGE_READONLY);

//phys page is in 20 upper bits of (V>>12)&0x3ff-th entry of page table
// this is what we need
entry=(DWORD*)TableMappedAddress;
physgdtbase=(entry[TableOffset]&0xfffff000);
//unmap everything
NtUnmapViewOfSection((HANDLE) -1, TableMappedAddress);

NtUnmapViewOfSection((HANDLE) -1, DirectoryMappedAddress);

GDT对于user-mode来说是不可访问的,但是有了上面的解释,这种限制已经再也不能影响我们了----我们已经找到了GDT所驻留的页的物理地址,不是吗?为了能访问GDT,首先我们必须映射这一页到虚拟地址V,利用函数NtMapViewOfSection()。
这里没有保证GDT开始于页的开始处,所以我们必须使用虚拟地址的低12位作为一个偏移量去进入这一页。因此,我们必须加上(gdtbase&0xFFF)到V。从目前来看我们已经有权利从虚拟地址V来读写GDT。因此我们能从一个user-mode的应用程序得到对内核地址空间的读写权。如果我们能到执行权这不是太棒了吗?这就是我们一直在干得事情,只就是为什么上面的每一件事都被说到了,并且这就是为什么我们要得到GDT的地址,而不是内核地址空间中的其它地址---GDT会给与我们的程序进入内核的权利而不需要去写一个驱动。

代码特权和保护
GDT能够持有段描述符(Segment Descriptors),本地描述符表(Local Descriptor Table,LDT),门调用描述符(Call Gate Descriptors)和任务状态段描述符(Task State Segment Descriptors,TSS)。尽管它们中的每一个都有自己的二进制表示,但是所有上述的描述符都是8 bytes。段描述符和门调用描述符的二进制形式被如下的8-bytes结构所表示:
struct SegmentDescriptor
{
WORD LimitLow;
WORD BaseLow;
DWORD BaseMid : 8;
DWORD Type : 5;
DWORD Dpl : 2;
DWORD Pres : 1;
DWORD LimitHi : 4;
DWORD Sys : 1;
DWORD Reserved_0 : 1;
DWORD Default_Big : 1;
DWORD Granularity : 1;
DWORD BaseHi : 8;

}

struct CallGateDescriptor
{
WORD offset_low;
WORD selector;
BYTE param_count :5;
BYTE unused :3;
BYTE type :5;
BYTE dpl :2;
BYTE present :1;
WORD offset_high;
} ;

LDTs和门调用描述符在windows NT中是不使用的。尽管,考虑到性能的原因,在windows NT中所有使用者的进程都运行在一个单任务的环境中,那里的GDT中几乎没有什么TSS描述符。TSS主要的任务是为了“异常的情况”,例如:系统崩溃――它们的任务是在CPU重起之前确保系统有足够长的时间来抛出一个蓝屏。无论怎么,这部分对我们来说没什么用处。那么段描述符呢?可以思考一下,windows曾经使用的平坦内存模式,所以我们不应该对它感到厌烦。
实际上,事情从来都不会那么简单。通过在code,data,stack中设置BaseLow, BaseMid, BaseHi这几个字段Windows的平坦内存模式会被应用同时在GDT中额外的段描述符会被置0。结果是code,data,stack和额外的段都会被映射进相同的虚拟内存地址0中,所以,当我们使用内存地址时不需要指定段和偏移量。但是,段仍然会在那里,因为如果不使用分段法的话就没有方法去执行基于x86机器上的保护操作系统。问题就是那样,严格来说,在基于x86的机器上内核和user操作系统模式没有这样的事情。代替的是,执行特权指令和访问管理员页的能力被控制了,被code段的Descriptor Privilege Level(DPL)域(field)所控制。因此,为了区分非特权指令和特权指令,则两个code段时必须的。特权的代码段的DPL为0,非特权的DPL为3。尽管它们映射到相同的虚拟地址0,它们还是会被处理器作为不同的代码段来区别对待。CS寄存器被用来解释从表开始的位置到当前正在运行的代码段描述符其字节的偏移量(…)。这就是CPU如何知道当前正在运行的代码的权利。在windows NT中,CS寄存器值可以是0x8(当执行特权指令时),或者0x1B(非特权指令)。
因此,特权的等级是通过CS寄存器来定义的,而不是代码其自身的地址来决定的。如果一些代码在执行时CS的值为0x8,那么它们就会被当作特权指令来对待,但是如果同样的指令在执行时CS的值为0x1B则它们就会被当作非特权指令对待。我可以猜到你的问题----毕竟,非特权指令是没有机会进入地址0x80000000之上的。那么这不是有点自相矛盾吗?一点也不,再看一下PageDirectoryOrTableEntry这个结构,把注意力集中到UserSupervisor这一位,那么所有的事情就会开始清楚了--- windows只是给这些映射到地址空间0x80000000之上的,仅用来作为管理的页,在它们的页表中打了标记而已。如果非特权指令想进入这些页,那么就会产生非法访问的异常(access vilation exception)。如果CS的值为0x1B,那么任何驻留在这些页中的函数不能被运行,同时非法访问的异常(access vilation exception)会直接被抛出。因此,内核地址空间保护是通过联合了分段法和页层次的保护机制一起来实现的。
从非特权代码段到特权代码段的过渡能够如下方法的任意一个实现:
1.通过INT n指令。这个指令将user-mode SS寄存器的值,user-mode ESP寄存器的值,EFLAGS寄存器,user-mode CS寄存器的值和返回的地址(注意顺序)压入内核的堆栈中,同时转换执行到INT n的句柄。Windows NT已这样的方式设置中断描述符表中的DPL。这种方式使user-mode下的代码被允许去执行中断0x3, 0x4, 0x2A, 0x2B, 0x2C和0x2E。
2.SYSENTER指令。这个指令设置了调用线程的ESP值,这个值通过SYSENTER_ESP_MSR规格模式的寄存器来定义规格,设置了CS的值,这个值通过SYSENTER_CS_MSR规格模式的寄存器来定义规格,并且转换执行指令到地址,其规格是被SYSENTER_EIP_MSR规格模式的寄存器来定义(这些寄存器是不能被user-mode的代码所访问的)。标志和返回地址也不被保存。Windows NT/2000不使用SYSENTER指令,同时在windows XP下这些指令被转换成系统服务的分派者。
3.通过远程调用“门操作”,当内核的入口已经已经通过call gate产生,CPU从user的堆栈中拷贝32个DWORD(精确的数字是通过门调用描述符(call gate descriptor)中的param_count域来指明的)到内核中,然后压入user-mode的CS同时返回内核堆栈的地址,接着设置CS的值,这个值是被门调用描述符的selector域来指定,最后转换执行指令到地址,这步是被门调用描述符中offset_low和offset_high域来指定的。Windows是不使用门调用的。

第二个选择是经常被windows所使用的,同时他们转换执行指令到某种系统定义的地址。因此,我们不能使用它们去转换执行指令到一些我们自己选择的模棱两可的地址上。门调用则是另外一回事情----windows曾经不使用它们,我们可以按照自己喜欢的方式来自由的使用它们。如果我们在GDT中建立了一个门调用,我们可以转换执行指令到任何一个我们在门调用描述符中所定义的地址。当然,这些在我们进程地址空间中地址肯定是有效的,而且你必须理解的是,正在运行指令的特权等级是由CS的值来定义的而不是EIP。所以,如果我们指定一些在门调用描述符被我们应用程序中使用的函数的地址,然后调用通过门调用来调用它,那么这些函数将会被CPU作为特权指令来对待,尽管这些函数是驻留在我们进程中的user-mode的地址空间。这些函数将可以访问内存地址,IO端口,产生中断,调用ntoskrnl.exe的输出,也就是说可以做任何一件内核模式中的驱动才能做的事。同时,这些函数不应该去尝试调用由user-mode下的dll中使用的API函数。为什么?因为这些API函数会调用本地API,并且本地的API会激发系统的服务,也就是说进入内核模式通过系统服务的调度,而这时我们已经是特权模式了!!!这不会对我们有任何好处。通用的规则是最好不要让windows知道我们的这些小技巧。那我们如何回到user-mode呢?通过类似的IRETD, SYSESIT或RETF指令就可以实现了。尽管返回的方法应该和调用用的方法相匹配,但也不是绝对必要的。例如,在windows XP中,从中断INT 0x2B的句柄是由SYSEXIT指令产生的而不是IRETD指令----只要能处理好内核和用户堆栈进入退出这两部分,任何工作都可以处理的很好了。在我们现在的特殊的情况下,很明显,RETF指令是逻辑上最合理的离开内核模式的方法,所以我们如果要回到user-mode的话要使用RETF指令。

在GDT中设置门调用
现在我们知道我们需要知道的任何事情了,为了在GDT中设置门调用,让我们的应用程序得到进入内核的权利。看看下面的代码:
//now let's map gdt
PBYTE GdtMappedAddress=0;phys.QuadPart=physgdtbase;MappedSize=4096;
NtMapViewOfSection(Section, (HANDLE) -1, (PVOID*)&GdtMappedAddress, 0L,MappedSize,
&phys, &MappedSize, ViewShare,0, PAGE_READWRITE);
gdtbase&=0xfff;
GdtMappedAddress+=gdtbase;

CallGateDescriptor * gate=(CallGateDescriptor * )GdtMappedAddress;

//now let's find free entry in GDT. Type of current gdt entry does not matter - Present
// bit is 48-th bit for all type of descriptors, so we interpret all descriptors
//as call gates
selector=1;
while(1)
{
if(!gate[selector].present)break;
selector++;
}

// now let's set up a call gate
gate[selector].offset_low = (WORD) ((DWORD)kernelfunction & 0xFFFF);
gate[selector].selector = 8;
gate[selector].param_count = 1; //we will pass a parameter
gate[selector].unused = 0;
gate[selector].type = 0xc; // 32-bit callgate

gate[selector].dpl = 3; // must be 3
gate[selector].present = 1;
gate[selector].offset_high = (WORD) ((DWORD)kernelfunction >> 16);

//we don't need physical memory any more
NtUnmapViewOfSection((HANDLE) -1, GdtMappedAddress);
CloseHandle(Section);

首先,利用上面我们已经介绍的方法去映射GDT,同时在GDT中去寻找一些没有使用的项。所有GDT的描述符都是8个字节的(…)。这对于所有类型的描述符都是类似的。因此,当我们在GDT中去寻找一个空的项,我们可以象看待门调用描述符一样去看待所有的GDT中的项,而不用去考虑它们真正的类型。在找到一个空的项之后,我们就把它象门调用描述符一样设置。我们设置它的类型是0xC为了是指出它是32位的门调用,它的DPL值为3这样以user-mode的代码就可以访问了,它的selector域的值为0x8,同时它的offset_low和offse_high域分别对应于我们即将要调用的函数的低16位和高16位。那么被param_count域所指定的变量又是什么呢?曾经的这些文章做得都是不同寻常的事情,我相信去让我们的内核代码去做一些还不被大众所知道的事情,这是一个好的想法。一些在codeproject上的文章向你解释了如何去中断能够从注册表中获得的信息。显然,我将要告诉你,很多令人激动的方法能得到中断的信息同时即不需要去解释中断的资源又不需要去深挖注册表。因此,我们即将把IRQ作为一个变量传递到我们的函数,同时我们的函数会返回对应于这个IRQ的中断向量。

Advanced Programmable Interrupt Controller(APIC)
系统是如何映射IRQs到中断向量表同时定义他们的优先权?它依靠你的机器是否支持APIC。这个是可以被CPUID发现的从APIC_BASE_MSR模式寄存器中读取。如果APIC是支持的同时你在EAX寄存器中使得CPUID指令为1,那么EDX寄存器的第9位就会被这些指令所设置。为了发现APIC是否能用,你必须去读APIC_BASE_MSR规格模式的寄存器—如果APIC是可行的那么其第11位必定被设置。除非你的计算机已经完全过时了,否则我能99.9%的保证你的计算机中会有APIC并且是能用的。如果不是由对应的IRQ所产生的中断向量,怎中断向量的值等于0x30+IRQ,例如,计时器(IRQ0)的中断向量为0x30,键盘(IRQ1)的中断向量为0x31等等。这也是当APIC不存在或不起作用的时候Windows NT如何映射硬件的中断。这些中断的优先权是被IRQ所包含的-----所以此处我们什么都不能做。
如果APIC存在且能用的话,那么对于程序来说就越来越有趣了。系统中的每一个CPU都有自己本地的APIC,其物理地址是由APIC_BASE_MSR模式寄存器来指定的。通过从寄存器中读出和写入,局部的APIC是可以被编程利用的。例如:通过Task Priority寄存器,处理器的IRQL能够被修改,这个寄存器的位置是从APIC的基址开始的0x80的偏移量,这个也就是KeRaiseIrql()和KeLowerIrql()所作的事情。如果你想产生一个中断,你可以通过Interrupt Command寄存器来完成,这个寄存器的位置是从APIC的基址开始的0x300的偏移量,这个也就是HalRequestSoftwareInterrupt()所作的事情。你也可以指定是否想让CPU它自己去处理中断或者向系统中的所有的CPU去分发这个中断。本地的APIC程序设计是个很广泛的话题,所以它已经超出了这篇文章所讨论的范围。如果你需要更多的信息,我向你强烈建议去阅读Inter开发人员手册的第3卷。
所有本地的APIC和IO 的APIC交流都在主板上,这是通过APIC 总线来完成的。IO APIC映射到IRQ的中断向量而且可以一直映射有24个中断向量。通过读写它的寄存器IO APIC是支持编程的。这些是32位的ID寄存器(其偏移量为0),32位的version寄存器(其偏移量为0x1),32位的Arbitration寄存器(其偏移量为0x2)和24个64位的Redirection Table寄存器,每一个Redirection Table寄存器都对应一些给定的IRQ。对应某些给定的IRQ的Redirection Table寄存器,它的位置可以用0x10+2*IRQ来计算。如果你想知道Redirection Table的二进制结构,我建议你应该读Intel的IOAPIC手册-----我们的兴趣仅仅就是Redirection Table的低8位,因为他们指出了对应于给定的IRQ的中断向量。中断优先级能够用 向量/16来计算,并且曾经的操作系统设计者能够以他们所希望的方式把IRQ映射到中断向量中,他们能分配任意的中断优先权等级给任意的IRQ。
IO APIC使用间接的地址方案,也就是指上述所提及的寄存器是不能被直接访问的。那么它们是怎么被访问的呢?IO APIC提供了2的直接访问寄存器的方法。这是IOREGSEL和IOWIN寄存器,其位置从IO APIC的基址开始的位移量分别是0和0x10。IO APIC映射到物理内存的地址是0xFEC00000。尽管,Intel允许操作系统的设计者去重新把IO APIC放到其他的物理地址处,但是Windows NT是不可以的。所以,我们可以大胆的假设一下IO APIC就在你机器的物理地址的0xFEC00000处,于是IOREGSEL和IOWIN寄存器物理地址就可以知道是0xFEC00000和0xFEC00010。为了访问这些寄存器,你必须映射它们到non-cache的内存中。为了能读任何间接访问的寄存器,你必须写它对于IOREGSEL寄存器的偏移量---随后在读取IOWIN寄存器,那里会有间接访问的寄存器的返回值。所有的返回值都是32位的。如果你想读对应于IRQ的Redirection Table寄存器的低32位或者高32位,你必须分别的去写0X10+2*IRQ 或 0X10+2*IRQ+1到IOREGSEL寄存器中,然后在从IOWIN寄存器读取查找的信息。
我们如何才能映射IO APIC到虚拟内存呢?如果我们使用一个正规的驱动,我们需要调用MmMapIoSpace()。但是,我们的情况是有点不同的。如果CPU对待我们的代码作为特权的代码,那么这个就不是必须的了在这篇主题中windows一直和我们在分享这样一个观点----所有要做的事情都是看你想做什么。有些ntoskrnl.exe的输出(例如:ExAllocatePool())能够被我们的代码所调用同时没有任何问题,但是MmMapIoSpace()是不属于这其中的。如果你的代码调用MmMapIoSpace(),我们就会得到一个蓝屏和IRQL_NOT_LESS_OR_EQUAL的错误。那么我们将怎么做呢?这是当我们欺骗映射的某些页的时候虚拟地址0会很容易得到,所以我们将继续使用它。
下面的代码映射IO APIC到虚拟地址0,同时获得对应的被给于的IRQ的中断向量:
//map ioapic - make sure that we map it to non-cached memory.
_asm
{
mov ebx,0xfec00000
or ebx,0x13
mov eax,0xc0000000
mov dword ptr[eax],ebx
}


//now we are about to get interrupt vector
PULONG array=NULL;

//write 0x10+2*irq to IOREGSEL
array[0]=0x10+2*irq;

// subsequent read from IOWIN returns 32 low-order bits of Redirection Table //that corresponds to our IRQ.
// 8 low-order bits are interrupt vector, corresponding to our IRQ
DWORD vector=(array[4]&0xff);

正如你所看见的,IO APIC程序设计是做比讲容易的多得事情----那么多的解释却只有几行代码。但是为什么我们要把IO APIC映射到0呢而不是一些更常规的地址?仅仅是因为地址0是可以保证不会被使用的,所以映射IO APIC到这个地址是事情开始时首先要做的

把它们放到一起
现在让我们把它们放到一起,看看如下的代码----调用内核模式的代码
// now we will get interrupt vectors
DWORD res; DWORD resultarray[24];ZeroMemory(resultarray,sizeof(resultarray));

for (x=0;x<25;x++)
{
//let's call the function via the call gate. Are you ready???

WORD farcall[3];farcall[2] = (selector<<3);
_asm
{
mov ebx,x
push ebx
call fword ptr [farcall]
mov res,eax
}

if(x==24)break;
//if the return value is 500 and this was not the final invocation,
//apic is not present. Inform the user about it, and that't it
if(res==500)
{
MessageBox(GetDesktopWindow()," APIC is not supported","IRQs",MB_OK);
break;
}

resultarray[x]=res;
}

在C里面是没有办法通过门调用实现远程调用的,所以我们只能用ASM的方式去调用内核模式的函数。客户端自身的代码是很清楚的---它把IRQ的值推入堆栈中,通过门调用来调用内核函数并且在数组中保存结果。这样做是因为IRQ的值是从0-23,另外当指令中包含不存在的IRQ24时,程序终结。当接收到参数24时,为了确保我们的试验未留下任何痕迹,核函数会在GDT中清理门调用。我们在获得所有的IRQ信息后,会用MessageBox()通知用户每个IRQ值。我希望没必要在这里列出所有的代码。

现在让我们来看一下内核中的函数:

void kernelfunction(DWORD usercs,DWORD irq)
{
DWORD absent =0; BYTE gdtr[8];
//check if ioapic is present and enabled
if(irq<=23)
{
_asm
{
mov eax,1
cpuid
and edx, 0x00000200
cmp edx,0
jne skip1
mov absent,1
skip1: mov ecx,0x1b
rdmsr
and eax,0x00000800
cmp eax,0
jne skip2
mov absent,1
}

//if APIC is enabled, get vector from it and return
skip2: if(!absent)
{
//map ioapic - make sure that we map it to non-cached memory.
//Certainly,we have /to do it only upon the
//function's very first invocation, i.e. when irq is 0
if(!irq)
{
_asm
{
mov ebx,0xfec00000
or ebx,0x13
mov eax,0xc0000000
mov dword ptr[eax],ebx
}
}

//now we are about to get interrupt vector
PULONG array=NULL;
//write 0x10+2*irq to IOREGSEL
array[0]=0x10+2*irq;

// subsequent read from IOWIN returns 32 low-order bits of Redirection Table
//that corresponds to our IRQ.
// 8 low-order bits are interrupt vector, corresponding to our IRQ
DWORD vector=(array[4]&0xff);

// return interrupt vector. Dont forget that we must return with RETF,
//and pop 4 bytes off the stack


_asm
{
//
mov eax,vector
mov esp,ebp
pop ebp
retf 4
}

}
}

//either apic is not supported, or irq is above 23,i.e. this is the last invocation
//therefore, clean up gdt and return 500
_asm
{
//clean up gdt
sgdt gdtr
lea eax,gdtr
mov ebx,dword ptr[eax+2]
mov eax,0
mov ax,selector
shl eax,3
add ebx,eax
mov dword ptr[ebx],0
mov dword ptr[ebx+4],0
// adjust stack and return
mov eax,500
mov esp,ebp
pop ebp
retf 4
}
}

一旦我们的内核函数申明了本地变量,并且因此需要一个标准的函数进程,写一个单独的例行程序是毫无意义的。我们的函数只需要一个参数,但是,一旦通过门调用调用它,CPU会把用户模式的CS值推入堆栈,放在返回地址的下面。你知道如何向编译器解释这一点吗?我也不知道。因此,为了确保编译器能够产生正确的代码,我们用函数参数的形式在堆栈中显示之一附加值——无论如何,我们将忽略它。如果IRQ参数是24,也就是这个函数的最终调用,或者APIC无效,kernelfunction()会清理GDT并返回错误代码500。如果一切运行顺利,它将把IO APIC指向虚地址0,得到与IRQ参数相应的中断向量,并且返回这个向量。这里没有什么特别的东西。唯一值得提及的是在我们返回前,应该恢复EBP和ESP寄存器——这很重要。而我们要用RETF指令返回,并从堆栈中弹出4个字节是很容易理解的。

还有另外一件需要处理的事情——我们要确定我们的代码要适合复合处理器和SMP机器。随着线程技术HT的到来,我们必须永远假定我们的代码会在SMP机器上运行,Windows视支持HT技术的CPU为两个独立的处理器,并且,如我所知,Intel已经不再生产不支持HT的CPU了,在Windows下在SMP机器上运行,所有的CPU都有其自己的GDT,在默认情况下,任何线程可运行于系统内任何一个CPU上。我希望你可以想象一下如果我们被保证我们的代码可以运行在不同的处理器上,那么情况会有多混乱-----当运行在CPU A的时候,我们可以建立一个门调用;也可以当运行在CPU B的时候,尝试进入内核中。因此,我们不得不预防我们的代码运行在不止一个处理器上。用下面的方法可以完成(go()是个函数,这个函数正如你已经在文章中所看见的那样都是运行在user-mode下的)。
DWORD dw;

HANDLE thread=CreateThread(0,0,(LPTHREAD_START_ROUTINE)go,0,Create_SUSPENDED,&dw);

SetThreadAffinityMask (thread,1);
ResumeThread(thread);

总结
最后我想关于关于一些警告我在简单说几句。首先,你的特权代码的功能,和普通的内核模式的驱动相比,将会是永远受到限制的了。你已经知道了不是所有的ntoskrnl.exe输出都是可以被我们的代码安全的调用(MmMapIoSpace()仅仅只是一个例子)。因此,你应该使用这些保守的方法。第二点,我不想劝说你在产品中去使用这些小技巧---它们更应该作为一种分析工具,侵入系统检查和其他“不支持”的软件。所有不支持这种技巧而带来的问题是其中的系统规格。把事情弄得更糟的是,也许还有硬件的规格----这些代码能在机器A上有一些小的影响,但是或许能造成机器B的崩溃,即使代码运行在相同的windows版本中。因此,如果你的代码能够在你的开发机上完美的运行,那你也不要高兴得太早,你永远也不会知道它什么时候会在其它的平台上运行。
这个例子程序在我的机器上做过彻底的测试,是windows XP SP2 ---- 它运行的很好而且似乎没有一点问题。但是,我真的不知道它将会在你的机器上工作如何----因为找出问题这是你的事情了。如果有些东西出错的话,不要犹豫赶快告诉我。当然如果你能同时提供一些关于你机器的信息(CPU,主板,操作系统版本,等等)的话,那就会更好了,同时在加上问题的描述-----谁知道呢,也许这是些隐藏的bug。为了运行这个例子,你必须做的唯一一件事情就是去点击它的图标,然后等待一些message boxes – 也许在它们弹出前,这会花掉你几分钟等待时间,所以请耐心等待。

程序的源代码在QQ群中有,如果要加入请输入验证码:kernel


文章来自: 本站原创
Tags:
评论: 0 | 查看次数: 8658