尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入理解栈与内存布局:从函数调用到栈溢出利用核心逻辑

深入理解栈与内存布局:从函数调用到栈溢出利用核心逻辑 刚入门pwn的朋友十有八九都会卡在同一个地方拿到一个二进制文件看反汇编代码像看天书明明知道是栈溢出却不知道栈到底是个什么东西为什么改几个字节就能劫持程序流程。其实pwn和二进制安全的底层逻辑非常朴素——只要你真正理解了“栈”和“内存”这两个核心概念再去看那些漏洞利用的招式会发现它们全都是围绕同一套规律在转。这篇文章我想从最本质的视角把pwn最依赖的两个基础概念讲透进程的内存布局是什么样的函数调用时栈上到底发生了什么以及这些底层机制怎么一步步转化为缓冲区溢出和返回地址劫持的利用原语。内容不需要你事先会汇编、懂内核只要你有基本的C语言基础跟着走一遍就能建立完整的画面。写这篇文章也是做个备忘给自己做pwn题时的“兜底笔记”整理完发现很多之前靠背的结论其实只要理解了内存模型就完全不用背。1. 从一段漏洞代码说起为什么pwn离不开“栈”1.1 一个经典的栈溢出样本先看一段最简单也最典型的代码#include stdio.h #include string.h void win() { printf(You Win!\n); } void vulnerable() { char buf[16]; gets(buf); } int main() { vulnerable(); return 0; }这段代码编译成32位程序后vulnerable函数里有一个16字节的缓冲区但gets会往里面读入任意长度的数据。如果你输入超过16字节的内容多出来的部分就会“溢出”到缓冲区之外覆盖栈上的其他数据。大多数pwn入门题都是这个套路而利用的关键就在于搞清楚“多出来的部分到底覆盖了什么”。在给新手讲的时候我习惯把栈比作一摞盘子push就是往上放一个盘子pop就是拿走最上面的盘子。函数调用的时候也一样调用函数前先把参数、返回地址压进栈里进入被调函数后再为局部变量腾出空间。buf[16]就是你在栈上硬生生占的16个盘位gets往里塞数据就是往盘子上叠东西叠多了自然就掉到别人的区域里了。1.2 函数调用时计算机到底做了什么要理解溢出到底破坏了什么得先知道一次正常的函数调用会动哪些手脚。拿上面代码中main调用vulnerable来说实际执行过程是call vulnerable指令会把main中下一条指令的地址压入栈中这是“返回地址”保证函数结束后能回到调用处继续执行。进入vulnerable后编译器生成的指令会先push rbp把上一层函数的栈底指针保存起来然后mov rbp, rsp将当前栈顶作为新函数的栈底接着sub rsp, 0x20之类的指令为局部变量腾空间。函数结束时顺序反过来先leave恢复rbp和rsp再ret弹出返回地址并跳转过去。所以一个函数在栈上占据的这块区域专业术语叫“栈帧”。栈帧里从高地址到低地址大概是调用者保存的参数和返回地址、被调函数的rbp、局部变量。我们后面会画一个更完整的图但你先记住最关键的一句话局部变量在低地址返回地址在高地址如果局部变量往高地址方向越界写入就可能一路覆盖到返回地址。我第一次看这个流程的时候在想为什么会有人设计出这种看似不安全的机制其实栈这种LIFO结构天然就是为函数调用设计的后调用的函数先返回嵌套调用层级可以无限深存储效率极高而且完全由硬件指令直接支持。安全性问题是在后来才被重视的C语言里gets、strcpy这类函数不检查边界配合栈的固定布局才形成了溢出利用的基础。2. 内存布局二进制程序眼中的“世界地图”2.1 从高地址到低地址一次把段看全一个进程的逻辑地址空间在开启了默认特性之前先以最原始的x86 32位模型为例大致长这样地址方向区域作用高地址内核空间操作系统驻留区域用户程序不可访问高地址栈函数调用时自动分配向下增长中低地址堆程序运行时动态分配向上增长低地址BSS 段存放未初始化的全局变量低地址数据段存放已初始化的全局变量最低地址代码段存放机器指令只读这里面最需要建立直觉的是栈和堆的方向栈向下增长从高地址往低地址堆向上增长从低地址往高地址。两个区域相向扩张中间有共享库映射区和随机化的空隙。谁是高地址谁是低地址真的不需要死记硬背你理解函数调用的方向就行栈顶地址用rsp寄存器保存每次push都让rsp减小所以新的栈帧总是出现在更低的地址上。这也是为什么栈溢出能覆盖“上面”的数据——“上面”指的是高地址方向也就是调用者保存的返回地址那侧。2.2 栈向下、堆向上两个方向的博弈堆和栈为什么非要相向而长其实是为了最大化利用内存空间。程序运行时栈的需求量和堆的需求量都在动态变化如果两者同向增长总有一方会很快撞到另一方相向增长时中间的自由空间可以被两边灵活共用。虽然现代操作系统已经用虚拟内存加页表管理地址空间这个经典设计依旧保留了下来。堆和栈还有一个本质区别栈的内存分配和释放完全是编译器安排好的函数一进入就sub rsp一退出就add rsp速度飞快堆则是程序员用malloc/free显式管理底层通过brk或mmap向内核要内存还要处理碎片和空闲链表轴速度慢得多。pwn题里涉及堆利用heap exploitation时难点其实不只是堆块本身的布局还有glibc的malloc实现里那些精密的元数据管理逻辑。顺着说一个很多新手困惑的点为什么栈上局部变量的地址在调试时老是变这是因为Linux默认开了ASLR地址空间布局随机化每次运行程序栈、堆、共享库的基址都会随机偏移。但注意如果是32位程序且没有开启PIE位置无关可执行文件代码段的位置是固定的这就是很多入门题能直接“ret2text”跳到目标函数地址的原因。后面做pwn题都会用checksec工具看一眼它显示的就是这些保护开关的状态。2.3 一个实操实验用gdb观察内存分布光说不练不行我们实际来看一下。用下面这个简单程序编译成64位关闭PIE更方便观察#include stdio.h #include stdlib.h int global_var 42; int main() { int stack_var 10; int *heap_var malloc(16); printf(stack: %p\n, stack_var); printf(heap: %p\n, heap_var); printf(global: %p\n, global_var); printf(main: %p\n, main); return 0; }在Linux下编译运行会得到类似下面的输出地址数值因ASLR每次不同但相对关系很稳定stack: 0x7ffc4a3b0d4c heap: 0x602010 global: 0x601040 main: 0x400536一眼就能看出来stack_var是栈上的地址接近0x7ffc...非常高的位置heap_var是堆上的地址在0x60...附近main函数是代码段的地址在0x400...附近。三者的地址区间天然分隔。我再强调一次栈在高地址往低地址长堆在低地址往高地址长——这两个方向一旦搞反后面理解偏移计算全都会乱。3. 栈帧的完整生命周期3.1 栈帧里到底放了什么把视角拉近到单个函数这是整个pwn利用里我最喜欢拿笔画图的地方。假设main调用func(arg1, arg2)func内部声明了char buf[16]编译成x86-64代码后调用者和被调者会形成这样一层栈帧高地址 ----------------------------- | 调用者的局部变量... | ----------------------------- | 实参 arg2 | | 实参 arg1 | | 返回地址call指令压入 | | 保存的 rbppush rbp | | buf[0..15] | -- rbp 指向这里附近 | 填充或其它局部变量 | -- rsp 指向栈顶 ----------------------------- 低地址在实际的x86-64调用约定System V AMD64里前6个整数参数是用寄存器传递的不需要压栈但额外的参数或者被调用者再调用别的函数时必要时也会用到栈上参数区。所以这个图上参数的位置在不同体系结构下会变化但“返回地址在局部变量上方”这个核心关系在所有常见的栈溢出利用中基本不变。这也是理解栈溢出的唯一一张关键图往buf里写入的数据按地址递增方向移动先覆盖buf自己的空间接着覆盖保存的rbp再往上就是返回地址。功课做得扎实的人这时候已经在心算偏移量了buf到返回地址之间隔了多少字节就覆盖到哪里。3.2 rbp/rsp到底在管什么再看两个寄存器的作用这也是新手看反汇编时最烦躁的地方。rsp栈指针始终指向当前栈顶是动态变化的rbp栈底指针也叫帧指针通常指向当前栈帧的固定位置用来访问参数和局部变量。函数开头典型的“序言”prologue是push rbp mov rbp, rsp sub rsp, 0x20push rbp保存调用者的栈底指针mov rbp, rsp把此刻的栈顶设为新栈帧的底部sub rsp, 0x20给局部变量腾空间。函数内所有局部变量的访问都基于rbp做偏移[rbp-0x10]就是某个局部变量[rbp0x10]就是返回地址向上的参数区域。为什么有rsp还要rbp因为rsp在执行过程中会变比如push、call、分配临时空间用它访问局部变量会导致每个指令里的偏移都要动态算非常容易出错。有了固定的rbp编译器生成的代码就简单多了。现代编译器的-fomit-frame-pointer优化可以省略rbp直接用rsp加偏移但这属于进阶优化pwn题里大多数题目为了方便调试都不会开。3.3 函数返回时栈上发生了什么函数结尾对应“尾声”epilogue常见指令是leave retleave等价于mov rsp, rbp; pop rbp作用是回收当前栈帧并恢复调用者的rbpret弹出栈顶数据作为返回地址并跳转。这里就是被攻击的核心点。正常情况下ret弹出的是合理的返回地址程序按部就班执行。但如果栈上的返回地址被覆盖成了攻击者指定的值ret就会跳出常规流程跳到攻击者希望执行的代码位置。这就是为什么pwn圈常说“控制返回地址就控制了世界”所有栈利用的终点几乎都是这个指令。我自己在做题时有一些积累的心得leave; ret这两个连写的指令在gadget里出现频率极高ROP面向返回编程里经常拿它来做栈迁移。理解它“先用rbp恢复rsp再弹出rbp最后弹出返回地址”的执行顺序可以让你后面构造payload时对栈布局心里有数。4. pwn视角下的栈攻击核心逻辑如何转化为漏洞4.1 栈溢出发生的三个条件现在把前面的内容串起来你就会发现一个栈溢出漏洞能被打其实只需要三个条件同时成立程序把不可信的外部数据拷贝到栈上的缓冲区且没有边界检查比如gets、strcpy、sprintf等危险函数。缓冲区之后紧跟关键数据保存的rbp、返回地址或者相邻的局部变量、函数指针。系统没有足够强的防护机制拦截这种越界改写或者攻击者能绕过已有防护。对应到代码审计和CTF题目分析第一步永远是找“危险的输入点”。看函数名就能筛掉一大批gets、strcpy、strcat、sprintf、scanf(%s)都不检查长度read、fgets、snprintf如果长度参数计算错误同样可能溢出。拿到一个二进制题后我用objdump或IDA先定位这些函数再找它们写入的目标缓冲区位置基本上就能判断有没有入口。4.2 ret2text极简实战第一次劫持控制流拿最经典的“ret2text”来说这是最基础的栈溢出招式。题目里通常有一个没有被调用但打印了flag的函数比如叫win、backdoor、shell之类的攻击目标很明确——把返回地址改成这个函数的地址。以64位程序为例流程是这样用checksec看保护这里假设关了PIE和Canary地址固定且栈上无金丝雀。用反汇编工具找到目标函数地址假设是0x4006cd。确定缓冲区到返回地址的偏移一般通过gdb计算或pattern工具如pwntools的cyclic找到。构造payloadbA * offset p64(0x4006cd)发送给程序。用pwntools写是这样的from pwn import * p process(./vuln) elf ELF(./vuln) win_addr elf.symbols[win] payload bA * 0x28 p64(win_addr) p.sendline(payload) p.interactive()第一道坎通常是偏移量怎么算。最稳妥的办法是用cyclic 200生成一串无规律字符作为输入程序崩溃后用gdb查看崩溃时的ret地址那4/8个字节在pattern里的位置就能立刻得出偏移。我现在做题时直接把这套流程固化成脚本模板了每次都省下大量排查时间。第二道坎是64位程序的栈对齐问题调用win时如果ret跳过去时栈顶没有按16字节对齐某些用到了movaps指令的libc函数会直接SIGSEGV。解法很简单在返回地址前面多ret一个地址也就是payload改成bA * offset p64(ret_addr) p64(win_addr)其中ret_addr可以是任意一个ret指令的地址用ROPgadget --binary ./vuln | grep ret就能找。这个问题我一开始没记住凡是遇到ret2libc或ret2text莫名其妙崩溃的第一反S应该就是看看对齐。4.3 canary、PIE等现代防御为什么有效既然栈溢出这么容易利用现代操作系统和编译器当然不会坐视不管。这里每种防御机制本质上都在攻击“栈布局可预测”和“代码地址固定”这两个假设。Canary栈金丝雀在rbp和局部变量之间放一个随机值函数返回前检查它是否被改过。攻击者要覆盖返回地址就必然先覆盖这个随机值而它每次运行都不同无法直接预测。绕过思路一般是先泄露canary值再在payload中原样写回。PIE位置无关可执行文件把程序自身代码段的地址也随机化ret2text就没法用固定地址。绕过需要先泄露一个代码段内的地址再通过偏移计算出目标地址。NX非可执行栈栈内存不可执行想直接执行shellcode就行不通了。绕过思路转型为“代码复用”这就是ROP。ASLR操作系统层面的地址随机化共享库和堆栈的地址每次都变。对ret2libc这类利用影响大因为需要知道libc函数地址。把这几个机制放在一起看你会发现它们共同构建了一个多层防线就算你找到了溢出点还要面对随机地址、栈上随机值、不可执行栈这几道关卡。现代pwn题的难度实质上就是在逐步突破这些防线底层逻辑仍然是“往栈上写数据、劫持控制流”只是每次都要为新的防护机制花心思。我在做题时觉得最实用的是这两个习惯拿到题先第一件事就是checksec把保护情况写下来决定攻击路线。凡是涉及到泄露地址的题先搞清楚拿到的地址是哪个段的基址再换算目标地址不要猜。5. 常见问题与排查技巧实录5.1 栈地址总变PIE/ASLR在作祟刚入门时最容易懵的一个现象是gdb里调试时明明看到某个地址跑第二次就变了。这基本就是ASLR在起作用。区分一下关了ASLR的系统上栈地址和共享库地址每次都固定。默认Linux发行版开了ASLR只有关了PIE的代码段地址是固定的栈、堆、libc的基址都随机。gdb默认会关闭部分ASLR所以你在gdb里看到的栈地址和直接运行时的栈地址可能不一样这也会让调试时的payload偏移计算产生困惑。解决思路是别依赖固定地址而是用信息泄露拿到当前运行时的地址再动态计算。调试时可以用gdb的set disable-randomization on固定随机化方便观察结构但做题时payload必须按泄露地址来算。5.2 用gdb调试栈崩溃的通用套路我把一套自己在用的排查流程分享出来遇到栈溢出相关崩溃基本够用用cyclic生成填充发给程序记录崩溃现场。gdb跑崩溃文件core dump或者直接gdb ./vuln后run input看到崩溃指令位置。看rsp/rip的异常值用cyclic -l算出偏移。检查目标函数地址是否正确64位下检查栈对齐。如果崩溃位置在libc里很可能不是简单溢出问题需要结合泄露调试。日常调试中我给自己的要求是“每个关键步骤都打印一下”看到异常早发现。复杂题目建议自己构造debug脚本用pwntools的gdb.attach()配合断点比纯黑盒瞎猜效率高很多倍。5.3 新手最容易踩的3个坑最后整理一下我接触过的新手包括当年的我自己最容易踩进去的坑逐一记下来能让你节省大量时间。第一个坑是不看保护直接构造payload。拿到题就写p64(addr)结果程序开了PIE地址一直是错的白白浪费时间。正确顺序永远是先checksec知道对手是谁再出招。第二个坑是分不清32位和64位的地址宽度和传参方式。32位参数走栈64位前几个参数走寄存器payload里p32和p64混用也经常导致错误。而且64位下地址自带的0x00字节如果输入函数是strcpy或gets这类遇到\x00就截断的函数就需要找libc里的地址或用write等一次打印更多字节来泄露不能直接用printf看截断过的字符串。第三个坑是忽略canary的存在。有时候一次输入明明覆盖到了返回地址但程序没有立刻崩溃多半就是canary被覆盖触发了检测。检查canary是否开启的方法很简单反汇编里搜索fs:0x28相关指令或者直接看checksec结果。绕过canary的基础打法分两步先通过格式化字符串或越界读泄露canary再在payload中正确保留这个值。这个过程需要精确计算canary在payload中的offset一个字节错位都会导致崩溃。这些坑说穿了都是对栈布局和内存模型理解不够深导致的只要每次调试都回到那张“栈帧图”上核对一遍很多困惑会瞬间通。到这一步栈和内存的核心逻辑就完整地串起来了从虚拟地址空间的整体布局到函数调用栈帧的创建与销毁再到栈溢出如何修改返回地址实现劫持控制流最后是各种防御机制对利用方式的限制。我个人实际做pwn题时的体会是与其去背大量的payload模板和gadget参数不如先把这张栈帧图和“局部变量在低地址、返回地址在高地址”这个最基本的关系刻在脑子里几乎所有入门题目都能在这个框架内解开。再往后不管做堆题还是内核题这层底层的地基打牢了学习曲线都会顺畅很多。
返回列表