深入理解計算機(jī)系統(tǒng) 第三章 程序的機(jī)器級表示(下)

[toc]

關(guān)鍵詞

過程,數(shù)組,內(nèi)存結(jié)構(gòu),緩沖區(qū)溢出

數(shù)組和緩沖區(qū)溢出一定去看看匯編代碼,然后做練習(xí)題,就算懂了原理,也要看一下數(shù)組是怎么操作的。

因為匯編語言里面的操作和你寫的C代碼可能很不一樣。比如你寫了一個for循環(huán),求和一個一維整型數(shù)組里面的所有數(shù)。那么你在C代碼里面可能會寫for (int i = 0; i < N; i++),但是匯編里面很可能不會出現(xiàn)一個寄存器來保存整數(shù)N來判斷,而是判斷指針會不會超過(指針名的地址 + N * size)。


過程

過程是種抽象,一個函數(shù)調(diào)用另一個函數(shù)就是個過程。抽象是什么我也不懂,沒學(xué)過面向?qū)ο?,不過這章講的東西我基本上都搞懂了。

為啥要用程序棧

首先說下為啥要用程序棧,為啥不是程序隊列程序樹啥的呢?因為我們調(diào)用函數(shù)的思想和棧正好相符。如果P函數(shù)調(diào)用Q函數(shù),那么我們肯定希望P的必要信息被保留,Q的必要信息被暫時生成,在Q運行結(jié)束后,Q里面的所有auto自動變量都應(yīng)該被釋放,這就是函數(shù)的思想,就算沒學(xué)計算機(jī)原理也大概都知道。

這個思想和棧的數(shù)據(jù)結(jié)構(gòu)是完全相符的。內(nèi)存在計算機(jī)看來就是個大數(shù)組,而且棧完全可以由數(shù)組實現(xiàn)。而且我們用數(shù)組實現(xiàn)棧的時候,pop出棧的時候需要擦除掉pop的內(nèi)容嗎?不用移動棧頂?shù)奈恢镁托?/strong>。

當(dāng)主函數(shù)調(diào)用P函數(shù)時,掛起主函數(shù),在棧中向棧頂開辟一片新的空間給P用,棧頂指針移動開辟空間,當(dāng)P調(diào)用Q的時候同理。當(dāng)Q運行結(jié)束的時候,計算機(jī)根本不用去擦除Q的信息,只要移動棧頂指針回到P函數(shù)給Q函數(shù)釋放空間前的位置就行了。

這也就解釋了為什么遞歸容易造成棧溢出,因為計算機(jī)會規(guī)定,這些函數(shù)占的地方不能超出某個閾值,但是如果遞歸無限循環(huán),那會導(dǎo)致程序棧一直在變大,無法被釋放(想釋放得等函數(shù)return,但是如果遞歸一直不結(jié)束就不會觸發(fā)任何一個終止的return),最后就gg了。

程序棧細(xì)節(jié)

內(nèi)存結(jié)構(gòu).png

首先,程序棧是很反人類的倒著的。你可以想象成一個反重力水桶,它倒扣在地上,但是水桶里的水出人意料的貼在水桶底部,在高處懸浮著。這就是我們的程序棧了。

這個水桶的地址是正常思維,離地面越高,地址越大,反之亦然。程序棧寄存器%rsp永遠(yuǎn)指向水的最低點。每次我們調(diào)用程序棧,那么棧指針地址會減小(地址減小反而是開辟空間)。

每次調(diào)用函數(shù)多出來的地方,我們叫做棧幀。這里重點要說下call和ret這兩個匯編代碼的作用。


當(dāng)我們遇到 call的時候,程序棧會干三件事情

  1. 棧指針%rsp減小,給返回地址開辟空間

  2. 將call這一條代碼的下一條代碼的地址(即返回地址),push壓棧,作為將來Q結(jié)束時回來的路標(biāo)。

  3. 將程序寄存器PC(它指哪cpu運行哪里)里面的地址,換成Q的起始地址,讓cpu開始運行Q的代碼。


當(dāng)程序遇到 ret的時候,程序棧會干三件事情

  1. 返回地址出棧

  2. 棧指針%rsp增加,釋放空間

  3. 將程序寄存器PC里面的地址,換成剛被pop出棧的返回地址。讓cpu繼續(xù)運行P函數(shù)的后續(xù)代碼。


數(shù)據(jù)儲存

之前說過,有6個寄存器來保存6個函數(shù)參數(shù),但是如果函數(shù)參數(shù)大于6個咋辦?

答案是讓程序棧開辟空間,給多余的參數(shù)騰地方。但其實很多函數(shù)根本就用不到這么多參數(shù),所以參數(shù)都在寄存器里面存著,程序棧上面僅僅開辟出來8個字節(jié)保存返回地址而已。

有時候數(shù)據(jù)必須存在內(nèi)存中:

  1. 寄存器不足,上面提了

  2. 如果對一個局部變量使用了取地址符 &,那么必須得生成一個地址在內(nèi)存上面。

  3. 數(shù)組

課上有人提問,如果被調(diào)用的Q在棧上開辟了16個字節(jié),那么最后計算機(jī)能記住Q開辟了多少字節(jié)然后一鍵還原嗎?

它就是能。首先如果Q沒有不確定的變長數(shù)組,那么它的空間在運行前就被確定了(各種類型的大小都是確定的)。如果Q有變長數(shù)組,那么計算機(jī)會使用特殊寄存器%rbp來保存開辟前的地址,最后也能還原。

被調(diào)用者保存

這個詞我第一次見是看16個寄存器列表的時候,有個編譯器就被注釋--被調(diào)用者保存。%rbp 和 %rbx 都是被調(diào)用者保存類。

啥意思?很簡單,就是假設(shè)P有個局部變量 num 保存在%rbx中,當(dāng)P調(diào)用Q,調(diào)用結(jié)束后,我立馬去訪問 %rbx,那么一定能獲得 num 的值,計算機(jī)親口保證,在被調(diào)用的函數(shù)Q在結(jié)束的時候會讓%rbx和以前保持一致。

我為啥沒說讓 %rbx 不變呢?因為它是可能變的,在Q的運行過程中,它如果一直不動 %rbx 就算了,如果它動了,那么它一定會在Q棧幀中開辟出一個地方保存原 %rbx 的值,最后再把這個值賦值回去。

為什么要這樣子?這個東西叫約定俗成,就跟化學(xué)課為什么 1 摩爾等于XXXX,IEEE的浮點數(shù)要搞成三部分一樣。大家的cpu都這樣設(shè)計,就算換成不同的編譯器去編譯代碼,得到的結(jié)果都是一樣的。

調(diào)用者保存

這又是啥意思?就是 調(diào)用者你自己負(fù)責(zé)的意思, Q和P說,這個寄存器的值你自己保存啊,到時候我可能會把它改了,你要是沒存我可不負(fù)澤任。所以P一般會把這些值保存在自己開辟出的棧幀上面。


圖3.32

這個例子強(qiáng)烈建議看下,如果能秒懂那你很無敵。太長了就不敲了。

練習(xí)題 3.35

我加大了一個下難度,如果你能做出來那說明你真正理解了,做不出來就看書去吧老鐵。

  1. 請還原原函數(shù),變量名你隨便起
  2. 為什么第一條要pushq %rbx?
long rfun(unsigned long x) 
x in %rdi

rfun:
    pushq   %rbx
    movq    %rdi, %rbx
    movl    $0, %eax
    testq   %rdi, %rdi
    je      .L2
    shrq    $2, %rdi
    call    rfun
    addq    %rbx, %rax
.L2:
    popq    %rbx
    ret

答案:

long rfun(unsigned long x) {
    if (x <= 0) {
        return 0;
    }
    unsigned long nx = x >> 2;
    long rv = rfun(nx);
    return x + rv;
}

因為要被調(diào)用者保存,這個%rbx是保存x的,x會參與到最后的return中,那么這個遞歸必須要保證,經(jīng)過遞歸之前的 x 能夠被保存下來。怎么保存的你不用管,但是只要放在 被調(diào)用者保存 寄存器,它就一定會被保存。


數(shù)組和結(jié)構(gòu)體

數(shù)組的東西其實和C Primer Plus 講的重合度挺高的,講了很多關(guān)于數(shù)組的基本操作。

數(shù)組在內(nèi)存

  1. 數(shù)組在內(nèi)存里是一個整體,里面的元素肯定都是挨著的。不過假設(shè)我們連續(xù)創(chuàng)建兩個數(shù)組,并不能保證這兩個數(shù)組是挨著的。二維數(shù)組是放完一排再放一排,整體上每排之間都是緊挨著的。

  2. 感覺看了數(shù)組的視頻,只要把(%, %, i)這個東西搞懂了,數(shù)組的事情就完全明白了。二維數(shù)組的話 就是
    數(shù)組頭 + size_t*寬*index_i + size_t*index_j


結(jié)構(gòu)體

重心就是字節(jié)對齊,其他的和數(shù)組沒什么太大區(qū)別。因為匯編代碼不求會寫,只要看懂就行了,所以不用記太多東西。

對齊的原因是硬件的問題,計算機(jī)每次大概取64字節(jié)的數(shù)據(jù)來讀,如果沒有字節(jié)對齊,而且有個double類型的只被截取了一半,那么計算機(jī)需要花點時間去讀另一半,速度會變慢。這個字節(jié)對齊過程是編譯器自動給你干的,程序員能做的就是盡量的注意定義順序,來讓被浪費的空間最小化。

順便提下,就算不是字節(jié)對齊,x86也能讀也能正常運行,就是慢。


內(nèi)存結(jié)構(gòu)

之前說了程序棧像一個反重力水桶,而在內(nèi)存的結(jié)構(gòu)中,程序棧正好處于最上層。注意下圖每個模塊之間可能是有空白的,不是上來大家就是緊挨著的。

內(nèi)存結(jié)構(gòu).png

首先說下47位這個概念,因為目前我們的內(nèi)存幾乎不可能填滿64位,因為這特么實在太大了,所以系統(tǒng)就規(guī)定,最大的地方就是47位,不會再大了。所以最大的地址是多少?

就是0x00007FFFFFFFFFFF,11個F就是44個1,再加上7對應(yīng)的3個1,正好47位。那我知道這個有什么用?在看機(jī)器代碼的時候,如果你看到地址是0x00007FFFFXXXXXX打頭的,那么你就知道這個地址多半就是程序棧所在的地方。

內(nèi)存還有一個很有意思的設(shè)定,那就是棧是完全從上到下連貫的,但是堆的內(nèi)存分配不是連續(xù)的。假設(shè)我初始化兩個指針 p1 p2

int *p1 = malloc(1<<20);
int *p2 = malloc(4);

很明顯,p1的空間賊大,但是p2就很小,只能存一個int,那么x86會把偏大的內(nèi)存p1給放在很高的地方(貼近棧),p2放在很低的地方(貼近Data)。如果你再申請內(nèi)存,新生成的內(nèi)存p3會在它們中間的空白挑個地方落草。如果你一直申請內(nèi)存而且不釋放,最后內(nèi)存都被用完了,malloc失敗,會malloc(0)。

為什么?寫書的教授也不知道,規(guī)定。


緩沖區(qū)溢出怎么攻擊

C語言不設(shè)置邊界檢查,最容易導(dǎo)致的就是緩沖區(qū)溢出被攻擊。

我們看到下面這個代碼有個長度為4的char型數(shù)組,但是在棧上,并不會因為這里的長度是4,就只分配4個字節(jié)。

int main(void)
{
    char buf[4];
    gets(buf);
    puts(buf);
}

事實上這里分配了30個字節(jié),而且buf對應(yīng)的空間在30個字節(jié)的最低端(我也不知道為什么是最低端,公開課上的ppt就是這樣,也沒解釋),我是在我的電腦的匯編代碼中看到了這句話:
401554: 48 83 ec 30 sub $0x30,%rsp
結(jié)尾還有一句對應(yīng):
40157a: 48 83 c4 30 add $0x30,%rsp

很明顯這是初始化主函數(shù)的時候,給分配了30個字節(jié)的空間,而且,我在上一篇博客,第三章上里面說了,當(dāng)PC讀到call時,它會把返回地址push壓棧進(jìn)入程序棧,再進(jìn)入被調(diào)用函數(shù),在讀到ret的時候,會把這個地址pop出來,然后把這個地址的值放在%rip里面,這就是PC要運行的下一條指令。

什么意思呢?就是 返回地址那8個字節(jié),緊緊挨著下面被分配的30個字節(jié)。


注入攻擊

如果我們不止在gets環(huán)節(jié)中輸入4個,而是輸入30個以上,那么就會填充到之前被push的 返回地址,返回地址被破壞,如果我們是黑客,那就可以用輸入把返回地址刻意改成了我們想要的地方,然后在那個地方加入一些code(咋加的我也不懂),就可以運行我們想要的代碼。

我自己認(rèn)為,想完成這個操作,黑客必須知道源碼或者要猜出來,輸入多少個字節(jié)后能改變返回地址。


返回攻擊

這一段我也沒太聽懂,我理解的是,把一段代碼,我們叫做gadget,和return連接起來,還是利用PC讀到return的原理,如果我們可以把gadget想辦法插入到程序棧的末尾(別管怎么插進(jìn)去的,反正就插進(jìn)去了),那么通過讓程序一直讀取到惡意的return,從而pop出黑客設(shè)計好的 gadget 返回地址,以達(dá)到執(zhí)行特定命令的效果,等做完了Lab再詳細(xì)解釋。


防御機(jī)制

有三種方法,基本上現(xiàn)在可以防住絕大多數(shù)緩沖區(qū)攻擊。

棧隨機(jī)

正常的棧開始是從47位頂端開始下降的,但是這樣子如果別人有你的源代碼,那么就能精確的算出來,你的return的返回地址將會出現(xiàn)在哪個對應(yīng)的地址,并且每次運行這個地址都是固定的。那只要通過注入攻擊就可以精確修改return地址。

所以編譯器可以,每次在最高內(nèi)存地址的地方上,隨機(jī)減個一到兩兆,每次都是不一樣的,自然就沒辦法那么準(zhǔn)確的找到return 返回地址了。

限制可執(zhí)行代碼區(qū)域

硬件上的優(yōu)化,將內(nèi)存標(biāo)記成三種形式:可寫,可讀,可執(zhí)行。也就是黑客放進(jìn)來的代碼可能處在不可執(zhí)行區(qū),或者有的地方無法修改,只是可讀,就能讓攻擊沒那么容易。

棧破壞檢測(金絲雀canary)

煤礦地下可能有瓦斯,金絲雀對這玩意特別敏感,如果它不叫了或者死了,工人就可以逃命了。

同樣的原理,回到上面棧開辟30字節(jié)的例子,編譯器可能隨機(jī)在棧的備用區(qū)域中選8個字節(jié)作為canary(意思就是這個canary在30個字節(jié)里選但不影響buf),然后它會檢查這個值有沒有被改變,不管是注入還是返回攻擊,都會惡意地改變canary的值,那么編譯器會認(rèn)定這個代碼有問題,會強(qiáng)制終止程序的執(zhí)行。

這個是最強(qiáng)的方法,幾乎可以杜絕這種攻擊。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容