背景知识
地址空间(Address space,Prozess Adressraum)
地址空间可以被看作是一个巨大的 一维字节数组,在程序运行时(与它潜在的大小相比),其中只有少数位置存有数据。由于地址空间占用非常稀疏,操作系统会将其划分为大小相等的 页(Pages),其中只有被操作系统释放(映射)的页才可以被访问。
程序从硬盘被加载到低地址空间(对应的页由操作系统或程序加载器自动提供)。
用于执行的机器代码存放在 文本段(Text Segment) 中,静态初始化的变量和字符串常量存放在 数据段(Data Segment) 中。静态变量如果在程序开始时尚未被赋值,则会放在 BSS 段 中,并由操作系统填充为零字节。
程序所需的动态链接库也由这三类段组成,并由程序加载器加载到更高的内存地址。由于这些库所需的内存是通过 mmap 系统调用 向操作系统申请的,因此它们也被称为 MMap 段。
在程序初始化过程中,还会额外保留两个区域:
- 栈(Stack):用于自动管理的变量。
- 堆(Heap):用于动态分配的变量。
栈在每次函数调用时会扩展一个 栈帧(Stack Frame)。在栈帧的内存区域中存储有当前函数的局部变量,以及一些管理信息,例如 返回地址。返回地址记录了当前执行的函数是从哪一个程序地址被调用的。随着函数调用深度的增加,栈会从高地址向低地址方向增长。
当需要在函数执行完毕后变量仍然存在时,就必须使用 堆 来进行动态内存分配(因为栈上的变量会在函数返回时自动释放)。在这种情况下,程序可以通过 libc 提供的分配器 使用 malloc 函数向系统申请内存。如果可能,分配器会返回对程序启动时预留的堆区域的引用;如果堆空间不足,分配器会通过 mmap 系统调用 向操作系统请求一个新的 MMap 段,并返回对该段的引用。
C 标准库(libc)除了包含内存分配器之外,还提供了许多常用函数,以便简化与操作系统的交互。
在程序执行过程中,如果访问了一个无效的地址(即未映射的页),操作系统会向程序发送一个 段错误(Segmentation Fault) 信号。如果程序没有对此进行处理,就会导致程序终止。

X86汇编
按 SysV x64 ABI,函数前三个参数放在 rdi, rsi, rdx。所以
1 | pop rdi ; ret |
会起到设置参数的作用。(将指定参数存入寄存器。)
pop rbp 则是会把栈顶的 8 字节弹到寄存器 rbp,同时 rsp += 8。
安全性检查
我们可以使用checksec命令检查一份二进制文件的安全性.
安装:
1 | sudo apt install checksec |
例子:
1 | └─$ checksec ./vuln |
1 | └─$ checksec ./racecar |
解释:
Arch:二进制文件的架构amd64-64-little: x86-64 架构的64位小端序ELF可执行文件;i386-32-little:x86 架构的32位小端序ELF可执行文件
RELRO:Read-Only Relocations,表示重定位表只读,能防止通过修改 GOT(全局偏移表)来劫持控制流。Partial RELRO:只部分保护;Full RELRO:GOT 完全只读,更安全。
Stack:是否启用了栈金丝雀(Stack Canary)。No canary found:没有保护,容易被栈溢出攻击;Canary found:能检测栈溢出覆盖返回地址的行为。
NX:Non-eXecutable stack/heap,表示数据段不可执行。disabled:攻击者可以直接在栈/堆执行恶意代码;enabled:栈和堆不能直接执行代码,阻止传统 shellcode 注入。
PIE:Position Independent Executable,是否支持地址随机化(ASLR)。No PIE:程序总是加载在固定地址(如 0x400000),便于攻击者构造 ROP;PIE enabled:主程序基址随机化,增加攻击难度。
Stripped:是否剥离了符号信息。No:包含函数名、符号,方便调试和逆向分析;Yes:已剥离,更难逆向,但对运行安全性影响不大。
Debuginfo:是否带有调试信息(DWARF 等)。No:一般发布版本应去掉;Yes:含源码级调试信息,方便开发调试,但可能泄露过多信息。
下面介绍一些基本的漏洞以及对应的攻击方法:
溢出 Buffer Overflow
漏洞/常见危险函数
Buffer overflow(缓冲区溢出)漏洞 常见于不做边界检查或边界检查错误的输入/拷贝函数;可覆盖栈/堆/静态区中的相邻数据(如返回地址、函数指针、对象元数据等)。
gets()- 没有任何输入长度限制/检查。
fgets(buf, size, stdin)如果
size大于给buf的实际大小,则会溢出。1
2char buf[32];
fgets(buf, 128, stdin);
scanf("%s", buf)%s会不断读入字符直到遇到空白符(空格、回车、制表符等)。安全写法:
1
scanf("%15s", buf); // 最多读 15 个字节 + 1 个 '\0'
read(0, buf, count)如果
count大于给buf的实际大小,则会溢出。1
2char buf[32];
read(0, buf, 0x100);
栈溢出(Stack buffer overflow)
栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。(https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/stackoverflow-basic/ )
结构体字段劫持
最简单的攻击目的便是修改写入变量附近的某个变量的值。比如说在下面这个例子里:
1 |
|
我们写入的变量为user这个struct里的name,但是由于输入长度限制为200,而实际的name的存储空间仅为64,并且struct里的内容的存储空间是连续的,所以可以通过输入
1 | 'A'*64 + '\x01\x00\x00\x00' |
将原本的is_admin的值修改为(被覆盖掉为)1。(注意大部分架构都是使用的小端序,并且int的大小为4个Byte,所以是\x01\x00\x00\x00)
ROP(Return Oriented Programming)
ret2text
ret2text(Return-to-Text)即控制程序执行程序本身已有的的代码 (即, .text 段中的代码) 。
这种Exploit一般需要:
1 | Stack: No canary found |
如果开启了PIE,则需要先泄露基址,再计算所需函数的实际地址。
例子(假设程序中原本有一个不需要参数的win()函数,它会调用system("/bin/sh")。):
1 | win_address = 0x0000000000401172 |
ret2shellcode
ret2shellcode(Return-to-Shellcode)指的是:
在利用栈溢出等漏洞时,将自己编写的shellcode注入到内存(常见是栈/堆/.bss)里,再通过覆盖返回地址或利用跳转 gadget(如 jmp *sp)把控制流重定向到该 shellcode,从而直接执行任意指令。
这种Exploit一般需要:
1 | NX: NX enabled #重中之重 |
最小化示例(x64,NX 关闭;利用 jmp rsp gadget;PIE 关闭):
1 | from pwn import * |
当程序执行ret时,RIP被改成JMP_RSI,CPU开始执行jmp rsi。这条指令的含义是:跳到RSI指向的地址继续执行。此时RSI里还保留着刚才缓冲区的地址,所以会跳转到缓冲区的开头执行我们放在里面的shellcode。
ret2libc
ret2libc(Return-to-Library)指的是:
在存在栈溢出等漏洞、但无法直接执行注入的 shellcode(通常因为 DEP/NX 保护禁止在栈上执行代码)的情况下,攻击者将程序的控制流劫持到libc库中的现成函数,比如 system(),从而达到执行任意命令的目的。
这种通常较为复杂。
格式化字符串漏洞 Format String Vulnerability
正常情况:
在进入printf函数之后,函数会首先获取第一个参数,一个一个读取其字符会遇到两种情况:
- 当前字符不是
%,直接输出到相应标准输出。 - 当前字符是
%, 继续读取下一个字符- 如果没有字符,报错;
- 如果下一个字符是
%, 输出%; - 否则根据相应的字符,获取相应的参数,对其进行解析并输出。
例子:
1 |
|
栈的大概结构:
1 | +----------------------+ |
函数会从栈里正常读取指定的变量的值。
而当我们在使用格式化字符串函数但是并没有给定具体变量的情况下:
1 |
|
则会从栈中依次读取未定义的值作为参数进行格式化输出。
在CTF的题目里这个漏洞一般的表现如下:
1 |
|
我们可以利用这个漏洞读取栈上的内容(如变量值、返回地址等)或者通过 %n 格式符(就是我们之前提到的那个危险的格式符)向指定内存地址写入数据。
GDB
GDB(GNU Debugger)是 GNU 项目的调试器,主要用于调试 C/C++ 等程序。
安装
1 | sudo apt install gdb |
安装Pwndbg
pwndbg 是 GDB 的调试插件,提供栈/堆/寄存器上下文展示以及 cyclic、rop、heap、format 等命令,用于更高效地调试二进制漏洞。
安装:
1 | # 1) 依赖 |
安装前:

安装后:

常用命令
使用GDB打开二进制文件
1
2
3gdb ./vuln
#或者
gdb -q ./vuln-q:quiet,安静模式,不显示启动欢迎信息。或者是先普通打开gdb,然后再选择文件:
1
2
3gdb
pwndbg> file ./pwn
运行程序
1
2
3
4run
run < input.txt # 用文件输入
run <<< "AAAA" # 简单输入
查看汇编代码
1
2
3
4disassemble main
disass main
disassemble win
设置断点
1
2break main # 在 main 函数处断点
b main
查看寄存器
1
2
3
4info registers
i r
x/20gx $rsp # 查看栈内容(20 个 8 字节,从 RSP 开始)
搜索gadgets
1
2
3
4
5rop --grep "ret"
rop --grep "pop rdi ; ret"
rop --grep "pop rsi ; pop r15 ; ret"
- a
确定返回地址偏移
1 | pwndbg> | cyclic 1200 | tee /tmp/pat > /dev/null |
| cyclic 1200 | tee /tmp/pat > /dev/null:生成模式串并保存到文件cyclic 1200:让 pwndbg 生成长度为 1200 字节的 De Bruijn 模式串(也叫“花指纹/模式串”)。它的特性是:任意连续的 n 字节子串在整段里唯一(默认 n=4)。长这样:1
2pwndbg> | cyclic 200 | tee /tmp/pat
aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaatee /tmp/pat:写入文件/tmp/pat。> /dev/null:隐藏输出。
x/gx $rsp:读取“将要被 ret 弹到 RIP”的 8 字节x(examine):查看内存。/gx:一次显示 1 个 8 字节(g=8 bytes,“giant word”)并用 十六进制(x)格式。$rsp:取 RSP 寄存器 作为要查看的内存地址。
cyclic -n 8 -o 0x...:用得到的返回地址反推偏移-o(offset):告诉cyclic“这就是我在栈上读到的那 8 个字节”,请帮我算“它在刚才那段模式串里的起始位置(偏移)”。-n 8:在 64 位上我们读的是 8 字节(gx),要用 8 字节粒度的唯一性去匹配;否则默认 n=4 可能匹配失败或给错结果。0x...:把上一步x/gx $rsp看到的 十六进制数原样填进来。
输出:一个十进制数字,比如 Found at offset 18 —— 这就是覆盖到返回地址的偏移(字节数)。
例子


1 | pwndbg> | cyclic 1200 | tee /tmp/pat > /dev/null |
所以偏移为18。
Ropgadget
用于高效准确查找gadgets。
下载:
1 | sudo apt install python3-ropgadget |
用法:
1 | ROPgadget --binary ./vuln | grep -E "pop rdi ; ret" |
Pwntools
Pwntools 是面向 CTF/Pwn 场景的 Python 库,提供连接服务、构造 payload、地址与数据的打包/解包、gadget 检索、shellcode 汇编以及 GDB 调试等功能,用于高效编写与调试利用代码(exploit)。
安装
1 | pip3 install pwntools -U |
常用 API(按任务分类)
1) 连接与交互
- (假设
io=)process(path)/remote(host, port):本地测试/远程连接 io.send(data)/io.sendline(data):发送数据/行io.recv(n)/io.recvline()/io.recvuntil(delim):接收io.sendafter(delim, data)/io.sendlineafter(delim, data):等提示再发(菜单题常用)io.clean(timeout=0.1):清空缓冲垃圾输出io.interactive():拿到交互式 shell,类似于nc。切换至这个模式时会自动print当前所有缓冲数据。
2) 打包/解包与快捷拼接
p32(x) / p64(x),u32(b) / u64(b):整型与字节序互转(小端)flat(*args, filler=b'A', length=None):会把传入的各类对象智能转换成一段字节串。它会根据context(架构/字节序/位宽)自动处理对齐与打包。例子:
1
2
3
4
5
6
7
8
9payload = flat({
0: shellcode, # shellcode
256: JMP_RSI_Adress
})
payload = flat({
0: b"A"*84,
84: win_Adress
})fit({offset: data, ...}, filler=b'A'):按偏移放置数据cyclic(n)/cyclic_find(value, n=4/8):花指纹与偏移定位(也可用 pwndbg 的)
3) 程序信息与 ROP 工具
ELF(path):读符号、plt/got、段地址等elf.symbols[]:从符号表读取符号(函数/全局变量)的地址。返回int。比如说:1
main = elf.symbols['main'] # main 函数入口
elf.search():在可执行文件已映射的各段中按字节序列搜索内容并返回一个生成器(迭代得到每个匹配的地址)。常配合next(...)取第一个匹配。。比如说1
2
3elf.search(b'/bin/sh') # 查找'/bin/sh'字符串
pop_rdi = next(elf.search(asm('pop rdi ; ret'))) # 查找'pop rdi ; ret'命令elf.got[]:获取 GOT 表项地址(存放真实函数地址的指针位置)。返回int(可写段;Full RELRO 下只读)。比如说1
got_puts = elf.got['puts'] # 取 puts 的 GOT 表项地址(&puts@GOT)
elf.plt['puts']:获取 PLT 跳板(桩函数)的地址。返回int。比如说:1
plt_puts = elf.plt['puts'] # 调用 puts@plt,把某地址当作参数打印
ROP(elf):自动搜 gadget/拼 ROProp.find_gadget(['pop rdi', 'ret']),rop.call('puts', [addr]),rop.chain()
context.binary = elf:让 pwntools 自动跟随架构
4) Shellcode / 汇编
asm('mov rax, 60; xor rdi, rdi; syscall'):将汇编转为机器码shellcraft.sh()/asm(shellcraft.sh()):/bin/sh的Shellcode。disasm(b'\x90\x90\xcc'):反汇编字节流
5) 调试辅助
gdb.attach(io, gdbscript='b *0x401234\nc'):本地挂 gdbgdb.debug([path], gdbscript=...):由 gdb 启动进程(便于断点)
6) 杂项
hexdump(data):十六进制打印log.info()/success()/warning():美化日志context.timeout = 2:全局超时pause():脚本暂停,手动操作后继续
模板
process用于本地测试,地址给二进制文件的地址;remote用于连接服务器。
1 | from pwn import * |
题目分类
- Pwn相关基础知识:
- Buffer Overflow:
- 修改目标变量的值:
- ROP:
- ret2libc:
- 攻防世界 level0 Writeup
- 攻防世界 level2 Writeup
- HTB You_know_0xDiablos Writeup
- TJCTF 2025 pwn/i-love-birds Writeup
- TUM Binary Exploitation qualification challenge (这题在利用Buffer Overflow漏洞修改返回地址的基础上多了一个需要绕过的检测。)
- ret2shellcode:
- 3
- ret2libc:
- 格式化字符串漏洞:
- HTB racecar Writeup(利用格式化字符串漏洞读取信息。)
- 攻防世界 CGfsb Writeup (利用格式化字符串漏洞修改目标变量的值。)
- 未完待续…