ATL Thunk機制深入分析

findumars發表於2019-03-21

如果你有SDK的程式設計經驗,就一定應該知道在建立視窗時需要指定視窗類,視窗類中的一種重要的引數就是視窗過程。任何視窗接收到的訊息,都是由該視窗過程來處理。

在物件導向程式設計中,如果還需要開發人員來使用原始的視窗過程這種程式導向的開發方式,物件導向就顯得不那麼純粹了。所以,在介面程式設計的框架中,框架往往會隱藏視窗過程,開發人員看到的都是一個個的類。

如果要處理某一個訊息,則需要在視窗對應的類中加入響應的message map即可。

那麼,框架是如何將視窗過程跟視窗對應的類關聯起來呢? ATL中用的是一個叫thunk的機制。由於我們收回來的dump有大量的視窗過程出問題的case,最後發現跟thunk有一定的關係,所以我對ATL的thunk做了 一番研究。

Thunk的基本原理是分配一段記憶體,然後將視窗過程設定為這段記憶體。這段記憶體的作用是將視窗過程的第一個引數(視窗控制程式碼)替換成類的This指標,並jump到類的WinProc函式中。這樣就完成了視窗過程到類的成員函式的一個轉換。

 

這裡面有幾個點需要重點研究一下:

  1. 什麼時候分配thunk這段記憶體,又在什麼時候將視窗過程設定為thunk的這段記憶體。
  2. 記憶體是怎麼分配的,是一段堆上的記憶體嗎?
  3. 這段記憶體到底是什麼東西?

 

我們先來看看第一個問題:

什麼時候分配thunk這段記憶體,又在什麼時候將視窗過程設定為thunk的這段記憶體。

ATL在建立視窗時,使用的視窗類是通過一段巨集來定義的:DECLARE_WND_CLASS(_T("My Window Class"))

這段巨集的定義如下:

複製程式碼
#define DECLARE_WND_CLASS(WndClassName) \
static ATL::CWndClassInfo& GetWndClassInfo() \
{ \
static ATL::CWndClassInfo wc = \
{ \
{ sizeof(WNDCLASSEX), CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS, StartWindowProc, \
0, 0, NULL, NULL, NULL, (HBRUSH)(COLOR_WINDOW + 1), NULL, WndClassName, NULL }, \
NULL, NULL, IDC_ARROW, TRUE, 0, _T("") \
}; \
return wc; \
}
複製程式碼

 

 

可以看到,這個巨集實際上是在定義一個靜態函式,他的功能是返回一個ATL::CWndClassInfo物件,這個物件實際上就是ATL對視窗類的封裝,其中視窗過程被指定為StartWindowProc。

當使用者呼叫CWindowImpl::Create來建立視窗時,會呼叫到CWindowImpl的父類CWindowImplBaseT的create函式:

這個函式如下:

複製程式碼
template <class TBase, class TWinTraits>
HWND CWindowImplBaseT< TBase, TWinTraits >::Create(HWND hWndParent, _U_RECT rect, LPCTSTR szWindowName,
DWORD dwStyle, DWORD dwExStyle, _U_MENUorID MenuOrID, ATOM atom, LPVOID lpCreateParam)
{
。。。。

// Allocate the thunk structure here, where we can fail gracefully.
result = m_thunk.Init(NULL,NULL);
      .......
HWND hWnd = ::CreateWindowEx(dwExStyle, MAKEINTATOM(atom), szWindowName,
dwStyle, rect.m_lpRect->left, rect.m_lpRect->top, rect.m_lpRect->right - rect.m_lpRect->left,
rect.m_lpRect->bottom - rect.m_lpRect->top, hWndParent, MenuOrID.m_hMenu,
_AtlBaseModule.GetModuleInstance(), lpCreateParam);
.............
return hWnd;
}
複製程式碼


 

可以看到,我們首先對thunk用NULL進行了初始化,正如註釋所說的,這這裡初始化是因為如果分配記憶體失敗了,可以更好的進行錯誤處理。實際上thunk也完全可以在後面處理視窗的第一個訊息時進行初始化。

接著呼叫windows API CreateWindowEx來建立視窗,前面我們知道,這個視窗的使用的視窗類的視窗過程是StartWindowProc,我們接著看它的處理程式碼。

 

複製程式碼
template <class TBase, class TWinTraits>
LRESULT CALLBACK CWindowImplBaseT< TBase, TWinTraits >::StartWindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CWindowImplBaseT< TBase, TWinTraits >* pThis = (CWindowImplBaseT< TBase, TWinTraits >*)_AtlWinModule.ExtractCreateWndData();
pThis->m_hWnd = hWnd;

// Initialize the thunk. This is allocated in CWindowImplBaseT::Create,
// so failure is unexpected here.
pThis->m_thunk.Init(pThis->GetWindowProc(), pThis);
WNDPROC pProc = pThis->m_thunk.GetWNDPROC();
WNDPROC pOldProc = (WNDPROC)::SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)pProc);
return pProc(hWnd, uMsg, wParam, lParam);
}
複製程式碼

 

這是類的一個靜態函式,所以可以作為視窗過程直接使用。我們可以看到,他的引數正是視窗過程的四個引數。

函式首先拿到This指標,將傳遞進來的視窗控制程式碼賦給this的成員變數m_hWnd。然後m_thunk.Init來重新初始化thunk,這個時候傳遞進去的不再是兩個NULL,而是類的一個成員函式和This指標。

這個初始化具體做什麼我們後面再具體分析。

初始化完成之後,我們就可以拿到這個thunk的地址(m_thunk.GetWNDPROC就是獲取thunk的地址),然後呼叫SetWindowLongPtr將視窗過程設定成thunk的地址。下次視窗有訊息來時就直接跑到thunk裡面去了

最後直接呼叫thunk的地址,是為了將StartWindowProc正在處理的這個訊息也傳遞給thunk處理,以免丟失了視窗的第一個訊息。

 

這段記憶體到底是什麼東西?

我們下面來重點分析m_thunk.Init是完成什麼功能。

CDynamicStdCallThunk.Init的程式碼如下:

複製程式碼
         BOOL Init(DWORD_PTR proc, void *pThis)
{
if (pThunk == NULL)
{
pThunk = new _stdcallthunk;
if (pThunk == NULL)
{
return FALSE;
}
}
return pThunk->Init(proc, pThis);
}
複製程式碼

 

程式碼很簡單,分配一段結構(_stdcallthunk),然後繼續呼叫這個結構的init函式。(提前說一下的是,這裡override了new這個operator,真正做的事情不是簡單的從堆上分配記憶體,後面會詳細介紹)

結構

複製程式碼
struct _stdcallthunk
{
DWORD m_mov; // mov dword ptr [esp+0x4], pThis (esp+0x4 is hWnd)
DWORD m_this; //
BYTE m_jmp; // jmp WndProc
DWORD m_relproc; // relative jmp
BOOL Init(DWORD_PTR proc, void* pThis)
{
m_mov = 0x042444C7; //C7 44 24 0C
m_this = PtrToUlong(pThis);
m_jmp = 0xe9;
m_relproc = DWORD((INT_PTR)proc - ((INT_PTR)this+sizeof(_stdcallthunk)));
// write block from data cache and
// flush from instruction cache
FlushInstructionCache(GetCurrentProcess(), this, sizeof(_stdcallthunk));
return TRUE;
}
。。。。
複製程式碼

 

Thunk有四個成員,第一個成員是m_mov,被賦值為0x042444C7,第二個成員是m_this,被賦值為視窗對應類的地址。

這兩個DWORD實際上組成了一條彙編語句:

mov dword ptr [esp+0x4], pThis

通過前面我們知道,視窗過程已經被設定成這段記憶體的起始地址,也就是說視窗過程的第一行程式碼就是這行程式碼。

Esp是指向棧頂的指標,esp+0x4則是視窗過程的第一個引數hWnd,這段程式碼的意思就是說將this指標覆蓋掉視窗過程的第一個引數hWnd。

我們知道,類成員函式的第一個引數都是this指標,有了this指標,類成員函式就可以呼叫了。

下面的事情就是準備jump到成員函式中:m_jmp賦值為0xe9,一個相對跳轉指令,m_relproc被賦值為相對成員函式相對thunk的地址,這兩個成員變數也組成了一條彙編語句:

jmp WndProc

回到前面看看傳遞進來的成員函式的原型:

pThis->m_thunk.Init(pThis->GetWindowProc(), pThis);

 

GetWindowProc實際上就是返回成員函式WindowProc,原型如下:

template <class TBase, class TWinTraits>

LRESULT CALLBACK CWindowImplBaseT< TBase, TWinTraits >::WindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);

可以看到,成員函式只有三個引數,視窗過程的第一個引數被修改成了this指標,所以達到了巧妙將視窗過程修改成類的成員函式的目的。

Thunk的基本原理到這裡已經結束了,還剩下一個問題就是前面提到的對new的override,這就牽涉到關於ATL thunk的最後一個問題:

 

記憶體是怎麼分配的,是一段堆上的記憶體嗎?

為什麼要對new進行override?因為windows xp sp2之後,為了對付層出不窮的緩衝區溢位攻擊,windows推出了一個新的feature叫Data execution prevention。如果這個feature被啟用,那麼堆上和棧上的資料是不可以執行的,如果thunk是位於new出來的程式碼,那麼一執行就會crash。

為了解決這個問題,ATL override了new和delete運算子。

Override後的new最終會呼叫到函式__AllocStdCallThunk_cmn:

 

複製程式碼
PVOID __AllocStdCallThunk_cmn ( VOID )
{
PATL_THUNK_ENTRY lastThunkEntry;
PATL_THUNK_ENTRY thunkEntry;
PVOID thunkPage;

if (__AtlThunkPool == NULL) {
if (__InitializeThunkPool() == FALSE) {
}
}

if (ATLTHUNK_USE_HEAP()) {
// On a non-NX capable platform, use the standard heap.
thunkEntry = (PATL_THUNK_ENTRY)HeapAlloc(GetProcessHeap(), 0, sizeof(ATL::_stdcallthunk));
return thunkEntry;
}
thunkPage = (PATL_THUNK_ENTRY)VirtualAlloc(NULL, PAGE_SIZE, MEM_COMMIT,PAGE_EXECUTE_READWRITE);

// Create an array of thunk structures on the page and insert all but
// the last into the free thunk list.

// The last is kept out of the list and represents the thunk allocation.
thunkEntry = (PATL_THUNK_ENTRY)thunkPage;
lastThunkEntry = thunkEntry + ATL_THUNKS_PER_PAGE - 1;
do {
__AtlInterlockedPushEntrySList(__AtlThunkPool,&thunkEntry->SListEntry);
thunkEntry += 1;
} while (thunkEntry < lastThunkEntry);

return thunkEntry;
}
複製程式碼

 

函式首先判斷是不是第一次被呼叫,如果第一次被呼叫,則呼叫__InitializeThunkPool來進行初始化(後面會詳細介紹他)。初始化主要是用來判斷Data execution prevention功能是否啟用了。

如果沒有啟用,則簡單多了,直接呼叫HeapAlloc來分配記憶體。

如果啟用了則複雜多了。ATL會呼叫VirtualAlloc來分配一段PAGE_EXECUTE_READWRITE屬性的記憶體,這段記憶體是可以被執行的,為了節省記憶體,將這段記憶體分成很多塊,每一塊大小就是一個thunk的大小。

然後將這些塊壓入到一個list當中,需要的時候則從中取出,釋放的時候又將塊壓入到list中。

 

由於即使只建立一個視窗也需要分配一個頁面的大小,如果這個程式中有多個dll,每個dll都建立一個ATL的視窗,那麼就會佔用到很多頁面空間,浪費記憶體。為了節省記憶體的使用,windows在程式的一個重要結構PEB偏移0x34的地方加入了一個域:

0:007> dt ntdll!_PEB

   +0x000 InheritedAddressSpace : UChar

   +0x001 ReadImageFileExecOptions : UChar

   。。。。。

   +0x030 SystemReserved   : [1] Uint4B

   +0x034 AtlThunkSListPtr32 : Uint4B

   +0x038 ApiSetMap        : Ptr32 Void

。。。。。。

在前面的初始化函式__InitializeThunkPool中,會嘗試從這個位置獲取Thunk的list的head,如果發現是空的,才會呼叫VirtualAlloc來建立新的頁面:

 

複製程式碼
BOOL static DECLSPEC_NOINLINE __InitializeThunkPool ( VOID )
{
#define PEB_POINTER_OFFSET 0x34

PSLIST_HEADER *atlThunkPoolPtr;
PSLIST_HEADER atlThunkPool;

result = IsProcessorFeaturePresent( 12 /*PF_NX_ENABLED*/ );
if (result == FALSE) {
// NX execution is not happening on this machine.
// Indicate that the regular heap should be used by setting
// __AtlThunkPool to a special value.
__AtlThunkPool = ATLTHUNK_USE_HEAP_VALUE;
return TRUE;
}

atlThunkPoolPtr = (PSLIST_HEADER *)((PCHAR)(Atl_NtCurrentTeb()->ProcessEnvironmentBlock) + PEB_POINTER_OFFSET);
atlThunkPool = *atlThunkPoolPtr;
__AtlThunkPool = atlThunkPool;
return TRUE;
}
複製程式碼

 

https://www.cnblogs.com/georgepei/archive/2012/03/30/2425472.html

相關文章