从逆向角度看C++
1.2.1
- 虚函数地址表(虚表)
- 定义:当类中定义有虚函数时,编译器会把该类中所有虚函数的首地址保存在一张地址表中,即虚函数地址表。
- 虚表信息在编译后被链接到执行文件中,因此所获得的虚表地址是一个固定的地址。
- 虚表中虚函数的地址排列顺序依据虚函数在类中的声明顺序而定。
虚表指针
- 同时编译器还会在类的每个对象添加一个隐藏数据成员,称为虚表指针,保存着虚表的首地址,用于记录和查找虚函数。
- 虚表指针的初始化是通过编译器在构造函数中插入代码实现的。由于必须初始化虚表指针,编译器会提供默认的构造函数。
虚函数调用过程
- 虚表间接寻址访问:
使用对象的指针或引用调用虚函数。根据对象的首地址,取出相应的虚表指针,在虚表查找对应的虚函数的首地址,并调用执行。 - 直接调用访问:
使用对象调用虚函数,和调用普通成员函数一样。 - 虚函数的识别:
- 类中隐式定义一个数据成员
- 数据成员在首地址处,占4字节
- 构造函数初始化该数据成员为某个数组的首地址
- 地址属于数据区,相对固定的地址
- 数组的成员是函数指针
- 函数被调用方式是thiscall
- 构造函数与析构函数都会将虚表指针设置为当前对象所属类中的虚表地址。
- 构造函数中是完成虚表指针的初始化,此时虚表指针并没有指向虚表函数。
- 执行析构函数时,其对象的虚表指针已经指向某个虚表首地址。虚函数是在还原虚表指针,让其指向自身的虚表首地址,防止在析构函数中调用虚函数时取到非自身虚表
内存中虚表结构图
在 VC2008 中编译如下代码并调试
#include "stdafx.h"
#include <stdio.h>
#include <windows.h>
#include <iostream>
using namespace std;class base_class {
private:int m_base;
public:virtual void v_func1() {cout << "This is base_class's v_func1()" << endl;//printf("This is base_class's v_func1()");}virtual void v_func2() {cout << "This is base_class's v_func2()" << endl;//printf("This is base_class's v_func2()");}virtual void v_func3() {cout << "This is base_class's v_func3()" << endl;//printf("This is base_class's v_func3()");}
};int main(int argc, char* argv[]) {base_class MyClass;base_class v_func1();//MyClass v_func1();base_class v_func2();//MyClass v_func2();base_class v_func3();//MyClass v_func3();return 0;
}
找到程序入口和main函数位置,进入main函数,进入call语句
可以看到虚表指针是通过offset来定义的,相当于是一个全局的地址,由编译器固定
如果继续向下执行,可以推测这句语句执行的效果是将0x00416804这个位置的内容(也就是虚表指针),放到ds:[0012FF70].
在数据窗口中跟随eax,这是之行前后改地址处发生的变化
我们知道,虚表指针指向虚表首地址,在虚表中存放着地址信息(各虚函数的首地址)
在数据区选中指针,右键-在数据窗口中跟随DWORD,找到了我们刚刚定义的几个函数
在反汇编窗口跟随前三个字段表示的地址,
进入v_func1进行查看,可以看到就是实现一个打印的功能
1.2.2
从反汇编侧方位看C++虚函数表
#include "stdafx.h"
#include <windows.h>
#include <stdio.h>
#include <iostream>using namespace std;class employee {
public:employee() { printf("employee()!\n");}~employee() { printf("~employee()!\n");}
};class manager : public employee
{
public:manager() { printf("manager()!\n");}~manager() { printf("~maneger()!\n");}
};int main(int argc, char* argv[]) {manager My;getchar();return 0;
}
在VS2008编译出现如下错误,尝试将运行时库调成“多线程调试(/MTd)”:
error LNK2019: 无法解析的外部符号 \_\_malloc_dbg,该符号在函数 "void * \_\_cdecl operator new(unsigned int,struct std::_DebugHeapTag_t const &,char *,int)"
在OllyICE的mian函数中第一个call指令会获取类的首指针的地址,也就是我们定义的manager类
可以发现指针所指位置的代码对应的就是初始化构造函数的内容。在解析完类之后、程序退出后,会用指针的方式调用析构函数把现在类初始化、堆栈、结构、指针的一些临时信息清空然后释放,让系统去回收这一部分内存。
跟进到该构造函数中,发现ECX指向地址被填充为CC,这是因为它其中没有其他一些成员的函数。
下面第一个call会获取指向其父类也就是employee类的指针,它也是一个构造函数,会根据可访问的内容(public)进行初始化。
从反汇编角度看this指针
再来看第二段代码
#include "stdafx.h"
#include <windows.h>
#include <stdio.h>struct MyStruct {int x ;int y ;
};//函数在结构体外部
void Max(MyStruct* str) {if (str->x > str->y)printf("%d",str->x);elseprintf("%d",str->y);
}int main(int argc, TCHAR* argv[]) {MyStruct haha ;haha.x = 1 ;haha.y = 2 ;Max(&haha);printf("%d\n",sizeof(haha));return 0;
}
可以找到给结构体的两个变量赋值的语句,执行后在内存中跟随到1和2已经被写入。
或者直接在Command:dd eax+4
接下来程序将1和2作为参数压入,并在下面第一个call指令调用Max函数进行比较。
1.2.3
#include "stdafx.h"
#include <windows.h>
#include <stdio.h>class MyTest {
public:MyTest();~MyTest();void SetTest(DWORD dwTest);DWORD GetTest();
public:DWORD m_dwTest;
};MyTest::MyTest() {printf("1111\r\n");}MyTest::~MyTest() {printf("2222\r\n");}
void MyTest::SetTest(DWORD dwTest) {this->m_dwTest = dwTest;
}
DWORD MyTest::GetTest() {return this->m_dwTest;
}int main(int argc, char* argv[]) {MyTest Test;Test.SetTest(1);int Number = Test.GetTest(); //添加了Set,Get方法,并调用getchar();return 0;
}
这里简单说一下OD断点运作的原理:通过触发软件断点使用CC
填充了这一段,使得本来可以正常执行的程序生成一条系统的SEH异常链,调试器通过捕捉改异常,中断主线程,使新线程执行到断点位置
MyTest Test;
首先运行到MyTest Test
,它会调用printf打印1111。
Test.SetTest(1);
接着是,Test.SetTest(1)
,这里采用硬编码的方法(直接push 1)传参,接着调用GetTest
不难看出,C++的一行指令在汇编中需要四行来完成,从逆向类的角度可以这么看
this->m_dwTest;
##################################
mov dword ptr ss:[ebp-0x8],ecx # i=this
mov eax,dword ptr ss:[ebp-0x8]
mov ecx,dword ptr ss:[ebp+0x8]
mov dword ptr ds:[eax],ecx
执行这四句之前,0x0012FE68处内容为初始化的CC
执行第一句,把this指针的首地址压栈
第二句把该地址赋给eax
第三句,把第一个参数(在ebp+8处)给到ecx
第四句,把该参数写到指针所指向的数据区中去(也就是写到类对象的那个局部变量m_dwTest里去)
int Number = Test.GetTest();
再接着是int Number = Test.GetTest()
,作用是取出类对象中刚刚被赋值的局部变量
查看汇编,一共是三句
int Number = Test.GetTest();
##################################
mov dword ptr ss:[ebp-0x8],ecx
mov eax,dword ptr ss:[ebp-0x8]
mov eax,dword ptr ds:[eax]
执行第一句,将ecx的值作为一个地址指针压栈
执行第二句,将该指针赋给eax
执行第三句,将数据区中eax指向的内容保存到eax,作为函数结果进行返回
跳出GetTest函数可以看到,程序紧接着使用该返回值进行了写回(选中右键-数据窗口跟随-内存地址)
1.2.4 弹出Radmin的对话框
安装Radmin:Radmin是一款远控软件,区别于木马的是,它作用与局域网,也就是说它只能连接静态ip,不具备反弹连接(上线之后主动请求客户端)的功能。
双击受控机口会弹出一个对话框,目的是安全性配置,否则任何人都可以连接我们的被控端,从而对非授限的主机进行操作。
在这一部分我们的目标是:使用远程线程的代码来代替用户的手动输入。
打开x32_dbg,点击文件-附加-双击打开,程序暂停在主线程的代码部分;或者文件-打开程序,F9运行,即可找到程序的入口。
在这里我们选择附加
该弹窗是用mfc写的,我们可以使用一些工具抓到它的句柄。从Spy4Win可以看出,这里使用的是mfc的win32界面,上面有很多控件,绑定着一个标识ID。
现在的窗口设计是独占式线程,也就是说不进行输入主线程会一直暂停直到子线程结束。我们如果要对此进行修改,意味着而我们必须生成一个新线程,于是想到CreateThread。
使用快捷键Ctrl+G找到构造新线程的部分
注意看,在0x7C8106F6处有一个retn 18,这不是返回值,而是Windows下API调用的一个约定,目的是由内部函数来平衡堆栈。
在IDAx86中打开kernel32.dll,在导出表中查找函数CreateThread并跳转,可见内容与x32_dbg中一模一样
对该部分内容F5反编译为伪代码
HANDLE __stdcall CreateThread(LPSECURITY_ATTRIBUTES lpThreadAttributes, DWORD dwStackSize, LPTHREAD_START_ROUTINE lpStartAddress, LPVOID lpParameter, DWORD dwCreationFlags, LPDWORD lpThreadId)
{return CreateRemoteThread((HANDLE)0xFFFFFFFF,lpThreadAttributes,dwStackSize,lpStartAddress,lpParameter,dwCreationFlags,lpThreadId);
}
第一个参数lpThreadAttributes
是线程的安全属性,用于避免回调函数崩溃;
第二个参数dwStackSize
表示堆栈大小,指的不是CreateThread函数的堆栈大小,而是回调函数里局部堆栈的大小;
第三个参数lpStartAddress
,设置回调函数的指针;
第四个参数lpParameter
,用于传参(强转成指针,什么都可以传);
第五个参数dwCreationFlags
相当于一个flag标志(CreateThread调用CreateRemoteThreadEx_0再调用NTCreateThread,flag保证一致性);
第六个参数lpThreadId
是分配的线程ID。
这就解释了为什么最后要加一个retn 18,因为一共6个参数,每个参数占用4个字节(在内存中以4B对齐),十进制的24就是是十六进制的18,从而做到现场还原。
现在我们在CreateThread的第一句下一个断点,并再次双击受控机图标,发现程序确实被我们断下来了,这说明我们确实找对了位置。
我们现在通过单步+栈回溯的方法找到CreateThread的调用者(或者Alt+F9)
设置断点让程序运行至此
再往外层单步走三层,程序运行到一个较关键位置
通过jnz和jmp可以初步判断这里是一个分支语句
mov esi,dword ptr ss:[esp+83F4]
push eax
push edi
add ebp,3458
push ebp
push esi
call radmin.143F8E0 # 这里调用的CreateThread
add esp,10
test eax,eax
也就是说上面这一部分就是负责弹窗的汇编代码。
那么接下来我们在这段代码的开头设一个断点,重新运行程序,使之中断在该位置。
在内存窗口中跟随地址[esp+83F4],下图错误,应是ESI的值(指针)000B0BF2(指向的内存地址)
经历一系列压栈后,EAX值为00009C49(功能号),EDI值为008C5A74,EBP值为00128B94,ESI值为000B0BF2
跟随EDI指向内存内容如下,这里其实是我们需要连接的被控端的结构体,用于存放配置信息。可以看到存在User等字样,再往下拉可以看到有我们的要连接的IP信息127.0.0.1
知道了调用前的状态,接下来我们就可以伪造寄存器了。这里我们使用Code Injector远程注入汇编代码
pushad
mov esi,0
mov eax,0x9C49
mov edi,0x008C5A74
push eax
push edi
mov ebp, 0x00128B94
push ebp
push esi
mov eax,0x143F8E0
call eax
add esp, 10
popad
选择要注入的目标程序
F9运行,成功调用弹窗