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

pc_loop

不同架构可能会给这个模型做微调,比如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。当然,也可以不必阅读,掌握 cdgrepls 以及软件包管理器的安装命令(Ubuntu 下是 apt)即可。

如果没有 Python 基础,推荐阅读菜鸟教程以及各种公开的 Python 课程。这里对 Python 的要求不高,掌握基本的代码阅读能力和编程能力即可。

工具清单

  1. IDA / Ghidra:两个著名的逆向工具。Ghidra 全开源;本文以 IDA 为主,可用 IDA Free,也可用 Ghidra
  2. Python:版本选择最新版本即可
  3. pwntools:Python 包,提供传输二进制数据、接收并解析数据、查找符号表等功能 pwntools
  4. pwndbg:gdb 调试插件,提供更好的调试体验 pwndbg 安装
  5. ROPgadget:自动寻找程序 gadget ROPgadget
  6. checksec:检查 Linux 二进制文件、运行进程以及系统内核启用了哪些安全保护。checksec
  7. 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

那么这个是什么?这个pushpop又是啥意思?ret是不是就是return

要理解这个,我们先需要理解内存模型以及寄存器,这样我们才能看懂指令的意思是什么

内存模型

Linux 内存模型

这是一个典型的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,但是这五个数据,在内存中存储的位置不一样:cd 这两个变量本身在 stackd 指向的 malloc 内存在 heapaebssbdata。我们来一一解释这四个区域分别是什么。

栈(stack):这部分内存区域,存储的是函数内没有static修饰的数据(也就是局部变量)。有一个栈底,位于某个较高的内存位置。存储数据时,像栈一样压入,栈顶指针随即下移。因此,这个部分的内存扩大时,是向下生长的。

bss和data:这两个内存区域,存储全局变量与static修饰的变量。bss存储初始化为0的部分,data存储初始化非0的部分。这两个的区别我们在这里不详细展开

heap:这部分内存,我们简化理解,是通过mallocfree手动管理的内存区域。其内存增长方向为逐渐向上。

在本篇文档中,我们重点考虑如何在内存部分进行攻击,所以我们会详细介绍这部分内容

寄存器

寄存器就是 CPU 能直接访问(读写)的变量。读写内存需要地址,寄存器只需要名字。

在 x86_64 架构下,主要有两种寄存器:通用寄存器和特殊寄存器。

通用寄存器:CPU 理论上可以随便操作的寄存器,但为了程序的有序性,会在特殊场合给它们约定含义。有 rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8-r15。现阶段只需知道rbp/rsp/rip 三个,其余寄存器的作用之后讲到。

特殊寄存器:程序不能像通用寄存器那样直接读写,只能通过特定指令间接改变——jmp/call/ret 改变 ripcmp/test 影响 rflags。一般考虑 riprflags 就足够。

内存模型的相关寄存器

对于这个内存区域,至少需要两个指针:当前栈帧指针(之后会讲)和栈顶指针。这两个指针分别由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

stack_push

  • pop x:先x=[rsp],再rsp+=8。这里的x,就只能是寄存器或者内存地址了

stack_pop

  • jmp frip=f,也就是rip指向f的位置,再开始执行f下面的代码

  • call f:先压入返回地址(call 后面那条指令的地址),再 jmp f

stack_call

  • ret:相当于 pop rip——把栈顶的返回地址弹进 rip,跳回去。

stack_ret

现在,我们可以来尝试理解这段代码了!

"f":
	push rbp
	mov rbp, rsp
	pop rbp
	ret

首先,push rbp,把rbp的内容保存到栈上

frame_push

其次,mov rbp,rsp,令rbp=rsp

frame_mov

随后,pop rbp,把栈上的内容弹出给rbp

frame_pop

然后,retrip接收栈上弹出的地址。

frame_ret

好啦,你已经能成功理解一个空函数的汇编代码啦,鼓掌。(呱唧呱唧)

函数栈帧

但是啊,有一个问题:为什么一个空函数也要包含汇编指令呢,明明它什么也没干?这里就涉及到一个概念——函数栈帧

我们来看这样一段程序

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)

为什么需要栈帧?主要因为它解决了两个问题:

  1. 每次调用都需要一块独立的数据区。 局部变量、call 压入的返回地址、保存的旧 rbp,都要有地方存放。如果 f 被多次调用(例如递归),每次调用都必须有自己的一份,否则内层调用会覆盖外层调用的数据。
  2. 执行过程中 rsp 会一直变化,不能直接当固定参照物。 每次 pushpop,或者用 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 的例子:

  1. g 执行 call f,把返回地址压栈;
  2. f 开头 push rbp,把 grbp 压栈保存;
  3. mov rbp, rsp 后,rbp 固定指向 f 的栈帧基址;
  4. f 结尾 pop rbp,恢复 grbp
  5. ret 弹出返回地址到 rip,回到 g 继续执行。

g_call_f

所以:

  • 序言(开头)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 调用完怎么收拾现场。这一节还会出现 subaddleavenop 几个新指令,正好借这个机会补上。

前六个参数走寄存器

我们把 gcall 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),可以发现前六个参数根本没有压栈,而是被依次放进了六个寄存器:

g_call_args_regs

参数 寄存器
第 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, 1mov rdi, 1 效果完全一样,mov r9d, 6mov r9, 6 也是同理,而 32 位写法机器码更短,编译器当然选短的。

第七个参数开始走栈

从第七个参数开始,寄存器不够用了,于是编译器把它们放到了栈上。注意压栈顺序:

    push 10   ; 第 10 个参数
    push 9    ; 第 9 个参数
    push 8    ; 第 8 个参数
    push 7    ; 第 7 个参数

g_call_args_stack

四个 push右边的参数先压栈:先压第 10 个参数 10,最后压第 7 个参数 7。在 64 位模式下,每次 push 压入 8 字节,四个参数共 32 字节。当然,这些参数都是小常数,可以直接 push;如果是 64 位大常数,就要按上一节说的 mov rax, imm64; push rax 来。

call 压入返回地址、f 再压入旧 rbp 之后,f 看到的栈是这样的:

stack_frame_args

这也解释了 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

f_prologue_spill

-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,结果保存在第一个操作数里。与它对应的是 subsub 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; retpop rsi; ret:这些寄存器是调用者保存的,劫持控制流时随便填,不用担心破坏什么必须恢复的状态。

栈对齐:为什么是 16 字节

回到 g 的开头:

    push rbp
    mov rbp, rsp
    sub rsp, 16

这里出现了 subsub rsp, 16 就是 rsp = rsp - 16,因为栈向下生长,所以它把栈顶又往下推了 16 字节,给局部变量 u 腾地方。

ulong,只占 8 字节,为什么不是 sub rsp, 8?这就是栈对齐规则在起作用。System V AMD64 要求:

  • 执行 call 之前,rsp % 16 == 0
  • call 压入返回地址后,进入函数时 rsp % 16 == 8

g 进入时 rsp % 16 == 8push rbp 后变成 0sub rsp, 16 后还是 0;再 push 四个参数(32 字节)后仍为 0,于是执行 call f 时恰好满足“callrsp % 16 == 0”。

为什么违反了对齐会崩:编译器默认这条约定成立,因此可能直接生成 movaps 这类要求 16 字节对齐的 SSE 指令。以后我们做 ROP 时用 ret 直接跳进函数,常常比正常 call 差 8 字节,就会在 movaps 处崩溃。解决办法是多垫一个 ret gadget,把 rsp 拨回 % 16 == 8 的状态。

最后提醒一句:这套 System V AMD64 是 64 位 Linux 的调用约定。32 位 Linux 用的是 cdecl,参数全部压栈、调用者负责清理,和这里完全不同。看到 context.arch = "i386" 时不要套用 rdi 传参。

到这里,我们已经有能力读懂一个普通函数调用在汇编层面的完整过程了。下一章就开始搞破坏:如果输入比缓冲区长,会发生什么?

posted @ 2026-08-16 22:17  cmd_pig  阅读(20)  评论(0)    收藏  举报