ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

nssctf_sign-ezc++

2026/8/29 5:53:08 拓冰建站 浏览量
nssctf_sign-ezc++ 下载、查壳\、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 语言里的字符串对象定义语句stdC 标准库的命名空间standard所有标准库组件都在这个命名空间下。stringC 标准库提供的字符串类是封装好的高级字符串类型内部自动管理内存、支持拼接、比较、截取等各种操作比 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::allocatorcharC 标准库的内存分配器模板类专门负责给char类型的元素申请、释放内存是std::string底层用来管理字符串存储空间的工具。::allocator(v7)这是分配器的构造函数在栈上变量v7的位置构造出一个char类型的分配器对象。v3 (Man *)operator new(0x18ui64);这行是C 对象创建的第一步在堆上申请内存对应开发者写的new Man(...)语句的底层拆分。C 的new运算符在执行时会自动拆成两个步骤① 申请足够大小的堆内存② 在这块内存上调用类的构造函数完成初始化。IDA 反编译时会把这两个底层步骤分开显示这行对应的就是第一步。二、逐部分拆解operator newC 标准库提供的全局内存分配函数功能和 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::ManC 构造函数的命名格式类名::构造函数名。构造函数是类的特殊成员函数名字和类名完全相同专门负责对象创建时的初始化工作。第一个参数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 自动资源管理机制的标准体现完全由编译器自动生成。二、逐部分拆解~stringC 中析构函数的固定命名格式~波浪号是析构函数的专属标识函数名与类名一致。它的作用和构造函数完全相反构造函数负责创建、初始化对象析构函数负责清理、销毁对象。name要销毁的目标字符串对象的地址对应底层的this指针告诉析构函数要清理哪一个字符串对象。三、执行的核心操作调用这个析构函数时会自动释放name字符串内部申请的堆内存 —— 也就是存放NISACTF实际内容的那块动态存储空间把内存归还给系统避免内存泄漏。这是 CRAII资源获取即初始化机制的典型体现对象生命周期结束时自动完成资源清理不需要开发者手动调用free。std::allocator::~allocator(v7);这是std::allocatorchar内存分配器的析构函数调用和代码开头的分配器构造函数严格成对出现负责销毁分配器对象本身属于 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可跳转到虚表按CtrlE查看虚表项对应的函数地址直接定位到 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写解密脚本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]flagforiinrange(len(cipher)):tempchr(cipher[i]^0x0A)flagtempprint(flag)脚本逻辑很简单give_flag函数里对每个字符做了^ 0x0A的异或运算异或运算具有可逆性所以只要对密文再异或一次0x0A就能还原出原始 flag。运行脚本即可得到最终答案。