遞迴和Windows系統的棧保護
Windows系統上建立執行緒可以使用CreateThread() API,這個API的原型是:
HANDLE WINAPI CreateThread(
__in LPSECURITY_ATTRIBUTES lpThreadAttributes,
__in SIZE_T dwStackSize,
__in LPTHREAD_START_ROUTINE lpStartAddress,
__in LPVOID lpParameter,
__in DWORD dwCreationFlags,
__out LPDWORD lpThreadId
);
第二個引數就是指定新執行緒棧空間的大小,如果這個引數輸入0,則Windows給執行緒指定一個預設值,這個預設值的大小是1M位元組(這個數字來自MSDN文件)。關於dwStackSize引數有個很有意思的細節,後面會介紹到。對於使用預設棧空間大小的執行緒來說,呼叫算法系列文章第7篇提到的遞迴版本的IsEvenNumber()函式時,當n的值大於10000時就會導致棧溢位。在Windows系統上棧溢位會導致執行緒的意外終止,這種執行緒的意外終止通常都會導致整個軟體無法正常工作。如果在遞迴計算的過程中能夠提前預知到這種情況的堆疊溢位並終止後續的遞迴運算,對提高程式的安全性和健壯性都很有幫助,本話題就討論了一種能夠應用與Windows
檢測的方法很簡單,就是在遞迴演算法的下一次巢狀呼叫之前,判斷一下執行緒當前棧地址與執行緒棧空間邊界的差值,當差值小於事先指定的安全值時就設定出錯標誌,並終止進一步的巢狀呼叫,使已經進行過的遞迴呼叫安全地“回溯”到演算法起始位置。設定安全值的意義在於當溢位即將發生時,需要做一些特殊處理,這些特殊處理可能涉及一些函式呼叫(包括作業系統的API),因此需要預留一些棧空間來保證這些操作正常進行。要對函式遞迴呼叫巢狀太深導致的執行緒堆疊溢位進行檢測,必須要知道兩個屬性,一個是執行緒當前棧指標,另一個是執行緒棧空間的邊界。執行緒棧空間的邊界與棧的增長方向有關,Windows系統的執行緒堆疊是從高地址方向向低地址方向增長的,因此棧邊界就是執行緒棧基址與執行緒棧空間大小的差值。
Windows提供了API GetThreadContext()用於獲取執行緒某一時刻的上下文資訊(對於64位的應用程式,對應的API是Wow64GetThreadContext()),其中暫存器資訊部分包括ESP暫存器的值,這個API的原型是:
BOOL WINAPI GetThreadContext(
__in HANDLE hThread,
__in_out LPCONTEXT lpContext
);
使用GetThreadContext()獲取執行緒當前棧指標的程式碼如下:
CONTEXT thCtx;
HANDLE hThread = ::GetCurrentThread();
thCtx.ContextFlags = CONTEXT_FULL;
/*函式呼叫點 A*/
if(::GetThreadContext(hThread, &thCtx))
{
// using thCtx.Esp;
}
這段程式碼存在一個問題,就是thCtx.Esp的值實際上是執行緒執行在GetThreadContext()函式內部某個位置時的棧指標,並不是“函式呼叫點 A”處的棧指標。通過多次對比實驗,我們發現thCtx.Esp的值和“函式呼叫點 A”處實際的ESP值存在一個固定的差值(thCtx.Esp的值比“函式呼叫點 A”處實際的ESP值小),通過補償這個差值,可以比較準確的得到執行緒執行到某位位置時的棧指標。
如果編譯器支援嵌入式彙編程式碼,則可以直接通過ESP暫存器獲取執行緒當前位置的棧指標,對於微軟的編譯器,可以這樣做:
DWORD stack = 0;
__asm
{
mov eax, esp
mov stack, eax
}
相對於執行緒棧指標來說,獲取執行緒棧基址和棧空間邊界是個比較麻煩的事情,因為沒有API可以直接獲取這些值,因此只能用到一些所謂的未公開的文件中提到的方法。在介紹這些方法之前首先要介紹一個未公開的資料結構:TEB(Thread Environment block)。TEB是記錄執行緒資訊的一個重要的資料結構,系統為每個執行緒建立一個對應的TEB結構儲存執行緒相關的資訊,根據未公開的文件介紹,在Ring 3層次上的TEB偏移 0x04位置就是執行緒的棧基址,偏移0x08位置就是執行緒棧空間的下限(Windows系統的棧是向低地址方向增長的)。有了這個資訊,剩下的事情就是找到執行緒的TEB在記憶體中的地址。這就需要另一個重要的,但是很少有人關注的資訊,那就是FS段選擇器永遠指向當前執行緒的TEB結構,其中0x18偏移位置就是TEB在記憶體中的映象地址。有了這個映象記憶體地址,就可以通過+0x04偏移得到執行緒棧基址,+0x08偏移得到執行緒棧空間邊界。下面就是獲取這兩個值的封裝函式,用了嵌入式彙編程式碼:
DWORD GetCurrentThreadStackBase()
{
DWORD stackBase = 0;
__asm
{
mov eax, fs:[18h] /*TEB*/
mov eax, [eax + 0x04]
mov stackBase, eax
}
return stackBase;
}
DWORD GetCurrentThreadStackLimit()
{
DWORD stackLimit = 0;
__asm
{
mov eax, fs:[18h] /*TEB*/
mov eax, [eax + 0x08]
mov stackLimit, eax
}
return stackLimit;
}
以上的偏移位置都是基於Windows XP系統的,其他版本的Windows可能會有變化,但是都可以從網上查到,也可以通過除錯符號自己計算。至此,所有的準備功課都做完了,以上文提到的遞迴版本的IsEvenNumber()函式為例,可以這樣進行棧溢位預防:
bool IsEvenNumber(int n)
{
DWORD stack = GetCurrentThreadStack();
DWORD stackLimit = GetCurrentThreadStackLimit();
if(overSign == 1)
{
return false; /*出錯了,需要“回溯”*/
}
if((stack - stackLimit) < STACK_LIMIT_OPT)
{
/*可以在這裡安排設定錯誤標誌的程式碼*/
overSign = 1;
return false; /*強制返回,使得前面的遞迴呼叫安全地“回溯”*/
}
if(n >= 2)
return IsEvenNumber(n - 2);
else
{
if(n == 0)
return true;
else
return false;
}
}
STACK_LIMIT_OPT就是前面提到的那個安全值,這個值的大小需要根據出錯處理流程的差異進行調整。
前文提到,CreateThread() API函式的dwStackSize引數隱藏了一些很有意思的細節,這裡就說明一下。MSDN文件中提到,呼叫CreateThread() 函式建立執行緒時,如果dwStackSize引數傳0值,Windows給執行緒指定的棧空間大小是系統預設值,也就是1M位元組。但是實際上這1M位元組並不是立即保留給執行緒獨立使用的,而是首先預保留4K位元組,隨著執行緒的使用逐步增加。也就是說,執行緒TEB結構中的棧空間邊界並不是一開始就設定為“執行緒棧基址-1M位元組”後的值,而是“執行緒棧基址-4K位元組”後的值。通過除錯可以觀察到,隨著遞迴呼叫的進行,執行緒TEB結構中的棧空間邊界值不斷變化,直到最後達到“執行緒棧基址-1M位元組”為止。這是Windows系統為節省記憶體做的一種策略,使得同等條件下系統能夠支援建立更多的執行緒。如果呼叫CreateThread() 函式建立執行緒時,指定了dwStackSize的值會怎樣呢?結果就是Windows一下子為執行緒保留了dwStackSize指定大小的棧空間(會按照64k為單位對dwStackSize進行圓整),棧空間邊界的值初始化為“執行緒棧基址-dwStackSize”,並保持不變。
瞭解到這個細節之後,你就會發現IsEvenNumber()函式中所做的溢位判斷是不安全的,在dwStackSize引數使用了0值的情況下會失效,因為TEB結構中的執行緒棧空間邊界是個不斷變化的值。在這種情況下,通過執行緒棧基址結合棧空間大小進行判斷可能會更安全一點,這裡就不再贅述了,讀者可以使用本文提到的GetCurrentThreadStackBase()函式自行修改。