1. 程式人生 > >Windows系統呼叫的穿牆之術

Windows系統呼叫的穿牆之術

對作業系統有所瞭解的大概都聽說過使用者模式和核心模式,我們電腦中的大多數程式,都屬於使用者程式,處於使用者模式,其程式碼只能訪問 0~2G的使用者空間,如果想訪問 2G~4G系統空間的內容,就必須進入核心模式。換句話說,使用者模式和核心模式之間彷彿間隔了一道高牆,使用者模式的程式碼如果想擁有更大的權利,就必須穿過這道高牆。以使用者模式呼叫的ReadFile函式為例,要從檔案(背後隱藏的是硬碟)中讀取資料,就不可避免的要進入核心模式,使用系統空間的硬碟驅動,它通常會呼叫如NtReadFile的系統呼叫進入核心完成任務。本文將重點講述Windows是如何利用系統呼叫幫我們實現穿牆之術的。

一些基礎知識

1)R3與R0

提到穿牆之術,我們就要將牆兩邊的情況摸清楚。Intel將CPU的執行狀態分為四種:0級、1級、2級和3級。Windows和Linux不約而同的都只使用了0級和3級,竊以為其原因是如果使用4級的話,會大大增加系統的複雜度,效果未必見得有多好,似乎得不償失。0級權利大,3級權利小,如圖1所示。0級和3級對執行許可權、記憶體訪問範圍和出錯的影響都存在重大的區別。0級對應核心模式,核心的程式碼和資料都處於該模式下,可訪問的記憶體範圍為0~4G的空間,而3級則對應使用者模式,可訪問的記憶體範圍為0~2G空間。通常3級下的程式出錯,只需要簡單的將程序停掉就可以了,而如果在0級出現問題,我們看到的只能是藍屏。實際上,特權的概念與我們生活中的意思並無太大區別,越是核心的,掌握的資源越多,其出錯導致的後果也就越嚴重。

2)全域性描述符(Global Description Table,GDT)

如果要將全域性描述符完全講清楚,要引入N個門的概念,N個跳來跳去的關係,讀者的大腦可能在進入正題前就有了快進的衝動。我們將抓住重點,只講述與系統呼叫有關的一些基礎知識。

事情要從Windows的定址方式說起,Windows採用段頁結合的定址方式,比如類似001b:00414d05的地址,冒號前面的001b是一個16位的選擇符,冒號後面的是一個32位的虛擬地址。顧名思義,選擇符就類似於一個索引,索引對應著表,這個表就是全域性描述符表(當然還有區域性描述符表,就不多說了)。好,我們看看選擇符和全域性描述符表的廬山真面目,再來看看兩者結合後是如何幫助我們完成定址任務的。段選擇符的結構圖2所示。在段選擇符中,前13位是真正的索引,指定了一個段在段描述符表中的編號,因為是13位,所以最多指定2^13=8192個段。第2位的表指示位說明了此段位於全域性描述表(0)還是區域性描述表(1)。第0-1位指明瞭當前特權級,表示該段是核心模式(0)還是使用者模式(3)。

選擇符的結構比較簡單,下面我們看看全域性描述符表的結構。前面提到段索引是 13位的,所以我們的這個表裡也最多描述8192個段,而對於每個段,使用圖3中的8位元組結構進行描述。

我們看重點,在這8位元組的結構中,分3段記錄了共32個位元的基地址,這個基地址告訴大家,我所描述的段的起始地址在這裡。在Windows中,全域性描述符表的頭幾項是固定的,如圖4中所描述的GDT,第0項空閒,第 1項描述核心態的  CS(code segment,程式碼段),第 2項描述的是核心態的  SS(stacksegment,堆疊段),第3項描述的是使用者態的CS,第4項描述的是使用者態的SS。另外需要補充一點的是,Intel專門有個GDTR暫存器,存放GDT表在記憶體中的起始地址。有了這些,我們再看看001b:00414d05這個地址是如何被解析的。001b變為二進位制的形式,如段選擇符中的內容,前面的 11表示取全域性描述符表中的第 3項,因此是使用者 CS(程式碼段),後面的 11表示該段的內容處於使用者層,通過使用者 CS描述符中的內容確定段的基地址,之後與001b:00414d05中的偏移 00414d05相加,得到最後的 32位線性地址。事情很簡單,現在我們只要記住Windows採用的是段頁式的定址方式,一個地址以段地址:偏移地址的形式標記。

3)穿牆的跳板:共享頁面

我們時常會有這樣的需求,如果有一些變數或者函式指標在核心初始化時就已經固定了,現在需要每個程序都能在使用者模式下訪問到,如何實現呢?Windows採用共享頁面的方式實現,即將這部分內容對映到每個程序的使用者空間去,如圖 5所示。以 0x7ffe0000(使用者空間)起始的頁面與0xffdf0000(核心空間)起始的頁面實際上對應著相同的物理頁面,每個應用程式載入時,都會將這個頁面載入到固定的地址0x7ffe0000上,這樣每個使用者程序都可以直接訪問這些在核心初始化期間就已經固定下來的內容。這個被同時對映的頁面在系統地址空間稱為KI_USER_SHARED_DAT,在使用者空間稱為MM_SHARED_USER_DATA_VA,包含了兩個重要的成員變數:所有應用程式進入核心的唯一入口 SystemCall和相反方向的出口SystemCallReturn。

兩條指令sysenter/sysexit

下面進入本文正題:系統呼叫。首先需要明確一下我們的目標:系統呼叫的穿牆之術究竟做了什麼?其實就是完成了模式轉換(使用者模式到核心模式)。具體到操作,就是使用者程式碼段與核心程式碼段(R3 CS←→R0 CS)的切換,使用者堆疊段與核心堆疊段(R3 SS←→R0 SS)切換,使用者程式指標與核心程式指標的切換(R3 EIP←→R0 EIP),使用者堆疊指標到核心堆

棧指標的切換(R3 EIP←→R0 EIP)。

很多介紹系統呼叫的文章,一般都是將int2E與sysenter/sysexit指令同時進行講解,先講前者,再介紹後者。根據我的經驗,一般硬著頭皮讀完 int 2E的堆疊變換,就會有高海拔缺氧的感覺,到了 sysenter/sysexit指令,就頭暈眼花恨不得草草了事。實際上,sysenter/sysexit指令是在PetiumⅡ之後就支援的,並且sysenter/sysexit指令執行的效率要比int2E高很多,因此目前電腦中執行的機制都是使用sysenter/sysexit指令的,所以本文將主要介紹Windows是如何利用sysenter/sysexit指令實現穿牆之術的。

Intel提供的 sysenter/sysexit指令與以往不同,不涉及到堆疊的操作。因此,這兩條指令不會自動把使用者空間的堆疊指標儲存在系統空間堆疊上,甚至也不將返回地址壓入堆疊中,這些工作都需要作業系統配合完成,所以我們的講解將分為Intel(硬體指令)為我們做了什麼和Windows(軟體程式碼)為我們做了什麼。

下面介紹三個專門用於快速模式切換的暫存器,一個段選擇符 IA32_SYSENTER_CS,指定了特權級0的程式碼段選擇符,兩個偏移量指標IA32_SYSENTER_ESP和IA32_SYSENTER_EIP。IA32_SYSENTER_ESP是核心棧指標的32位偏移,IA32_SYSENTER_EIP是目標例程的32位偏移。

好像有些眼熟,CS、EIP、ESP不正是我們要完成模式切換的四大內容之三嗎?說的很對,獨缺  SS。聯想到前面講述的,在    Windows中,核心   SS與核心   CS相鄰,將IA32_SYSENTER_CS+8(還記得我們的段選擇符的結構嗎?實際上就是段索引+1)就能夠得到核心SS的段選擇符了。從另一個角度說,Intel的這種設計,節省了IA32_SYSENTER_SS、IA32_SYSENTER_RING3_CS和IA32_SYSENTER_RING3_SS三個暫存器,迫使作業系統在設計全域性描述符表時,必須將核心 CS、核心 SS,以及後面還會提到的使用者 CS、使用者 SS的順序固定排列。

1)Sysenter指令

有了IA32_SYSENTER_CS、IA32_SYSENTER_ESP和IA32_SYSENTER_EIP這三個暫存器,我們的任務好像變得簡單了很多,不妨直接祭出   Sysenter指令的內部邏輯,當系統執行Sysenter指令,其背後完成了以下動作:

①將IA32_SYSENTER_CS和IA32_SYSENTER_EIP分別裝載到CS和EIP暫存器中;

②將IA32_SYSENTER_CS+8和IA32_SYSENTER_ESP分別裝載到SS和ESP暫存器中;

③切換到特權級0;

④清除eflags中的VM標誌(虛擬8086模式);

⑤執行目標例程(實際上就是從現在的CS:EIP地址上開始執行)。其流程如圖6所示,一切都很明瞭,不多說了。

2)sysexit指令

有了sysenter的基礎,我們先把sysexit指令的內部邏輯和流程圖羅列如圖7所示,當執行sysexit指令時,完成了以下的動作:

①將IA32_SYSENTER_CS+16(使用者CS)裝載到CS暫存器;

②將EDX暫存器中的指標裝載到EIP暫存器中;

③將IA32_SYSENTER_CS+24(使用者SS)裝載到SS暫存器中;

④將ECX暫存器中的指標裝載到ESP暫存器中;

⑤切換到特權級3;

⑥執行EIP暫存器中指定的使用者模式程式碼。

IA32_SYSENTER_CS+16與IA32_SYSENTER_CS+24,如前所述,是選擇符(注意不是指標),分別用於獲得全域性描述符表中的使用者CS段描述符以及使用者SS段描述符。而為了正確返回使用者空間,在步驟②和步驟④中使用了EDX和ECX,分別賦予EIP和ESP暫存器。不要問為什麼,Intel就是這樣規定的,反正你執行sysexit指令,我就將EDX和ECX放入EIP和ESP暫存器,而如何將進入核心空間前,使用者空間的EIP和ESP儲存,並在執行sysexit指令前放入EDX和ECX,則是作業系統(軟體)的事了!

Windows的工作

Windows說:Intel老大,你就給了我兩個指令,我需要完成引數的傳遞,定義核心模式和使用者模式的入口例程,順利完成sysenter/sysexit切換過程中EIP與ESP的設定,任務量巨大!下面來看看Windows是怎麼做的。

首先在核心初始化時,會看到如下的程式碼:

Ke386WrMsr(IA32_SYSENTER_CS,KGDT_R0_CODE,0);
Ke386WrMsr(IA32_SYSENTER_ESP,(ULONG)KeGetCurrentPrcb()-àDpcStack,0);
Ke386WrMsr(IA32_SYSENTER_EIP,(ULONG)KiFastCallEntry,0);

即分別賦予三個快速系統呼叫暫存器初值,IA32_SYSENTER_CS被賦予KGDT_R0_CODE,即我們前面提到的為0x8,Windows中所有的核心程式碼段都共用這個描述符,實際計算得到的段基地址總是為  0。IA32_SYSENTER_ESP被賦予   KeGetCurrentPrcb()→DpcStack,而DpcStack指向一個獨立的核心堆疊;IA32_SYSENTER_EIP被賦予KiFastCallEntry函式的地址,換句話說,當應用程式在使用者模式下執行sysenter指令時,會跳轉到KiFastCallEntry函式執行。

其次,系統在啟動的時候會判斷CPU是否支援快速系統呼叫功能,如果支援的話,將MM_SHARED_USER_DATA_VA頁面的SystemCall成員賦予KiFastSystemCall函式地址,SystemCallReturn成員賦予KiFastSystemCallRet函式地址。

準備工作做完了,我們以ntdll檔案中NtReadFile函式的執行為例:

Ntdll!NtReadFile:
Mov eax,b7h
//函式呼叫號
Mov edx,MM_SHARED_USER_DATA_VA+SystemCall
Call [edx]
Ret 24h
其中  EDX暫存器被賦予MM_SHARED_USER_DATA_VA+SystemCall,這應該就是
KiFastSystemCall函式地址,那麼這個函式又做了什麼呢?
KiFastSystemCall:
Mov edx,esp
Sysenter
KiFastSystemCallRet:
Ret

很簡單,就是將當前的使用者模式ESP賦予EDX之後,執行Sysenter指令。進入核心模式後,如果想取得使用者模式的所有引數,只需要直接movesp,edx就可以得到R3的堆疊了,完全不用拷貝,就能直接得到所有的引數。而KiFastSystemCallRet就是一個簡單的返回函式。

通過前面的講解知道,sysenter指令完成了核心模式 CS:EIP和 SS:ESP的設定,此時由於EIP=KiFastCallEntry函式地址,進入了KiFastCallEntry函式的地盤,這個函式又做了什麼呢?為了儲存進入前使用者模式的一些資訊,順利返航,這段函式會執行一系列的壓棧動作,將使用者模式SS(0x23)、使用者模式ESP(目前放於EDX中)、EFLAG、使用者模式程式碼段選擇符(0x1b)、使用者模式EIP(此時為KiFastSystemCallRet)依次壓入堆疊,如圖8所示。

之後核心會通過一個叫做 SSDT(系統服務分發表)的東西,找到目標函式地址執行,我們會在將來的文章裡專門討論SSDT,在此就不贅述了。上述整體流程如圖9所示。

當系統服務例程執行完畢之後,我們要準備返回了。如果例程檢查到應該回到使用者空間,則跳轉到FastExit函式。FastExit函式包含下列程式碼:

Pop edx
;將EIP=KiFastSystemRet賦予EDX
Add esp,4  ;跳過CS
And dword ptr[esp],0xfffffdff   ;設定EFLAG
Popf
;賦予EFLAG
Pop ecx
Sysexit
;將ESP賦予ECX
;執行Sysexit指令

參考圖8堆疊中的內容,就不難理解Windows為什麼在這裡讓EDX和ECX分別被賦予返回使用者空間的程式指標和堆疊指標,下面我們就可以放心執行sysexit指令了。此時程式會跳轉到KiFastSystemCallRet函式,這個函式實際上就是一個ret語句,注意此時程式跳轉到哪裡:是函式Ntdll!NtReadFile中Call  [edx]之後的Ret  24h,還是KiFastSystemCall函式中Sysenter指令之後的KiFastSystemCallRet標號呢?很顯然,Sysenter指令並沒有壓棧動作,Call[edx]有壓棧,而執行ret指令就是取堆疊中的內容作為返回地址,因此程式不會再執行sysenter指令後的程式碼,而是進入Ntdll!NtReadFile函式,直接執行ret24h繼續返回。穿牆之術的返回流程如圖10所示。

小結

本文對利用sysenter/sysexit指令實現快速系統呼叫的方式進行了講解,最重要的是把兩點弄清楚,第一點是sysenter/sysexit指令執行時究竟做了哪些動作,第二點是為了配合這些指令,Windows做了哪些精巧的設計,比如堆疊的變化,EDX的變化.(完)