news 2026/10/21 7:35:08

Pwn入门必看:汇编基础与Linux内存模型全拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Pwn入门必看:汇编基础与Linux内存模型全拆解

CTF Pwn模块系列分享(二):汇编基础+Linux内存模型拆解

上一篇文章写了Pwn环境搭建和工具链配置,当时就有朋友在评论区说:环境是搭好了,pwndbg也装上了,但打开第一道入门题,反汇编窗口一拉出来,满屏的mov、push、pop,看得懂单词,看不懂程序。更别提什么栈溢出、返回地址、PIE、ASLR,题解里每个字都认识,连起来就不知道在说什么。

这个状态我太熟了。Pwn题目表面上考的是漏洞利用,实际上考的是两样东西:你能不能读懂汇编,你能不能在大脑里构建出程序运行时的内存布局。这两样不过关,刷再多题都是被题解牵着走——看完这题会了,换个壳又不会了。这篇文章就是系列第二篇,专门把汇编基础和Linux内存模型这两块地基拆开揉碎,讲清楚原理,再带你用GDB亲手验证一遍。适合刚学完C语言、知道变量和函数是怎么回事,但没接触过底层的朋友。看完你至少能自己看懂一段反汇编代码,理解栈帧结构,明白为什么"覆盖返回地址"能劫持程序流程。

  1. 为什么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最底层的地基打牢。

  1. 汇编基础:先把寄存器家族认个脸熟

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名称主要用途攻击者视角
累加器EAXRAX函数返回值、运算结果函数调用后看看RAX里装着什么,很多信息泄露从这里来
基址指针EBPRBP当前栈帧的底部栈帧结构的关键标记,很多题靠它定位局部变量
栈指针ESPRSP当前栈顶永远盯住它,它是栈上的"游标"
指令指针EIPRIP下一条要执行的指令地址劫持它,就能劫持程序流程,这是Pwn的终极目标
参数寄存器-RDI/RSI/RDX/RCX/R8/R9x64下传递前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这个地址本身,存入rax

lea不访问内存,只做地址运算。看到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 ; 按位与,用于判断是否为0

cmp只改标志位,真正的分支靠后面的条件跳转指令实现,常见的包括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语法,再临时转换也不迟。

  1. 函数调用现场:栈帧与返回地址是怎么被安排的

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入门最核心的"悟了"瞬间,就是你意识到:函数调用栈上那个被保存的返回地址,是程序信任你、没有任何防御机制验证的"软肋"。普通程序觉得它只会被正常调用路径覆盖,攻击者却说,不,我来帮你覆盖。

  1. 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题最常见的两个阶段。理解了这个博弈逻辑,你看任何中长期题目都会更有方向感。

  1. 动手验证:用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 ./test

5.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]就是栈区。你自己动手跑一遍之后,这张内存地图就会刻进脑子里,比背十遍表格都管用。

  1. 新手常踩的坑和几个学习建议

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、堆利用、格式化字符串,都会有一种开窍的感觉。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 7:34:46

CMSIS-DAP协议核心命令数据包逐字节拆解与调试实战

平时我自己做调试器固件和工具链适配的时候&#xff0c;最常干的一件事就是对着CMSIS-DAP协议的数据包发呆。命令手册翻过很多遍&#xff0c;但真正到了要自己组包、抓包、定位“为什么上位机连不上”的时候&#xff0c;需要的不是“知道有个命令叫DAP_Transfer”&#xff0c;而…

作者头像 李华
网站建设 2026/10/21 7:34:39

VDA Band协同带宽:汽车电子敏捷开发的工程化落地框架

简介&#xff1a;本资源为德国汽车工业协会&#xff08;VDA&#xff09;发布的《Agile Collaboration》英文版官方指南&#xff0c;面向汽车制造业研发管理者、敏捷转型实践者及质量体系工程师&#xff0c;聚焦解决传统开发模式下跨组织协作低效、响应市场变化迟滞等核心问题。…

作者头像 李华
网站建设 2026/10/21 7:34:16

Claude Code 插件安全:供应链攻击、依赖投毒与自查指南

你给 Claude Code 装的插件&#xff0c;可能正在被“偷梁换柱”。这不是危言耸听。最近我帮一个朋友排查 Claude Code 使用问题&#xff0c;发现他明明安装的是一个看起来很正规的“代码格式化增强”插件&#xff0c;实际运行的却是另一套完全不同的代码。那套代码会在每次对话…

作者头像 李华
网站建设 2026/10/21 7:30:41

从视频孪生到空间语义:构建能“看懂”场景的智能数字孪生系统

如果你做过数字孪生类项目&#xff0c;大概率经历过这个场面&#xff1a;大屏上实时渲染的楼宇、道路、车辆&#xff0c;效果非常炫酷&#xff0c;领导看完先夸一句“真逼真”&#xff0c;然后紧接着问一句——“但你能不能告诉我&#xff0c;画面里这辆车停在这儿是什么情况&a…

作者头像 李华
网站建设 2026/10/21 7:28:37

CAD机械版怎样将自定义的超级卡片设置为明细表表格

很多机械设计团队会预先做好专属超级卡片&#xff0c;把常用零件属性字段、排版格式、字体大小都提前定义好&#xff0c;方便重复调用。但出明细表时&#xff0c;常发现软件默认生成的表格样式和自定义卡片完全脱节&#xff0c;卡片里精心配置的字段映射、列宽比例、表头样式全…

作者头像 李华
网站建设 2026/10/21 7:27:41

Vibe Coding 拉老虎机:AI生成代码正在剥夺你的编程直觉

上周有个朋友兴致勃勃地跟我讲&#xff0c;他三天就做出了一整个项目原型&#xff0c;全程都在 Vibe Coding——代码全部交给 AI 生成&#xff0c;他自己一行都没细看。我顺口问了句&#xff1a;如果某个页面出了问题&#xff0c;你打算怎么定位&#xff1f;他答得理直气壮&…

作者头像 李华