面向 Pwn 入门者的 C++ 零基础教程
0. Pwn 和 C++ 的关系
Pwn 是 CTF 中研究程序如何与内存交互的方向。许多题目使用 C 或 C++ 编写。想做 Pwn,不一定要成为 C++ 工程师,但至少要能:
- 看懂程序从哪里读入数据、如何处理数据、最后输出什么;
- 知道变量、数组和指针在内存中的大致样子;
- 识别不安全的输入处理方式;
- 用调试器观察程序状态。
学习顺序很重要:不要一开始就背漏洞利用脚本。先理解程序正常怎么运行,异常行为才会变得可解释。
1. 准备环境
推荐在 Linux 环境中练习,因为大多数 Pwn 题运行在 Linux。Windows 用户可使用 WSL、虚拟机,或先使用在线 C++ 编译器完成语法练习。
Linux 中安装编译器:
sudo apt update
sudo apt install build-essential gdb
创建文件 hello.cpp,写入下面的代码:
#include <iostream>
int main() {
std::cout << "Hello, Pwn!" << std::endl;
return 0;
}
编译并运行:
g++ hello.cpp -o hello
./hello
你应看到:
Hello, Pwn!
1.1 一次运行发生了什么?
hello.cpp(人写的源代码)
↓ g++
hello(计算机执行的程序)
↓ ./hello
屏幕上的输出
.cpp是 C++ 源代码文件。g++是编译器:把人可读的代码翻译为机器可执行的指令。-o hello表示把输出程序命名为hello。./hello表示运行当前目录中的hello。
2. 第一段代码,逐行阅读
#include <iostream>
int main() {
std::cout << "Hello, Pwn!" << std::endl;
return 0;
}
| 代码 | 含义 |
|---|---|
#include <iostream> |
引入输入、输出工具。 |
int main() |
程序从 main 函数开始执行。 |
{ 与 } |
表示一段代码的开始和结束。 |
std::cout |
把内容输出到屏幕。 |
"Hello, Pwn!" |
字符串,也就是一段文本。 |
std::endl |
换行。 |
return 0; |
告诉系统程序正常结束。 |
先记住两个规则:C++ 大多数语句末尾要有分号 ;;字符串必须用英文双引号包住。
3. 变量:给数据贴标签
变量可以理解成一个有名字的小盒子,用来保存数据。声明变量时通常写:
类型 名字 = 初始值;
#include <iostream>
int main() {
int score = 100;
char grade = 'A';
double price = 3.14;
std::cout << score << std::endl;
std::cout << grade << std::endl;
std::cout << price << std::endl;
return 0;
}
常见类型:
| 类型 | 保存内容 | 示例 |
|---|---|---|
int |
整数 | 42、-7 |
char |
单个字符 | 'A'、'x' |
double |
小数 | 3.14 |
bool |
真或假 | true、false |
注意:'A' 是一个字符,使用单引号;"A" 是文本,使用双引号。它们不是同一种数据。
3.1 修改变量
int lives = 3;
lives = 2;
lives = lives + 1;
最后 lives 的值是 3。等号 = 在这里是“把右边的结果放进左边的变量”,不是数学课中的“左右相等”。
4. 输入与输出
程序可以从键盘读取数据:
#include <iostream>
int main() {
int age = 0;
std::cout << "请输入年龄:";
std::cin >> age;
std::cout << "明年你将是 " << age + 1 << " 岁。" << std::endl;
return 0;
}
std::cout << 内容:输出。std::cin >> 变量:等待用户输入,并存入变量。
Pwn 题中,尤其要留意所有“外部输入”:键盘输入、网络数据、文件内容、命令行参数。程序若没有正确限制输入,它们就可能成为问题来源。
5. 条件判断:程序如何做选择
if 让程序根据条件走不同路径:
#include <iostream>
int main() {
int password = 0;
std::cin >> password;
if (password == 1234) {
std::cout << "欢迎" << std::endl;
} else {
std::cout << "密码错误" << std::endl;
}
return 0;
}
常见比较符号:
| 写法 | 含义 |
|---|---|
== |
是否相等 |
!= |
是否不相等 |
>、< |
大于、小于 |
>=、<= |
大于等于、小于等于 |
&& |
并且 |
| ` | |
! |
取反 |
最常见新手错误:把判断中的 == 写成 =。前者是比较,后者是赋值。
6. 循环:重复做事情
for 循环适合明确重复次数的场景:
for (int index = 0; index < 5; index++) {
std::cout << index << std::endl;
}
它会输出 0 到 4。可以拆成三部分理解:
for (初始动作; 继续条件; 每轮结束后的动作) {
重复执行的代码;
}
while 则是“条件成立就继续”:
int number = 0;
while (number < 3) {
std::cout << number << std::endl;
number++;
}
如果循环变量永远不改变,条件可能永远为真,程序就会卡在无限循环中。
7. 函数:给一段操作起名字
函数把一段可复用的代码组织起来:
#include <iostream>
int add(int first, int second) {
return first + second;
}
int main() {
int result = add(2, 3);
std::cout << result << std::endl;
return 0;
}
add 接收两个整数,计算后把结果交给调用它的地方。这里:
int add(...)开头的int:函数最终返回一个整数;first、second:函数的参数;return first + second;:把结果返回。
看 Pwn 源码时,可以先找 main,再顺着它调用的函数看。不要试图一眼看懂所有函数。
8. 数组:连续排列的多个盒子
数组保存一组同类型数据:
int numbers[4] = {10, 20, 30, 40};
数组下标从 0 开始:
std::cout << numbers[0] << std::endl; // 10
std::cout << numbers[3] << std::endl; // 40
可以把它想象为:
下标: 0 1 2 3
内容: 10 20 30 40
数组大小是 4 时,合法下标只有 0 到 3。访问 numbers[4] 已经越界:程序会读写本不属于这个数组的内存。这类边界问题是 Pwn 中非常核心的主题。
9. 字符串与字符数组
Pwn 题里经常会遇到 C 风格字符串:
char name[8] = "cat";
它在内存中类似:
'c' 'a' 't' '\0' ? ? ? ?
结尾的 \0 是空字符,表示“字符串到这里结束”。所以一个能放 7 个可见字符的 char name[8],还要留一个位置给 \0。
现代 C++ 也有更方便的 std::string:
#include <iostream>
#include <string>
int main() {
std::string name;
std::getline(std::cin, name);
std::cout << "你好," << name << std::endl;
}
做入门练习时优先使用 std::string;学习 Pwn 阅读旧代码时,则必须理解 char[] 和结尾的 \0。
10. 内存的直觉:地址与指针
程序运行时,变量会放在内存中。内存可以粗略看成一排有编号的位置,编号叫地址。
int score = 100;
int* pointer = &score;
score保存值100。&score表示“score的地址”。pointer是指针变量,保存这个地址。int*表示它指向一个int。
通过指针读取或修改目标变量:
std::cout << *pointer << std::endl; // 读取 score,输出 100
*pointer = 200; // 修改 score
std::cout << score << std::endl; // 输出 200
这里的星号有两种常见含义:
int* pointer; // 声明:pointer 是“指向 int 的指针”
*pointer // 使用:取出 pointer 所指向地址里的值
10.1 为什么指针对 Pwn 很重要?
程序通过地址访问内存;函数返回、局部变量、数组和许多对象都与内存布局有关。当代码发生越界读写、错误使用指针等问题时,本应分开的数据可能相互影响。
此阶段不必背地址细节。目标只是建立这条因果链:
输入长度未检查 → 写过数组边界 → 改到相邻内存 → 程序行为异常
11. 栈:函数的临时工作区
每次调用函数,程序通常会为这个函数准备一块临时空间,常被称为“栈帧”。其中可能放着局部变量和函数返回所需的信息。
void greet() {
char message[16] = "hello";
std::cout << message << std::endl;
}
可以先用下面的简化图理解:
greet() 的临时区域(示意,不代表实际顺序)
+-----------------------+
| message[16] |
+-----------------------+
| 函数返回相关的信息 |
+-----------------------+
函数结束后,这块临时区域会被回收。后面会更细地讲栈、寄存器和调用约定;现在先记住:局部数组的边界必须严格遵守。
12. 一个安全的长度检查例子
不要复制不安全的输入方式。下面是一个简单、可读的写法:
#include <iostream>
#include <string>
int main() {
std::string name;
std::cout << "输入昵称(最多 16 个字符):";
std::getline(std::cin, name);
if (name.size() > 16) {
std::cout << "昵称太长。" << std::endl;
return 1;
}
std::cout << "昵称已保存:" << name << std::endl;
return 0;
}
核心思路不是死记某个函数,而是:
- 输入来自外部,不可信;
- 接收数据的空间有限;
- 在使用数据前检查长度和格式;
- 超出预期时安全地拒绝或截断。
13. 常见危险信号(阅读代码时)
当你在授权靶场源码中看到下列情况,应停下来检查边界与输入:
| 信号 | 应思考的问题 |
|---|---|
char buffer[32] |
缓冲区只有多大? |
用户输入写入 buffer |
有没有长度上限? |
numbers[index] |
index 是否保证在合法范围? |
指针 pointer |
是否初始化?是否已经失效? |
new / delete |
是否重复释放或释放后继续使用? |
memcpy、strcpy 等 |
复制的长度是否受控?目标空间够不够? |
这不是让你去攻击真实程序,而是训练一种审计习惯:先确认数据从哪里来、流向哪里、边界在哪里。
14. 用编译器帮忙发现问题
练习时请打开警告,并保留调试信息:
g++ -Wall -Wextra -g demo.cpp -o demo
-Wall -Wextra:让编译器报告更多可疑写法;-g:加入调试信息,便于后面用 GDB 观察程序。
还可以用 AddressSanitizer 辅助发现许多内存错误:
g++ -Wall -Wextra -g -fsanitize=address demo.cpp -o demo
./demo
它不能代替人工理解,但非常适合新手检查数组越界、使用已释放内存等问题。
15. 初识 GDB:观察而非猜测
假设程序叫 demo:
gdb ./demo
进入后可以使用:
break main 在 main 入口暂停
run 开始运行
next 执行下一行,不进入函数内部
step 执行下一行,必要时进入函数
print variable 查看变量的值
backtrace 查看函数调用链
quit 退出
建议先用 GDB 调试自己的加法程序、数组程序。能把“代码中的变量”对应到“运行时的数值”,再学习内存查看会轻松得多。
16. 小练习
下面程序包含输入、函数、数组和边界检查:
#include <iostream>
int sum(const int values[], int count) {
int total = 0;
for (int index = 0; index < count; index++) {
total = total + values[index];
}
return total;
}
int main() {
int values[3] = {0, 0, 0};
for (int index = 0; index < 3; index++) {
std::cout << "请输入第 " << index + 1 << " 个整数:";
std::cin >> values[index];
}
std::cout << "总和是:" << sum(values, 3) << std::endl;
return 0;
}
试着回答:
values有几个元素?合法下标是什么?- 为什么循环条件是
index < 3而不是index <= 3? sum(values, 3)中的3有什么作用?- 把
values[3]改成values[5]后,还应修改哪些地方?
17. 建议练习路线
按顺序完成,不会的地方就回看对应章节:
- 输出自己的名字、年龄和一句话;
- 读入两个整数,输出较大的那个;
- 使用循环计算
1到100的和; - 读入 5 个整数,输出最大值;
- 写一个函数判断一个整数是否为偶数;
- 用 GDB 在循环中暂停,查看
index与数组内容; - 给每个数组访问加上边界思考:下标来自哪里、范围是什么?
每次练习都使用:
g++ -Wall -Wextra -g 文件名.cpp -o 程序名
当你能独立写出第 4、5 项,并解释数组下标和指针的区别,就可以进入 CTF Pwn 的下一阶段:Linux 基础、程序保护机制、汇编基础、GDB/GEF 或 pwndbg,以及只在授权环境中进行的漏洞分析。
18. 最小速查表
| 看到的写法 | 快速理解 |
|---|---|
int value = 7; |
创建一个整数变量 |
char text[16]; |
创建可容纳 16 个字符位置的数组 |
array[index] |
访问数组第 index 个位置(从 0 开始) |
&value |
取得变量地址 |
int* pointer |
创建指向整数的指针 |
*pointer |
访问指针所指地址中的值 |
if (condition) |
条件为真才执行 |
for (...) |
重复执行 |
return value; |
从函数交回结果 |

说些什么吧!