CTF Pwn模块系列分享(二):汇编基础+Linux内存模型拆解
上一篇文章写了Pwn环境搭建和工具链配置,当时就有朋友在评论区说:环境是搭好了,pwndbg也装上了,但打开第一道入门题,反汇编窗口一拉出来,满屏的mov、push、pop,看得懂单词,看不懂程序。更别提什么栈溢出、返回地址、PIE、ASLR,题解里每个字都认识,连起来就不知道在说什么。
这个状态我太熟了。Pwn题目表面上考的是漏洞利用,实际上考的是两样东西:你能不能读懂汇编,你能不能在大脑里构建出程序运行时的内存布局。这两样不过关,刷再多题都是被题解牵着走——看完这题会了,换个壳又不会了。这篇文章就是系列第二篇,专门把汇编基础和Linux内存模型这两块地基拆开揉碎,讲清楚原理,再带你用GDB亲手验证一遍。适合刚学完C语言、知道变量和函数是怎么回事,但没接触过底层的朋友。看完你至少能自己看懂一段反汇编代码,理解栈帧结构,明白为什么"覆盖返回地址"能劫持程序流程。
- 为什么Pwn的第一步是啃汇编和内存
1.1 看不懂汇编的那一刻,你已经落后于题解了
很多人的入门路径是这样的:看writeup,下载题目附件,checksec看保护,然后直接拖进IDA。IDA确实能还原伪代码,__readgsqword、sub_4006B6这些也能看个大概。但问题在于,真正的漏洞点往往不在伪代码里,而在内存布局的细节里。
举个例子:一道栈溢出题,伪代码里就看到一个read(fd, buf, 0x100),buf的大小只有0x20。如果你不懂栈,你就不知道多读的那0xE0个字节会覆盖到栈上的什么东西,更不知道为什么要精确地覆盖到偏移0x28处才能劫持到返回地址。这些知识,伪代码不会告诉你,IDA的绿色高亮也不会告诉你,只有当你理解函数调用时栈上到底发生了什么,才能答得出来。
说句不那么中听的话:如果看不懂汇编和内存模型,你连题解都看不懂——不是看不懂攻击思路,而是看不懂攻击思路为什么能成立。
1.2 Pwn的本质:在代码的"内存剧本"里做文章
我们不妨换一个视角来看待程序。一个程序运行起来,本质上是CPU按照指令一条一条地执行,这些指令操作的数据都存放在内存里。内存就是程序演出的舞台,指令是剧本,寄存器是演员手中的道具,栈是舞台的一块特定区域,堆是另一块。你写C代码的时候,编译器已经帮你安排好了这一切。
而Pwn(攻陷)的本质,就是发现程序里某个环节出现了编剧没写到的操作——比如输入了超出预期的数据、访问了不该访问的内存、用错误的方式处理了数据——然后借助这个错误,把你自己的数据写进关键位置,从而篡改后续的"剧本"。想做到这一点,你必须清楚这个舞台上各个区域的位置、大小、作用,以及哪些位置是"一碰就炸"的关键枢纽,比如返回地址。
这就是为何我要把汇编和内存模型放在同一个章节里讲:汇编描述的是数据的流动方式,内存模型描述的是数据的存放位置。两者合在一起,你才算真正认识一个正在运行的程序。
1.3 这篇分享的阅读起点
我需要先说明这篇内容假设你具备什么基础,免得有人看到一半觉得太浅或者跟不上:
- 会写基本的C语言,理解变量、数组、函数的概念,知道指针是什么;
- 在Linux命令行下能编译一个C文件,比如
gcc -o test test.c; - 对寄存器、栈、堆这些词有模糊概念,但没系统地梳理过;
- 不要求任何Pwn基础,也不要求汇编基础。
如果你满足以上条件,这篇内容就是为你准备的。我们用十几个最核心的指令、一张内存布局图、一次动手实验,把Pwn最底层的地基打牢。
- 汇编基础:先把寄存器家族认个脸熟
2.1 x86与x64:同样的名字,不同的位数
在Linux下做Pwn,你打交道的基本只有两种架构:x86(32位)和x64(64位)。第一种,寄存器是32位的,名字以E开头,比如EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP、EIP;第二种,寄存器是64位的,名字以R开头,比如RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP、RIP,并且多了R8到R15这8个额外的通用寄存器。
这个区别不只是名字的变化。x64下参数传递更宽松,运算能力更强,所以编译器生成的代码风格也会不同。为了便于理解,后面的内容我会以x64为主,因为在目前的CTF比赛中,绝大多数Pwn题都是64位程序,掌握x64比掌握x86更有现实价值,而x86的概念在理解了x64之后再去看会非常简单。
我把常见的寄存器列了个表,建议先混个脸熟,不要死记,后面用的时候会不断重复。
| 寄存器 | x86名称 | x64名称 | 主要用途 | 攻击者视角 |
|---|---|---|---|---|
| 累加器 | EAX | RAX | 函数返回值、运算结果 | 函数调用后看看RAX里装着什么,很多信息泄露从这里来 |
| 基址指针 | EBP | RBP | 当前栈帧的底部 | 栈帧结构的关键标记,很多题靠它定位局部变量 |
| 栈指针 | ESP | RSP | 当前栈顶 | 永远盯住它,它是栈上的"游标" |
| 指令指针 | EIP | RIP | 下一条要执行的指令地址 | 劫持它,就能劫持程序流程,这是Pwn的终极目标 |
| 参数寄存器 | - | RDI/RSI/RDX/RCX/R8/R9 | x64下传递前6个参数 | 找gadget时经常遇到,比如pop rdi; ret |
| 额外寄存器 | - | R8-R15 | 通用 | 偶尔出现在gadget里 |
有几个约定需要单独强调:RBP是栈帧的底部标记,它配合RSP可以确定当前函数的局部变量区域在哪;RIP是CPU要执行的下一条指令地址,正常情况下你无法直接修改它,但如果你能控制栈上被ret弹出的数据,就是间接控制了RIP。攻击者费尽心思做的很多事,最终目的只有一个:让RIP指向攻击者想要执行的代码。
2.2 Pwn里最高频的七类指令,够用但别贪多
很多教材讲汇编会把几十条指令全列一遍,看得人昏昏欲睡。其实Pwn入门阶段,你用到的指令不超过二十条。真正高频的,我总结为七类。
第一类:mov(传送数据)。
mov rax, rbx ; 把rbx的值赋给rax mov rax, [rbp-0x10] ; 把rbp-0x10处内存里的值赋给rax mov [rbp-0x10], rax ; 把rax的值存入rbp-0x10处内存注意区分两种操作:不带中括号是对寄存器/立即数直接操作,带中括号表示访问内存。很多新手混淆"寄存器里存的地址"和"这个地址指向的内存内容"。在Pwn中,你要知道一条mov是在搬数值,还是在搬内存里的数据。
第二类:lea(加载有效地址)。
lea rax, [rbp-0x20] ; 计算出rbp-0x20这个地址本身,存入raxlea不访问内存,只做地址运算。看到lea rax, [rbp-0x20],你可以直接理解为rax = rbp - 0x20,它经常用来把某个局部变量的地址装进寄存器,为后续的函数调用准备参数。这一点在逆向时非常有用:看到lea,说明接下来大概要拿地址做文章了。
第三类:push和pop(入栈/出栈)。
push rax ; 先让rsp -= 8,再把rax的值写到rsp指向的新位置 pop rax ; 先读取rsp指向位置的值,再让rsp += 8记住那句经典口诀:push先减后写,pop先读后加。栈从高地址向低地址生长,所以入栈会让栈指针变小,这一点后面讲内存模型时会详细展开。
第四类:call和ret(函数调用与返回)。
call 0x401000 ; 把当前指令的下一条地址压栈,然后跳转到0x401000 ret ; 从栈顶弹出一个地址,跳转过去call做的事情可以拆成两步:先把返回地址(call指令的下一条指令地址)压入栈,再跳转到目标地址。ret则是反过来:从栈顶弹出地址,跳转过去。这个机制是整个栈溢出攻击的核心——如果你能修改栈上存储的返回地址,ret之后程序就去了你指定的地方。
第五类:leave(函数返回清理栈帧)。
leave ; 等价于 mov rsp, rbp; pop rbp函数退出前常用的指令。先将栈顶恢复为当前栈帧底部,再弹出旧的RBP值,这样调用者的栈帧就被恢复回来了。理解了leave,你就能理解为什么很多函数的尾部是leave; ret连写。
第六类:add和sub(加减运算)。
sub rsp, 0x20 ; 栈上分配0x20字节的局部空间,rsp减少0x20 add rax, 1 ; rax加1在函数开头,你经常能看到sub rsp, 0xXX,这就是给局部变量腾地方。这里要留意栈对齐的问题:x64下的System V调用约定要求在执行call之前rsp按16字节对齐,所以你会看到有时候明明是0x18就够用的局部变量,编译器偏偏给你分配0x20,就是为了对齐。如果你自己写exp模拟函数调用时破坏了对齐,某些用到SSE指令的函数(如movaps)会直接崩溃。
第七类:cmp和test(比较与跳转)。
cmp rax, rbx ; 比较两者 jz 0x401020 ; 相等则跳转 test rax, rax ; 按位与,用于判断是否为0cmp只改标志位,真正的分支靠后面的条件跳转指令实现,常见的包括jz/jnz(相等/不等跳转)、jg/jle(有符号大于/小于等于跳转)、ja/jbe(无符号大于/小于等于跳转)。在逆向时,看到这类指令组合,你就能还原出源码里的if或循环。
说句实在话,上面这七类指令已经能覆盖你解Pwn入门到进阶80%以上的汇编阅读需求。不要贪多,遇到不认识的指令时,gdb里用help或者上网查瞬时补上就行,关键是把这几类指令和它们对内存的影响刻进脑子里。
2.3 AT&T语法和Intel语法的"人格分裂"问题
新手最容易踩的坑之一:为什么objdump里看到的汇编和IDA里看到的不一样?在IDA里是mov rax, rbx,在gdb里却变成了mov %rbx, %rax,而且操作数顺序还反了。
这其实是两种汇编语法的问题。IDA默认显示Intel语法,操作数顺序是"目的在左,源在右";而gdb默认配置的objdump指令输出常常是AT&T语法,操作数顺序是"源在左,目的在右",寄存器前还要加%,立即数前加$。
我在入门时就被这个坑搞得很烦。后来我养成了一个习惯:不管用哪个工具,先把显示统一成Intel语法。gdb里执行set disassembly-flavor intel,或者直接在~/.gdbinit里写上这一行,之后disassemble出来的就永远是Intel风格了。pwndbg这类插件通常也会自带配置,你可以在启动后确认一下。
这里给新手一个明确建议:以Intel语法为准去看汇编,因为绝大多数CTF的writeup、IDA、pwndbg插件都默认显示Intel语法,你学这一套就够用了。等以后读一些老文章遇到AT&T语法,再临时转换也不迟。
- 函数调用现场:栈帧与返回地址是怎么被安排的
3.1 栈这个结构为什么天然适合函数调用
栈(stack)是一种后进先出的数据结构。程序运行时的调用栈(call stack)利用的正是这个特性:函数A调用了函数B,B执行完返回后,A可以无缝接着往下执行。为什么天然适合?因为函数调用天然就是一个先进后出的过程——先进入的函数要等后进入的函数执行完才能结束,所以用栈来保存每一层函数的现场,再合适不过。
栈在虚拟内存中从高地址向低地址生长。每次push,栈顶(RSP保存的地址)减小;每次pop,栈顶增大。很多人第一次听"栈向下生长"会觉得反直觉,其实你只需要把它想象成一个从高地址开始、往低地址方向"倒扣"的容器,存放的数据越多,容器的底部就越往低地址压。这个方向决定了你在计算偏移时的参照系,一定要在心里建立起来。
3.2 一次完整的call调用到底发生了什么
我把一次函数调用的全过程拆成五步,每一句都要看懂:
假设funcA正在执行,调用funcB:
第一步:caller准备参数。
在x64下,参数依次放入RDI、RSI、RDX、RCX、R8、R9;参数超过6个,才放到栈上。这一步由调用者来做。
第二步:call指令压下返回地址。
call funcB时,CPU会先把call指令的下一条指令的地址(即funcA中调用点之后的返回位置)压入栈中,然后跳转到funcB的入口。此时栈顶就是返回地址。
第三步:callee执行序言(prologue)。
funcB开头一般长这样:
push rbp ; 保存调用者的RBP,压栈 mov rbp, rsp ; 把当前栈顶当作新栈帧底部,rbp = rsp sub rsp, 0x20 ; 为局部变量分配空间执行完后,从RBP到RSP之间的区域就是funcB的栈帧,局部变量、临时数据都放在这里。
第四步:函数体执行。
代码在栈帧内读写局部变量,比如mov [rbp-0x8], rax,就是往局部变量槽里写数据。
第五步:函数返回。
函数结尾通常长这样:
leave ; mov rsp, rbp; pop rbp ret ; pop 返回地址,跳转回去leave先把栈顶恢复到RBP位置,再弹出旧RBP,这样RSP恰好指向返回地址。紧接着ret从栈顶弹出返回地址并跳转,程序回到funcA继续执行。到这里,一次调用完成。
这个流程的理解太重要了。尤其是第二步和第五步:返回地址在栈上,ret依赖栈顶数据跳转。如果你在函数体内有缓冲区溢出,又能写入栈帧内部的局部变量区域,那么多写进去的数据就会顺着栈帧一路往上盖,最终盖到返回地址上。等函数执行ret时,CPU从栈顶弹出一个被攻击者控制的地址,程序跳转过去——这就叫栈溢出控制RIP。
3.3 调用约定:x64的寄存器传参和x86的栈传参
调用约定(calling convention)规定了函数参数怎么传、返回值放哪里、寄存器由谁保存。做Pwn不一定要求背全,但两个最关键的必须清楚。
x64下的System V调用约定(Linux默认):
- 参数依次放入RDI、RSI、RDX、RCX、R8、R9,多出的参数从右到左压栈;
- 返回值放在RAX中;
- RBP、RBX、R12-R15由被调用者负责保存,即被调函数如需使用必须先保存原值,返回时恢复;
- RDI、RSI、RDX、RCX、R8、R9、RAX、R10、R11为调用者保存寄存器,意味着被调函数可以随意使用,返回时无需恢复;
- 栈对齐:在
call指令执行前,RSP需要按照16字节对齐。
x86下的cdecl调用约定(32位常见):
- 所有参数从右到左压栈传入;
- 返回值在EAX中;
- 函数返回后由调用者清理栈上的参数。
区别一句话总结:64位优先用寄存器传参,32位几乎全靠栈传参。这也是为什么你在64位题目里经常需要找pop rdi; ret这样的gadget——因为攻击目标函数需要参数,你先要把参数装进RDI,再跳过去。
3.4 从攻击者角度重新看这个流程
把上面这个正常的流程,用攻击者的眼睛再看一遍:
当funcB执行leave; ret的时候,RSP正好指向返回地址。攻击者如果能通过某个漏洞,在返回地址所在位置之前写入一串精心构造的数据,让返回地址变成攻击者想要的地址,那么ret的一瞬间,RIP就落到了攻击者指定的位置。接下来会发生什么,就看攻击者想干什么了:可能是跳到一个后门函数(ret2win),可能是跳到一段ROP链的起点(ret2libc/ret2syscall),也可能是跳到shellcode。
整个Pwn入门最核心的"悟了"瞬间,就是你意识到:函数调用栈上那个被保存的返回地址,是程序信任你、没有任何防御机制验证的"软肋"。普通程序觉得它只会被正常调用路径覆盖,攻击者却说,不,我来帮你覆盖。
- Linux进程的虚拟内存地图:从.text到.stack
4.1 进程眼里有一整块"私有领土"
我们常说程序运行起来是一个进程。这个进程看到的地址空间,是一个从0到0x7fffffffffff(64位下用户态的默认最大值)的连续虚拟地址空间。它以为自己独享了这块极大的内存,但实际上,物理内存是共享的,CPU通过MMU(内存管理单元)把虚拟地址翻译成物理地址。这个机制带来的好处很多,比如进程之间互不干扰、一个进程崩溃不需要牵连别人、可以用虚拟内存的方式管理文件的映射等。
对Pwn选手来说,虚拟内存的意义在于:每个区域有固定的相对位置和默认属性,攻击者利用漏洞时总要考虑"这个目标地址位于哪个段""那个段是否可执行""这个段会不会被随机化"。你需要先把这个大洲的地形摸清楚,才能判断哪条路能走。
4.2 从低地址到高地址:每个段的角色
64位Linux进程的用户态虚拟内存布局,从低到高大致如下表:
| 区域 | 大概位置 | 存放内容 | 攻击者关注点 |
|---|---|---|---|
| .text(代码段) | 低地址附近 | 程序机器指令 | 只读可执行,是函数地址所在地 |
| .rodata | 低地址附近 | 只读常量字符串 | 经常存flag、"Welcome"提示等 |
| .data / .bss | 低地址附近 | 已初始化全局变量 / 未初始化全局变量 | 可读写,有时存放全局结构体 |
| 堆(heap) | 低地址向高地址生长 | 动态分配的内存(malloc/free) | 堆利用的战场,低地址往上长 |
| mmap映射区 | 堆上方 | 共享库、内存映射文件 | libc基址在这里,ret2libc要leak它 |
| 栈(stack) | 高地址附近 | 局部变量、返回地址、函数调用现场 | 向下生长,栈溢出的主战场 |
| 内核区 | 最高地址 | 内核代码与数据 | 普通用户态程序不可接触 |
有两个细节需要注意:
第一,栈和堆的生长方向是反着的:栈从高地址往低地址长,堆从低地址往高地址长。两者一个往下,一个往上,中间隔着大片未映射的区域。如果某种漏洞让堆块溢出越过边界,就可能污染到堆顶之上的其他数据;栈溢出的内容则往高地址方向覆盖,覆盖到储存返回地址的位置。
第二,.text段在内存里默认是可执行但不可写的。这意味着你不能直接修改代码段,但可以跳过去执行它。如果你想让进程执行你自己写的代码,要么把shellcode放到可执行的内存区域,要么利用libc里现成的函数片段,这就是后面会展开的ret2libc之类的技巧。
4.3 地址随机化:PIE、ASLR、RELRO这些名词一次理清
很多新手在checksec阶段就被一堆缩写吓住,这里我挑几个和内存模型强相关的讲明白。
ASLR(地址空间布局随机化)是操作系统层面的机制。开启之后,每次启动程序,栈基址、堆基址、共享库基址都会随机变化。换句话说,你在GDB里看到的栈地址、libc地址,下次运行就可能变。Pwn中绕ASLR的基本思路是"先泄漏后利用":通过漏洞先读出某个随机地址,再算出目标地址,再实施攻击。
PIE(位置无关可执行文件)是编译层面的机制。如果程序开启了PIE,就连它自身的代码段、数据段基址都会被随机化,每次运行程序的基址都不同;关闭PIE(通常是-no-pie编译)时,程序加载在固定地址,比如0x400000附近,代码里的函数地址就是固定的,攻击者可以直接拿writeup里的地址用。因此,Pwn题里checksec看到PIE未开启,通常意味着思路能简单不少。
RELRO(重定位只读)和内存模型也有关:Full RELRO会把GOT(全局偏移表)设为只读,防止攻击者改写GOT表项;Partial RELRO则保留一定的可写空间。这部分在入门阶段先知道名字就行,等做到格式化字符串、GOT劫持题时再深挖来得及。
这串机制背后有一个核心逻辑:防御者让内存里每个关键位置的地址"变得不可预测",攻击者要利用就必须先让信息泄露配合;而泄露和利用这两步,恰好构成Pwn题最常见的两个阶段。理解了这个博弈逻辑,你看任何中长期题目都会更有方向感。
- 动手验证:用GDB把内存模型变成眼睛能看见的东西
5.1 准备一个最简单的实验程序
光看理论容易飘,我们动手做个小实验。打开终端,新建一个test.c:
#include <stdio.h> int add(int a, int b) { int sum = a + b; return sum; } int main() { int x = 3; int y = 4; int result = add(x, y); printf("result: %d\n", result); return 0; }编译时注意关闭PIE和栈保护,便于观察:
gcc -o test test.c -no-pie -fno-stack-protector然后启动gdb:
gdb ./test5.2 反汇编main函数,观察寄存器变化
在gdb中输入:
set disassembly-flavor intel disassemble main你会看到类似这样的输出(地址可能略有差异):
Dump of assembler code for function main: 0x0000000000401136 <+0>: push rbp 0x0000000000401137 <+1>: mov rbp,rsp 0x000000000040113a <+4>: sub rsp,0x20 0x000000000040113e <+8>: mov DWORD PTR [rbp-0x14],0x3 0x0000000000401145 <+15>: mov DWORD PTR [rbp-0x10],0x4 0x000000000040114c <+22>: mov edx,DWORD PTR [rbp-0x10] 0x000000000040114f <+25>: mov eax,DWORD PTR [rbp-0x14] 0x0000000000401152 <+28>: mov esi,edx 0x0000000000401153 <+29>: mov edi,eax 0x0000000000401155 <+31>: call 0x401120 <add> 0x000000000040115a <+36>: mov DWORD PTR [rbp-0x8],eax 0x000000000040115e <+40>: mov eax,DWORD PTR [rbp-0x8] 0x0000000000401161 <+43>: mov esi,eax 0x0000000000401163 <+45>: lea rdi,[rip+0x2e9a] 0x000000000040116a <+52>: mov eax,0x0 0x000000000040116b <+53>: call 0x401040 <printf@plt> 0x0000000000401170 <+58>: mov eax,0x0 0x0000000000401175 <+63>: leave 0x0000000000401176 <+64>: ret End of assembler dump.几处值得注意:push rbp; mov rbp, rsp; sub rsp, 0x20就是函数的序言;[rbp-0x14]和[rbp-0x10]是局部变量x和y;mov esi, edx和mov edi, eax正是在为调用add准备参数——x64下第一个参数放RDI,第二个放RSI。这样汇编和源码就对上了。
5.3 单步执行,亲眼看看栈的变化
在main的call add那一行下一个断点:
break *0x0000000000401155 run info registers rsp rbp先看进入add之前的寄存器。然后单步一次:
si info registers rsp rbp你会发现RSP减小了8,RBP还未变化。再执行几次,进入add的序言后,RBP变成了当前RSP,之后sub rsp, 0x10分配局部变量区。这个时候用x/20gx $rsp观察栈内存,你能看到从栈顶往下依次是局部变量区、旧RBP、返回地址、调用者局部变量。返回地址就是call之后那句mov DWORD PTR [rbp-0x8], eax的地址,也就是0x000000000040115a。
这个实验的本质在于:把抽象的"返回地址被压栈"变成你在GDB里肉眼可见的具体内存内容。你甚至可以直接用set {unsigned long}($rsp) = 目标地址来手工修改栈上的返回地址,然后执行ret,看看RIP会不会变成你指定的值——当然自己实验可以,别拿真实程序开玩笑。
5.4 查看进程的内存映射,理解各段位置
在gdb里执行:
info proc mappings或者装了pwndbg的话直接:
vmmap你会看到类似这样的输出:
Start End Permissions Name 0x0000000000400000 0x0000000000401000 r--p ./test 0x0000000000401000 0x0000000000402000 r-xp ./test 0x0000000000402000 0x0000000000403000 r--p ./test 0x0000000000403000 0x0000000000404000 rw-p ./test 0x00007ffff7dd1000 0x00007ffff7df6000 r--p /lib/x86_64-linux-gnu/libc.so.6 ...(省略) 0x00007ffffffde000 0x00007ffffffff000 rw-p [stack]注意最低地址处几个相邻的区域,就是上文的.text、.rodata、.data、.bss这些段,因为-no-pie,所以起始地址固定在0x400000附近。上方大块的libc.so.6是动态链接进来的共享库,它的起始位置就是之后ret2libc攻击时你想要的libc基址。最高处的[stack]就是栈区。你自己动手跑一遍之后,这张内存地图就会刻进脑子里,比背十遍表格都管用。
- 新手常踩的坑和几个学习建议
6.1 最容易混淆的三个知识点
带过好几个刚开始学Pwn的朋友,我发现有几个点反反复复地绊人,拿出来单独说。
第一,栈顶地址的"方向感"混乱。不少人画栈帧图时,把"栈顶在最高地址"画反了。记住口诀:栈顶RSP是低地址,栈底RBP是高地址。sub rsp, 0x20表示栈顶往下走,即分配空间。每次看到指令,先把RSP和RBP谁大谁小理清楚,后面所有偏移量计算才不会乱。
第二,返回地址与RBP的混淆。上文的栈帧里,从当前RBP往上(高地址方向)偏移8个字节,保存的是旧的RBP;再往上偏移8个字节,才是返回地址。很多新手写exp时,把覆盖偏移算到了RBP的位置,结果只覆盖了RBP,程序的流程没被劫持,只在函数返回时报错。记住:需要控制的是返回地址,不是RBP。
第三,call和ret不是普通的跳转。call不仅要跳转,还要压返回地址;ret不仅要跳转,还要弹出地址。如果只用jmp的思维去理解它们,你永远想不明白为什么栈上多了8个字节、为什么ret能改变RIP的方向。
6.2 不要一开始就扎进堆利用
Pwn的方向很多,栈溢出、格式化字符串、整数溢出、堆利用、内核利用,往外还有各种奇怪的利用方式。我的建议是,入门阶段先把精力集中在栈方向和格式化字符串上。
原因很简单:它们的利用模型相对清晰,调试器里容易观察,而且几乎覆盖了Pwn最核心的思维方法——理解内存布局、控制关键指针、绕过保护。堆利用牵扯到更复杂的内存管理机制、bin结构、堆风水,如果你连栈帧都没看明白就冲进去,大概率会被各种chunk结构直接劝退。
等你把栈方向的ret2win、ret2shellcode、ret2libc、ROP基础链都刷过一遍,再回头看堆利用,会轻松得多。
6.3 工具链与调试习惯的实战建议
- GDB插件强烈推荐pwndbg,它在GDB基础上加了
vmmap、heap、cyclic、checksec等命令,省掉大量手动操作。安装时注意和GDB的版本匹配,遇到启动报错优先看Python版本兼容问题。 - 遇到地址计算,尽量用Python的
pwnlib(pwntools)来做。不要手算偏移,用cyclic生成模式串、cyclic_find找偏移,又快又不容易错。 - 每次做题,我先checksec,在心里快速过一遍保护情况,再看反汇编时心里会有一个"哪些地址可以利用、哪些地址需要泄露"的大致预期。养成这个习惯,效率比见题就莽高得多。
- 调试时多用
x/系列命令打印内存、info registers看寄存器、context(pwndbg)看上下几行汇编。看到内存布局之后,不要急着跳过,自己口头复述一遍"这里是什么、为什么这个值被存在这",比看十篇writeup都巩固。
我在实际带新手的过程中发现,几乎所有人卡住的点都不是"不会写exp",而是"exp里每一个变量代表内存里哪一块内容"没想清楚。所以这篇文章最后再分享一个大实话:学Pwn,慢就是快。花两个晚上亲手在GDB里把栈帧拆开、把虚拟内存地图看明白,比刷十道题却每道都靠照抄题解要有用得多。等你真正能在脑子里"运行"程序的内存模型,后面再学ROP、堆利用、格式化字符串,都会有一种开窍的感觉。