特别说明:近期找不到工作特别着急,借论坛破解版块录制一节课,准备作为简历上的作品试试能不能入行IT教培行业。
0x1 课前准备
1. Dev-C++ 下载与安装
下载地址:https://sourceforge.net/projects/orwelldev```/
推荐版本:5.11(稳定版,内置MinGW编译器,安装后可直接使用)
安装注意:
- 安装语言选 English,完成后首次启动时可切换为简体中文。
- 安装路径建议保持默认,避免后续编译时出现路径问题。
2. IDA Pro 下载与准备
下载地址:https://pan.baidu.com/s/1z5VmZ5Pz2tp_KzNNvn7aJw?pwd=52pj
提取码:52pj
版本说明:该版本为吾爱破解论坛提供的,本人电脑 win7 不能选用高版本,故采用 7.7 版本,适合分析我们编译的 32 位 C++ 程序。
安装注意:
- 下载后解压即可使用,无需安装。
- 首次启动时,如果提示“是否加载插件”,选择“是”即可。
0x2函数与引用的底层真相
1. 为什么要学习 C++?
同学们好,欢迎来到我们的第一节课。
在开始写代码之前,我想先问大家一个问题:我们为什么要学 C++?现在市面上有 Python、Java、Go 那么多编程语言,为什么我们偏偏选择 C++?
C++ 是理解计算机底层的“最佳窗口”。 Python 像自动挡汽车,你只管踩油门,它帮你搞定一切;但 C++ 像手动挡,你得自己控制离合器、换挡,虽然难,但开过一次之后,你就彻底理解汽车是怎么工作的了。学完 C++,你再去看其他语言,都会觉得“原来如此”。
所以,C++ 很难,但正因为难,它才值得学。今天这节课,我们不只学语法,更要学一个很多老师不会教的东西——用逆向工具,看到 C++ 程序在内存中的真实样子。
准备好了吗?那我们开始。
2. 写代码:从 0 开始构建程序
2.1 头文件:程序从哪里来?
我们先写第一行:
#include <iostream>
这一行是什么意思呢?
include 是“包含”的意思。iostream 是 C++ 标准库里的一个文件,它里面装着很多我们写程序时需要用到的工具。
打个比方:#include <iostream> 就像你写作业时翻开一本工具书,里面有你需要的公式和定理。你不必自己发明这些公式,直接用就行。
我们接下来要用到 cout 和 cin 这些东西来输入输出,所以我们需要把这本“工具书”包含进来。
再写第二行:
using namespace std;
这一行又是什么意思呢?
std 是 C++ 标准库的“名字空间”。简单来说,iostream 这本工具书里的所有工具,都存放在一个叫 std 的抽屉里。using namespace std; 就是告诉编译器:“我要用 std 这个抽屉里的所有工具,不用每次都告诉我它们是哪个抽屉的。”
如果你不写这一行,你每次用 cout 的时候都要写成 std::cout,很麻烦。所以我们写了这一行,方便自己。
2.2 main 函数:程序的入口
接下来,我们要写程序的“身体”——也就是主函数。
int main() {
}
main 是“主要”的意思。main 函数是程序的入口:也就是说,程序一运行,CPU 就会自动找到这个 main 函数,然后从它的第一行代码开始执行。没有 main 函数,程序就不知道从哪里开始跑。
int 是“整数”的意思,它表示这个 main 函数在运行结束后,会返回一个整数给操作系统。0 表示程序正常运行结束,其他数字表示出错了。
() 是函数的参数列表,这里我们暂时不传参数,所以是空的。
{} 里面的内容,就是程序实际要执行的指令。
2.3 第一个函数:加法器
现在我们要在 {} 里面写具体的指令了。
但是等一下,如果我们的程序只有 main 函数,它只能做一些基本的事情。如果我要让程序帮我算 3 + 5 等于多少呢?
我可以直接在 main 里写 int result = 3 + 5;,这样当然可以。但如果我有很多地方都要做加法,比如我要算 3+5、10+20、100+200,每一次都要写 3+5、10+20、100+200,太麻烦了。
所以我们定义一个“加法函数”,给它两个数,它帮我们算出结果,然后返回给我们。
int add(int a, int b) {
return a + b;
}
int add(int a, int b) —— add 是我们给这个函数起的名字;(int a, int b) 是它的参数,意思是“我需要你给我两个整数,我会分别把它们叫做 a 和 b”;前面的 int 表示这个函数会返回一个整数结果。
return a + b; —— 这是函数的核心工作:把 a 和 b 加起来,然后把结果返回给调用它的地方。
现在我们在 main 里调用它:
int main() {
int result = add(3, 5);
return 0;
}
这一行的意思是:调用 add 函数,把 3 和 5 传给它。add 函数算出 3+5=8,然后把 8 返回给 main 函数,存到 result 这个变量里。
2.4 问题来了:如果我想加小数呢?
好,现在我们的加法函数写好了,它只能处理整数。如果我今天想加两个小数,比如 3.5 + 2.7,这个函数还能用吗?
不行。因为 add 的参数是 int 类型,你传 3.5 进去,编译器会把它“截断”成 3,结果就错了。
那怎么办?最简单的办法是——再写一个加法函数,只不过参数改成 double 类型(双精度小数)。
double add_double(double a, double b) {
return a + b;
}
这个函数叫 add_double,参数是两个 double,返回的也是 double。它在 main 里可以这样调用:
double result2 = add_double(3.5, 2.7);
这样做当然可以。但同学们想一想:如果以后我还要加三个数、四个数,或者加 float、加 long,我是不是要写一堆不同名字的函数?add_three、add_four、add_float……名字越来越长,越来越难记。
C++ 觉得这样太麻烦了。于是 C++ 说:“你不用起那么多名字,我帮你搞定。”
2.5 C++ 的解决方案:函数重载
在 C++ 里,多个函数可以共用一个名字,只要它们的参数类型或数量不同,编译器就能自动区分。
我们把刚才的两个函数改成这样:
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
现在注意看:有两个函数都叫 add。
第一个 add 接受两个 int,处理整数加法。第二个 add 接受两个 double,处理小数加法。
它们的名字完全相同,但参数类型不同。当你调用 add(3, 5) 时,编译器一看,“哦,传的是整数”,它就自动调用第一个版本。当你调用 add(3.5, 2.7) 时,编译器一看,“哦,传的是小数”,它就自动调用第二个版本。
这就是函数重载——同一个函数名,多种用法,编译器自动帮你匹配最适合的那个版本。
这种写法在 C 语言里是不允许的。C 语言只允许每个函数有唯一的名字。所以如果你写 C 语言,加整数用 add_int,加小数用 add_double,名字永远不一样。这就是 C++ 比 C 更方便的地方之一。
2.6 第二个问题:引用和指针又是什么?
好,函数重载我们讲完了。现在我们来看第二个知识点:引用和指针。
在 main 函数里继续添加代码:
int main() {
int a = 10;
int &r = a; // 引用
int *p = &a; // 指针
r = 20;
*p = 30;
return 0;
}
第一行:int a = 10; —— 在内存里找了一个小格子,命名为 a,里面存了数字 10。
第二行:int &r = a; —— 这里的 & 不是取地址,而是声明引用。意思就是:“给 a 起个小名叫 r”。从此以后,你叫 r 或者叫 a,都是同一个人,同一个内存格子。
第三行:int p = &a; —— 这里的 表示声明指针,&a 是取 a 的内存地址。意思就是:“p 这个变量里存的是 a 的内存地址”。
引用和指针的区别,很多书上讲得很复杂。今天我们用一个简单的比喻来理解:
引用:就像你的大名和小名,都是指同一个人。
指针:就像你家门口的门牌号,它不是你家本身,但你可以通过门牌号找到你家。
但是问题又来了。
在电脑 CPU 的眼里,引用和指针真的有区别吗?
书上说它们是不同的。但我们今天不做书呆子,我们要用 IDA 亲眼看一下,在内存里,引用和指针到底长什么样。
3. 编译与反编译:用 IDA 看底层真相
3.1 完整代码展示
经过前面一步步的修改,我们最终的完整代码如下:
#include <iostream>
using namespace std;
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
int main() {
int x = 1, y = 2;
double m = 1.5, n = 2.5;
add(x, y); // 调用整数版本
add(m, n); // 调用小数版本
int a = 10;
int &r = a; // 引用
int *p = &a; // 指针
r = 20;
*p = 30;
int result = add(r, *p); // r 是 a 的引用,*p 是 a 的值,所以相当于 a+a
cout << "add(r, *p)的结果是:" << result;
system("pause");
return 0;
}
3.2 函数入口:建立栈帧
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 40h
call ___main
| 指令 |
含义 |
通俗解释 |
| push ebp |
把旧的 ebp 值压入栈中保存 |
保存现场,等函数结束时要恢复 |
| mov ebp, esp |
把当前栈顶地址赋给 ebp |
建立新的栈帧基址,以后用 ebp+偏移 来访问参数 |
| and esp, 0FFFFFFF0h |
把 esp 按 16 字节对齐 |
编译器优化,让内存访问更快 |
3.3 整数加法:第一次调用 add(int, int)
mov [esp+40h+var_4], 1 ; x = 1
mov [esp+40h+var_8], 2 ; y = 2
mov eax, [esp+40h+var_8] ; 把 y(2)取到 eax
mov dword ptr [esp+40h+var_40+4], eax ; 第2个参数 = 2
mov eax, [esp+40h+var_4] ; 把 x(1)取到 eax
mov dword ptr [esp+40h+var_40], eax ; 第1个参数 = 1
call __Z3addii ; 调用 add(int, int)
C++ 的函数调用约定(__cdecl)是从右往左压参数:先压 y,再压 x。
3.4 小数加法:第二次调用 add(double, double)
fld ds:dbl_488000 ; 加载常量 1.5
fstp [esp+40h+var_10] ; m = 1.5
fld ds:dbl_488008 ; 加载常量 2.5
fstp [esp+40h+var_18] ; n = 2.5
注意:double 是浮点数,用的是 FPU 指令(fld、fstp),而不是普通的 mov。
fld [esp+40h+var_18] ; 加载 n
fstp [esp+40h+var_38] ; 第2个参数 = n
fld [esp+40h+var_10] ; 加载 m
fstp [esp+40h+var_40] ; 第1个参数 = m
call __Z3adddd ; 调用 add(double, double)
fstp st ; 清理 FPU 栈
Z3adddd 和 Z3addii 名字不同,这就是函数重载的底层实现!
3.5 核心对比:引用 vs 指针
mov [ebp+a], 0Ah ; a = 10
lea eax, [ebp+a] ; 取 a 的地址
mov [ebp+r], eax ; r = a 的地址
lea eax, [ebp+a] ; 再次取 a 的地址
mov [ebp+p], eax ; p = a 的地址
注意看:r 和 p 的赋值方式完全相同——都是 lea eax, [ebp+a],然后把 eax 存入对应的变量,两条汇编指令完全一样!
mov eax, [ebp+r] ; 取出 r 的值(a 的地址)
mov dword ptr [eax], 14h ; r = 20
mov eax, [ebp+p] ; 取出 p 的值(a 的地址)
mov dword ptr [eax], 1Eh ; *p = 30
mov eax, [ebp+p] ; 取出 p 的值
mov edx, [eax] ; *p = 30
mov eax, [ebp+r] ; 取出 r 的值
mov eax, [eax] ; r = 30
mov [esp+4], edx ; 第2个参数 = 30
mov [esp], eax ; 第1个参数 = 30
call __Z3addii ; add(30, 30) = 60
mov [ebp+result], eax ; result = 60
无论是通过 r 还是 p 修改,都是先取出地址,然后向该地址写入值。方式完全一致。
所以,引用本质上就是指针,只是语法上更安全而已。
4. 黑客视角:修改程序逻辑(实战演示)
我们看完了引用和指针的底层原理。现在,如果我是一个黑客,不想改源代码,能不能直接修改这个程序的行为?
大家看,我们程序里有一个 add 函数,它返回两个数的和。之前 add(r, *p) 返回的是 30 + 30 = 60。
但如果我们不修改源代码,直接在 IDA 里改掉它的参数,让它返回别的值呢?
在 IDA 中,找到修改 a 的指令。我们找到了这一行:
mov dword ptr [eax], 1Eh ; *p = 30(1Eh = 30)
选中这行指令,选择 Edit -> Patch program -> Assemble,把 1Eh 改成 0Ah(10):
mov dword ptr [eax], 0Ah ; *p = 10
然后选择 Edit -> Patch program -> Apply patches to input file,保存修改后的文件。
改完之后,我们再运行修改后的程序——add(r, *p) 的结果从 60 变成了 20!
同学们看到了吗?我们没改一行源代码,只是修改了内存中的一个值,就改变了程序的执行结果。
这就是逆向分析的力量——你能看到程序在内存中的真实样子,你就能控制它。
5. 总结
好,我们来回顾一下今天的内容。
第一,我们学会了 C++ 的函数重载——它的底层原理是编译器根据参数类型给函数改名字,add(int,int) 变成了 Z3addii,add(double,double) 变成了 Z3adddd。
第二,我们用 IDA 看到了引用和指针在底层没有任何区别——它们都是存地址,然后通过地址访问内存。
第三,我们动手演示了如何在不改源代码的情况下,通过修改内存值改变程序行为——这就是逆向分析的实战应用。
今天的课就到这里,谢谢大家。
0x3附录:C++ 语法速查卡片
下面是本节课用到的 C++ 语法点,按出现顺序排列,方便你在写代码时随时参考。
1. 头文件 #include
#include <iostream>
作用:引入标准输入输出库
记忆点:写代码前先“请工具书”
2. 命名空间 using namespace std;
using namespace std;
作用:使用 std 命名空间中的工具
记忆点:打开工具箱,随便拿
3. 主函数 main()
int main() {
return 0;
}
作用:程序的入口点
记忆点:没有 main 函数,程序不知道从哪里开始
4. 函数定义
int add(int a, int b) {
return a + b;
}
结构:返回值类型 函数名(参数列表) { 函数体 }
记忆点:把重复代码打包,取个名字
5. 函数重载
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
作用:同名不同参,编译器自动选
记忆点:同名不同参,编译器自动选
6. 引用 &
int &r = a; // r 是 a 的引用
作用:给变量起别名
记忆点:大名小名,都是同一个人
7. 指针 *
int *p = &a; // p 指向 a
作用:存地址
记忆点:门牌号 vs 房子本身
8. 输出 cout
cout << "Hello" << 123;
作用:在控制台打印内容
9. 报错的解决方案
| 报错信息 |
可能原因 |
解决办法 |
| undefined reference to |
函数只声明没实现 |
检查函数名是否拼写正确 |
| expected ';' before 'return' |
上一行忘了分号 |
补上 ; |
| 'cout' was not declared |
忘了 #include <iostream> |
补上头文件 |
| cannot convert 'double' to 'int' |
传参类型不匹配 |
检查函数参数类型 |
0x4学习资料
菜鸟教程 C++ 参考:https://www.runoob.com/cplusplus/cpp-tutorial.html
通过网盘分享的文件:课程笔记和视频
链接: https://pan.baidu.com/s/1tj-PHoph0ecQ9eXzvRoq2w?pwd=gd7w 提取码: gd7w
PS:由于电脑硬件不行,没找到合适的投屏软件和录屏软件,视频是分4段录的,顺序观看即可,要是能激起坛友们学习破解的兴趣就更好了。比较紧张,讲的不对地方也欢迎大家批评指正!