写在前面
此系列是本人一个字一个字码出来的,包括示例和实验截图。由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我。
你如果是从中间插过来看的,请仔细阅读 羽夏看Win系统内核——简述 ,方便学习本教程。
看此教程之前,问一个问题,你明确学系统调用的目的了吗? 没有的话就不要继续了,请重新学习 羽夏看Win系统内核——系统调用篇 里面的内容。
华丽的分割线
Windows API
API
全称为Application Programming Interface
,至于概念我就不多说了。下面我将介绍几个比较重要的Dll
,我们调用的很多重要的函数都在这些动态链接库里面:
Kernel32.dll
:最核心的功能模块,比如管理内存、进程和线程相关的函数等。User32.dll
:是Windows
用户界面相关应用程序接口,如创建窗口和发送消息等。GDI32.dll
:全称是Graphical Device Interface
,即图形设备接口
,包含用于画图和显示文本的函数.比如要显示一个程序窗口,就调用了其中的函数来画这个窗口。Ntdll.dll
:大多数API
都会通过这个DLL
进入内核(0环)。
这里提一句,并不是所有的API
必须进0环的,可以在3环完全实现。比如Ntdll.dll
导出的memcmp
函数,感兴趣的自己可以逆向一下。有关API
在3环层面调用过程将以我们最常用的ReadProcessMemory
这个函数来进行讲解。
函数解析
ReadProcessMemory
这个函数由Kernel32.dll
导出,然后我们拖到IDA
进行分析。至于怎么用IDA
分析不会的话,请参考前面的教程(我也忘了在那篇文章写过了)。我们在IDA
中定位到这个函数:
.text:7C8021D0 ; BOOL __stdcall ReadProcessMemory(HANDLE hProcess, LPCVOID lpBaseAddress, LPVOID lpBuffer, SIZE_T nSize, SIZE_T *lpNumberOfBytesRead)
.text:7C8021D0 public _ReadProcessMemory@20
.text:7C8021D0 _ReadProcessMemory@20 proc near ; CODE XREF: GetProcessVersion(x)+2F12F↓p
.text:7C8021D0 ; GetProcessVersion(x)+2F14E↓p ...
.text:7C8021D0
.text:7C8021D0 hProcess = dword ptr 8
.text:7C8021D0 lpBaseAddress = dword ptr 0Ch
.text:7C8021D0 lpBuffer = dword ptr 10h
.text:7C8021D0 nSize = dword ptr 14h
.text:7C8021D0 lpNumberOfBytesRead= dword ptr 18h
.text:7C8021D0
.text:7C8021D0 mov edi, edi
.text:7C8021D2 push ebp
.text:7C8021D3 mov ebp, esp
.text:7C8021D5 lea eax, [ebp+nSize]
.text:7C8021D8 push eax ; NumberOfBytesRead
.text:7C8021D9 push [ebp+nSize] ; NumberOfBytesToRead
.text:7C8021DC push [ebp+lpBuffer] ; Buffer
.text:7C8021DF push [ebp+lpBaseAddress] ; BaseAddress
.text:7C8021E2 push [ebp+hProcess] ; ProcessHandle
.text:7C8021E5 call ds:__imp__NtReadVirtualMemory@20 ; NtReadVirtualMemory(x,x,x,x,x)
.text:7C8021EB mov ecx, [ebp+lpNumberOfBytesRead]
.text:7C8021EE test ecx, ecx
.text:7C8021F0 jnz short loc_7C8021FD
.text:7C8021F2
.text:7C8021F2 loc_7C8021F2: ; CODE XREF: ReadProcessMemory(x,x,x,x,x)+32↓j
.text:7C8021F2 test eax, eax
.text:7C8021F4 jl short loc_7C802204
.text:7C8021F6 xor eax, eax
.text:7C8021F8 inc eax
.text:7C8021F9
.text:7C8021F9 loc_7C8021F9: ; CODE XREF: ReadProcessMemory(x,x,x,x,x)+3C↓j
.text:7C8021F9 pop ebp
.text:7C8021FA retn 14h
.text:7C8021FD ; ---------------------------------------------------------------------------
.text:7C8021FD
.text:7C8021FD loc_7C8021FD: ; CODE XREF: ReadProcessMemory(x,x,x,x,x)+20↑j
.text:7C8021FD mov edx, [ebp+nSize]
.text:7C802200 mov [ecx], edx
.text:7C802202 jmp short loc_7C8021F2
.text:7C802204 ; ---------------------------------------------------------------------------
.text:7C802204
.text:7C802204 loc_7C802204: ; CODE XREF: ReadProcessMemory(x,x,x,x,x)+24↑j
.text:7C802204 push eax ; Status
.text:7C802205 call _BaseSetLastNTError@4 ; BaseSetLastNTError(x)
.text:7C80220A xor eax, eax
.text:7C80220C jmp short loc_7C8021F9
.text:7C80220C _ReadProcessMemory@20 endp
从上面的代码可知,这个函数啥也没做,只是调用了NtReadVirtualMemory
这个函数去实现读取内存。我们跟过去看看:
.idata:7C801418 ; NTSTATUS __stdcall NtReadVirtualMemory(HANDLE ProcessHandle, PVOID BaseAddress, PVOID Buffer, SIZE_T NumberOfBytesToRead, PSIZE_T NumberOfBytesRead)
.idata:7C801418 extrn __imp__NtReadVirtualMemory@20:dword
不幸的是,这个函数是人家导入的,如何查到从哪里导入的呢?我们可以按照如下图所示的操作找到:
我们知道NtReadVirtualMemory
这个函数是来自ntdll.dll
。然后我们重新定位到IDA
的位置:
.text:7C92D9E0 ; __stdcall NtReadVirtualMemory(x, x, x, x, x)
.text:7C92D9E0 public _NtReadVirtualMemory@20
.text:7C92D9E0 _NtReadVirtualMemory@20 proc near ; CODE XREF: LdrFindCreateProcessManifest(x,x,x,x,x)+1CC↓p
.text:7C92D9E0 ; LdrCreateOutOfProcessImage(x,x,x,x)+7C↓p ...
.text:7C92D9E0 mov eax, 0BAh ; NtReadVirtualMemory
.text:7C92D9E5 mov edx, 7FFE0300h
.text:7C92D9EA call dword ptr [edx]
.text:7C92D9EC retn 14h
.text:7C92D9EC _NtReadVirtualMemory@20 endp
我们发现这个函数给eax
赋个值,然后给edx
个地址,然后call
一下地址的内容,然后就平栈(由于STDCALL
调用约定)返回了。至此,你或许就看不懂了。我们来看看这个地址到底存着什么。
_KUSER_SHARED_DATA
当你看到这个时,你猜测这个地址存储的是_KUSER_SHARED_DATA
结构体,对的。它的结构如下图所示:
nt!_KUSER_SHARED_DATA
+0x000 TickCountLow : Uint4B
+0x004 TickCountMultiplier : Uint4B
+0x008 InterruptTime : _KSYSTEM_TIME
+0x014 SystemTime : _KSYSTEM_TIME
+0x020 TimeZoneBias : _KSYSTEM_TIME
+0x02c ImageNumberLow : Uint2B
+0x02e ImageNumberHigh : Uint2B
+0x030 NtSystemRoot : [260] Uint2B
+0x238 MaxStackTraceDepth : Uint4B
+0x23c CryptoExponent : Uint4B
+0x240 TimeZoneId : Uint4B
+0x244 Reserved2 : [8] Uint4B
+0x264 NtProductType : _NT_PRODUCT_TYPE
+0x268 ProductTypeIsValid : UChar
+0x26c NtMajorVersion : Uint4B
+0x270 NtMinorVersion : Uint4B
+0x274 ProcessorFeatures : [64] UChar
+0x2b4 Reserved1 : Uint4B
+0x2b8 Reserved3 : Uint4B
+0x2bc TimeSlip : Uint4B
+0x2c0 AlternativeArchitecture : _ALTERNATIVE_ARCHITECTURE_TYPE
+0x2c8 SystemExpirationDate : _LARGE_INTEGER
+0x2d0 SuiteMask : Uint4B
+0x2d4 KdDebuggerEnabled : UChar
+0x2d5 NXSupportPolicy : UChar
+0x2d8 ActiveConsoleId : Uint4B
+0x2dc DismountCount : Uint4B
+0x2e0 ComPlusPackage : Uint4B
+0x2e4 LastSystemRITEventTickCount : Uint4B
+0x2e8 NumberOfPhysicalPages : Uint4B
+0x2ec SafeBootMode : UChar
+0x2f0 TraceLogging : Uint4B
+0x2f8 TestRetInstruction : Uint8B
+0x300 SystemCall : Uint4B
+0x304 SystemCallReturn : Uint4B
+0x308 SystemCallPad : [3] Uint8B
+0x320 TickCount : _KSYSTEM_TIME
+0x320 TickCountQuad : Uint8B
+0x330 Cookie : Uint4B
在User
层和Kernel
层分别定义了一个_KUSER_SHARED_DATA
结构区域,用于User
层和Kernel
层共享某些数据。它们使用固定的地址值映射,_KUSER_SHARED_DATA
结构区域在User
层地址为0x7ffe0000
,在Kernel
层地址为0xffdf0000
。虽然它们指向的是同一个物理页,但在User
层是只读的,在Kernnel
层是可写的,通过页的限制保证在3环的安全性。因为里面有几个成员是十分重要的,有一个成员就是3环API
进入内核的入口。
根据0x7FFE0300
这个地址,我们不难看出它是在调用SystemCall
里面的代码,接下来看看这个函数到底是干啥的。
我们先!process 0 0
遍历一下进程:
kd> !process 0 0
**** NT ACTIVE PROCESS DUMP ****
(部分进程快照略……)
Failed to get VadRoot
PROCESS 896ffda0 SessionId: 0 Cid: 0a7c Peb: 7ffde000 ParentCid: 08bc
DirBase: 16840680 ObjectTable: e1ac9078 HandleCount: 36.
Image: cmd.exe
我们想要读取0x7FFE0300
这个地址的内容,这个地址是3环应用的地址。如果读取某个进程的内存,必须有它的CR3
,即和这个进程关联起来,我们需要.process + PROCESS 的地址
进行:
kd> .process 896ffda0
ReadVirtual: 896ffdb8 not properly sign extended
Implicit process is now 896ffda0
WARNING: .cache forcedecodeuser is not enabled
然后我们dd
一下这两个地址,看看内容是否一样:
kd> dd 0x7ffe0000
7ffe0000 000f3594 0a03afb7 3daf17c0 00000017
7ffe0010 00000017 8b7792b3 01d7d56a 01d7d56a
7ffe0020 f1dcc000 ffffffbc ffffffbc 014c014c
7ffe0030 003a0043 0057005c 004e0049 004f0044
7ffe0040 00530057 00000000 00000000 00000000
7ffe0050 00000000 00000000 00000000 00000000
7ffe0060 00000000 00000000 00000000 00000000
7ffe0070 00000000 00000000 00000000 00000000
kd> dd 0xffdf0000
ReadVirtual: ffdf0000 not properly sign extended
ffdf0000 000f3594 0a03afb7 3daf17c0 00000017
ffdf0010 00000017 8b7792b3 01d7d56a 01d7d56a
ffdf0020 f1dcc000 ffffffbc ffffffbc 014c014c
ffdf0030 003a0043 0057005c 004e0049 004f0044
ffdf0040 00530057 00000000 00000000 00000000
ffdf0050 00000000 00000000 00000000 00000000
ffdf0060 00000000 00000000 00000000 00000000
ffdf0070 00000000 00000000 00000000 00000000
既然内容是一样的,我们再看看它们的物理页是不是一样的:
kd> !vtop 16840680 0x7ffe0000
X86VtoP: Virt 000000007ffe0000, pagedir 0000000016840680
X86VtoP: PAE PDPE 0000000016840688 - 00000000823e5001
X86VtoP: PAE PDE 00000000823e5ff8 - 00000000814bf067
X86VtoP: PAE PTE 00000000814bff00 - 0000000000041025
X86VtoP: PAE Mapped phys 0000000000041000
Virtual address 7ffe0000 translates to physical address 41000.
kd> !vtop 16840680 0xffdf0000
X86VtoP: Virt 00000000ffdf0000, pagedir 0000000016840680
X86VtoP: PAE PDPE 0000000016840698 - 00000000823e3001
X86VtoP: PAE PDE 00000000823e3ff0 - 0000000000af3163
X86VtoP: PAE PTE 0000000000af3f80 - 0000000000041163
X86VtoP: PAE Mapped phys 0000000000041000
Virtual address ffdf0000 translates to physical address 41000.
!vtop
这个指令可以帮我们拆分虚拟地址到物理地址。为什么不在段页的部分讲是因为怕你懒,缺少练习。可以验证它们的物理页是一样的。
我们先看看0xffdf0300
这个地址里面存的是什么,先dd
一下:
kd> dd 0xffdf0300
ffdf0300 7c92e4f0 7c92e4f4 00000000 00000000
ffdf0310 00000000 00000000 00000000 00000000
ffdf0320 00000000 00000000 00000000 00000000
ffdf0330 43dc3855 00000000 00000000 00000000
ffdf0340 00000000 00000000 00000000 00000000
ffdf0350 00000000 00000000 00000000 00000000
ffdf0360 00000000 00000000 00000000 00000000
ffdf0370 00000000 00000000 00000000 00000000
然后我们uf
一下看看汇编:
kd> uf 7c92e4f0
7c92e4f0 8bd4 mov edx,esp
7c92e4f2 0f34 sysenter
7c92e4f4 c3 ret
可以发现,这个函数只是把esp
的值交给了edx
,然后调用sysenter
。这个汇编就是快速调用。为什么叫快速调用?中断门进0环,需要的CS
、EIP
在IDT
表中,需要查内存(SS
与ESP
由TSS
提供),而CPU
如果支持sysenter
指令时,操作系统会提前将CS
/SS
/ESP
/EIP
的值存储在MSR
寄存器中,sysenter
指令执行时,CPU
会将MSR
寄存器中的值直接写入相关寄存器,没有读内存的过程,所以叫快速调用,但本质是一样的。
其实,快速调用并不是一直存在的,在比较古老的CPU
是不支持快速调用的。它们进入内核的方式很简单粗暴,就是使用中断门。
CPU
如何知道是否支持快速调用呢?当通过eax=1
来执行cpuid
指令时,处理器的特征信息被放在ecx
和edx
寄存器中,其中edx
包含了一个SEP位(11位)
,该位指明了当前处理器知否支持sysenter
/sysexit
指令,具体细节可以查看白皮书。
通过逆向汇编代码可以看出,不管CPU是否支持快速调用,它都是调用该地址。这就说明操作系统在初始化该结构体的时候必须先判断支不支持,然后填入适当的值。如果CPU
支持快速调用,操作系统就会填入KiFastSystemCall
函数的地址,我们可以看一下:
.text:7C92E4F0 ; _DWORD __stdcall KiFastSystemCall()
.text:7C92E4F0 public _KiFastSystemCall@0
.text:7C92E4F0 _KiFastSystemCall@0 proc near ; DATA XREF: .text:off_7C923428↑o
.text:7C92E4F0 mov edx, esp
.text:7C92E4F2 sysenter
.text:7C92E4F2 _KiFastSystemCall@0 endp
如果CPU
不支持快速调用,操作系统就会填入KiIntSystemCall
函数的地址,我们可以看一下:
.text:7C92E500 ; _DWORD __stdcall KiIntSystemCall()
.text:7C92E500 public _KiIntSystemCall@0
.text:7C92E500 _KiIntSystemCall@0 proc near ; DATA XREF: .text:off_7C923428↑o
.text:7C92E500
.text:7C92E500 arg_4 = byte ptr 8
.text:7C92E500
.text:7C92E500 lea edx, [esp+arg_4] ;参数指针
.text:7C92E504 int 2Eh ; DOS 2+ internal - EXECUTE COMMAND
.text:7C92E504 ; DS:SI -> counted CR-terminated command string
.text:7C92E506 retn
.text:7C92E506 _KiIntSystemCall@0 endp
.text:7C92E506
本篇内容就先讲解这么多,进入0环的部分将在下一篇进行讲解。接下来我们将用代码重写ReadProcessMemory
的3环部分,代码如下:
#include "stdafx.h"
#include
#include
const int test=0x1234;
BOOL __declspec(naked) __stdcall ReadProcMem0(DWORD handle,DWORD addr,unsigned char* buffer,DWORD len,DWORD sizeread)
{
_asm
{
mov eax, 0BAh ;
mov edx, 7FFE0300h;
call dword ptr [edx];
retn 14h;
}
}
BOOL __declspec(naked) __stdcall ReadProcMem1(DWORD handle,DWORD addr,unsigned char* buffer,DWORD len,DWORD sizeread)
{
_asm
{
mov eax, 0BAh;
lea edx, [esp+4];
int 2Eh;
retn 14h;
}
}
int main(int argc, char* argv[])
{
int buffer = 0;
ReadProcMem0((DWORD)GetCurrentProcess(),(DWORD)&test,(unsigned char*)&buffer,4,NULL);
printf("第一次 buffer的值为:%x\n",buffer);
buffer=0;
ReadProcMem1((DWORD)GetCurrentProcess(),(DWORD)&test,(unsigned char*)&buffer,4,NULL);
printf("第二次 buffer的值为:%x\n",buffer);
system("pause");
return 0;
}
从上面的代码可以看出ReadProcMem0
是还通过SystemCall
进0环,ReadProcMem1
直接重写了SystemCall
进入0环(为什么没用sysenter
?编译不通过)。如下是结果:
第一次 buffer的值为:1234
第二次 buffer的值为:1234
请按任意键继续. . .
本节练习
本节的答案将会在下一节进行讲解,务必把本节练习做完后看下一个讲解内容。不要偷懒,实验是学习本教程的捷径。
俗话说得好,光说不练假把式,如下是本节相关的练习。如果练习没做好,就不要看下一节教程了,越到后面,不做练习的话容易夹生了,开始还明白,后来就真的一点都不明白了。本节练习不多,请保质保量的完成。
1️⃣ 自己编写WriteProcessMemory
函数(不使用任何DLL
,直接调用0环函数)并在代码中使用。
下一篇
系统调用篇——0环层面调用过程