如何入门 pwn
注意,pwn 方向本身上手门槛是比其他方向更高的,可能几周甚至几个月都没有理解 pwn 到底是在做什么。希望读者在上手 pwn 方向时,能够静下心来。对于网络安全,虽然说现在 ai 已经能随便爆杀传统 ctf 绝大部分题目,但网络安全≠ctf,古法入门是精进技术的必经之路。
本文面向对计算机了解不深的新生,只做引子,不讨论具体攻击手法与技术细节。
Your PC
在认识 pwn 之前,我们先认识一下自己的电脑。以常规的笔记本为例,我们在买电脑时,可能会关注:
-
操作系统:Windows 11
-
处理器(cpu):AMD Ryzen 9 8940HX
-
显卡:NVIDIA GeForce RTX 5060 Laptop GPU
-
内存:16GB DDR5-5600
-
存储:1TB PCIe 4.0 SSD
-
显示屏:…(一堆参数)
-
…(诸如外观、重量、接口等其他配置信息)
计算机总体上是 “硬件+软件” 的组合。说直白点,硬件就是看得见摸得着的东西,把你的电脑拆开,就可以看得见摸得到你的cpu、显卡、内存条;而操作系统、你玩的游戏、你用的浏览器,是运行在硬件之上、看不见摸不着的“指令和数据”,也就是软件。
这里以一个例子简单解释一下上述硬件是做什么的:以放电影为例,双击桌面上一个电影文件,“双击”这个动作是操作系统提供的,这个文件是储存在硬盘上的。双击后,操作系统会把电影从硬盘拷贝到内存上,这是因为cpu无法处理硬盘上的数据。cpu处理内存上的数据后,处理结果就是一部分数据为图像发送给显卡,一部分是声音,发送给声卡。显卡通过显示器把图像显示出来,声卡通过扬声器播放出声音。通过这个例子可以看出:cpu主要用于处理数据、控制数据流向;显卡负责处理图像数据;内存负责临时存放运行中的程序和数据,速度快,并和cpu高速通信,但是断电后数据会丢失;硬盘负责长期存储文件,但速度慢,断电后数据不会丢失。
你的电脑有两组关键程序:第一组是在主板上(可能在ROM,也可能在闪存上)的固化程序:BIOS系统(或UEFI系统);第二组是通常在硬盘上特定位置(如MBR、EFI分区等)的一段引导程序。在你按下开机键的那一刻,电脑会最先运行BIOS系统(或UEFI),大致会进行如下工作:
-
硬件自检:检查硬件是否完整。
-
初始化:对外部设备进行一些初始化操作。
-
寻找启动盘:按设定的启动顺序找到可引导设备(如硬盘)。
-
加载引导程序:将存储设备的引导扇区(UEFI通常加载EFI系统分区)加载到内存,并启动引导程序。
接下来的工作由引导程序接力完成:
-
初始化硬件:为操作系统提供可运行的软硬件环境。
-
加载操作系统内核:将选定的操作系统内核载入内存。
-
控制权移交:将系统控制权交给操作系统。
交接控制权后,操作系统内核进行:
-
内核进行硬件初始化(驱动加载、内存管理、设备驱动等)。
-
启动系统服务(守护进程、网络、文件系统)。
-
交由用户态建立用户界面(命令行或图形界面)。
至此,才算完整启动操作系统。
About API
经历上述步骤,你成功进入了操作系统。你使用记事本打开一个文本文档,这里就出现了几个问题:
-
记事本怎么知道文件里的内容是什么?
-
记事本怎么让操作系统在屏幕上显示一个窗口?
-
记事本又是怎么把修改后的内容保存到硬盘上的?
换句话说,作为软件的记事本,是如何操控硬件的?
回顾前面提到的开机过程,操作系统接管计算机后,会负责管理计算机的各种资源。
这里需要注意,普通应用程序通常不能直接随意操作所有硬件资源。假设有这样一种情况:你正在用记事本编辑一篇文章,同时使用浏览器下载文件,后台还有一个游戏正在运行。如果这三个程序都能够随意控制硬盘,甚至直接修改硬盘上的任意数据,那么很容易发生冲突,甚至破坏其他程序保存的数据。因此,现代操作系统需要为不同程序提供统一的资源管理和访问机制。
我们可以把操作系统理解为一个管理者。普通程序如果希望读取文件、使用网络、创建窗口,就可以通过操作系统提供的接口请求相应的服务。而这种接口,就引出了一个非常重要的概念:API(Application Programming Interface,应用程序编程接口)。
假设你去餐厅吃饭,你不需要亲自走进厨房,研究灶台怎么使用,也不需要知道厨师如何准备掌勺。对于顾客来说,重要的不是菜谱和火候的控制,而是知道自己能点什么、怎么点、最后能得到什么。
程序调用 API 也是类似的道理。假设我们正在开发一个记事本程序,希望从硬盘上读取一个文件。我们不需要自己研究 SSD 的读写协议,更不需要从零实现一套文件系统。我们只需要调用系统提供的文件操作 API,告诉它:我希望打开 C:\Users\test\Desktop\hello.txt 这个文件,并读取其中的内容。系统会根据这个请求,完成相应的处理,并把结果返回给程序。
简而言之,API 就是软件向其他软件或系统功能请求服务时,所遵循的一套接口约定。
需要注意的是,API 并不一定属于操作系统。编程语言的标准库、第三方软件库、网络服务也都可以提供 API。我们接下来主要讨论的是操作系统提供的 API。
回到记事本问题上,我们要打开 hello.txt 这个文件,记事本应当进行了如下调用:
-
记事本调用“显示窗口API”,操作系统显示记事本窗口
-
记事本调用“创建/打开文件API”,操作系统打开 hello.txt
-
记事本调用“读取文件内容API”,操作系统读取 hello.txt 文件内容
-
修改了文件并保存,记事本调用“保存文件API”,操作系统把内容写回硬盘
About Function
在上文中,我们提到了 API,也知道了程序可以通过调用 API 来完成一些操作。但是这里又出现了一个新的问题:程序究竟是如何“调用”API 的?
为了理解这个问题,我们需要先认识一个编程中非常重要的概念:函数(Function)。
假设我们现在需要计算两个数字的和,例如:
1 + 2 = 3
3 + 4 = 7
5 + 6 = 11
对于计算机来说,它需要执行相应的指令才能完成加法操作。
如果我们希望编写一个程序,反复计算两个数字的和,最直接的方法当然是每次都写一遍加法代码。但是,如果一个程序需要执行几百次甚至几千次类似的操作,每次都重复编写相同的代码显然不太合理。
因此,我们可以将这些重复使用的代码单独整理出来,为其起一个名字。以后需要使用时,只需要通过这个名字调用它即可。这就是函数最基本的思想:将完成某项特定任务的代码组织起来,使程序可以在需要的时候调用它。
我们来看一个简单的 C 语言程序:
1 |
|
这里我们定义了一个名为 add 的函数,它的作用是计算两个整数的和,并在 main 中调用了该函数,传入了 3 和 5,然后返回了 8。这段程序进行的步骤如下:
-
进入 main 函数。
-
调用 add(3, 5)。
-
进入 add 函数。
-
执行 add 函数。返回结果 8。
-
回到 main 函数,输出 8。
-
main 函数结束。
通过这个简单例子,我们可以发现,一个函数主要涉及:
-
函数体,也就是函数的具体实现。
-
参数,调用函数时传递给函数的值,也就是这个例子中的
int a``int b。 -
返回值,函数运行后的结果。
当然,不是所有函数都需要有参数,也不是所有函数都需要有返回值。
仍然以记事本为例,记事本调用“显示窗口API”中,“显示窗口API”的具体实现也是通过函数编写,这种 API 统称为 函数式API。函数是 API 的一种表现形式。
How run?
我们已经了解了什么是函数,什么是 API,我们写下了一个简单的整数加法程序,接下来该运行这个程序了。我们该如何运行这个代码?配置c语言环境时,或许听说过 mingw64,或者是 gcc,也可能进行过把 .c 文件变为 .exe 文件的过程了。为什么要把 .c 变成 .exe 才能运行?.c 和 .exe 有什么不同?.c 具体是怎么变成 .exe 的?
首先,我们需要明确一件事:CPU 并不能直接理解我们写下的 C 语言代码。
我们在编辑器中写下的:
1 | int result = add(3, 5); |
对于人来说,这行代码很好理解:调用 add 函数,把 3 和 5 相加,并将结果保存到 result 变量中。
但是 CPU 并不知道什么是 add,也不知道什么是 int。CPU 能够直接执行的是一条条机器指令(Machine Instruction)。
不同架构的 CPU 支持的机器指令并不完全相同。例如,你可能听说过 x86、x86-64、ARM、RISC-V 等架构。它们各自有不同的指令集,也就是 CPU 能够理解和执行的指令集合。
对于大多数普通 Windows 电脑来说,目前使用的通常是 x86-64 架构。我们接下来的讨论也主要围绕 x86-64 展开。
那么,既然 CPU 不能直接理解 C 语言,我们就需要一种工具,把人类编写的 C 语言代码转换成 CPU 能够执行的机器指令。
这种工具就是编译器(Compiler)。
从 C 语言到可执行文件
回顾前面提到的 GCC,它就是一套常用的编译工具。准确来说,GCC 不只是一个单独的编译程序,而是一套支持多种编程语言的编译器工具集。
而 MinGW-w64 则是一个用于构建 Windows 程序的工具链项目,通常包含面向 Windows 的 GCC、链接器以及相关库文件等。
假设我们将之前的加法程序保存为 main.c,然后执行:
1 | gcc main.c -o main.exe |
如果没有报错,那么当前目录下就会出现一个 main.exe 文件。
这条命令中的:
-
gcc:调用 GCC 编译工具。 -
main.c:我们编写的 C 语言源代码文件。 -
-o:指定最终输出文件的名称。 -
main.exe:生成的 Windows 可执行文件。
看起来,我们只执行了一条命令,就把 C 语言变成了可执行文件。但实际上,这个过程并不是一步完成的。为了便于理解,我们可以将它拆分成四个主要阶段:
预处理 → 编译 → 汇编 → 链接
1. 预处理(Preprocessing)
在之前的 C 语言程序中,我们写了这样一行代码:
1 |
它的作用是什么?
stdio.h 是 C 标准库提供的一个头文件,其中声明了 printf 等函数。
预处理器会处理 #include、#define 等预处理指令。例如,遇到 #include <stdio.h> 时,就会将对应头文件的内容引入到当前源文件中。
我们可以使用下面的命令查看预处理后的结果:
1 | gcc -E main.c -o main.i |
打开 main.i,你会发现它比原来的 main.c 长了很多。
这是因为头文件中存在大量函数声明、类型定义等内容。
需要注意的是,这一步只是引入了 printf 的声明等信息,并不意味着 printf 函数的全部实现代码已经被复制到了我们的程序中。
2. 编译(Compilation)
经过预处理后,编译器会进一步分析代码,并将其转换成较低层次的表示,最终生成目标架构的汇编代码。
可以执行:
1 | gcc -S main.c -o main.s |
然后打开生成的 main.s 文件。
你可能会看到类似这样的内容:
1 | mov eax, 3 |
这里先不需要理解每条指令的具体含义,只需要知道:
-
mov:可以用于传送数据。 -
add:执行加法。 -
eax:CPU 中的一个寄存器。
这两条指令大致表示:先将数字 3 放进 eax 寄存器,再将 5 加到这个寄存器中。
当然,这只是为了说明汇编语言而简化的示例。真实编译出来的汇编代码会受到编译器、优化选项和目标平台的影响,并不一定包含这两条指令。
汇编语言已经非常接近 CPU 所执行的操作。但是,汇编语言依然不是 CPU 直接执行的机器码。
3. 汇编(Assembly)
接下来,汇编器会把汇编代码转换成机器码。
执行:
1 | gcc -c main.c -o main.o |
便可以得到一个 main.o 文件。
这个文件中已经包含了机器指令,但通常还不是完整的可执行程序。
我们在代码中调用了 printf 函数,但是自己并没有编写 printf 的具体实现。编译器能够通过 stdio.h 中的函数声明知道 printf 应该如何调用,但程序最终还需要确定它所依赖的外部函数应当如何找到。
这就需要最后一个步骤。
4. 链接(Linking)
链接器(Linker)负责将一个或多个目标文件以及所需的库组织起来,解析符号引用、处理重定位信息,生成最终的可执行文件。
例如:
1 | gcc main.o -o main.exe |
对于 printf 这样的库函数,链接器会根据使用的库和链接方式,决定如何处理相应的函数引用。
这里又涉及两种概念:
-
静态链接:将所需的库代码直接链接到最终程序中。
-
动态链接:程序运行时依赖外部共享库,例如 Windows 中的
.dll文件、Linux 中的.so文件。
对于动态链接,链接阶段并不需要把库函数的全部实现复制进可执行文件,而是留下相应的依赖和符号等信息,供程序加载或运行时使用。
以 printf 函数为例,在静态链接下,链接器会把 printf 具体实现完整的“拷贝”到你的程序中,即使程序放到另一个没有共享库的系统上也能运行;除此之外,操作系统也有自己的共享库,为了减少程序所占用的空间,在动态链接下,无需完整拷贝这个函数的具体实现,只需要留下关键的信息,让操作系统运行该程序时,找到操作系统的库文件,从库文件中加载函数即可。
至此,我们终于得到了一个可以运行的程序。
以上只是对编译过程的简化描述。实际编译器内部还涉及词法分析、语法分析、语义分析、代码优化等步骤,感兴趣的读者可以自行了解。
可执行文件又是什么?
现在,我们已经知道如何把 .c 文件转换成 .exe 文件。
但这又带来了一个问题:
.exe 文件里面究竟存放着什么?为什么双击它,操作系统就知道应该如何运行?
首先需要明确的是,.exe 并不是简单地把所有机器指令按顺序堆放到文件中。
Windows 中常见的可执行文件采用 PE(Portable Executable)格式,而 Linux 中常见的可执行文件采用 ELF(Executable and Linkable Format)格式。
我们暂时不讨论两者的具体结构,只需要知道,可执行文件通常包含以下信息:
-
文件头:告诉操作系统这是一个什么类型的文件、面向什么架构等。
-
代码:程序运行时需要执行的机器指令。
-
数据:程序中需要使用的一些数据,例如字符串、初始化的全局变量等。
-
其他信息:例如程序入口、依赖的动态库、重定位信息等。
可以把可执行文件理解为一份按照约定格式组织好的程序说明书。
当操作系统读取这份文件时,就可以根据其中的信息,知道应该如何准备程序的运行环境,以及从什么位置开始执行程序。
About Process
现在,我们双击刚刚生成的 main.exe。
操作系统识别出这是一个可执行文件,然后为它创建运行所需的环境。
这里需要引入一个新的概念:进程(Process)。
我们之前一直在使用“程序”这个词,但程序和进程并不完全相同。程序是存储在硬盘上的指令和数据,而进程可以简单理解为程序的一次运行实例。
例如,你在硬盘上保存了一份 notepad.exe,同时打开三个记事本窗口,就可能出现三个记事本进程。
虽然它们可以运行同一份程序代码,但各自拥有自己的运行状态和资源。
-
程序是相对静态的,存储在硬盘上。
-
进程是动态的,是程序运行时的一种存在形式。
进程还包含许多运行时信息,例如虚拟地址空间、线程、打开的文件句柄等。
程序运行时,发生了什么?
我们可以把启动程序的过程简化为:
-
用户请求运行一个可执行文件。
-
操作系统创建进程,并准备该进程需要的资源。
-
操作系统将可执行文件的相关部分映射到进程的虚拟地址空间,并按需将数据加载到物理内存。
-
装载器处理程序依赖的动态库、必要的重定位等信息。
-
操作系统安排线程执行,CPU 从程序的入口位置开始执行机器指令。
注意,这里并不是简单地把整个 .exe 文件原封不动复制到内存中,也不是说 CPU 会直接从硬盘读取程序指令。
操作系统会通过虚拟内存机制管理这些代码和数据,部分内容可以在真正需要访问时才被载入物理内存。
另外,程序入口也不一定是我们编写的 main 函数。通常情况下,程序会先执行语言运行库提供的启动代码,在完成必要的初始化后,才调用 main。
现在终于把之前的内容串起来了:
我们编写 C 语言代码,经过预处理、编译、汇编和链接得到可执行文件;操作系统为可执行文件创建进程;CPU 执行程序对应的机器指令。
但又引出下一个问题:程序运行时,它的指令和数据被放在了内存中的什么位置?
About Memory
在之前介绍电脑硬件时,我们提到过,内存主要负责临时保存运行中的程序和数据。
我们先从一个简单的问题开始。
假设我们定义了两个变量:
1 | int a = 10; |
当程序运行到这里时,这两个变量的值需要存放在某个位置。
它们可能被放在寄存器里,也可能被存放在内存中。
如果数据存放在内存中,那么 CPU 如何知道去哪里寻找它们?
这就需要使用地址(Address)。
内存地址
可以把内存想象成一排连续编号的小格子。
1 | 地址 内容 |
这里左边的 0x1000、0x1001 等就是内存地址,右边则是对应地址中存放的数据。
其中的 0x 表示后面的数字采用十六进制表示。
为什么不用我们熟悉的十进制?因为计算机底层大量使用二进制,而十六进制和二进制之间的转换非常方便。一个十六进制数字刚好可以表示四个二进制位。
在通常按字节寻址的计算机中,每个地址对应一个字节,也就是 8 个二进制位。
上面的示例假设:
-
int占据 4 个字节。 -
数据采用小端序(Little Endian)存储。
因此,整数 10 在内存中被表示为 0A 00 00 00,整数 20 则被表示为 14 00 00 00。
在 C 语言中,可以通过取地址运算符 & 查看一个变量的地址:
1 |
|
这里:
-
a表示读取变量的值。 -
&a表示取得变量的地址。 -
%p用来输出指针形式的地址。
你可以尝试多运行几次这个程序,观察输出的地址是否发生变化。
需要注意,我们在程序中看到的地址一般是虚拟地址,并不一定是实际物理内存中的地址。
现代操作系统通过虚拟内存机制,为每个进程提供相对独立的地址空间。程序使用虚拟地址访问内存,操作系统与 CPU 中的内存管理硬件负责完成相应的地址转换和权限检查。
这也是为什么两个不同进程中可以出现数值相同的虚拟地址,但它们并不一定指向同一块物理内存。
指针
既然变量有地址,那么我们能不能把这个地址保存下来?
当然可以。
在 C 语言中,专门用于保存地址的变量通常被称为指针(Pointer)。
例如:
1 |
|
这里:
1 | int *p = &a; |
表示定义一个指向 int 类型对象的指针 p,并让它保存变量 a 的地址。
而:
1 | *p |
则表示访问指针 p 所指向的位置,并读取其中的值。
这种操作叫作解引用(Dereference)。
通过指针,程序可以间接访问某个内存位置的数据。
同样,程序也可以通过指针修改对应内存中的数据。
指针是 C 语言中非常强大的功能,也是经常感到困惑的部分。在学习 pwn 的过程中,指针又恰恰是必须理解的内容。因为大量二进制漏洞的本质,都和程序错误地访问、修改或使用内存有关。
About Stack
前面我们讨论了内存地址与指针,但还有一个问题没有解决。
在之前的加法程序中:
1 | int add(int a, int b) |
main 调用了 add,add 执行结束后,又返回了 main。
那么 CPU 是如何知道函数执行结束后,应该回到哪里继续执行的?
这就需要介绍栈(Stack)。
栈是一种遵循**后进先出(Last In, First Out,LIFO)**规则的数据结构。
可以想象一下,桌子上叠放了一摞书。先放上去的书位于下面,后放上去的书位于上面。如果只能从顶部取书,那么最后放上去的书就会最先被取走。
栈的基本操作通常有两种:
-
push:将数据压入栈。 -
pop:从栈顶取出数据。
在 x86-64 架构中,CPU 提供了与栈相关的指令,也有专门的栈指针寄存器 rsp,用于记录当前栈顶的位置。
函数调用与栈
回到最开始的代码:
1 | int result = add(3, 5); |
程序执行到这行代码时,需要跳转到 add 函数,执行加法,然后回到 main 继续执行。
在 x86-64 架构中,一种典型的函数调用方式是使用 call 指令。
call 会完成两个重要的操作:
-
将下一条指令的地址压入栈中,作为返回地址。
-
跳转到目标函数开始执行。
函数执行完毕后,可以使用 ret 指令,从栈中取出返回地址并跳转回去。
例如:
1 | call add |
这里的 call add 会跳转到 add 函数,等到 add 执行 ret 后,CPU 就可以回到调用位置后面的指令继续执行。
返回地址并不是栈中唯一保存的内容,在典型的函数执行过程中,栈还可能用于保存局部变量、需要保留的寄存器值,以及其他临时数据。函数对应的这部分栈空间通常被称为栈帧(Stack Frame)。
当然,具体有哪些数据被放在栈上,取决于目标架构的调用约定、编译器和优化方式。例如,在常见的 x86-64 调用约定中,部分函数参数优先通过寄存器传递,而不是全部放进栈中。
栈与 pwn 有什么关系?
我们终于可以接触到一个最经典的 pwn 漏洞:栈缓冲区溢出(Stack Buffer Overflow)。
先来看这样一段 C 语言代码:
1 |
|
这里定义了一个大小为 8 字节的字符数组 name。
如果我们输入:
1 | Alice |
程序可以正常保存这个字符串,并在末尾添加字符串结束符 \0。
但是,如果输入:
1 | AAAAAAAAAAAAAAAAAAAAAAAA |
会发生什么?
这里输入了 24 个字符,而 name 只有 8 字节的空间。
由于 scanf("%s", name) 并没有限制读取的最大字符数,程序就可能尝试把超过数组容量的数据写入相邻的内存区域。
这就是一种典型的缓冲区溢出。
在 C 语言中,越界写入会导致未定义行为。程序可能崩溃,也可能表面上正常运行,或者破坏其他数据。
如果数组恰好位于栈上,并且栈布局满足一定条件,那么越界写入就有可能破坏函数栈帧中的其他内容,甚至影响保存的返回地址。
而我们刚刚提到,ret 指令会使用栈中保存的返回地址来决定接下来执行哪一条指令。
那么,一个很自然的问题就出现了:
如果攻击者能够通过输入数据,影响函数返回时使用的地址,是否就有机会改变程序原本的执行流程?
答案是:在满足一定条件的情况下是可以的。这就是传统栈溢出利用技术的核心思路之一,也是 pwn 方向中一个非常重要的起点。
当然,现代程序通常还受到栈保护、地址空间布局随机化、不可执行内存等安全机制的保护,因此并不是任何一个缓冲区溢出漏洞都可以直接实现控制流劫持。但是,理解缓冲区溢出、栈帧、返回地址和函数调用之间的关系,是学习后续漏洞利用技术的基础。
到这里,我们已经从硬件、操作系统、API、函数、编译,逐渐走到了程序的执行过程和内存布局。
END
正如文章开头所说,这篇文章只是入门 pwn 的一个引子,实际上,入门 pwn 所需要掌握的知识正是计算机领域最基础的知识,即使不是做安全相关工作,也应当掌握这些。至于 pwn 环境配置,有许多相关博客,且笔者很长时间没更新过环境,就不在此赘述了。
pwn 的知识比较散,学习通常先只学习单个漏洞点,而高难题目或者实战中通常是多个漏洞点组成漏洞链。前期大致的学习顺序为 Linux x64 glibc 下的 栈 -> 堆/IO -> 内核。
如果本文对入门 pwn 起到作用,并成功完成了 ret2text 类题目,那么可进一步尝试:
-
学习动态调试,观察栈是如何变化的。
-
学习寄存器和寄存器传参。