pwn入门必读:栈溢出原理与内存布局实战详解

发布时间:2026/9/14 4:04:37
pwn入门必读:栈溢出原理与内存布局实战详解 先给各位对二进制安全感兴趣的朋友交个底这篇文章我要讲的是pwn方向绕不开的第一座山——”栈“和”内存“。不管你是刚看完几篇pwn入门文章、正准备在buuctf上刷第一道题还是已经能跑通ret2text但一遇到ret2libc就发懵这篇文章都适合你。我会把进程内存布局、栈帧结构、函数调用时栈的变化、以及栈溢出背后那条最简单的控制流劫持逻辑用最直白的方式串一遍。我见过太多人一上来就刷题结果连system地址该填在哪、偏移量怎么算都搞不清楚。归根结底不是题难是底层的“内存直觉”没建立起来。这篇文章就是帮你把这块地基补上——不扯玄乎的理论全部用能落地的东西讲清楚另外附上我做实验时踩过的坑和排查经验。1. 为什么pwn入门必须先啃掉“栈”和“内存”1.1 pwn的本质你是在对抗程序的控制流先说个常被忽略的事实pwn题无论简单复杂最后几乎都能归到一句话——想办法让程序执行你希望它执行的代码或逻辑。而程序怎么知道自己下一步该执行哪条指令靠的是CPU里的指令指针寄存器x86-64下叫RIP32位下叫EIP。这个寄存器指向哪块内存CPU就执行哪里的指令。所以绝大部分pwn利用的本质就是在想尽办法修改RIP的值。那RIP的值是从哪来的最常见的一种途径就是函数返回时从栈上弹出的“返回地址”。函数调用完要回到调用者继续执行总得有人记住“回哪去”这个信息就存在栈上。如果你能通过某种手段改写这个返回地址函数一返回程序就乖乖跳到你指定的位置——这就是经典的栈溢出利用流程。理解了这条链路你再看任何pwn题思路都会清晰很多先找到能输入数据的地方再找到输入的数据会不会落到栈上、能不能覆盖到返回地址最后决定把它改成什么。所有步骤都绕不开对栈和内存的理解。1.2 计算机内存一张按地址编号的超大表格很多初学者一听“内存”就头大觉得是个很玄的东西。我打个比方内存就像一栋巨大的旅馆每个房间都有唯一房号地址房间里能住8个字节64位系统或4个字节32位系统的数据。CPU干活的时候就是不停地说“把1024号房间的内容取出来”、“把2048号房间改成某个值”。这里有个初学者容易懵的点程序里说的“内存地址”基本都不是物理内存的真实地址而是虚拟内存地址。每个进程都以为自己独占了一大片连续的空间从0到很大实际上操作系统在背后把这套虚拟地址映射到物理内存上。为什么这么设计一来是隔离——你的进程不能随便读别人的进程内存二来是简化——每个进程的地址空间看起来都是统一规整的编译器、加载器都好干活。这个”虚拟地址空间“的布局是理解一切的基础。如果不清楚一个变量到底住在哪一段你就很难判断溢出时覆盖的到底是什么数据、覆盖了之后会造成什么后果。1.3 本文解决什么问题适合谁来读这篇文章不是某个题目的writeup而是把pwn最底层的两块内容——内存布局和栈机制——系统讲透。读完之后你再看任何讲栈溢出的writeup至少能看懂为什么填充offset个字符后就是返回地址为什么有的payload要拼上ebp为什么有时候还要考虑栈对齐。适合人群很明确刚开始学pwn、被各种writeup里的术语栈帧、偏移量、gadget、返回地址砸晕的入门者或者会抄exp但不懂原理、一换题目就不会做的人。如果你已经是能独立做中难题的老手这篇文章对你可能偏基础但里面有些排查经验和细节或许也能帮你查漏补缺。2. 进程的虚拟内存布局先看懂地图再谈利用2.1 从高地址到低地址进程空间的五大区域x86-64 Linux下一个进程的虚拟地址空间从上到下大致长这样内核空间高地址用户态无法访问栈区Stack从高地址向低地址生长存放函数调用相关的局部变量、返回地址、参数等堆区Heap从低地址向高地址生长负责动态内存分配malloc、new出来的东西BSS段存放未初始化的全局变量和静态变量数据段.data存放已初始化的全局变量和静态变量代码段.text存放程序指令也就是你的代码这里最关键的是栈和堆的生长方向相反。栈从高地址往低地址长堆从低地址往高地址长。为什么这么设计主要是为了避免两者在空间上打架一个从上往下、一个从下往上中间隔着大片空闲区域可以灵活伸缩。我第一次画这张图的时候最大的困惑是为什么栈偏偏要从高地址往低地址走后来看了一些资料才明白这是历史设计使然也没必要纠结为什么只要记住“向下生长”这个事实后面看反汇编、算偏移才不会被绕晕。2.2 为什么Linux把栈放在高地址、堆放在低地址这个问题我当年想了很久。答案其实很朴素不同段的大小与增长需求不同操作系统需要给它们合理的生长空间。栈帧随着函数调用深度的增加而不断向下扩展堆则随着malloc不断向上扩展。把两者放在中间区域的上下两端可以让它们各自向中间生长空间利用最灵活。另外代码段放在最低的位置是因为代码段大小在编译后是固定的不需要动态增长放在底部可以让上方的数据区BSS、堆、栈获得尽可能连续的大空间。这种布局基本是所有现代操作系统Linux、Windows、macOS统一采用的方案可能细节上起始地址不同但大体结构一致。2.3 x86-64下各段的典型地址范围以我常用的64位Linux调试环境为例默认情况下不开PIE各段大致是这样区域典型地址范围主要存储内容代码段(.text)0x400000 ~ 0x4xxxxx程序指令、只读常量数据段(.data)0x6xxxxx 附近已初始化的全局变量、静态变量BSS段紧随.data之后未初始化的全局变量、静态变量堆区(Heap)0x6xxxxx 向上增长malloc动态分配的数据栈区(Stack)0x7ffcxxxxxxxx 向下增长局部变量、返回地址、函数调用信息内核区0xffffffff80000000 以上内核代码与数据用户态不可访问注意这里有个很重要的细节栈区的地址通常非常大0x7ff...开头和堆区、代码区隔得很远。这意味着栈溢出时你很难直接往上覆盖到代码段或堆段因为栈向低地址方向增长而你覆盖的方向也是向低地址走。但栈溢出覆盖的是自己所在的栈帧内部以及更高地址的栈帧——包括当前函数的返回地址这一点至关重要。3. 栈的核心逻辑函数调用的“账本”3.1 栈寄存器rsp和rbp是干什么的x86-64下有两个寄存器跟栈强相关RSP栈指针始终指向栈顶也就是当前栈帧的最低地址处。push指令会把RSP减8并把数据写进去pop则把数据弹出来然后把RSP加8。RBP基址指针也叫帧指针指向当前栈帧的底部高地址端主要用于定位局部变量和参数。函数开头通常有push rbp; mov rbp, rsp两句话把上一个函数的RBP保存起来同时建立当前函数的栈帧。为什么有了RSP还要RBP因为RSP会随push/pop不停变化用它来引用局部变量很麻烦而RBP在整个函数执行期间基本不变用它加减一个偏移就能稳定访问到每一个局部变量和参数。不过现在编译器默认开了优化后比如-O2经常会把RBP省略掉直接用RSP加偏移来访问局部变量。这就是为什么有时候反汇编看不到push rbp; mov rbp, rsp的影子。初学阶段建议编译时加-fno-omit-frame-pointer或者干脆-O0让栈帧结构完整呈现出来方便对照学习。3.2 一次函数调用栈上到底发生了什么这个流程必须烂熟于心每次卡壳就回来重新看一遍。假设main函数调用func(a, b)在x86-64下System V AMD64调用约定默认情况下前6个参数用寄存器传rdi、rsi、rdx等多余的参数或者特定的编译选项下才会压栈。为了讲清楚栈帧我用比较基础的视角来说明函数调用的完整过程调用方caller先把参数放到寄存器或者压栈。执行call func指令。这条指令做了两件事把下一条指令的地址即返回地址压入栈中然后跳转到func的入口。被调用方callee的入口处编译器生成push rbp保存调用方的RBP。mov rbp, rsp建立新的栈帧底部。sub rsp, 0xXX为局部变量分配空间。函数体执行期间RBP固定RSP在局部变量区上方浮动。函数结束leave等价于mov rsp, rbp; pop rbp把栈帧回收恢复调用方的RBP。ret等价于pop rip从栈顶弹出返回地址程序跳回调用方继续执行。把第3步和第5步连起来看你会发现一个关键点返回地址存储在RBP上方8字节处。如果局部变量区的写入越界一路向高地址覆盖最先碰到的是保存的RBP再往上就是返回地址。3.3 一张图看懂栈帧的内存排列很多教程都爱画栈帧图但画法略有差异。我按自己的理解重新整理一下从低地址到高地址高地址 ----------------------- - 调用方的栈帧底部更早的帧 | 调用方局部变量 | ----------------------- | 返回地址 | - call 指令压入的位置 ----------------------- | 保存的 RBP旧rbp | - push rbp当前rbp指向这里 ----------------------- - 当前 rbp栈帧底部 | 当前函数局部变量 | | (向下扩展) | ----------------------- - 当前 rsp栈顶 低地址这个排列就是所有栈溢出利用的基础。当你通过某个输入函数往局部变量区写数据时数据从低地址向高地址覆盖顺序是先是局部变量区再是保存的RBP最后是返回地址。所以栈溢出覆盖返回地址需要的填充长度 局部变量区大小 8RBP大小。如果在32位程序里RBP是4字节填充长度就是局部变量区大小 4。这个差别在写exp时特别容易踩坑一会儿会细说。3.4 栈和堆别再傻傻分不清了很多新手总是把栈和堆弄混我直接用一段话帮大家彻底分清楚栈由编译器自动管理存放局部变量、参数、返回地址。特点是分配和释放都极其快其实只是移动RSP指针容量小通常几MB生命周期跟着函数走——函数返回栈帧就销毁。堆由程序员手动管理malloc/free、new/delete存放动态分配的数据。容量大得多可以达到几个GB但分配释放速度相对慢生命周期完全由你掌控不释放就可能内存泄漏。用生活化的类比栈就像你桌上的便签纸随手写随手撕翻一页就没了堆就像你的仓库可以寄存长期使用的杂物但要自己记得管理。pwn里栈溢出和堆溢出的利用思路差别巨大这篇文章聚焦栈但理解堆/栈的区别能帮你后期顺利转向堆利用。4. 实操亲手看一遍栈的变化比背十遍理论都管用4.1 准备一个最小的实验环境建议在Linux虚拟机或者WSL里做实验我用的环境是Ubuntu 22.04 gcc 11.4 gdb 12.1 pwntools。最小示例代码我写成了这样// stack_demo.c #include stdio.h #include string.h void func(char *input) { char buf[16]; strcpy(buf, input); printf(buf: %s\n, buf); } int main(int argc, char **argv) { func(argv[1]); return 0; }编译命令要刻意关掉保护方便观察原始栈结构gcc -m64 -fno-stack-protector -no-pie -O0 -g -o stack_demo stack_demo.c参数解释一下-fno-stack-protector关掉栈保护canary-no-pie关掉地址随机化中的PIE程序基址固定-O0不优化保证栈帧完整-g生成调试信息方便gdb看源码。4.2 反汇编看编译器生成的函数序言先用objdump -d stack_demo或gdb里的disassemble func看一下func函数的汇编Dump of assembler code for function func: 0x0000000000401146 0: push rbp 0x0000000000401147 1: mov rbp,rsp 0x000000000040114a 4: sub rsp,0x20 0x000000000040114e 8: mov QWORD PTR [rbp-0x18],rdi 0x0000000000401152 12: lea rax,[rbp-0x10] 0x0000000000401156 16: mov rdi,rax 0x0000000000401159 19: mov rax,QWORD PTR [rbp-0x18] 0x000000000040115d 23: mov rsi,rax 0x0000000000401160 26: call 0x401040 strcpyplt 0x0000000000401165 31: lea rax,[rbp-0x10] 0x0000000000401169 35: mov rdi,rax 0x000000000040117a 44: call 0x401030 putsplt 0x000000000040117f 49: nop 0x0000000000401180 50: leave 0x0000000000401181 51: ret注意sub rsp,0x20分配了32字节但char buf[16]明明只有16字节。为什么要多分配因为编译器还要用一部分栈空间保存传入的参数rdi被存在[rbp-0x18]处而且为了对齐编译器通常会分配比实际所需更多的栈空间。这说明栈帧大小不是你从源码里直接猜出来的必须以反汇编为准。这算是我踩过的一个比较典型的坑——刚开始总觉得buf[16]就是16字节偏移结果一调试发现偏移根本对不上。这里buf的起始位置是[rbp-0x10]也就是相对RBP偏移-16那么覆盖到保存的RBP需要写16字节到返回地址需要再写8字节总共offset 24。这和我在3.3节讲的公式完全对应local区16字节 RBP 8字节。4.3 gdb单步追踪验证栈的每一步变化启动gdbgdb ./stack_demo break func run AAAA在func入口停住后看寄存器(gdb) info registers rbp rsp rip rbp 0x7fffffffe180 0x7fffffffe180 rsp 0x7fffffffe170 0x7fffffffe170 rip 0x401146 0x401146 func此时RBP和RSP还不相等因为第一条push rbp还没执行。我习惯用si单步指令一步步走每走一步都看一眼栈上的变化。执行完push rbp后(gdb) x/4gx $rsp 0x7fffffffe168: 0x0000000000401186 0x0000000000000000 0x7fffffffe178: 0x0000000000000000 0x00007ffff7de5d90第一个值0x401186就是返回地址main里call func的下一条指令0x7fffffffe168是旧的RBPmain的RBP。继续走完mov rbp,rspRBP就等于RSP了栈帧正式建立。然后走到call strcpy之前用x/16bx $rbp-0x10看一下buf(gdb) x/16bx $rbp-0x10 0x7fffffffe160: 0x41 0x41 0x41 0x41 0x41 0x41 0x41 0x41 0x7fffffffe168: 0x41 0x41 0x41 0x41 0x41 0x41 0x41 0x41这里我输入的是AAAAAAAAAAAAAAAA16个A所以buf[0..15]全是0x41。此时如果继续往高地址看就是保存的RBP再往上就是返回地址。你可以尝试输入超过16字节的参数执行完strcpy后再看这附近的内存就能直观地看到数据把RBP和返回地址覆盖掉了。这种“亲眼看到”的过程比我讲一百句都管用。建议新手一定要亲手跑一遍不要只看writeup就觉得懂了。4.4 一次完整的栈溢出偏移量计算利用gdb或者pwntools的cyclic来计算偏移量是写exp最常用的一招。我一般用pwntools的方式清爽且快from pwn import * io process(./stack_demo) io.sendlineafter(b:, cyclic(200)) # 这里your_program接收方式按实际情况调整 io.wait() core io.corefile esp core.esp if core.arch i386 else core.rsp offset cyclic_find(core.read(esp, 4)) print(offset)但上面的示例程序是从命令行参数接收输入用corefile计算偏移量时参数传递略有不同。我更常用的方法是直接gdb里跑(gdb) run $(python3 -c print(A*24 BBBBBBBB))程序崩溃后gdb会停在ret指令处你观察RIP的值。如果RIP变成了0x42424242BBBB说明偏移正好24返回地址被成功覆盖为0x42424242。这种“手工验证法”虽然原始但你对偏移量的理解会非常深刻——因为你是看着数据一步步把返回地址盖掉的。再补一个细节在64位程序里如果RIP被改成0x42424242但你看到的crash位置可能不是func的ret而是__strcpy_sse2或者__GI___libc_free里。别慌用bt回溯一下调用栈找到我们自己的函数帧再看偏移量对不对。5. 常见问题与排查技巧实录5.1 偏移量总算不对先检查这几个地方偏移量永远算不对几乎都是下面这几个原因按顺序排查基本能解决编译器优化改变了栈帧布局-O2优化下局部变量可能被优化到寄存器里或者栈帧干脆被折叠。学pwn阶段一律-O0看反汇编为准。缓冲区到返回地址之间不只有RBP还有padding编译器出于对齐目的会插入padding字节。我记得像char buf[24]加上8字节RBP中间可能还夹着几个字节的填充导致实际偏移不是源码里看出来的数字。遇到这种情况直接用cyclic跑一遍最靠谱。32位和64位搞混了RBP的大小差4字节经常有人套错。明确自己调试的是多少位的程序32位偏移 局部变量区 464位偏移 局部变量区 8。输入数据里有特殊字符被截断比如你用gets并且payload里有\x00字符串会被截断在第一个空字节处后续数据进不来。这类细节在做ret2libc时尤其关键payload构造时要小心目标地址里不能含有\x00字节。5.2 为什么反汇编里看不到RBP编译器优化惹的祸很多新手一上来就反汇编一个有-O2编译的程序发现函数开头根本没有push rbp; mov rbp, rsp瞬间就懵了。这不是你理解错了而是编译器把栈帧优化掉了——这种情况下函数直接用RSP加偏移访问局部变量少了一对push/pop执行效率更高同时也能释放出RBP寄存器给别的用途。调试这种程序时你就不能按照“RBP位置”去想栈布局了而要看RSP和局部变量偏移。这也是为什么pwn题在编译时通常使用-O0或者-fno-omit-frame-pointer的原因——保持栈帧清晰可见。如果遇到某个题目有优化一定要以反汇编和动态调试时的实际偏移量为准别从源码猜。5.3 gdb里看到的地址和单独运行不一致那可能是ASLR在作怪ASLR地址空间布局随机化会导致每次运行时栈地址、堆地址、甚至库函数地址都不一样。我刚开始做实验的时候经常gdb里看到的栈地址是0x7fffffffe160关掉gdb一跑就变成0x7ffd1f3a7a50一度以为自己找错了函数。其实处理办法很简单实验阶段用setarch -R ./stack_demo或者echo 0 | sudo tee /proc/sys/kernel/randomize_va_space临时关闭ASLR临时操作测试完记得恢复。但做真实pwn题时ASLR通常不会被关掉所以你需要学会用泄漏地址 相对偏移来稳定利用比如ret2libc里的libc基址计算就是这么来的。这道坎每个pwner都迈过核心思想是ASLR随机化的只是基址而模块内部的相对偏移是固定的。所以只要泄漏出某个函数的实际地址就能反推出整个模块的基址从而算出system或/bin/sh的真实位置。5.4 保护机制速查看到这几样心里要有数pwn题常常见到一堆缩写我整理了一个速查表新手可以对照着记保护机制全称作用影响CanaryStack Protector在局部变量和保存的RBP之间插入随机值返回前检查是否被修改阻止直接覆盖返回地址需要先泄漏canaryNXNo-eXecute栈和堆区域不可执行不能直接跳转到栈上执行shellcodePIEPosition Independent Executable程序基址随机化代码段、数据段地址不固定需要先泄漏程序地址RELRORelocation Read-OnlyGOT表只读或部分只读防止篡改GOT表Full RELRO时不能改GOT入门阶段你可以先挑没有canary的题练手比如buuctf上的ret2text、ret2shellcode类题目理解栈溢出基本流程。等熟练了再去应对canary绕过泄漏canary、NX绕过ROP、PIE绕过地址泄漏这些进阶玩法。这里多提一句做pwn题前第一步永远是checksec。装好pwntools之后一句checksec ./binary就能看到全部保护机制根据它来决定利用思路。我见过不少新手上来就逆代码、找偏移结果忘了看保护折腾半天才发现shellcode根本执行不了——这种冤枉路能少走就少走。6. 从栈溢出到pwn实战我的几个核心体会6.1 先建立“偏移量直觉”再谈花活我经常跟人说栈溢出题做到最后拼的不是谁会的gadget多而是谁对栈布局的直觉更敏锐。看到char buf[64]第一反应不是“64字节”而是“先反汇编、看sub rsp多少、buf在rbp-多少、距离返回地址到底多远”。这个习惯越早建立越好。建议训练方式找三五道简单的栈溢出题不要急着看writeup先用gdb把每个函数的栈帧画出来标注出局部变量、保存的RBP、返回地址的位置然后用cyclic验证自己的判断。这个过程重复几次后面看到反汇编代码脑子里的栈帧图会自动浮现。6.2 万变不离其宗控制流最终都会回到栈上很多人学pwn学到后面会被各种高级技巧吓到——ret2csu、SROP、ret2dlresolve等等。但你把它们的外壳剥掉内核还是那件事想方设法往栈上放好数据然后让ret指令把RIP带到你希望的地方。所有的gadget、rop chain本质都是一串精心布置的栈数据配合ret来连续转移控制流。所以栈的理解深度直接决定了你在pwn这条路上能走多远。不管是用ret2text直接跳后门函数还是ret2libc调用system你都要清楚payload从哪个字节开始是第一个返回地址后面紧跟着的又是哪个寄存器的设置。这些基础打牢了后面的路会顺很多。6.3 故障排查的思路从崩溃现场逆向定位最后分享一个非常实用的排查思路。当你的exp跑崩了不要急着瞎试按这个顺序来gdb ./pwn用run payload复现崩溃现场。看info registers rip确认控制流被劫持到了哪里。如果RIP是0x41414141说明偏移量没找对或者payload被截断如果RIP指向一个奇怪的非代码地址可能是地址计算错误如果RIP指向了某个gadget但接着又段错误可能是栈对齐问题或下一个地址放错了。x/20gx $rsp查看栈顶数据确认当前栈上内容和你预想的是否一致。用bt回溯调用栈确认崩溃点是在哪一层函数里特别留意是不是在libc函数内部崩的。对照保护机制逐一排除canary没绕过NX没绕过还是PIE没绕过这个排查流程我用了很久基本能解决90%的栈溢出问题。剩下的10%可能是gadget不可达、信号处理、或者程序流程复杂导致的那就要结合具体题目再分析了。写到这里栈和内存这块地基算是给你铺得差不多了。你可能会发现pwn并没有想象中那么神秘——它本质上是一场“对程序控制流”的博弈而栈就是这场博弈的棋盘。我在实际调试中最大的感受是与其追着各种高深的技巧跑不如先把栈帧变化练成肌肉记忆。现在你可以打开gdb亲手跑一遍本文的示例再去找一道简单的ret2text题目试试手。等你看到RIP真的被你payload里的地址牵着走的时候那种感觉很爽。