分类: C/C++
2008-04-23 22:08:15
通用 Thunk
作者:
本文同时发表在 codeproject 网站:参见:
介绍
这篇文章提出了一种基于Thunk技术,让一个成员函数成为一个回调函数的通用方法。文章主要讨论原理,同时也提供了一份实现和示例。
背景
许多库需要我们提供一个函数作为回调,这使得使用 “面向对象编程”(OOP) 出现了麻烦。因为普通的C函数没有成员函数需要的this指针。Thunk技术是一种快速但是平台相关的解决此问题的方法。我最近研究过许多有关thunk技术的文章,我认为许多解决方案都是针对于特定问题的。我设计了一组类,来提供一种通用的解决方案。
环境
开发环境 : IA32,Windows Xp SP2,Visual Studio 2005
用法
源代码提供了5(实际上4)个类(全都在 Thunk 名字空间中)。它们的每一个对象都有2个属性,对象和方法。它们可以动态的创建一些机器码。执行这些机器码将在逻辑上和调用 Obj.Method(…); 举例来说,如果我们想要设计一个类来进行窗口子类化的工作,我们可以按下面5个步骤使用通用Thunk
class CSubClassing {
private:
Thunk::ThisToStd m_thunk;
//1.选择一个合适的Thunk类
// ThisToStd 类使一个使用__thiscall 约定的成员函数 (LRESULT SubProc(…) )
//成为一个使用_stdcall 约定的回调函数WNDPROC)
//2.实例化一个对象.
public:
CSubClassing() {
m_thunk.Attach(this);
//3.附加到想要回调的对象上
m_thunk.AttachMethod(&CSubClassing::SubProc);
// 4.附加成员函数
// to do
}
void Attach(HWND hWnd) {
m_oldProc = (WNDPROC)SetWindowLong(hWnd,GWL_PROC
,m_thunk.MakeCallback());
// 5.转化到回调函数指针
//SetWindowLong函数使用一个LONG值来表示WNDPROC
// to do
}
private:
//这个非静态成员函数将被Windows回调
LRESULT SubProc(HWND hWnd,UINT msg,WPARAM wParam,LPARAM lParam) {
if (msg!=WE_NEEDED)
return CallWndProc(m_oldProc,hWnd,msg,wParam,lParam);
// to do
}
WNDPROC m_oldProc;
}
这5个类(class)都有相同的界面和使用方式。一旦你依据成员函数与回调函数的调用约定选定好了一个Thunk类,就可以按照上面的步骤做一些有用的事情 :
如WNDPROC,THREADPROC,hooking,等等原理
原理中最重要的是函数的调用约定(Calling Convention) ,调用者和被调者之间的约定。普通C函数通常使用3种调用约定 : “__cdecl” “__stdcall” “__fastcall” 成员函数通常使用 “__thiscall””__stdcall” “__cdecl”
我们需要着重关注以下3点:
调用者准备的参数和返回地址总不是被调用者所期待的那样,因为被调用者还需要一个this指针。平衡堆栈的方式也许也会不同。我们的工作就是以被调用者期望的方式,准备好this指针,同时弥补2者在平衡堆栈上的差异。
为了简单起见,我们以 “ void func(int); void C::func(int); ”为例,首先,我们来看看当使用__stdcall
约定的func被调用的时候,会发生什么。
func(1212); 编译器会像这样准备参数和返回地址 :
PUSH 1212 ; 使得堆栈增加4
CALL func; 使得堆栈也增加4(因为返回地址也被压入堆栈)
0x50000:...;被调用者返回这里,我们假设这里的地址是0x50000
调用者希望被调用者使用 RET 4 (使得堆栈减少8:参数1212使用4,返回地址0x50000也使用4)来平衡堆栈,所以在这之后没有多余的机器码。所以,在这之后,堆栈是这个样子:
... 1212 0x50000 <- ESP
然后,我们来看看使用__thiscall 的被调用者所希望的参数和返回地址。一个真正的成员函数被调用时。
C obj; obj.func(1212);
编译器以这样的方式准备参数:
PUSH 1212; MOV ECX,obj; CALL C::func
所以,在这之后,堆栈是这个样子:
… 1212 0x50000 <- ESP
ECX 保存着 this 指针。
这也就是被调用者(void __thiscall C::func(int); ) 需要的形式。
第3,我们看看被调用者如何返回。
事实上,它使用 RET 4 来返回到0x50000
所以,我们唯一需要做的就是准备好this指针,然后跳转到成员函数。(不需要更多的工作,参数和返回值已在正确位置,堆栈也将被正确的平衡。)
设计 ThisToStd
在我们设计第1个,也是最简单的类 ThisToStd 之前,我们还需要3种信息。
1、我们需要一种得到函数地址的方法。
对于数据指针,我们可以转化(cast)它到一个 int 值
void *p = &someValue; int address = reinterpret_cast不同于数据指针,函数指针有更多的限制。(p); /* 如果检查对64位机的可移植性,将会得到一个警告。不过可以忽略它,因为这个thunk只用在32位机上^_^*/
void __stdcall fun(int) { … }
void C::fun(int) {}
//int address = (int)fun; // 不允许
//int address = (int)&C::fun; // 同样错误
有2种方法来进行一个强力的转化
template所以,我们可以实现一个方法dst_type pointer_cast(src_type src) { return *static_cast ( static_cast (&src) ); } template dst_type union_cast(src_type src) { union { src_type src; dst_type dst; } u = {src}; return u.dst; }
template更多详细信息见 ThunkBase.hint PointerToInt32(Pointer pointer) { return pointer_cast (pointer); // or union_cast (pointer); } int address = PointerToInt32(&fun); // 可以 int address = (int)&C::fun; // 也可以
0xFF000000 : 0xE9 0x33 0x55 0x77 0x99 0xFF000005 : ...
0xE9 是一个 JMP 指令,紧接着的4字节将被解释为偏移
offset = 0x99775533 (在Intel x86 机器上,低字节存储在低地址上) = -1720232653
源 (src) = 0xFF000000 (JMP指令的地址) = 4278190080
目的地 (dst) = src offset 5 (JMP占1字节,偏移占4字节) = 4278190080 – 1720232653 5 =
2557957432 = 0x98775538
所以在指令 “ JMP -1720232653 “ 之后,下一条被执行的指令将在
0x98775538 : ...
基于这点,我们可以实现2个方法:
void SetTransterDST(
void *src /* the address of transfer instruction*/
,int dst /* the destination*/ ) {
unsigned char *op = static_cast(src);
switch (*op ) {
case 0xE8: // CALL offset (dword)
case 0xE9: // JMP offset (dword)
{
int *offset = reinterpret(op);
*offset = dst – reinterpret(src) - sizeof(*op)*1 – sizeof(int);
}
break;
case 0xEB: // JMP offset (byte)
...
break;
case ...:
...
break;
default :
assert(!”not complete!”);
}
}
int GetTransnferDST(const void *src) {
const unsigned char *op = static_cast< const unsigned char *>(src);
switch (*op ) {
case 0xE8: //CALL offset (dword)
case 0xE9: //JMP offset (dword)
{
const int *offset = reinterpret_cast(op);
return *offset PointerToInt32(src) sizeof(*op) sizeof(int);
}
break;
case 0xEB: // JMP offset(byte)
...
break;
case ...:
...
break;
default:
assert(!”not complete!”);
break;
}
return 0;
}
更多详细信息 见 ThunkBase.cpp
3.栈的生长
在Win32平台下,栈朝着低地址生长。
也就是说,当栈增加N ESP就减少N,反之亦然。我们来设计这个类
class ThisToStd
{
public:
ThisToStd(const void *Obj = 0,int memFunc = 0);
const void *Attach(const void *newObj);
int Attach(int newMemFunc);
private:
#pragma pack( push , 1) // 强制编译器使用1字节长度对齐结构
unsigned char MOV_ECX;
const void *m_this;
unsigned char JMP;
const int m_memFunc;
#pragma pack( pop ) // 恢复对齐
};
ThisToStd:: ThisToStd(const void *Obj,int memFunc)
: MOV_ECX(0xB9),JMP(0xE9) {
Attach(Obj); // 设置this指针
Attach(memFunc); // 设置成员函数地址(使用偏移)
}
const void* ThisToStd::Attach(const void *newObj) {
const void *oldObj = m_this;
m_this = newObj;
return oldObj;
}
int ThisToStd::Attach(int newMemFunc) {
int oldMemFunc = GetTransferDST(&JMP);
SetTransferDST(&JMP,newMemFunc);
return oldMemFunc;
}
我们以如下方式使用这个类 :
typedef void ( __stdcall * fun1)(int);
class C { public : void __thiscall fun1(int){} };
C obj;
ThisToStd thunk;
thunk.Attach(&obj); // 假设 &obj = OBJ_ADD
int memFunc = PointerToInt32(&C::fun1); //假设memFunc = MF_ADD
thunk.Attach(memFunc); // thunk.m_memFunc 将被设置为MF_ADD – (&t.JMP)-5
fun1 fun = reinterpret_cast(&thunk); //假设 &thunk = T_ADD
fun(1212); // 与 obj.fun(1212) 有同样效果
它是如何工作的,当CPU执行到 fun(1212); 机器码如下:PUSH 1212; CALL DWORD PTR [fun]; 0x50000 : … ; 假设 RET_ADD = 0x50000 // CALL DOWRD PTR [fun] 与CALL(0xE8) offset(dword) 不同 //我们只需要知道: 它将RET_ADD压栈,然后跳转到T_ADD
执行完这2条指令后,栈是这个样子 :
… 1212 RET_ADD <- ESP
下一条被执行的指令,是在thunk 的地址处 (T_ADD)
thunk的第1字节是 “const unsigned char MOV_ECX” –被初始化为0xB9.
紧接着的4字节是 “const void *m_this”
在 thunk.Attach(&obj); 后,m_this = OBJ_ADD
这5字节组成一条合法的指令
T_ADD : MOV ECX,OBJ_ADD
thunk的第6字节是 “const unsigned char JMP” –被初始化为0xE9.
紧接着的4字节是 “const int m_memFunc”
将被 thunk.Attach(memFunc) 修改
这5字节又组成一条合法指令
T_ADD 5 : JMP offset
offset = MF_ADD - &thunk.JMP – 5 ( 由 thunk.Attach() 和SetTransferDST 设置)
所以,这条指令执行后,下一条被执行指令将在这里:
MF_ADD : …
现在,this指正已经准备好,(参数和返回地址也由fun(1212)准备好,而且 C::fun1 将会使用RET 4 返回到 RET_ADD,并正确的平衡堆栈。
所以,它成功了!
设计 StdToStd
让我们由以下3步分析:
1. 调用者如何准备参数和返回地址?
一般的说,一个使用__stdcall 的普通C函数会将参数从右向左依次压栈。我们假设它使得栈增长了 N。注意:N并不总等于参数数目×4!
CALL 指令将返回地址压栈,使得栈再增长4
参数 m <-ESP 4 N
参数 m-1
…
参数 1 <- ESP 4
返回地址 <- ESP
它将平衡堆栈的工作交给被调用者。(使用RET N)
2. 被调用者如何得到参数与返回地址?(它希望何种方式?)
一个和上述普通C函数具有相同参数列表,使用__stdcall的成员函数,希望参数,返回地址和this指针像这样准备 :
参数 m <- ESP 8 N
参数 m-1
…
参数 1 < -ESP 8
this < -ESP 4
返回地址 <-ESP
3. 被调用者如何返回?
它使用 RET N 4 返回。
所以我们的工作是在参数1和返回地址之间插入this指针,然后跳转到成员函数。
(我们插入了一个this指针使得栈增加了4,所以被调用者使用 RET N 4 是正确的)
在设计 StdToStd 之前,让我们定义一些有用的宏。
相信我,这将使得源代码更加容易阅读和改进。
MachineCodeMacro.h
#undef CONST
#undef CODE
#undef CODE_FIRST
#ifndef THUNK_MACHINE_CODE_IMPLEMENT
#define CONST const
#define CODE(type,name,value) type name;
#define CODE_FIRST(type,name,value) type name;
#else
#define CONST
#define CODE(type,name,value) ,name(value)
#define CODE_FIRST(type,name,value) :name(value)
#endif
ThunkBase.h
#include “MachineCodeMacro.h”
namespace Thunk {
typedef unsigned char byte;
typedef unsigend short word;
typedef int dword;
typedef const void* dword_ptr;
}
StdToStd.h
#include
#define STD_TO_STD_CODES() \
/* POP EAX */ \
CONST CODE_FIRST(byte,POP_EAX,0x58) \
/* PUSH m_this */ \
CONST CODE(byte,PUSH,0x68) \
CODE(dword_ptr,m_this,0) \
/* PUSH EAX */ \
CONST CODE(byte,PUSH_EAX,0x50) \
/* JMP m_memFunc(offset) */ \
CONST CODE(byte,JMP,0xE9) \
CONST CODE(dword,m_memFunc,0)
namespace Thunk {
class StdToStd {
public:
StdToStd(const void *Obj = 0,int memFunc = 0);
StdToStd(const StdToStd &src);
const void* Attach(const void *newObj);
int Attach(int newMemFunc);
private:
#pragma pack( push ,1 )
STD_TO_STD_CODES()
#pragma pack( pop )
};
StdToStd.cpp
#include
#define THUNK_MACHINE_CODE_IMPLEMENT
#include
namespace Thunk {
StdToStd::StdToStd(dword_ptr Obj,dword memFunc)
STD_TO_STD_CODES()
{
Attach(Obj);
Attach(memFunc);
}
StdToStd::StdToStd(const StdToStd &src)
STD_TO_STD_CODES()
{
Attach(src.m_this);
Attach( GetTransferDST(&src.JMP) );
}
dwrod_ptr StdToStd::Attach(dword_ptr newObj) {
dword_ptr oldObj = m_this;
m_this = newObj;
return oldObj;
}
dword StdToStd::Attach(dword newMemFunc) {
dword oldMemFunc = GetTransferDST(&JMP);
SetTransferDST(&JMP,newMemFunc);
return oldMemFunc;
}
}
宏 CONST CODE_FIRST(byte,POP_EAX,0x58) 在StdToStd.h 中,将被替换成: “const byte
POP_EAX;”因为参数数量×4不总是等于N,所以我们不能使用SUB ESP,N来设置ESP(比如参数列表含有double)
我们也不能修改返回地址,使它跨过“ADD ESP,N”的指令,因为这条指令并不总是紧接着CALL指令(调用caller 的CALL指令)
(比如 返回类型是double的情况)
一个可能的实现是在某个地方保存ESP,在被调用者返回后将它传送回ESP。
让我们来看看第1个实现:
ThisToCdecl 36.h #define __THIS_TO__CDECL_CODES() \ /* MOV DWORD PTR [old_esp],ESP */ \ CONST CODE_FIRST(word,MOV_ESP_TO,0x2589) \ CONST CODE(dword_ptr,pold_esp,&old_esp) \ \ /* POP ECX */ \ CONST CODE(byte,POP_ECX,0x59) \ \ /* MOV DWORD PTR [old_return],ECX */ \ CONST CODE(word,MOV_POLD_R,0x0D89) \ CONST CODE(dword_ptr,p_old_return,&old_return) \ \ /* MOV ECX,this */ \ CONST CODE(byte,MOV_ECX,0xB9) \ CODE(dword_ptr,m_this,0) \ \ /* CALL memFunc */ \ CONST CODE(byte,CALL,0xE8) \ CODE(dword,m_memFunc,0) \ \ /* MOV ESP,old_esp */ \ CONST CODE(byte,MOV_ESP,0xBC) \ CONST CODE(dword,old_esp,0) \ /* MOV DWORD PTR [ESP],old_retrun */ \ CONST CODE(word,MOV_P,0x04C7) \ CONST CODE(byte,_ESP,0x24) \ CONST CODE(dword,old_return,0) \ /* RET */ \ CONST CODE(byte,RET,0xC3)1、我们将ESP保存到old_esp中。
ThisToCdecl.h #define THIS_TO_CDECL_CODES() \ /* CALL Hook */ \ CONST CODE_FIRST(byte,CALL,0xE8) \ CONST CODE(dword,HOOK,0) \ \ /* this and member function */ \ CODE(dword,m_memFunc,0) \ CODE(dword_ptr,m_this,0) \ \ /* member function return here! */ \ /* MOV ESP,oldESP */ \ CONST CODE(byte,MOV_ESP,0xBC) \ CONST CODE(dword,oldESP,0) \ \ /* JMP oldRet */ \ CONST CODE(byte,JMP,0xE9) \ CONST CODE(dword,oldRet,0)这些机器码首先调用“Hook”函数,这个函数做如下工作:
void __declspec( naked ) ThisToCdecl::Hook() {
_asm {
POP EAX //1
// p=&m_memFunc; &m_this=p 4; &oldESP=p 9; &oldRet=p 14
// Save ESP
MOV DWORD PTR [EAX 9],ESP //3
ADD DWORD PTR [EAX 9],4 //4
// Save CallerReturn(by offset)
//src=&JMP=p 13,dst=CallerReturn,offset=CallerReturn-p-13-5
MOV ECX,DWORD PTR [ESP] //3
SUB ECX,EAX //2
SUB ECX,18 //3
MOV DWORD PTR [EAX 14],ECX //3
// Set CalleeReturn
MOV DWORD PTR [ESP],EAX //3
ADD DWORD PTR [ESP],8 //4
// Set m_this
MOV ECX,DWORD PTR [EAX 4] //3
// Jump to m_memFunc
JMP DWORD PTR[EAX ] //2
}
}
我们使用 CALL offset(dword) 跳转到Hook,这个指令会将返回地址压栈。所以,CALL HOOK之后,堆栈如下 :class CNeedCallback {
private:
CThunk m_thunk;
public:
CNeedCallback() :m_thunk(this,Thunk::Helper::PointerToInt32(&CNeedCallback::Callback)) {}
private:
returnType Callback(….) {}
}
所以,每个thunk对象的Obj和Method属性在构造后就不再改变。我不知道在这种情况下FlushInstructionCache是否有必要。如果你认为有,请在
ThunkBase.cpp中定义 THUNK_FLUSHINSTRUCTIONCACHE ,或者简单的去掉第4行注释。
特别感谢
Illidan_Ne 和Sean Ewington ^_^.