跟我一起学Windows界面封装(四) 之 窗口过程函数(下) -- 奇妙的Thunk技术

      本文是笔者参考多方资料,同时研究ATL源码后写的一些心得,因为在看源码时会出现很多点不懂,因此文章主要从这些小点出发而撰写,可能外人看来会有些语无伦次或者不够流畅,请见谅,不是计算机科班出身,才疏学浅,整理出来也只是希望将我所学能够和大家一起交流,其中也不免会出现理解错误,也请指正!

Thunk技术

         在窗口的过程函数中参数HWND和具体的窗口实例是一对一的关系,但HWND对我们来说又是不能直接用,所以就会想,要是能够将HWND变成this那该多好啊,毕竟有了this再获取HWND也不难,ATL的Thunk技术就是这么考虑的。该技术就是通过嵌入一段机器码,并在进入窗口过程函数StartWndProc之前执行,进而完成偷天换日将HWND换成this,这样就能达到我们的目的了。这个想法真的很大胆,实现起来更奇妙,下面就进入这个奇妙的世界。

ATL的思路是,每次在系统调用WndProc 的时候,让它鬼使神差地先走到我们的另一处代码,让我们有机会修改堆栈中的 hWnd。

调用约定__stdcall以及函数调用过程

    为了将HWND替换成this,我们必须了解函数的压栈规则,这样我们才能知道该怎么偷天换日。窗口过程函数的调用规则是CALLBACK,也就是__stdcall.该调用约定是将函数的参数从右向左依次压入堆栈。还是看一下反汇编吧:

void _stdcall StdFunc(int a, int b, int c)
{
	std::cout<

在函数调用的时候反汇编后代码为:

 StdFunc(1,2,3);

0139161E  push        3 

01391620  push        2 

01391622  push        1 

01391624  call        StdFunc (13911EAh) 


即我们将main函数里面的代码替换成:

__asm{
		push 3;
		push 2;
		push 1;
		call StdFunc;
	}

来执行会得到一样的结果。

我们知道call指令会先将程序寄存器IP先压入堆栈,然后再JMP到响应函数的地址去执行函数。我们在看看上面的地址13911EAh会执行什么指令:

StdFunc:

013911EA jmp         StdFunc (1391500h)

而1391500h地址就是StdFunc函数的起始位置。

void _stdcall StdFunc(int a, int b, int c)
{
01391500  push        ebp  
01391501  mov         ebp,esp  

从这我们可以理解了函数的调用过程:

1.      从右向左push参数,如上面的先c,再b,最后a的顺序;

2.      Call:函数(内部实现为puship; jmp 到代码空间寻址);

3.      Jmp:跳转函数

4.      函数调用时就保存各种寄存器,然后取出参数执行。

5.      函数执行完毕后执行ret回到调用位置。

 

经过这种分析,我们可以想象,当有窗口消息时,系统会将hwnd、msg、wparam和lparam参数值依次压入堆栈,然后执行指令,func的地址也就是我们在wndclass中传入的窗口函数地址。最后通过该地址就可以jmp到具体的代码空间去执行了。

如此一来,我们的入手点只能在call指令对应的func这个地址值了,因为我们只能控制它。假如我们将该地址值设置到我们的自定义的指令处执行,然后替换HWND,最后再继续步骤3跳转到过程函数这不就实现我们的目的了嘛。

没错,atl就是这么干的!其过程可以描述如下:

Push lparam
..
Push hwnd		--- esp+4个字节,即esp+0x04
Push eip			--- esp寄存器指向栈顶元素(假如你不信的话就看下面的小实验)
Call 0x112345678(0x112345678是我们偷天换日的地方)
					Address:0x112345678
					   mov dword ptr [esp+4] this  // 将this移到堆栈的第二个位置处
                       jmp wndproc
wndproc:
… ...

找到机器码

ATL的thunk技术就是在完成函数跳转之前执行如下的汇编代码:

__asm
{
    mov dword ptr [esp+4],pThis  ;调用 WndProc 时,堆栈结构为:RetAddr, hWnd, message, wParam, lParam, ...故 [esp+4]
    jmp WndProc
}

因为我们没法直接插入汇编代码,所以我们只能将一段内存模拟成代码段并写入对应的机器码来实现了。因此需要知道这里面的指令了:mov dword ptr [esp+4]、jmp所对应的机器码。

先弄一个小程序探测下这两行语句的机器码:

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    return 0;
}
int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow)
{
    MessageBox(NULL, NULL, NULL, MB_OK);
    __asm
    {
        mov dword ptr [esp+4], 1
        jmp WndProc
    }
    return 0;
}

最前面的 MessageBox 是为了等下调试的时候容易找到进入点。

然后使用 OllyDbg,在 MessageBoxW 上设置断点,执行到该函数返回:


    这里我们看到,mov dword ptr [esp+4]的机器码为 C7 44 24 04,后面紧接着的一个 DWORD mov的第二个操作数。jmp的机器码是 e9,后面紧接着的一个 DWORD是跳转的相对地址。其中 00061000h - 0006102Bh = FFFFFFD5h。如此前期的各种调研工作完成了,下面就用这些东西来写出我们的代码吧。


实现Thunk

1)  先定义我们的Thunk:

#pragma pack(push,1)
struct XThunk
{
	DWORD m_move;		//mov dword ptr[esp+0x4]的机器码
	DWORD m_this;		//mov的参数this
	BYTE m_jmp;			//jmp的机器码
	DWORD m_relProc;	//jmp的参数:相对地址

	XThunk() : m_move(0x042444c7), m_jmp(0xe9)
	{
	}
	void Init(DWORD pThis, DWORD pWndProc)
	{
		m_move = 0x042444c7;
		m_jmp = 0xe9;
		m_this = pThis;
		m_relProc = pWndProc - ((DWORD)this + sizeof(XThunk));
	}
};
#pragma pack(pop)

注意:用#progma pack(push,1)来告诉编译器不要进行字节对齐,即将对齐字节设为1.

1)  XMoudle:

其中定义记录this的变量:void* m_pCurWnd;因为我们是单线程的,this指针也只会用一次就可以清除了,所以没必要在定义map来记录所有this和hwnd,从这点看效率上肯定比之前的方法高很多。

2)  XWindowImpl:

其中需要定义一个thunk成员:XThunk* m_pThunk;

由于保存Thunk的是一段机器指令,我们肯定不能以普通的分配方式来分配一块内存了,这里必须需要用 VirtualAlloc 来申请一段有执行权限的内存空间存放机器指令。

HWND Create(LPCTSTR szTitle, RECT* pRect = nullptr)
	{
		T::GetXWndClassInfo().Register();

		RECT rc;
		if (pRect == nullptr)
			rc = rcDefault;
		else
			rc = *pRect;
		m_pThunk = (XThunk *)VirtualAlloc(NULL, sizeof(XThunk), MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
		_XModule.m_pCurWnd = (void*)this;

		HWND hWnd = ::CreateWindow(T::GetXWndClassName(), szTitle, WS_OVERLAPPEDWINDOW, 
			rc.left, rc.top, rc.right-rc.left, rc.bottom-rc.top,
			nullptr, nullptr, _XModule.GetInstance(), nullptr);

		ATLASSUME(m_hWnd == hWnd);

		return m_hWnd;
	} 

4)  窗口过程函数


static LRESULT CALLBACK StartXWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
	{
		ATLASSERT(_XModule.m_pCurWnd != nullptr);

		XWindowImpl* pThis = (XWindowImpl*)_XModule.m_pCurWnd;


		WNDPROC pWndProc = (WNDPROC)pThis->m_pThunk;
		pThis->m_pThunk->Init((DWORD)pThis, (DWORD)&XWindowImpl::RealXWndProc);

		pThis->m_hWnd = hWnd;
;
		SetWindowLong(hWnd, GWL_WNDPROC, (LONG)pWndProc);
		return pWndProc(hWnd, uMsg, wParam, lParam);
	}

	static LRESULT CALLBACK RealXWndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
	{
		XWindowImpl* pThis = (XWindowImpl*)hWnd;
		return pThis->ProcessXWndMessage(uMsg, wParam, lParam);
	}
public:
	LRESULT ProcessXWndMessage(UINT uMsg, WPARAM wParam, LPARAM lParam)
	{
		switch(uMsg)
		{
		case WM_CLOSE:
			this->DestroyWindow();
			break;
		case WM_DESTROY:
			PostQuitMessage(0);
			break;
		default:
			return ::DefWindowProc(this->m_hWnd, uMsg, wParam, lParam);
		}
		return 0;
	}

这里会看到我们定义了两个static函数:StartXWndProc、RealXWndProc。从这两个名字我们可以看出;前者是入口,后者才是真正的窗口过程函数。因为我们只能在第一条消息来的时候知道HWND,这时必须要有个临时的全局函数(StartXWndProc);然后再该函数中我们将HWND绑定的窗口过程函数地址指向我们的thunk,这样以后所有的消息均会进入我们真正的过程函数RealXWndProc。同时在RealXWndProc中参数HWND已经被我们修改成了对应的this指针,这时候就是见证奇迹的时候了,直接强制转换并调用成员函数ProcessXWndMessage来处理消息。

  其实过程可以描述为,我们把HWND对应的窗口过程函数地址改成了具体实例的this->m_hthunk的地址处,每次有消息时均会执行到这,然后再进入实际的RealWndProc之前均会执行thunk的机器码将hwnd替换成this,即完成了我们想要的偷天换日效果。


      Ok,至此thunk技术就完全暴漏在我们的面前了。有兴趣的可以在去看atl源码,相信你会很容易理解的。本文也是笔者自己在不断尝试和摸索中理解的,其中不免有些理解错误,还有讲述不当或并清楚的地方还望指出。

下面就进入我们的下一个议题吧 –封装控件。


你可能感兴趣的:(Windows开发,C/C++)