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

写在前面

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

你如果是从中间插过来看的,请仔细阅读 羽夏看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中定位到这个函数:

; BOOL __stdcall ReadProcessMemory(HANDLE hProcess, LPCVOID lpBaseAddress, LPVOID lpBuffer, SIZE_T nSize, SIZE_T *lpNumberOfBytesRead)
                public _ReadProcessMemory@20
_ReadProcessMemory@20 proc near         ; CODE XREF: GetProcessVersion(x)+2F12F↓p
                                        ; GetProcessVersion(x)+2F14E↓p ...

hProcess        = dword ptr  8
lpBaseAddress   = dword ptr  0Ch
lpBuffer        = dword ptr  10h
nSize           = dword ptr  14h
lpNumberOfBytesRead= dword ptr  18h

                mov     edi, edi
                push    ebp
                mov     ebp, esp
                lea     eax, [ebp+nSize]
                push    eax             ; NumberOfBytesRead
                push    [ebp+nSize]     ; NumberOfBytesToRead
                push    [ebp+lpBuffer]  ; Buffer
                push    [ebp+lpBaseAddress] ; BaseAddress
                push    [ebp+hProcess]  ; ProcessHandle
                call    ds:__imp__NtReadVirtualMemory@20 ; NtReadVirtualMemory(x,x,x,x,x)
                mov     ecx, [ebp+lpNumberOfBytesRead]
                test    ecx, ecx
                jnz     short loc_7C8021FD

loc_7C8021F2:                           ; CODE XREF: ReadProcessMemory(x,x,x,x,x)+32↓j
                test    eax, eax
                jl      short loc_7C802204
                xor     eax, eax
                inc     eax

loc_7C8021F9:                           ; CODE XREF: ReadProcessMemory(x,x,x,x,x)+3C↓j
                pop     ebp
                retn    14h
; ---------------------------------------------------------------------------

loc_7C8021FD:                           ; CODE XREF: ReadProcessMemory(x,x,x,x,x)+20↑j
                mov     edx, [ebp+nSize]
                mov     [ecx], edx
                jmp     short loc_7C8021F2
; ---------------------------------------------------------------------------

loc_7C802204:                           ; CODE XREF: ReadProcessMemory(x,x,x,x,x)+24↑j
                push    eax             ; Status
                call    _BaseSetLastNTError@4 ; BaseSetLastNTError(x)
                xor     eax, eax
                jmp     short loc_7C8021F9
_ReadProcessMemory@20 endp

  从上面的代码可知,这个函数啥也没做,只是调用了NtReadVirtualMemory这个函数去实现读取内存。我们跟过去看看:

NTSTATUS __stdcall NtReadVirtualMemory(HANDLE ProcessHandle, PVOID BaseAddress, PVOID Buffer, SIZE_T NumberOfBytesToRead, PSIZE_T NumberOfBytesRead)
                extrn __imp__NtReadVirtualMemory@20:dword

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

  我们知道NtReadVirtualMemory这个函数是来自ntdll.dll。然后我们重新定位到IDA的位置:

; __stdcall NtReadVirtualMemory(x, x, x, x, x)
                public _NtReadVirtualMemory@20
_NtReadVirtualMemory@20 proc near       ; CODE XREF: LdrFindCreateProcessManifest(x,x,x,x,x)+1CC↓p
                                        ; LdrCreateOutOfProcessImage(x,x,x,x)+7C↓p ...
                mov     eax, 0BAh       ; NtReadVirtualMemory
                mov     edx, 7FFE0300h
                call    dword ptr [edx]
                retn    14h
_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
ntdll!KiFastSystemCall:
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函数的地址,我们可以看一下:

; _DWORD __stdcall KiFastSystemCall()
                public _KiFastSystemCall@0
_KiFastSystemCall@0 proc near           ; DATA XREF: .text:off_7C923428↑o
                mov     edx, esp
                sysenter
_KiFastSystemCall@0 endp

  如果CPU不支持快速调用,操作系统就会填入KiIntSystemCall函数的地址,我们可以看一下:

; _DWORD __stdcall KiIntSystemCall()
                public _KiIntSystemCall@0
_KiIntSystemCall@0 proc near            ; DATA XREF: .text:off_7C923428↑o

arg_4           = byte ptr  8

                lea     edx, [esp+arg_4] ;参数指针
                int     2Eh             ; DOS 2+ internal - EXECUTE COMMAND
                                        ; DS:SI -> counted CR-terminated command string
                retn
_KiIntSystemCall@0 endp

  本篇内容就先讲解这么多,进入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环层面调用过程(上)

posted @ 2021-11-09 22:33  寂静的羽夏  阅读(1348)  评论(0编辑  收藏  举报