15分鐘帶你瞭解虛擬記憶體
前言
這篇文章主要是想盡量直觀的介紹虛擬記憶體的知識,而虛擬記憶體的知識不管作為在校學生的基礎知識,面試的問題以及計算機程式本身效能的優化都有著重要的意義。而起意寫這篇文章主要還是因為在python,人工智慧的大浪潮下,我發現好多人對這方面真的無限趨近於不知道。我不是說懂這些基礎知識比懂人工智慧水平就是高,但是作為一個軟體工程師,我覺得相對於調庫調參,我們更應該有更牢靠的基礎知識。不然很容易陷入,高深的數學不會,基礎的知識也不知道的尷尬境地。畢竟從事演算法核心的,沒有多少人,而作為工程師,我始終覺得我們的使命是如何把這些天賦異稟,腦袋發達的人的想法,構思,演算法變成真正可用的東西。而在我從業不算長的年限中遇過的人來看,這絕對不是一種很簡單的能力。
閱讀本文,需要有基本的c語言和python語言知識,如果提到虛擬記憶體,腦海中就有虛擬記憶體分佈圖的大概樣子,那就完美適配這篇文章了。我希望通過這篇文章可以幫助你可以通過推理的方法回答出虛擬記憶體的各種問題,可以知道這個東西是如何真正和程式結合起來的。
文章大體分為三個部分,
第一部分,介紹虛擬記憶體的基本知識
第二部分,會直觀的展示虛擬記憶體和我們的程式程式碼到底是怎麼聯絡起來的
第三部分,我會演示如何改掉虛擬記憶體的內容,和修改這些內容到底意味著什麼,吹的大一點,如何hack一個程式
本文所有的程式碼都很簡單,只有c語言程式碼和python程式碼,並且我都跑過,如果你使用以下的環境,應該程式碼都能跑起來看到結果:
- 一臺Linux發行版的機器,我用的,一個樹莓pi
- Python 3+
- gcc 5.4.0+
什麼是虛擬記憶體
如果你是一個程式設計師,至少你肯定聽過記憶體這個詞,雖然你可能真的不知道記憶體是什麼,但是確實在現代程式語言的包裝下,你依然可以寫出各種程式。如果你真的不知道,那麼我覺得還是應該去學習下記憶體的知識的以及計算機程式是如何被執行起來的。而什麼叫虛擬,我至今記得我大學作業系統老師上虛擬記憶體這一節的時候引用的解釋,我拙劣的翻譯成中文大概就是:
真實就是這個東西存在並且感受到,虛擬就是這個東西存在但是你感覺不到。
虛擬記憶體就是這麼一類東西,它確實存在,而你卻不能在程式中感受到他。為什麼要有虛擬記憶體,原因有很多,比如作業系統分配記憶體的時候,很難保證一個程式用的記憶體地址一定是連續的。比如記憶體是一個全域性的東西而且只有一個,而程式有無數個,直接操作記憶體出問題的概率大,管理也不方便等等。於是虛擬記憶體的概念就給計算機程式的編寫者,編譯器等等都提供了一段獨立,連續的“記憶體”空間。而實際上,這段記憶體不是真是存在的,其地址空間可以比真實的地址空間還要大,通過各種換出換入技術,讓程式以為自己執行在一段連續的地址空間上。虛擬記憶體的概念的偉大之處在於給電腦科學的各種概念設計提供了一種思路,隔離,虛擬,直到現在,docker,各種虛擬化技術不能不說和虛擬記憶體的概念沒有關係。
而提到虛擬記憶體那麼無論在什麼樣關於作業系統的教科書裡一定有這麼一張圖:
我當時在學習的時候老師會跟我們說這個虛擬記憶體由哪些部分組成,為了文章看起來比較整體,讓我再簡單的說明下,對於一個執行的程式,到底有哪些部分組成:
首先虛擬記憶體的定址地址是由機器和作業系統決定,比如你是一個32bit的作業系統,那麼定址空間就是4GB,換句話說你的程式可以跑在一個0到0xffff ffff的“盒子”裡,而如果你是64位的作業系統,那麼這個定址空間就會更大,意味著,你有更大的“盒子”,可以有更多的可能。
而圖中的低地址就是0x0,假設是32位作業系統,那麼高地址就是0xffff ffff。那麼,就讓我們按照人類的認知習慣,從低往高看看每一層都“住”著些什麼。
最下面是text段,這裡放著程式的執行的程式碼等等,如果你用objdump這樣的程式開啟一個程式,最前面你能看到應該是你的程式碼轉化而成的組合語言。
往上就是已初始化資料段和未初始化資料段,這裡存放著全域性變數,而這些都會被exec去執行,他們不僅有不同的名稱,還有不同的許可權,在後面的展示中,你可以直觀的看到這些。
而再往上是堆段,也就是面試中經常會被問的,malloc,new出來的記憶體是存放在哪裡的,沒錯,就是這裡。而他的上面是另一個面試問題的來源,區域性變數,引數都存在哪裡。
住在頂樓的是命令列引數,環境變數等等。
而這些都是理論書本上寫的,類似於告訴你兩點之間有且只有一條直線一樣。到底兩點之間是不是真的只能畫一條直線,最好的辦法應該是自己畫一畫,以真實去驗證理論。所以,到底一個程式在記憶體中真的是這樣嗎,或者說我們的程式程式碼到底和這樣一個概念有什麼關係,下面的章節就讓你看看“虛擬”是如何可以被真實的展示的。
/proc/{pid}/maps
在這一節的最開始,我不得不特別簡單的介紹linux下的proc資料夾,其實正確的應該叫他檔案系統。而這也是為什麼要使用Linux作為程式碼執行環境的原因,Windows上要看到一個程式的虛擬記憶體不是不可以,但是要去使用一些第三方工具,唯有Linux,在不需要任何工具的情況就能直觀的給你展示所有的內容。而Proc檔案系統就是這樣一個入口。
如果你在Linux的命令列中輸入ls /proc/,你會發現好多內容,其中有很多以數字為名字的資料夾。這些數字對應的就是一個一個的程序,而這些數字就是程序的pid,此時你可以更進一步,隨便選一個數字大一點的資料夾,看看裡面到底有什麼。在我的電腦上,我選了7199這個數字,使用ls /proc/7199。你會看到更多的檔案和資料夾,而且這些檔案的名字都很有意思,比如cpuset,比如mem,比如cmdline等等。沒錯,這些檔案裡儲存的就是該程序相關的資訊,比如命令列,比如環境變數等等。而LINUX中一切都是檔案的思想也在這裡得到了體現。proc是一種偽檔案系統(也即虛擬檔案系統),儲存的是當前核心執行狀態的一系列特殊檔案,使用者可以通過這些檔案檢視有關係統硬體及當前正在執行程序的資訊。而和我們這個主題相關的檔案就是/proc/pid/maps和/proc/pid/mem。一個顯示了改程序虛擬記憶體的分佈,一個就是真正的虛擬記憶體的檔案表現了。作為好奇的人類,你可以隨便找一個pid資料夾看看maps檔案裡的內容,而mem由於特殊設定是無法被直接讀取檢視的。或者,你可以跟著這篇文章後面的程式碼,檢視自己的程式的maps檔案。
我編寫了一個很簡單小程式叫做showVM,這個程式會是下一章的主角。在我執行showVM檔案後,使用下面的命令找到這個程式的id:
ps aux | grep showVM
在我的機器上,這一次執行分配的ID是20772,接下來就是讓人充滿啊!哈!感的時刻了。既然找到了id,根據最前面介紹的proc檔案系統知識,首先使用 cat /proc/20855/maps檢視下這個程序的虛擬記憶體分佈圖:
maps檔案是一個非常值得細細研究的檔案,這就是一個虛擬記憶體最好的示意圖。和上面的有一些些不同,貌似這個虛擬記憶體地址似乎不是從0x0開始到0xffff ffff結束,和我上面說的32位作業系統定址空間有點差別。而這個由於和本文所想介紹的主題不是那麼的聯絡緊密,而太多的細節容易讓人偏離主題,所以這個有興趣的話可以就是那句俗話,自己去搜索搜尋。
廢話不再多扯了,就從一眼最熟悉的兩個詞開始,stack和heap。maps檔案的第一列是地址,所以從這個檔案中可以最直接的驗證的就是heap是存在於低地址段,而stack位於高地址段。還有一個就是這兩個段的許可權都是可讀可寫,這樣保證了這兩段是可以被程式讀寫的。
這個時候再回到上面的示意圖中,可以看到圖中所繪,stack的更高地址儲存的是命令列引數,而heap更低地址是程式碼段和資料段。而這裡,我想從更低的地址開始說起,因為即使你從來沒接觸過aps檔案,你會發現最後一列是檔案的名稱,最低地址放著的是我們自己的程式程式碼檔案。這不足為奇,一個程式總要把自己的可執行部分放在虛擬記憶體中,這樣CPU才能找到並且執行,這裡比較有意思的是這裡貌似有三個重複的,但是仔細看,你會發現這三個部分的許可權是不同的,而示意圖中heap之下也正好有三個部分,看起來正好是對應了示意圖的三個部分。但是這個想法是不準確的,可以看到這三個部分:
第一個部分是可讀可執行許可權,這裡存放的是程式碼。
第二個部分只有讀許可權,這個部分涉及另外一類稱之為RELRO的技術,簡答來說這個技術在gcc,linux中採用可以減少非法篡改著修改可寫區域的機會,不是簡單的一節兩節可以說清楚的。考慮到這個和了解熟悉虛擬記憶體分佈的關係不大,如果沒有興趣,完全可以暫時忽略這個部分。
第三個部分是可讀可寫的部分,這裡存放的呢就是各種資料,和上面的示意圖可能有點不一樣,這裡包括已經初始化的和未被初始化的資料。
說完heap更低的地址,下面再看看另一個部分,stack更高的地址。這裡有很多縮寫名詞,而這些名詞又涉及到更多的細節,主要是核心態和使用者態的相關知識,這個部分就很深入而且不是很少的篇幅就能敘述清除的,在這裡只需要知道,在Linux虛擬地址空間對映中,最高的1GB是kernel space的對映,具體有什麼作用呢?可以完成比如使用者態,核心態資料交換,在這裡對映一些核心態的函式,加快呼叫核心態函式時的速度等等。這1GB的地址的內容,使用者態的程式是不可以讀不可以寫的。
對應著示意圖,似乎maps檔案多了一個部分,就是中間的一串.so檔案。當然,只要你稍微有點Linux的知識,你會知道這些都是Linux的庫檔案,也就是可執行程式。那麼虛擬記憶體裡面為什麼要放這麼多庫檔案呢?很明顯的一點,就是這些庫檔案肯定是我們的程式需要呼叫的檔案,這一部分叫做記憶體對映檔案,最大的好處就是可以提高程式的執行速度。
說了這麼多,對應著示意圖,Linux虛擬記憶體地址更準確的示意圖應該是這樣的:
迴歸程式碼
作為程式設計師,我們的世界裡最直接面對的就是程式碼了。如果書上描寫的一切不能用程式碼證明,感覺總是缺少點什麼,而這一節主要就是用真實的程式碼證明maps檔案裡面的各個區域。而和記憶體互動,最直接想到的應該就是使用c語言,而證明maps檔案的各個部分最簡單的方法就是打印出各個部分的地址然後和maps檔案一一對應。
1 /************************************************************************* 2 > File Name: showVM.c 3 > Author: 4 > Mail: 5 > Created Time: Wed 03 Jul 2019 01:24:28 PM CST 6 ************************************************************************/ 7 8 #include <stdio.h> 9 #include <string.h> 10 #include <stdlib.h> 11 #include <unistd.h> 12 13 14 int add(int a, int b){ 15 return a+b; 16 } 17 18 int del(int a, int b){ 19 return a-b; 20 } 21 22 int (*fPointer)(int a, int b); 23 int global = 0; 24 int global_uninitialized; 25 26 int main(int argc,char *argv[]) 27 { 28 int var = 0; 29 char *chOnHeap = "test"; 30 //chOnHeap = (char*)malloc(8); 31 int *nOnHeap = (int*)malloc(sizeof(int)*1); 32 *nOnHeap = 200; 33 34 fPointer = add; 35 while(1) 36 { 37 sleep(1); 38 printf("-------------------------------------------------------------------------------\n"); 39 printf("global address = %p\n",(void*)&global); 40 printf("global uninitialized address = %p\n",(void*)&global_uninitialized); 41 printf("var value = %d, address = %p\n",var,(void*)&var); 42 printf("chOnHeap value = %s, pointer address = %p, pointed address = %p\n",chOnHeap,(void*)&chOnHeap,chOnHeap); 43 printf("nOnHeap value = %d, pointer address = %p, pointed address = %p\n",*nOnHeap,(void*)&nOnHeap,nOnHeap); 44 45 printf("main address = %p\n",(void*)&main); 46 for(int i = 0; i < argc; i++){ 47 printf("argument address = %p\n",(void*)&argv[i]); 48 } 49 printf("add address = %p\n", (void *)&add); 50 printf("del address = %p\n", (void *)&del); 51 printf("function pointer address = %p, pointed address = %p ,value = %d\n",(void *)&fPointer,fPointer,(*fPointer)(10,20)); 52 53 printf("--------------------------------------------------------------------------------\n"); 54 } 55 56 free(nOnHeap); 57 //free(chOnHeap); 58 return 1; 59 }
然後使用以下命令編譯這個檔案:
gcc -Wall -Wextra -Werror showVM.c -o showVM
下面就是執行showVM,得到輸出如下,準確的說應該是一次輸出如下:
對應著上一節的maps檔案,我們就可以開始我們的程式碼驗證之旅了。
首先,對於global變數,不管是已初始化的或者是未初始化的,都是位於0x21000-0x22000這個段中的,對應上面的maps檔案,可以看到無論是初始化的資料或者未初始化資料都是放在上面所說的heap之下的第三部分,可寫可讀區域的。
接下來就是最常見的區域性變數的位置,在無數的關於c語言的書中,都會類似這樣的描寫: c語言中,一個變數是在棧上分配(儲存)的。這裡可以看到這個變數var的地址是0x7e8441d8,位於0x7e824000-0x7e845000之間,並且可以看到是更接近於7e845000,似乎可以印證棧都是從高地址向低地址增長的。不過,只有一個變數的話,有可能正好這個變數就坐落於這個區域。沒有關係,我們可以用宣告更多的變數看看棧到底是怎樣生長的。
在接下里的兩行,列印的是兩個指標的地址,而指標本身是一個變數,所以可以看到他們的地址都是在棧上。如果結合上面一個變數的地址來看,正好每一個都是前一個的地址減去4,而這和32位機器上指標的大小一致。可以看到,在虛擬記憶體中,棧是由高地址往低地址生長的。
還是這兩行,根據c語言書裡面關於變數分配的另外一句話,“指標資料都是儲存(分配)在堆上的”,似乎從這個輸出中看有點出入。對於這兩個指標,指向整數的那個指標,所指向的整數確實是分配在堆上的,因為地址0x1fce018確實坐落於0x1fce000-0x1fef000之間,而且從這個位置來看,堆似乎是從低地址往高地址分配的。而指向字串的那個指標所指的地址明顯不是在棧上,而是在0x10000-0x11000這個區域之間。這不是堆的區域,而是可執行檔案存放的區域,從下一行main函式的地址更加可以證明這一點。為什麼會這樣呢?因為c語言把這種字面量(string literal)都放在所謂的“文字常量區”,這裡的資料會在程式結束後由程式自己釋放,所以即使對於這個指標不進行free也不會造成記憶體洩露。所以,對於這道常見的面試題,“指標指向的值都分配在哪裡?”,如果你的回答可以提及文字常量區,那麼一定是更有加分的。
那麼,如果再多想一步,如何讓指向字串的指標所指的值也分配在堆上呢?辦法有很多,比如malloc之後用strncpy,有興趣可以試試,你會發現,這個時候指向的地址就是在堆上了。不過,千萬別忘了這樣的之後指標需要被free,不然就會有記憶體洩漏。另外,其實還有一個很有意思的行為,這個行為凸顯出了編譯器的機智。如果在這個檔案中再定義一個指標,指向的值還是“test”,那麼這兩個指標指向的地址會是一樣的,有興趣只要稍微在上面的程式碼中加一點內容就可以驗證。這種聰明的行為最直接的好處就是可以節省空間,很多這種細小的行為,至少我覺得真的是很有意思的。
講完了指標以及main函式的地址,在示意圖中說還有一部分位置是留給命令列引數的。於是,我也做了小小的驗證,可以看到,雖然我這個程式執行只有一個命令列引數,也就是程式名,但是不妨礙看看這個引數到底是在哪個區域中。可以看到其地址是在前面分配的棧空間的更高地址,344明顯大於1d4,所以說,和示意圖中說的一樣,命令列引數是位於棧空間之上的。
剩下來我想展示的是函式的地址,所謂呼叫函式,其實就是執行某一個地址的程式碼。所以,可以看到,函式地址是位於可執行區域的,和main的地址在一個區域,maps檔案裡也表明了這個區域具有的是可讀可執行許可權。
另外一個,既然函式是地址,那麼按照c語言的規範,就可以使用一個指標指向這個地址,而體現在程式碼之中,就是函式指標。最後一行,列印了指向add函式的函式指標的地址,因為這個指標是全域性定義的,所以指標本身的地址是位於全域性的資料去,和globa資料一樣。而指向的地址,就是add函式的地址,當然,執行的也就是add函式。
好了,現在我們使用程式本身打印出程式中不同變數的地址,並且我們知道了,maps 檔案可以顯示整個虛擬記憶體地址的分佈。而正如上面提到的,還有一個和虛擬記憶體相關的檔案,mem,這個檔案就是一個程式虛擬記憶體的對映。而作為一個檔案,就有可能有讀寫的許可權,而下一節,就是讓你看看如何hack掉一個正在執行的程式的行為(虛擬記憶體資料)。
修改一個執行的程式的小把戲
這一節,我想做的是,改掉一個正在執行的程式的函式指標指向的地址,這樣會讓一個函式的結果改變,或者說執行自己想要的函式。在一些用心良苦,技術高超的侵入者裡,就這一個行為就完全有可能控制你整個電腦。當然,在我這裡,我程式本身就知道函式的地址,所以,只要你理解上面所說的,看起來有點太過於玩具。而真正的黑客,會用精心構造好的程式碼修改掉虛擬記憶體中任何一個可以有寫許可權的地方,從而達到為所欲為的目的。
就像前面所說的,既然我知道一個指標的地址,而且又知道修改後函式應該指向的地址,那麼就很簡單了,讀出這個檔案,在這裡就是mem檔案了,將檔案寫指標指向這個位置,修改之,大功告成。而完成這個操作,可以選擇任一語言,只要有檔案操作的介面,而我,選擇的是python。
1 #!/usr/bin/env python3 2 # coding=utf-8 3 import sys 4 pid = int(sys.argv[1]) 5 address = int(sys.argv[2],16) 6 byte_arr = [] 7 for num in range(3,len(sys.argv)): 8 byte_arr.append(int(sys.argv[num],16)) 9 10 mem_filename = "/proc/{}/mem".format(pid) 11 print("[*] mem: {}".format(mem_filename)) 12 13 try: 14 mem_file = open(mem_filename, 'rb+') 15 except IOError as e: 16 print("[ERROR] Can not open file {}:".format(mem_filename)) 17 print(" I/O error({}): {}".format(e.errno, e.strerror)) 18 exit(1) 19 20 mem_file.seek(address) 21 mem_file.write(bytearray(byte_arr)) 22 23 mem_file.close()
在執行這個程式時,可能需要使用sudo來提升許可權執行。這個python程式很簡單,也沒啥錯誤提示,處理的,因為我只是想展示下基本的原理。這個指令碼接受的引數依次為pid,你想改變的地址的16進位制字串,比如我想改變的那個函式指標在檔案內的偏移就是他的地址 21040,想替換的終極資料,一個byte陣列。這裡有一點講究,就是你需要知道一些大端,小端機器的知識,這個並不難,搜尋引擎2分鐘就可以告訴你答案。我想把這個函式指標指向的地址改成減法函式的地址,看起來應該改成0x10504,也就是傳入01,05,04。但是如果你傳入這個資料,會發現執行著的showVM程式立刻就崩潰了。而如果你認真學習了關於大端小端的知識,你會發現這裡應該傳入的其實是04 05 01 00。這個原因,就留給熱愛探索的人吧。
好了,要想看到神奇的事情發生,只需要做兩步,第一步,執行showVM,第二步,根據你的輸出向這個python檔案傳入對應的引數,因為我又重新運行了下showVM,所以,下面執行的截圖和上面會略有不同:
準備好,奇蹟發生的時刻:
你可以看到,正在執行的程式,得到的結果變了,本來是10+20=30,現在變成了10-20=-10了。函式指標的地址也變了,確實指向了del。就這一套小把戲,理論上你可以改這個輸出中的任意地址,但是實際上,有些你是改不了的,因為許可權問題。
是不是很神奇?你還可以想想到其他有意思的實驗,比如修改掉一個執行程式的字串。方法也並不複雜,從maps檔案裡找到heap段的範圍,在這個範圍裡搜尋需要的字串。有可能搜不到,因為按照上面說的,字面量字串可能不是儲存在heap區域的,而他所儲存的區域你是無法修改的。這裡假設在heap中搜到你所需要的字串,那麼剩下的就是找到這個位置,修改其中的內容,你會發現和上面一摸一樣的效果。
最後我想說的是,如果觀察maps檔案更仔細一點,你會發現當你執行同一個程式,開頭的三個段地址是不會改變的,但是heap開始的地址貌似並不是固定的,為什麼要這麼做?這裡涉及到虛擬記憶體實現中的一個常見技術,這裡會有一個隨機gap,目的是增加安全性。因為前三段是固定的,而heap又是如此重要,因為你完全可以改變heap中的內容來改變一個指標指向的內容。所以一段隨機的偏移可以讓侵入者不那麼容易的找到heap段裡的資料。一個簡單的操作帶來的是一個安全性不小的提升,擾動其實是特別美妙的事情,隨機性才讓我們的世界變得如此豐富多彩。
這篇文章也在我的公眾號同步發表,我的這個公眾號嘛,佛系更新,當然,本質上是想到一個話題不容易(懶的好藉口),歡迎關注哦:
<