Thunk技术QA
1
Q:那么什么是Thunk技术呢?
A:这个问题其实问的不是很好,可以这么问,Thunk技术可以解决什么问题呢?Thunk技术就是解决在执行期改变CPU所执行的机器指令这个问题。
2
Q:怎么可能,一段程序不就是机器指令集嘛,怎么可能在运行时候改变机器指令执行顺序?
A:这里的改变是相对传统意义上CPU所执行的机器指令顺序来说的。具体来说,一般情况下,程序在运行时,机器指令是存在内存中的code segment里的,然后读到CPU中(缓存中),由EIP寄存器存储当前(将要)执行的机器指令,交由CPU执行。但是Thunk允许你将机器指令存储在data segment里,经过一些小技巧,这些机器指令最后能被CPU执行。
3
Q:看上去很Hacking的样子,应该很难吧?
A:Thunk技术本身并不复杂,但涉及到的知识点比较多,如果想搞的比较清楚,最需要的是耐心和刨根问底的态度。不过如果你耐心看完这篇QA,应该会有所了解: )
4
Q:那到底需要哪些背景知识呢?
A:
1. C/C++语言的知识,包括
1.1类/结构体基本知识
1.2函数指针
1.3回调函数
2. 汇编的知识,包括
2.1基本汇编指令
2.2Calling conventions
2.3对一段简单代码反汇编的阅读能力
5
Q:好吧,不得不说很多概念只是听过,不是很扎实,有的学过,现在忘的差不多了。。。
A:那很好啊,知识串烧起来学不仅量有保证,而且由于知识点彼此间有联系,对知识的理解与记忆也会加强,正好趁此机会学习一下吧。
1. C/C++语言的知识,包括
1.1类/结构体基本知识
1.1.1 编译器在编译类的成员函数时会在其参数列表里增加一个以该类为类型的参数,并在所有调用的地方将this作为参数传入
1.1.2当一个类所处的继承体系中没有虚函数时,其对象的地址也是其第一个成员变量的地址
1.2函数指针
1.2.1 typedef void (pFunc*) (int)定义了一种新的函数指针类型,该类型可以指向一个返回值为void,接受一个int类型参数的函数的入口地址,如 void func(int)
1.3回调函数
1.3.1回调函数首先是一个函数,其特点是一般我们将回调函数入口地址作为参数传入某一个希望调用回调函数的函数(Caller) ,然后在这个函数里调用回调函数,回调函数和函数指针的关系很密切,它们的关系是Caller函数的参数列表中一定有个形参是符合回调函数函数原型的函数指针。
2 汇编的知识(以32位机器为例)
2.1基本汇编指令
2.1.1寄存器
ESP:栈顶指针寄存器,总是存着当前栈顶内存的地址
EBP:栈基指针寄存器,总是存着当前栈基内存的地址
ESP,EBP所指的栈一般来讲都是一个特定函数的所用栈的栈顶和栈基,也就是说在程序执行的过程中,但凡遇到函数调用,它们俩就会双双变化,指向新的函数的栈顶和栈基,当函数调用结束会,它们会再一次发生变化,重新指向调用者栈的栈顶和栈基。当然这里涉及一个问题:如何在子函数调用结束后,ESP,EBP可以重新指向之前调用子函数的主函数栈的栈顶和栈基。
EIP:指令寄存器,总是存着将要执行的机器指令
2.1.2汇编指令
Push X:先ESP = ESP – 4再把X推进ESP所指栈上内存
Pop X:先把ESP所指栈上内存所存数据取出来放入X中,再ESP = ESP + 4
Jmp X:将EIP寄存器的值修改为(EIP原先的值+X),作用是让CPU执行的下一条指令更改为存在于地址为(EIP原先的值+X)的内存中存储的指令,这里X是一个相对量
Call X:先把Call后紧接着的指令(CS:EIP)Push进栈,再执行Jmp X指令
2.2Calling Conventions
2.2.1stdcall和cdecl
一个plain c function一般遵循stdcall或者cdecl或者fastcall(这里就不讲了),windows visual家族的编译器与linux下的gcc对这两者规范都支持。两者的特点是
stdcall:参数从右至左传入/callee清理函数栈/函数名字由下划线作为开始以“@参数列表长度”作为结束,如_myFunc@8
cdecall:参数从右至左传入/caller清理函数栈/函数名字由下划线作为开始
2.2.2thiscall
C++成员函数采用的是thiscall规范,其其特点是:
它的第一个参数传this至EAX寄存器/剩下的处理方式和stdcall一样/函数命名比较复杂,因为C++支持重载
2.3对一段简单代码反汇编的阅读能力
#include "stdafx.h"
#include<iostream>
using namespace std;
void free(int a){
int b = 1;
cout<<b<<endl;
}
int _tmain(int argc, _TCHAR* argv[])
{
free(1);
return 0;
}
------------------以下是上面程序在vs2008下反汇编的结果,有删减-------------------------------
0EA1127h jmp free (0EC13C0h)
void free(int a){
00EA13C0 push ebp
00EA13C1 mov ebp,esp
00EA13C3 sub esp,0CCh
00EA13C9 push ebx
00EA13CA push esi
00EA13CB push edi
00EA13CC lea edi,[ebp-0CCh]
00EA13D2 mov ecx,33h
00EA13D7 mov eax,0CCCCCCCCh
00EA13DC rep stos dword ptr es:[edi]
int b = 1;
00EA13DE mov dword ptr [b],1
}
00EA13E5 pop edi
00EA13E6 pop esi
00EA13E7 pop ebx
00EA13E8 mov esp,ebp
00EA13EA pop ebp
00EA13EB ret
int _tmain(int argc, _TCHAR* argv[])
{
00EA1450 push ebp
00EA1451 mov ebp,esp
00EA1453 sub esp,0C0h
00EA1459 push ebx
00EA145A push esi
00EA145B push edi
00EA145C lea edi,[ebp-0C0h]
00EA1462 mov ecx,30h
00EA1467 mov eax,0CCCCCCCCh
00EA146C rep stos dword ptr es:[edi]
free(1);
00EA146E push 1
00EA1470 call free (0EA1127h)
00EA1475 add esp,4
return 0;
00EA1478 xor eax,eax
}
00EA147A pop edi
00EA147B pop esi
00EA147C pop ebx
00EA147D add esp,0C0h
00EA1483 cmp ebp,esp
00EA1485 call @ILT+340(__RTC_CheckEsp) (0EA1159h)
00EA148A mov esp,ebp
00EA148C pop ebp
00EA148D ret
------------------反汇编结束----------------------------------------------------------------
我们先看一下free函数调用前后(以 call指令为基准)的汇编指令
free(1);
00EA146E push 1 调用前我们把唯一一个参数压栈
00EA1470 call free (0EA1127h) 调用函数,马上就要跳到其它地址了,不能顺序执行了 T T
00EA1475 add esp,4 调用后清理栈,这里是调用者在清理,这是…cdecall方式
再来看下free函数调用时的汇编指令
void free(int a){
00EA13C0 push ebp 把ebp寄存器所存内容压栈
00EA13C1 mov ebp,esp 让ebp现在保存现在esp寄存器所存内容,即push ebp后的栈顶指针
00EA13C3 sub esp,0CCh 让esp向上指向新的栈顶,0CCh是预留空间
00EA13C9 push ebx
00EA13CA push esi
00EA13CB push edi
00EA13CC lea edi,[ebp-0CCh]
00EA13D2 mov ecx,33h
00EA13D7 mov eax,0CCCCCCCCh
00EA13DC rep stos dword ptr es:[edi]
int b = 1;
00EA13DE mov dword ptr [b],1 给b复制,这里[b] == [esp + 4],vc 6.0就是这样显示的
}
00EA13E5 pop edi
00EA13E6 pop esi
00EA13E7 pop ebx
00EA13E8 mov esp,ebp 让esp重新指向push ebp后的esp,因为ebp正保存着它呢
00EA13EA pop ebp 因为这时esp所指内存空间里存的就是ebp,所以ebp的值得以恢复
00EA13EB ret 让EIP指向当前esp寄存器中保存的地址,调转到free(1)之后那条指令,
最后看和Thunk关系最紧密的call指令
00EA1470 call free (0EA1127h) 首先要跳到0EA1127h
0EA1127h jmp free (0EC13C0h) 0EA1127h还是一条jmp指令,跳到free的入口地址上
6
Q:基础知识我都懂了,Thunk技术具体怎么实现呢?
A:先考虑如下场景:
现有某第三方库提供接口如下
typedef void (*CB)(int a);
void thirdpart (CB func,int a);
你这边有一个类A,其中f函数是为Interface中的参数“量身定制的”。
class A
{
public:
void f(int a);
};
问题还是出现了,由于成员函数的prototype中存在隐式的参数this,所以当你写下
thirdpart (&A::f,1);时编译器还是无情的报错了。怎么办?第三方库显然是改不了的,自己这边也不好改,怎么办?嗯,是时候使用Thunk技术了。
#include "stdafx.h"
#include "wtypes.h"
#include <iostream>
using namespace std;
typedef void (_stdcall *CB)(int a);
void thirdpart(CB func,int a)
{
func(a);
};
template<typename dst_type,typename src_type>
dst_type pointer_cast(src_type src) {
return *static_cast<dst_type*>( static_cast<void*>(&src) );
}
#pragma pack(push,1)
typedef struct thunk
{
BYTE ins_move_this_to_ecx;
DWORD this_A;
BYTE ins_jmp;
DWORD addr;
void init(DWORD proc,void* pthis)
{
ins_move_this_to_ecx = 0xb9;
this_A = (DWORD)pthis;
addr = (DWORD)proc - ((DWORD)this + sizeof(struct thunk));
ins_jmp = 0xe9;
//FlushInstructionCache(GetCurrentProcess(),this,sizeof(THUNK));
}
}THUNK;
#pragma pack(pop)
class A
{
public:
A():x(10){}
thunk t;
CB c;
int x;
void f(int a)
{
printf("im in f:%d %d\n",a,x);
}
void start()
{
t.init(pointer_cast<int>(&A::f),this);
c = (CB)&t;
c(1);
}
};
int _tmain(int argc, _TCHAR* argv[])
{
A a;
a.start();
thirdpart(CB(&a.t),1);
system("pause");
return 0;
}
先解释下这里一些语句
1
#pragma pack(push,1)
struct x
{
…
}
#pragma pack(pop)
是希望编译器把x结构体中的数据按照1字节的方式对齐。
#pragma pack(push,1)做了两件事,先把当前对齐方式压栈(32位下一般是4字节),然后修改对齐方式为1
#pragma pack(pop)将先前对齐方式出栈,就是恢复到4字节的对齐方式,为什么要恢复?因为我们只希望编译器对特定的结构体进行这种操作,对于其余的我们希望用默认操作
2
DWORD == unsigned long
BYTE == unsigned char
这段代码的核心是THUNK结构体,
typedef struct thunk
{
BYTE ins_move_this_to_ecx;
DWORD this_A;
BYTE ins_jmp;
DWORD addr;
void init(DWORD proc,void* pthis)
{
ins_move_this_to_ecx = 0xb9;
this_A = (DWORD)pthis;
ins_jmp = 0xe9;
addr = (DWORD)proc - ((DWORD)this + sizeof(struct thunk));
//FlushInstructionCache(GetCurrentProcess(),this,sizeof(THUNK));
}
}THUNK;
结构体里面存有2条机器指令,因为每条机器指令分为操作符合操作数2部分,所以2条机器指令用4个变量来表示。值得注意的是操作符只需要用BYTE来存储就够了,而操作数因为涉及地址,需要用DWORD来存储。
当编译器看到如下两条语句后,就会把他们会变成(已简化)
c = (CB)&t;
c(1);
push 1
call c
c:mov ecx,this
jmp addr
为什么会这样呢?首先c里面存的是THUNK结构体的入口地址,即里面存的第一条指令的地址,然后我们把c转化成一个函数指针,类型是CB。这时,当编译器看到c(1)这句时,它完全认为这只是一个函数调用,所以它把这句汇编成:
push 1
call c
倘若c是一个实实在在的函数指针,这时会跳转到该函数的入口处,即该函数的第一条指令处,假如c是像我们这样指的是一个结构体的入口地址,而结构体里面存的又是机器指令,情况会怎样?其实和第一种情况一样的,这时CPU已经准备开始执行结构体里的这个“变量”了。这就是Thunk技术的核心原理了。
整理一下思路,为了让机器在某一个时候执行我们写下的机器指令:
1
我们定义了一个结构体,在结构体里面我们可以用2个变量来准确描述一条机器指令,于是我们便有了机器指令集
2
把该结构体地址赋给一个函数指针
3
用该函数指针假装函数调用,实际上是“欺骗”编译器执行我们的结构体里面的指令
掌握原理后,下面要做的就是在不同的场合合理利用这个技术了。回到之前提出的三方库的问题,thirdpart(&A::f,1);已经不行了,但是thirdpart(CB(&a.t),1);却是可以的,只要THUNK对象t里面含有跳转到f的指令即可(我们不是已经这样做了么?)
好了,至此已经把Thunk技术介绍的差不多了,犹如一个拼图游戏,我已经把大部分piece找好了放在你的面前,并且拼了大概,剩下的一些piece还有把它完整拼出来的活就交给你了(比如这句怎么理解:addr = (DWORD)proc - ((DWORD)this + sizeof(struct thunk)); 又比如typedef void (_stdcall *CB)(int a);这里为什么是__stdcall而不是__thiscall,我们不是在调用成员函数吗?)。
Have fun!