
下载、查壳\、64位\、IDA打开,按f5查看伪代码

代码分析:
是一道 C++ 面向对象 + 虚函数 入门逆向题,核心逻辑是:创建一个 Man 类对象,通过虚函数表调用第二个虚函数,flag 的输出逻辑就藏在这个被调用的虚函数里。
二、代码逐段解析
1. 变量与类结构
Man *v3;
Human *v4;
std::string name;
Human *m;
-
Human是基类,Man是继承自Human的派生类,两个类都包含虚函数。 -
name是 C++ 标准字符串对象,用来存储构造对象时传入的名称参数。std::string name;是 C++ 语言里的字符串对象定义语句: -
std:C++ 标准库的命名空间(standard),所有标准库组件都在这个命名空间下。 -
string:C++ 标准库提供的字符串类 ,是封装好的高级字符串类型,内部自动管理内存、支持拼接、比较、截取等各种操作,比 C 语言原生的char数组更易用。 -
name:变量名。
整句的意思就是:在栈上定义一个名为 name 的 C++ 标准字符串对象,初始状态是空字符串。
_main() 是 GCC/MinGW 编译器自动插入的运行时初始化函数 ,并不是开发者手写的业务代码。
它是 C++ 程序启动时的标准前置操作,作用是初始化 C++ 运行时环境,执行所有全局静态对象的构造函数,确保全局变量、全局类对象在 main 函数业务逻辑执行前都完成初始化。
2. 构造字符串与对象
std::string::string(&name, "NISACTF", &v7);
v3 = (Man *)operator new(0x18ui64);
Man::Man(v3, (std::string)&name, 4);
m = v3;
std::string::string(&name, "NISACTF", &v7);
这是 C++ 标准库内存分配器的构造语句 ,属于 std::string 字符串的配套底层实现代码,完全由编译器自动生成,不是开发者手写的业务逻辑。
逐部分拆解:
std::allocator<char>:C++ 标准库的内存分配器模板类 ,专门负责给char类型的元素申请、释放内存,是std::string底层用来管理字符串存储空间的工具。::allocator(&v7):这是分配器的构造函数,在栈上变量v7的位置,构造出一个char类型的分配器对象。
v3 = (Man *)operator new(0x18ui64);
这行是 C++ 对象创建的第一步:在堆上申请内存 ,对应开发者写的 new Man(...) 语句的底层拆分。
C++ 的 new 运算符在执行时会自动拆成两个步骤:① 申请足够大小的堆内存;② 在这块内存上调用类的构造函数完成初始化。IDA 反编译时会把这两个底层步骤分开显示,这行对应的就是第一步。
二、逐部分拆解
operator new
C++ 标准库提供的全局内存分配函数,功能和 C 语言的malloc类似,负责向系统申请指定字节数的堆内存,返回void*类型的通用指针。它只负责分配内存,不做对象初始化。0x18ui64
申请的内存大小,单位是字节:0x18是十六进制,等于十进制的 24 字节 ,这是Man类对象在内存中占用的总大小(包含虚函数表指针、所有成员变量、内存对齐填充)。ui64是unsigned __int64的缩写,表示这是 64 位无符号整数,对应 64 位程序的内存寻址。
(Man *)
强制类型转换。operator new返回的是无类型的void*指针,强转成Man*类指针后,才能用来调用构造函数、访问类成员。
Man::Man(v3, (std::string)&name, 4);
这是 Man 类的构造函数调用 ,对应 C++ 中 new Man(...) 的第二步:在已经申请好的堆内存上,执行类的构造函数,完成对象的初始化工作。
上一行 operator new 只负责申请空白内存,这一行负责把这块空白内存填充成一个完整的 Man 类对象。
二、逐部分拆解
Man::Man
C++ 构造函数的命名格式:类名::构造函数名。构造函数是类的特殊成员函数,名字和类名完全相同,专门负责对象创建时的初始化工作。- 第一个参数:
v3
这是 IDA 反编译的显式呈现,对应 C++ 的this指针 。
所有类的成员函数(包括构造函数),底层都会把对象的地址当成第一个隐藏参数自动传入。这里的v3就是上一行operator new申请到的堆内存首地址,构造函数会在这个地址上完成对象初始化。 - 第二个参数:
(std::string)&name
构造函数的第一个业务参数,内容是之前创建好的字符串"NISACTF"。
作用是初始化Man对象内部的字符串类型成员变量(比如对象的名字属性),(std::string)是 IDA 加上的类型强转标识。 - 第三个参数:
4
构造函数的第二个业务参数,是一个整型数值。
作用是初始化Man对象内部的整型成员变量(比如年龄、编号、等级这类属性)。
- 先创建一个内容为
NISACTF的字符串。 - 申请堆内存,调用
Man类的构造函数,传入两个参数:字符串NISACTF和数字4,完成对象初始化。 - 构造完成后,对象地址存入
m。
std::string::~string(&name);
3. 核心:虚函数调用
(*((void (__fastcall **)(Human *))m->_vptr_Human + 1))(m);
一、整体作用
这是 std::string 类的析构函数调用,和前面的字符串构造函数成对出现,负责字符串对象的销毁与资源释放,是 C++ 自动资源管理机制的标准体现,完全由编译器自动生成。
二、逐部分拆解
~string
C++ 中析构函数的固定命名格式:~波浪号是析构函数的专属标识,函数名与类名一致。它的作用和构造函数完全相反:构造函数负责创建、初始化对象,析构函数负责清理、销毁对象。&name
要销毁的目标字符串对象的地址,对应底层的this指针,告诉析构函数要清理哪一个字符串对象。
三、执行的核心操作
调用这个析构函数时,会自动释放 name 字符串内部申请的堆内存 ------ 也就是存放 "NISACTF" 实际内容的那块动态存储空间,把内存归还给系统,避免内存泄漏。
这是 C++ RAII(资源获取即初始化) 机制的典型体现:对象生命周期结束时,自动完成资源清理,不需要开发者手动调用 free。
std::allocator::~allocator(&v7);
这是 std::allocator<char> 内存分配器的析构函数调用,和代码开头的分配器构造函数严格成对出现,负责销毁分配器对象本身,属于 C++ 标准库的底层生命周期管理代码,完全由编译器自动生成。
二、逐部分拆解
~allocator
析构函数的标准命名格式:波浪号~+ 类名,作用与构造函数相反,用于在对象生命周期结束时执行清理工作。&v7
待销毁的分配器对象地址,对应底层的this指针,指定要销毁哪一个分配器实例,也就是代码开头构造的v7。
三、实际执行的操作
这里需要区分两个概念:分配器对象本身 和 分配器申请的内存。
- 这个析构函数只负责销毁
v7这个分配器工具对象本身。默认的标准分配器是无状态的,它的析构函数几乎不做任何实际操作,没有内存释放动作。 - 字符串实际内容占用的堆内存,已经在前面的
std::string::~string(&name)中,通过分配器的deallocate接口释放完成了,不需要在分配器析构时再处理。
简单理解:分配器是一个 "申请内存的工具",这行只是把这个工具本身销毁,真正的字符串内存已经在之前被工具回收好了。
(*((void (__fastcall **)(Human *))m->_vptr_Human + 1))(m);
一、整体作用
这是整段代码的核心业务逻辑 ,也是这道 C++ 逆向题的核心考点:通过对象的虚函数表指针,调用虚表中的第二个虚函数。flag 的生成或输出逻辑,就藏在这个被调用的虚函数内部。
二、逐层拆解语法(从内到外)
我们从最内层的表达式开始,一步步拆解这行复杂的指针运算。
1. m->_vptr_Human:取出虚函数表指针
- 含有虚函数的 C++ 对象,内存开头的 8 字节(64 位程序)是一个虚指针(vptr) ,它指向一张虚函数表(vtable)。
- 虚函数表本质是一个「函数指针数组」,数组里按顺序存放着该类所有虚函数的入口地址。
_vptr_Human就是Human类对应的虚指针,这里Man继承自Human,因此对象里沿用了这个虚指针,实际指向Man类自己的虚函数表。
2. (void (__fastcall **)(Human *)):强制类型转换
这是一段函数指针类型声明,作用是告诉编译器:这块内存要当成「函数指针数组的指针」来处理。
__fastcall:调用约定,64 位程序下默认使用,第一个参数通过rcx寄存器传递,这里正好对应 C++ 成员函数的this指针。(Human *):函数的参数是一个Human*类型的指针,也就是对象的this指针。**:二级指针,一级指向虚表的第一个函数指针,再解引用才是真正的函数入口地址。
3. + 1:偏移到第二个虚函数
虚函数表是按顺序排列的函数地址数组:
- 不加偏移时,指向虚表第 0 项,也就是第一个虚函数(通常是析构函数)。
+ 1表示向后偏移 1 个函数指针的长度(64 位下是 8 字节),指向虚表第 1 项,也就是第二个虚函数。
4. (* ... )(m):解引用并调用函数
- 最外层的
*:对偏移后的地址解引用,取出对应虚函数的入口地址。 (m):调用这个函数,传入参数m。这个参数就是成员函数的this指针,对应 C++ 里调用对象方法时自动传入的对象地址
- 虚指针与虚函数表 :C++ 中含有虚函数的类,每个对象的开头都会有一个虚指针(vptr) ,指向一张虚函数表(vtable)。虚函数表里按顺序存放着各个虚函数的入口地址。
m->_vptr_Human:取出对象里的虚指针,指向虚表的第一个函数。+ 1:向后偏移 1 个函数指针,取到虚表里的第二个虚函数。- 最后调用这个函数,参数
m就是 C++ 的this指针。
为什么是第二个虚函数? 虚函数表按声明顺序排列虚函数地址,
+1即跳过第一个、指向第二个。若Human基类先声明virtual void func1()再声明virtual void func2(),则+1取到的正是func2。这也是本题 flag 输出逻辑所在函数。
与this指针的关系 :C++ 成员函数调用时,编译器会把对象地址作为隐藏参数传入。这里显式传入m,等价于调用m->func2(),只是 IDA 把虚调用还原成了最底层的指针运算形式。
实战提示 :在 IDA 中双击m->_vptr_Human可跳转到虚表,按Ctrl+E查看虚表项对应的函数地址,直接定位到 flag 输出函数,是解这类题的通用套路。#### 4. 对象析构与释放
if ( m )
{
Human::~Human(m);
operator delete(v4);
}
调用完虚函数后,执行析构函数、释放堆内存,属于标准的 C++ 对象销毁流程,和 flag 逻辑无关。
双击 m->_vptr_Human,
可以看到虚函数表里存放着两个函数指针,第一个是析构函数,第二个就是我们要找的 give_flag 函数。核心是 Human 类,双击 Human 类
进入 Human 类的结构视图后,可以看到它的虚函数表指针 off_4863D0,点击它
虚表里列出了两个函数:第一个是析构函数,第二个是 _ZN5Human9give_flagEv,也就是 Human::give_flag()。点击 _ZN5Human9give_flagEv
按 F5 查看伪码,可以看到 give_flag 函数的逻辑:它把一段密文逐字符与 0x0A 进行异或,得到真正的 flag。
点击 flag 变量,
得到异或后的密文,写解密脚本即可还原 flag。

f5查看伪码,说明每个字符与0x0A进行异或

点击flag

得到异或后的flag,写解密脚本
python
cipher=[0x44, 0x59, 0x59, 0x49, 0x5E, 0x4C, 0x71, 0x7E, 0x62, 0x63,
0x79, 0x55, 0x63, 0x79, 0x55, 0x44, 0x43, 0x59, 0x4B, 0x55,
0x78, 0x6F, 0x55, 0x79, 0x63, 0x6D, 0x64, 0x77]
flag=""
for i in range(len(cipher)):
temp=chr(cipher[i]^0x0A)
flag+=temp
print(flag)
脚本逻辑很简单:give_flag 函数里对每个字符做了 ^ 0x0A 的异或运算,异或运算具有可逆性,所以只要对密文再异或一次 0x0A,就能还原出原始 flag。运行脚本即可得到最终答案。