深入分析win32堆結構與管理策略
1. 深入分析win32堆結構與管理策略
1.1. 前言
1.1.1. 堆與棧的區別
1、棧(stack)由作業系統自動分配釋放,用於存放函式的引數值、區域性變數等,在程式編譯後就已經規定好如何使用,使用多少記憶體空間。棧總是成“線性”變化。棧向低地址空間增長。
2、堆(heap)由開發人員分配和釋放,若開發人員不釋放,程式結束時由OS回收,分配方式類似於連結串列。堆向高地址增長。
下圖是經典的32位系統記憶體佈局,暫時我們只需要記住棧和堆的增長方向即可,後面實驗部分會用到。
1.2. 理論篇
現代作業系統的堆資料結構一般包括堆塊
和堆表
兩類。
堆表
:堆表一般位於堆區的起始位置
,用於索引堆區中所有堆塊的重要資訊,包括堆塊的位置、堆塊的大小、空閒還是佔用等。堆表的資料結構決定了整個堆區的組織方式,是快速檢索空閒塊、保證堆分配效率的關鍵。堆表在設計時可能會考慮採用平衡二叉樹等高階資料結構,用於優化查詢效率。現代作業系統的堆表往往不止一種資料結構。
堆塊
:出於效能的考慮,堆區的記憶體按不同大小組織成塊,以堆塊為單位進行標識,而不是傳統的按位元組標識。一個堆塊包括兩個部分:塊首
和塊身
。塊首位於一個堆塊頭部的幾個位元組,用來標識這個堆塊自身的資訊,例如,本塊的大小、本塊空閒還是佔用等資訊;塊身是緊跟在塊首後面的部分,也是最終分配給使用者使用的資料區。
在windows中佔用態的堆塊被應用程式自身所索引,堆表只索引空閒態的堆塊。其中,堆表有兩個重要的資料結構:空閒雙向連結串列(freelist)
和快速單身連結串列(lookaside)
。
堆的記憶體組織如下圖:
1.2.1. 堆塊
根據堆塊是否被佔用分為佔用態
堆塊和空閒態
堆塊。
佔用態
堆塊的資料結構如下:
空閒態
堆塊的資料結構如下:
對比上面兩圖可知,空閒態堆塊和佔用態堆塊的塊首結構基本一致。相對於佔用態的堆塊來說,空閒態堆塊的塊首後8個位元組存放了兩個指標地址,分別指向前驅堆塊和後向堆塊。
1.2.2. 堆表
1.2.2.1. 空閒雙向連結串列(空表)
堆區一開始的堆表區中有一個128
項的指標陣列,被稱做空表索引(Freelist array)
。該陣列的每一項包括兩個指標,用雙向連結串列組織一條空表,如下圖。
空表索引
的第二項(free[1]
)連結了堆中所有大小為8位元組
的空閒堆塊,之後每個索引項鍊接的空閒堆塊大小遞增8位元組,例如,free[2]
連結大小為16位元組的空閒堆塊,free[3]
連結大小為24位元組的空閒堆塊,free[127]
標識大小為1016位元組的空閒堆塊。因此有:
空表索引
的第一項(free[0]
)所標識的空表相對比較特殊。這條雙向連結串列連結了所有大於等於1024位元組的堆塊(小於512KB)。這些堆塊按照各自的大小在零號空表中升序地依次排列下去.把空閒堆塊按照大小的不同鏈入不同的空表,可以方便堆管理系統高效檢索。
1.2.2.2. 快速單向表(快表)
快表是Windows用來加速堆塊分配而採用的一種堆表。這裡之所以把它叫做“快表”是因為這類單向連結串列中從來不會發生堆塊合併,快表也有128條,組織結構與空表類似,只是其中的堆塊按照單鏈表
組織。快表總是被初始化為空,而且每條快表最多隻有4個結點,故很快就會被填滿。
快表結構:
1.2.3. 堆塊分配
堆中的操作可以分為堆塊分配
、堆塊釋放
和堆塊合併
(三種。其中,"分配"和"釋放"是在程式提交申請執行的,而堆塊合併則是由堆管理系統自動完成的。
注意:堆管理系統所返回的指標一般指向
塊身
的起始位置,在程式中是感覺不到塊首
的存在的。然而,連續地進行記憶體申請時,可能會發現返回的記憶體之間存在“空隙”,那就是塊首!
堆塊分配可以分為三類:快表分配
、普通空表分配
和零號空表(free[0])
分配。
快表分配
:找到大小匹配的空閒堆塊、將其狀態修改為佔用態、把它從堆表中“卸下”、最後返回一個指向堆塊塊身的指標給程式使用;普通空表分配
: 首先尋找最優的空閒塊分配,若失敗,則尋找次優的空閒塊分配,即最小的能夠滿足要求的空閒塊;零號空表(free[0])
:先從 free[0]反向查詢最後一個塊(即表中最大塊),看能否滿足要求,如果能滿足要求,再正向搜尋最小能夠滿足要求的空閒堆塊進行分配
1.2.4. 堆塊釋放
釋放堆塊的操作包括將堆塊狀態改為空閒,鏈入相應的堆表。所有的釋放塊都鏈入堆表的末尾
,分配的時候也先從堆表末尾拿。
1.2.5. 堆塊合併
當堆管理系統發現兩個空閒堆塊彼此相鄰的時候,就會進行堆塊合併操作.
堆塊合併
將兩個塊從空閒連結串列中“卸下”、合併堆塊、調整合並後大塊的塊首資訊(如大小等)、將新塊重新鏈入空閒連結串列。
1.3. 實驗篇
1.3.1. 實驗環境
作業系統 | 編譯環境 | build 版本 |
---|---|---|
win xp sp3 | vc6.0 | release |
1.3.2. 實驗程式碼
#include <windows.h>
#include<stdio.h>
int main()
{
HLOCAL h1, h2, h3, h4, h5, h6;
HANDLE hp;
//LoadLibrary("ntdll.dll");
HANDLE hHeap = GetProcessHeap();
hp = HeapCreate(0, 0x1000, 0x10000);
//printf("%d", hp);
__asm int 3
h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 3);
h2 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 5);
h3 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 6);
h4 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 8);
h5 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 19);
h6 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);
//free block and prevent coaleses
HeapFree(hp, 0, h1); //free to freelist[2]
HeapFree(hp, 0, h3); //free to freelist[2]
HeapFree(hp, 0, h5); //free to freelist[4]
HeapFree(hp, 0, h4); //coalese h3,h4,h5,link the large block to
//freelist[8]
printf("%s","xxx");
return 0;
}
注意,程式裡包含一句int 3的中斷指令,這裡用來中斷程式的執行的,因為我們不能直接把程式載入到偵錯程式裡面進行除錯,否則堆管理函式會檢測到有偵錯程式存在會啟用除錯態堆管理策略,與常態的堆管理策略會存在些許差異:
- 除錯堆不使用快表,只用空表分配
- 所有堆塊都被加上了多餘的16位元組尾用來防止溢位(防止程式溢位而非堆溢位攻擊)
- 包括8個位元組的0xAB和8個位元組的0x00
- 塊首的標識位不同
1.3.3. 除錯
編譯成功後,我們直接雙擊執行,如下
此時,我們附加我們的及時偵錯程式。根據原始碼可知,中斷是發生在HeapCreate
函式執行完成後的,HeapCreate
執行後會返回堆地址,結果儲存在eax
中,我們在偵錯程式發現eax值是:0x003A0000
也就是說HeapCreate
建立的堆區起始位置在003A0000
.即堆表從此位置開始,堆表中依次為段表索引(Segment List)
、虛表索引(Virtual Allocation list)
、空表使用標識(freelist usage bitmap)
和空表索引區
。
此處我們只關心堆偏移0x178
處的空表索引區,這個偏移是堆表起始的位置(根據上次我們介紹的堆表結構,堆表包含128的8個位元組的flink
和blink
地址。所以堆表的結束位置在:128*8=1024=0x400,加上偏移,0x178+0x400=0x578)
加上堆基址0x003A0000
+0x178
=0x0x003A0178
,我們來到這個地址。
如圖,這個地址便是free[0],佔8個位元組,flink
和blink
指向的地址都是0x003a0688
。後面的依次是free[1]、free[2],依次類推,我們發現free[1]、free[2]...free[127]都指向自身,根據連結串列的特點可知,它們都是空連結串列。
所以當一個堆剛剛被初始化時,只包含一個空閒態的大塊,這個塊也叫為"尾塊" free[0]指向這個"尾塊"
我們轉到"尾塊"的位置去看看(因為這裡只有一個堆塊,即free[0]指向的地址,free[0]=0x003a0688
)
上面理論篇,我們講過,空閒態的堆塊有8個位元組的flink與blink,分別指向前驅節點與後繼節點,此處的值均為0x003a0178
,這個地址是堆表free[0]的地址,可知,實驗與理論相符。
實際上。在上面我們有提到,堆管理系統返回的堆地址是指向塊身
的。在其前面還有8個位元組的塊首
,所以這個堆塊起始於0x003a0680
, 根據上面談到的塊首的結構。前2個位元組為塊大小,此處值是130
, 堆的計算單位是8位元組,也就是980位元組。
注意:堆大小包含塊首在內。
1.3.3.1. 堆塊分配
在繼續之前, 我們需要先了解堆塊的分配細節,
-
堆塊的大小包括了塊首在內,即如果請求32位元組,實際會分配的堆塊為40位元組:8位元組塊首+32位元組塊身;
-
堆塊的單位是8位元組,不足8位元組的部分按8位元組分配;
-
初始狀態下,快表和空表都為空,不存在精確分配。請求將使用“次優塊”進行分配(這個“次優塊”就是位於偏移 0x0688 處的尾塊。)。
-
由於次優分配的發生,分配函式會陸續從尾塊中切走一些小塊,並修改尾塊塊首中的size 資訊,最後把 freelist[0]指向新的尾塊位置。
記憶體請求分配情況
堆控制代碼 | 請求位元組數 | 實際分配(堆單位) | 實際分配(位元組) |
---|---|---|---|
h1 | 3 | 2 | 16 |
h2 | 5 | 2 | 16 |
h3 | 6 | 2 | 16 |
h4 | 8 | 2 | 16 |
h5 | 19 | 4 | 32 |
h6 | 24 | 4 | 32 |
在偵錯程式中,我們單步走過第一個HeapAlloc
,然後觀察記憶體空間。
tips: 對於我們主動設定的int 3指令,如果偵錯程式忽略異常後仍無法步過的話,可以在下一行程式碼處右鍵,此處設為新的eip。
按上面的分析,執行完h1 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 3);
後,會從0x3a0680
地址開始切出一塊大小為2個單位(16位元組)的空間分配給h1, 新的尾塊起始地址則為0x003a0690
,flink與blink地址位於0x003a0698
和0x003a069c
,其值0x003a0178
指向freelist[0]
, freelist[0]
則指向新的起始地址0x003a0698
,(003a0690+8位元組的塊首,我們上面有提到過指向塊身。)
尾塊起始處,如下圖,如我們所預期的一樣
另外,尾塊的大小為12e, 等於原來的130減去分配出去的2個單位,還剩下0x13-2=0x12e個單位(堆的單位,不是位元組),如上圖,也可以驗證。
h1所指向的堆塊起始位置則是0x003a0680,如上圖可知,大小為2個單位
堆表freelist[0]處,如下圖,如我們所預期的一樣
接著,會執行h2 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 5);
按分配原則,會從尾塊中再切一塊大小為2個單位(16位元組)的空間給h2,然後freelist[0]指向新的尾塊起始地址,新的尾塊仍指向freelist[0],剩下的尾塊大小為12e-2=12c個單位。
剩下的依次類推,
當執行完h6 = HeapAlloc(hp, HEAP_ZERO_MEMORY, 24);
後,堆分配情況如下圖所示,
剩下的堆大小為 130-2-2-2-2-4-4=120
單位,尾塊扔指向freelist[0](0x003a0178
),我們去看下freelist[0]的值,此時應該指向,尾塊的塊首0x003a0708
,如下圖
到此,堆的分配則執行完了。根據上面的理論可知,堆表中仍只有一個尾塊,不存在其它的堆塊。
1.3.3.2. 堆塊釋放
根據堆塊的大小,h1,h3為16位元組,則會鏈入freelist[2], h5為32位元組,會鏈入到freelist[4]。
當執行HeapFree(hp, 0, h1)
後,根據h1的大小為16位元組,可知它會被鏈入到freelist[2]中,我們先看下freelist[2]的地址,如下圖,freelist[2]的地址是0x003a0188
,根據連結串列規則freelist[2]會指向h1的地址,h1則會指向freelist[2],
執行後,原來h1所指向的堆塊變為空閒態並指向freelist[2]。如下圖,flink與blink都指向freelist[2],因為此時只有連結串列中就一個節點,
freelist[2]則指向原來的h1地址,如下圖:
接著會釋放h3,執行HeapFree(hp, 0, h3)
,執行完後,h3所指向的堆塊會被鏈入到freelist[2],並插入到整個連結串列的末尾。如下圖所示,原來h3所在的堆塊的blink(地址0x003a06ac)指向前一個堆塊,即原來的h1,h3的flink則指向freelist[2],因為它是最後一個元素。原來的h1的blink指向freelist[2],flink指向h3.
freelist[2]如下圖所示
形成的連結串列大概如下,
freelist[2] <---> h1 <---> h3
注h3的flink與freelist[2]的blink未給出。
再下一步,執行HeapFree(hp, 0, h5);
,釋放h5所在的堆塊,並鏈入freelist[4]
1.3.3.3. 堆塊合併
因為h1,h3,h5記憶體地址不相鄰,所以並不會發生堆塊合併,當釋放h4後,堆管理系統會發生現h3,h4,h5相鄰,則會進行堆塊合併。
首先這 3 個空閒塊都將從空表中摘下,然後重新計算合併後新堆塊的大小,最後按照合併後的大小把新塊鏈入空表。
h3、h4 的大小都是2個堆單位(8位元組),h5是4個堆單位,合併後的新塊為8個堆單位,將被鏈入 freelist[8]。
合併之前freelist[8]如下圖,
合併之前h3,h4,h5如下圖,
合併之後freelist[8]如下圖,flink與blink都指向合併之後的地址
合併之後的堆塊如下圖,大小8個堆塊,flink與blink都指向freelist[8]
另外,我們看合併之後的freelist[4]已經指向自己,為空連結串列(圖中框選中的位置),freelis[2]中,只剩下原來的h1所在的一個堆塊了。
1.4. 總結
- 堆的資料結構:
堆塊
、堆表
- 堆塊:包含
塊首
、塊身
堆表
:空閒雙向連結串列(freelist)
、快速單向連結串列(lookaside)
- 佔用態的堆塊:
8位元組的塊首+塊身
- 空閒態的堆塊:
16位元組的塊首(多了flink與blink)+塊身
。空閒態的堆塊變為佔用態時,flink與blink所在的空間將變為data區。
在win中,佔用態的堆塊被使用它的程式所索引,而堆表只索引所有空閒的堆塊。
- 參考:《0day,軟體安全漏洞分析技術》
</stdio.h></windows.h>