写在前面

  此系列是本人一个字一个字码出来的,包括示例和实验截图。由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我

  看此教程之前,问一个问题, 没有的话就不要继续了,请重新学习 羽夏看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

  不幸的是,这个函数是人家导入的,如何查到从哪里导入的呢?我们可以按照如下图所示的操作找到:

系统调用篇——3环层面调用过程-LMLPHP

  我们知道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环,需要的CSEIPIDT表中,需要查内存(SSESPTSS提供),而CPU如果支持sysenter指令时,操作系统会提前将CS/SS/ESP/EIP的值存储在MSR寄存器中,sysenter指令执行时,CPU会将MSR寄存器中的值直接写入相关寄存器,没有读内存的过程,所以叫快速调用,但本质是一样的。
  其实,快速调用并不是一直存在的,在比较古老的CPU是不支持快速调用的。它们进入内核的方式很简单粗暴,就是使用中断门。
  CPU如何知道是否支持快速调用呢?当通过eax=1来执行cpuid指令时,处理器的特征信息被放在ecxedx寄存器中,其中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 <windows.h>
#include <iostream>

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环层面调用过程

11-10 07:31