pwn总结(施工中)
pwn 总结
声明:本文使用人工智能辅助编写,并引用了 z3phyr 的 pwn basic 课件、Dennis Yurichev 的 Understanding Assembly Language,非常感谢。
一、什么是 pwn
要回答这个问题,需要我们对计算机的基础知识有一个大概的了解
我们考虑比较简单的情形:假设只有内存和CPU,内存模型为一个很长的一维数组,数组上存放了指令和数据。CPU从程序指定的位置PC(program counter,程序计数器;在 x86 架构中即rip)开始,把这个位置的内存存放的数据解释为指令。执行完指令后,若指令不改变PC,则PC往下移一个内存单元,CPU继续读取PC存储的指令并执行,直到指向的指令为结束。
写成伪代码是这样
输入: 一维数组 mem[], 存放指令和数据; 起始位置 PC = start
变量: PC
while 程序未结束 do
① 取指: instr ← mem[PC] // 把 PC 处的内存单元解释为一条指令
② 执行: 执行 instr // 指令可以读写 mem 任意位置,也可能改写 PC
③ 更新: 若 instr 没有修改 PC // 顺序执行的情形
PC ← PC + 1 // 注意: 真实机器是 PC ← PC + 指令长度
④ 回到 ①
end while

不同架构可能会给这个模型做微调,比如x86的指令是不定长的,所以PC不一定会加1,可能会加 1~15 字节不等。真实的CPU也会引入寄存器,缓存等等。但这些不是我们这节要关注的重点
注意到这个模型的一个特点
把这个位置的内存存放的数据解释为指令
也就是说,数据与指令,二者直接来看是没有什么区别的,48 89 D0既可以表示单纯的数字,也可以被解释为指令mov rax,rdx,这取决于PC有没有指向这块内存。如果PC指向了这块内存,那么CPU会把它当成指令执行,否则CPU会把它视作一个普通的数字。
如果程序存在内存安全缺陷,使我们可以改写最终会进入 rip 的数据(如栈上的返回地址、函数指针、GOT 表项),控制流就会偏离原方向,从而执行我们安排的代码。这就是内存安全漏洞。
提示:内存能否执行还取决于页权限(NX/DEP),这也是后面讲 ROP 的伏笔。
基于此语境,我们可以给pwn下一个定义了
pwn:发现并利用程序的内存安全缺陷,把“一次越界读写”升级为对程序(乃至整个系统)的控制。
在本文中,我们主要研究的就是内存安全缺陷方面的pwn
二、环境准备与工具
研究 pwn 需要两样东西:一个Linux环境,以及一套顺手的工具。
环境准备
研究 pwn 全程在 Linux 上完成,初学者选择 Ubuntu 24.04 作为初始环境即可。
如果你是第一次使用 Linux,推荐阅读「实用技能拾遗」系列辅学课程网站快速熟悉 Linux。当然,也可以不必阅读,掌握 cd、grep、ls 以及软件包管理器的安装命令(Ubuntu 下是 apt)即可。
如果没有 Python 基础,推荐阅读菜鸟教程以及各种公开的 Python 课程。这里对 Python 的要求不高,掌握基本的代码阅读能力和编程能力即可。
工具清单
- IDA / Ghidra:两个著名的逆向工具。Ghidra 全开源;本文以 IDA 为主,可用 IDA Free,也可用 Ghidra
- Python:版本选择最新版本即可
- pwntools:Python 包,提供传输二进制数据、接收并解析数据、查找符号表等功能 pwntools
- pwndbg:gdb 调试插件,提供更好的调试体验 pwndbg 安装
- ROPgadget:自动寻找程序 gadget ROPgadget
- checksec:检查 Linux 二进制文件、运行进程以及系统内核启用了哪些安全保护。checksec
- patchelf:patch 二进制程序的动态库链接,
sudo apt install patchelf即可下载
这些工具正文用到时都会再介绍,现在只需要知道有这些工具即可。
pwntools 热身
这里,我们进行一个非常小的pwntools演示
假设存在一个exploit.c
#include<stdio.h>
#include<string.h>
char s[20];
int main()
{
scanf("%s",s);
if(strcmp(s,"flag")==0)
{
printf("flag{try_use_pwntools}");
}
else
{
printf("Wrong!");
}
return 0;
}
可以直接使用输入的方式来得到flag(当然,因为它真的非常简单),但是如果使用pwntools,该如何使用?
这里,我们给出一个简单的示例
from pwn import *
p=process("./exploit")
p.sendline(b'flag')
print(p.recvall())
process:打开一个程序。我们可以对这个程序进行输入,同时接收它的输出(如果目标在远程,我们使用remote("host",port))
sendline:顾名思义,就是要发送一串字节流,在末尾以换行符结尾。常见使用的还有send,只发送字节流,不添加换行符
b'flag':则是把flag转为字节流,再发送给程序。所以b'\x66\x6c\x61\x67'的含义与上面一样,只不过换成了16进制格式。
在pwntools中,所有发送与接收的底层都是字节流,所以我们要对所有发送的内容转换成字节,在必要时也要对接收到的内容进行字节转换
这里有一些常见的方式
'flag'.encode() # 把flag转换为字节流(默认编码为utf-8,pwn中基本不需要考虑这个)
t=bytes.fromhex('666c6167') # 从16进制转换为字节 t=b'flag'
t=t.decode() # 从字节转换为字符串 t='flag'
hex(b'flag') # 从字节转换为16进制
其余的方式可以询问ai或者查看pwntools文档。之后涉及到新的字节转换(整数转换为字节)以及别的pwntools功能时,会额外补充。
recvall():接收所有内容直到收到EOF(文件结束)状态再断开。
还有几个常见的recv,在这里列举出来
p.recv(100) # 最多收100个字节
p.recvn(100) # 恰好收100个字节
p.recvline() # 收到换行符为止
p.recvlines(100) # 收100行,返回一个列表
p.recvuntil(b"flag") # 接收信息直到收到字节流b'flag'时停止
可以动手试看看!
上面的代码可以用以下命令编译(关闭 canary 和 PIE,保留动态链接):
gcc exploit.c -o exploit -fno-stack-protector -no-pie
感兴趣的话可以用 checksec 检查生成的文件,提前熟悉这个工具。
三、pwn 的一些基础知识
汇编
pwn 的核心攻击手段——栈溢出覆盖返回地址、ROP 劫持控制流、格式化字符串改 GOT——本质上都是操纵函数调用过程。所以在本篇文档中,我们主要涉及函数调用相关的汇编知识。
当然,阅读Understanding Assembly Language也是极好的。
首先,我们来一个空函数
void f()
{
return;
}
我们可以用gcc来输出其汇编
gcc -S -masm=intel test.c -o test.s
-S表示输出汇编代码,-masm=intel表示汇编代码的输出为intel风格。默认为AT&T风格,两者的不同可以自行对比。因为之后IDA的反汇编也是用intel风格,所以我这里就进行修改了
.file "test.c"
.intel_syntax noprefix
.text
.globl "f"
.type "f", @function
"f":
.LFB0:
.cfi_startproc
push rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp
.cfi_def_cfa_register 6
nop
pop rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size "f", .-"f"
.ident "GCC: (GNU) 16.1.1 20260728"
.section .note.GNU-stack,"",@progbits
摘掉零七八碎的部分,重要的就是这几个
"f":
push rbp
mov rbp, rsp
pop rbp
ret
那么这个是什么?这个push和pop又是啥意思?ret是不是就是return?
要理解这个,我们先需要理解内存模型以及寄存器,这样我们才能看懂指令的意思是什么
内存模型

这是一个典型的Linux内存模型,内存地址从低到高增加。
考虑一段C代码
#include<stdlib.h>
int a[1000];
int b=1;
int f()
{
int c=1;
int *d=malloc(10*sizeof(int));
static int e;
free(d);
return 0;
}
我们分别定义了五个东西:a,b,c,d,e,但是这五个数据,在内存中存储的位置不一样:c 和 d 这两个变量本身在 stack;d 指向的 malloc 内存在 heap;a、e 在 bss;b 在 data。我们来一一解释这四个区域分别是什么。
栈(stack):这部分内存区域,存储的是函数内没有static修饰的数据(也就是局部变量)。有一个栈底,位于某个较高的内存位置。存储数据时,像栈一样压入,栈顶指针随即下移。因此,这个部分的内存扩大时,是向下生长的。
bss和data:这两个内存区域,存储全局变量与static修饰的变量。bss存储初始化为0的部分,data存储初始化非0的部分。这两个的区别我们在这里不详细展开
heap:这部分内存,我们简化理解,是通过malloc和free手动管理的内存区域。其内存增长方向为逐渐向上。
在本篇文档中,我们重点考虑如何在栈内存部分进行攻击,所以我们会详细介绍这部分内容
寄存器
寄存器就是 CPU 能直接访问(读写)的变量。读写内存需要地址,寄存器只需要名字。
在 x86_64 架构下,主要有两种寄存器:通用寄存器和特殊寄存器。
通用寄存器:CPU 理论上可以随便操作的寄存器,但为了程序的有序性,会在特殊场合给它们约定含义。有 rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8-r15。现阶段只需知道rbp/rsp/rip 三个,其余寄存器的作用之后讲到。
特殊寄存器:程序不能像通用寄存器那样直接读写,只能通过特定指令间接改变——jmp/call/ret 改变 rip,cmp/test 影响 rflags。一般考虑 rip 和rflags 就足够。
内存模型的相关寄存器
对于栈这个内存区域,至少需要两个指针:当前栈帧指针(之后会讲)和栈顶指针。这两个指针分别由rbp(base pointer)和rsp(stack pointer)保存
而我们先前提到的内存模型中
CPU从程序指定的位置
PC开始,把这个位置的内存存放的数据解释为指令。
这个PC(程序计数器)指代的就是rip(instruction pointer)。此后不再使用PC,统一称rip。
一些相关的指令操作
我们有几个指令来进行与栈有关的指令操作
mov a,b:作用等价于a=b,这两边是随意的,无论是寄存器,内存存储数据还是一个数(当然,x86_64规定两边不能都是某个内存地址,即不能实现内存之间直接搬运数据)push x:先rsp-=8,再[rsp]=x。这里的x,可以是寄存器,也可以是某个数,还可以是某个内存地址
提示:x86-64 没有任意 64 位立即数的
push;需要压入 64 位大常数时,通常用mov rax, imm64; push rax。
pop x:先x=[rsp],再rsp+=8。这里的x,就只能是寄存器或者内存地址了
-
jmp f:rip=f,也就是rip指向f的位置,再开始执行f下面的代码 -
call f:先压入返回地址(call后面那条指令的地址),再jmp f。
ret:相当于pop rip——把栈顶的返回地址弹进rip,跳回去。
现在,我们可以来尝试理解这段代码了!
"f":
push rbp
mov rbp, rsp
pop rbp
ret
首先,push rbp,把rbp的内容保存到栈上
其次,mov rbp,rsp,令rbp=rsp
随后,pop rbp,把栈上的内容弹出给rbp
然后,ret,rip接收栈上弹出的地址。
好啦,你已经能成功理解一个空函数的汇编代码啦,鼓掌。(呱唧呱唧)
函数栈帧
但是啊,有一个问题:为什么一个空函数也要包含汇编指令呢,明明它什么也没干?这里就涉及到一个概念——函数栈帧。
我们来看这样一段程序
void f()
{
return;
}
void g()
{
f();
}
编译,得到汇编代码,并去除掉无关紧要的代码部分
"f":
push rbp
mov rbp, rsp
pop rbp
ret
"g":
push rbp
mov rbp, rsp
call "f"
pop rbp
ret
在这个部分中,我们发现:函数的开头和结尾,都有一段相同的话语,这是什么呢?
这其实是函数的序言(prologue) 和 尾声(epilogue)。它们的作用,是给每次函数调用建立和拆除一块独立的 栈帧(stack frame)。
为什么需要栈帧?主要因为它解决了两个问题:
- 每次调用都需要一块独立的数据区。 局部变量、
call压入的返回地址、保存的旧rbp,都要有地方存放。如果f被多次调用(例如递归),每次调用都必须有自己的一份,否则内层调用会覆盖外层调用的数据。 - 执行过程中
rsp会一直变化,不能直接当固定参照物。 每次push、pop,或者用sub rsp, N开辟局部变量,rsp都会移动。如果所有数据都用rsp + 偏移表示,同一个局部变量的偏移就要随时调整,非常麻烦。
所以我们约定:每次函数调用在栈上占用一整块连续区域,称为栈帧;再让一个相对稳定的寄存器 rbp 指向这个栈帧的基址,作为本次调用的“固定锚点”。这样:
- 局部变量用
[rbp - 偏移]访问; - 返回地址在
[rbp + 8]; - saved rbp 中保存的是调用者的
rbp,它像链表一样把各层栈帧串起来,返回时pop rbp就能恢复上一层。
开头和结尾重复的“话语”,正是建立和拆除栈帧的动作:
"f":
push rbp ; 保存调用者 g 的 rbp,形成 saved rbp
mov rbp, rsp ; 令 rbp = 当前栈顶,作为 f 的栈帧基址
...
pop rbp ; 恢复 g 原来的 rbp
ret ; 弹出返回地址,跳回 g 的 call 之后
结合 g 调用 f 的例子:
g执行call f,把返回地址压栈;f开头push rbp,把g的rbp压栈保存;mov rbp, rsp后,rbp固定指向 f 的栈帧基址;f结尾pop rbp,恢复g的rbp;ret弹出返回地址到rip,回到g继续执行。

所以:
- 序言(开头):
push rbp+mov rbp, rsp——保存旧栈帧,建立新栈帧; - 尾声(结尾):
pop rbp+ret——拆除当前栈帧,恢复上一层并跳回。
这也回答了“为什么空函数也要有这几句”:默认编译下,编译器仍按统一的栈帧规则生成序言和尾声,便于用 rbp 作为稳定锚点访问局部变量、支持调试;即使删掉这几句,ret 依然能靠 call 压入的返回地址正常返回。
如果函数有局部变量,编译器还会在
mov rbp, rsp后加sub rsp, N来分配局部变量空间,返回时常用leave(等价于mov rsp, rbp; pop rbp)来回收。这里因为是空函数,只剩最简单的空壳。
函数调用约定
很好,我们已经具备汇编的基本知识了!现在让我们来看看更复杂一点的函数
void f(long a1,long a2,long a3,long a4,long a5,long a6,long a7,long a8,long a9,long a10)
{
a10=a9+a8+a7+a6;
return;
}
void g()
{
long u=9;
f(1,2,3,4,5,6,7,8,9,10);
return;
}
(读者:这不是复杂一点!)
编译得到汇编代码,去除掉不重要的部分
"f":
push rbp
mov rbp, rsp
mov QWORD PTR -8[rbp], rdi
mov QWORD PTR -16[rbp], rsi
mov QWORD PTR -24[rbp], rdx
mov QWORD PTR -32[rbp], rcx
mov QWORD PTR -40[rbp], r8
mov QWORD PTR -48[rbp], r9
mov rdx, QWORD PTR 32[rbp]
mov rax, QWORD PTR 24[rbp]
add rdx, rax
mov rax, QWORD PTR 16[rbp]
add rdx, rax
mov rax, QWORD PTR -48[rbp]
add rax, rdx
mov QWORD PTR 40[rbp], rax
nop
pop rbp
ret
"g":
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR -8[rbp], 9
push 10
push 9
push 8
push 7
mov r9d, 6
mov r8d, 5
mov ecx, 4
mov edx, 3
mov esi, 2
mov edi, 1
call "f"
add rsp, 32
nop
leave
ret
先别被这一长串汇编吓到,我们把它拆成三部分看:参数怎么进到 f 里、f 里怎么使用参数、g 调用完怎么收拾现场。这一节还会出现 sub、add、leave、nop 几个新指令,正好借这个机会补上。
前六个参数走寄存器
我们把 g 里 call f 之前的动作单独拎出来:
push 10 ; 第 10 个参数
push 9 ; 第 9 个参数
push 8 ; 第 8 个参数
push 7 ; 第 7 个参数
mov r9d, 6 ; 第 6 个参数
mov r8d, 5 ; 第 5 个参数
mov ecx, 4 ; 第 4 个参数
mov edx, 3 ; 第 3 个参数
mov esi, 2 ; 第 2 个参数
mov edi, 1 ; 第 1 个参数
call "f"
对照 C 代码里的 f(1,2,3,4,5,6,7,8,9,10),可以发现前六个参数根本没有压栈,而是被依次放进了六个寄存器:

| 参数 | 寄存器 |
|---|---|
| 第 1 个 | rdi |
| 第 2 个 | rsi |
| 第 3 个 | rdx |
| 第 4 个 | rcx |
| 第 5 个 | r8 |
| 第 6 个 | r9 |
这就是 64 位 Linux 的 System V AMD64 调用约定的第一条规则:前六个整数或指针参数依次放在 rdi, rsi, rdx, rcx, r8, r9。以后我们在 ROP 里到处找 pop rdi; ret,就是为了控制第一个参数。
你可能会问:这里写的明明是 edi、esi、ecx,怎么说是 rdi、rsi、rcx?在 x86-64 中,往 32 位寄存器(edi)写入时,CPU 会自动把对应 64 位寄存器(rdi)的高 32 位清零。因为这里的参数都是小常数,mov edi, 1 和 mov rdi, 1 效果完全一样,mov r9d, 6 和 mov r9, 6 也是同理,而 32 位写法机器码更短,编译器当然选短的。
第七个参数开始走栈
从第七个参数开始,寄存器不够用了,于是编译器把它们放到了栈上。注意压栈顺序:
push 10 ; 第 10 个参数
push 9 ; 第 9 个参数
push 8 ; 第 8 个参数
push 7 ; 第 7 个参数

四个 push 是右边的参数先压栈:先压第 10 个参数 10,最后压第 7 个参数 7。在 64 位模式下,每次 push 压入 8 字节,四个参数共 32 字节。当然,这些参数都是小常数,可以直接 push;如果是 64 位大常数,就要按上一节说的 mov rax, imm64; push rax 来。
等 call 压入返回地址、f 再压入旧 rbp 之后,f 看到的栈是这样的:
这也解释了 f 开头为什么要把前六个参数存进自己的栈帧:
mov QWORD PTR -8[rbp], rdi
mov QWORD PTR -16[rbp], rsi
mov QWORD PTR -24[rbp], rdx
mov QWORD PTR -32[rbp], rcx
mov QWORD PTR -40[rbp], r8
mov QWORD PTR -48[rbp], r9

-8[rbp] 是 [rbp - 8] 的另一种写法;QWORD PTR 表示一次读写 8 字节。局部变量就是这么在栈帧里保存的。
再看 f 计算 a10 = a9 + a8 + a7 + a6 的部分:
mov rdx, QWORD PTR 32[rbp] ; rdx = a9 = 9
mov rax, QWORD PTR 24[rbp] ; rax = a8 = 8
add rdx, rax ; rdx = 9 + 8 = 17
mov rax, QWORD PTR 16[rbp] ; rax = a7 = 7
add rdx, rax ; rdx = 17 + 7 = 24
mov rax, QWORD PTR -48[rbp] ; rax = a6 = 6
add rax, rdx ; rax = 24 + 6 = 30
mov QWORD PTR 40[rbp], rax ; a10 = 30
这里出现了新指令 add:
add a, b等价于a = a + b,结果保存在第一个操作数里。与它对应的是sub:sub a, b等价于a = a - b。
nop 则是“什么都不做”指令,只负责占位,看到直接跳过即可。
最后看 g 调用完之后的收尾:
add rsp, 32
nop
leave
ret
call f 返回后,栈上还留着 g 自己压进去的四个参数,共 32 字节。add rsp, 32 就是把栈顶指针往上推 32 字节,把压栈时借的空间还回去——栈向下生长,sub 是借,add 是还。
leave 是我们前面预告过的指令:
leave等价于mov rsp, rbp; pop rbp,一步完成“回收局部变量空间 + 恢复调用者的rbp”。因为g开头用sub rsp, 16开辟过局部空间,所以结尾用leave;像f那样没有局部空间时,pop rbp就够了。
最后 ret 弹出返回地址,g 的这次调用就完整结束了。
返回值放在 rax
f 的返回值是 void,所以我们没看到返回值的例子。把函数改成:
long add(long a, long b)
{
return a + b;
}
编译后摘掉不重要的部分:
"add":
push rbp
mov rbp, rsp
mov QWORD PTR -8[rbp], rdi
mov QWORD PTR -16[rbp], rsi
mov rdx, QWORD PTR -8[rbp]
mov rax, QWORD PTR -16[rbp]
add rax, rdx
pop rbp
ret
调用约定规定:整数或指针返回值放在 rax。所以 call add 返回后,调用者直接去 rax 里拿结果即可,不需要额外约定。
以后你会经常看到 xor eax, eax,它和 mov eax, 0 等价——一个数和自己异或一定为 0,机器码还更短。xor a, b 的含义是 a = a ^ b。
哪些寄存器可以随便用
寄存器虽然多,但调用约定给它们分了工:
- 调用者保存寄存器:
rax, rcx, rdx, rsi, rdi, r8-r11。被调函数可以随便改,不需要恢复;调用者如果还想留着里面的值,必须在call之前自己保存。 - 被调用者保存寄存器:
rbx, rbp, r12-r15。被调函数如果用到了,返回前必须恢复原值。函数序言保存rbp就是这条规则。 rsp:永远是栈顶指针,不能随便当作普通变量用。
对 pwn 来说,这条分类解释了为什么 gadgets 喜欢用 pop rdi; ret、pop rsi; ret:这些寄存器是调用者保存的,劫持控制流时随便填,不用担心破坏什么必须恢复的状态。
栈对齐:为什么是 16 字节
回到 g 的开头:
push rbp
mov rbp, rsp
sub rsp, 16
这里出现了 sub。sub rsp, 16 就是 rsp = rsp - 16,因为栈向下生长,所以它把栈顶又往下推了 16 字节,给局部变量 u 腾地方。
但 u 是 long,只占 8 字节,为什么不是 sub rsp, 8?这就是栈对齐规则在起作用。System V AMD64 要求:
- 执行
call之前,rsp % 16 == 0; call压入返回地址后,进入函数时rsp % 16 == 8。
g 进入时 rsp % 16 == 8;push rbp 后变成 0;sub rsp, 16 后还是 0;再 push 四个参数(32 字节)后仍为 0,于是执行 call f 时恰好满足“call 前 rsp % 16 == 0”。
为什么违反了对齐会崩:编译器默认这条约定成立,因此可能直接生成
movaps这类要求 16 字节对齐的 SSE 指令。以后我们做 ROP 时用ret直接跳进函数,常常比正常call差 8 字节,就会在movaps处崩溃。解决办法是多垫一个retgadget,把rsp拨回% 16 == 8的状态。
最后提醒一句:这套 System V AMD64 是 64 位 Linux 的调用约定。32 位 Linux 用的是 cdecl,参数全部压栈、调用者负责清理,和这里完全不同。看到 context.arch = "i386" 时不要套用 rdi 传参。
到这里,我们已经有能力读懂一个普通函数调用在汇编层面的完整过程了。下一章就开始搞破坏:如果输入比缓冲区长,会发生什么?
本文来自博客园,作者:cmd_pig,转载请注明原文链接:https://www.cnblogs.com/cmd-pig/p/22508548

浙公网安备 33010602011771号