前言
這篇文章主要是想盡量直觀的介紹虛擬內存的知識,而虛擬內存的知識不管作為在校學生的基礎知識,面試的問題以及計算機程序本身性能的優化都有着重要的意義。而起意寫這篇文章主要還是因為在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段里的數據。一個簡單的操作帶來的是一個安全性不小的提升,擾動其實是特別美妙的事情,隨機性才讓我們的世界變得如此豐富多彩。
這篇文章也在我的公眾號同步發表,我的這個公眾號嘛,佛系更新,當然,本質上是想到一個話題不容易(懶的好借口),歡迎關注哦: