系統呼叫篇——3環層面呼叫過程

寂靜的羽夏發表於2021-11-09

寫在前面

  此係列是本人一個字一個字碼出來的,包括示例和實驗截圖。由於系統核心的複雜性,故可能有錯誤或者不全面的地方,如有錯誤,歡迎批評指正,本教程將會長期更新。 如有好的建議,歡迎反饋。碼字不易,如果本篇文章有幫助你的,如有閒錢,可以打賞支援我的創作。如想轉載,請把我的轉載資訊附在文章後面,並宣告我的個人資訊和本人部落格地址即可,但必須事先通知我

你如果是從中間插過來看的,請仔細閱讀 羽夏看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

  不幸的是,這個函式是人家匯入的,如何查到從哪裡匯入的呢?我們可以按照如下圖所示的操作找到:

系統呼叫篇——3環層面呼叫過程

  我們知道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環層面呼叫過程

相關文章