我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Pwn基本功:汇编基础与Linux进程内存模型全面拆解

Pwn基本功:汇编基础与Linux进程内存模型全面拆解 CTF Pwn模块系列分享二汇编基础Linux内存模型拆解聊到Pwn很多人第一反应是“栈溢出、ROP、shellcode”这些听起来很酷的名字但真到做题或者看别人的利用脚本时很快就卡住了。卡住的原因十有八九出在基础上看反汇编代码像看天书不知道栈是怎么涨的、参数是怎么传的、一个ret指令背后到底发生了什么。做Pwn如果不懂汇编和内存布局就像想当外科医生却不了解解剖学不是说完全没法动刀但每步都容易出大问题。这个系列第一篇主要讲了环境准备、工具链和Pwn题的基本运行逻辑。这一篇我们把两块最核心的地基拆开揉碎讲清楚汇编基础以x86/x64为主和Linux进程内存模型。这两块是后面所有利用技巧栈迁移、堆利用、格式化字符串、ROP链构造的基础无论你将来主攻哪个方向这几板斧都必须烂熟于心。这篇文章适合刚入坑Pwn、被反汇编和gdb输出劝退的新手也适合已经会写一点exp但对“为什么是这样”还一知半解的同学。我会把每个关键概念都拆开尽量用大白话和实际例子说明白“底层到底发生了什么”每个结论尽量交代它的来龙去脉方便你边看边在脑子里建立画面。1. 汇编基础Pwn人必须啃下的底层语法1.1 为什么要学汇编学哪些指令就够用先解决一个学习心态问题Pwn方向要学的汇编远远没有写编译器的人那么多不需要背几百条指令真正高频出现的核心指令几十条就够用。我的经验是先掌握“栈操作”和“跳转”两大块优先级最高因为这些指令直接对应利用思路里的流程控制和栈帧操作。像mov、push、pop、call、ret、leave、add、sub、lea它们是出现频率最高的。再者是各类jmp和条件跳转指令je、jne、jz、jnz等在判断逻辑和ROP中要频繁用到。剩下像syscall、int 0x80、test、cmp、xor这些也常见但逻辑相对简单边用边记就行。有的人喜欢先从Intel手册啃起我不是很推荐。真正高效的方式是开着反汇编窗口和gdb直接把C语言写的小程序反汇编来看遇到不懂的指令马上查这样大脑会把指令和具体行为绑定起来而不是死记硬背。你完全不需要做到看任何汇编都能无脑翻译但看到一个函数级汇编块至少能说出“它在干什么、参数在哪、栈上有什么、是否存在明显危险点比如边界检查缺失”这在Pwn中已经足够。1.2 寄存器体系x86与x64的差异对照寄存器是CPU用来存储数据和地址的高速“便签纸”。x86有eax、ebx、ecx、edx、esi、edi、ebp、esp这组32位寄存器x64则扩展到rax、rbx、rcx、rdx、rsi、rdi、rbp、rsp并新增了r8到r15八个通用寄存器。Pwn实操中最常打交道的是这几类寄存器x86x64主要用途累加/通用eaxrax返回值、临时数据基址ebprbp栈帧基址但现在常被当通用寄存器优化掉栈指针esprsp栈顶指针指向当前栈位置参数1栈上见1.4rdi第一个参数参数2栈上rsi第二个参数参数3栈上rdx第三个参数参数4栈上rcx第四个参数参数5/6栈上r8 / r9第五、六个参数段基址fs/gsfs/gs线程局部存储、canary等特殊用途需要注意的是x86下函数参数默认全部压栈传递而x64下如果参数不超过6个会优先使用寄存器传递超过6个才压栈。这是一个极为常见的差异点写利用时尤其要注意因为你控制的数据在栈上的偏移会因此完全不同。还有一个新手容易忽略的点rbp在现代编译器中经常被优化成普通通用寄存器尤其在编译选项开启-O2以上时你看到的函数开头可能根本没有push rbp; mov rbp, rsp这一套“栈帧建立”代码。所以学汇编时有“帧指针”的概念很好但在真实题目中不要默认一定有rbp栈帧。1.3 必备指令速览与操作数语法先从语法风格说起。Linux下用gcc/objdump默认是ATT风格写成mov $0x1, %eax意思是将立即数1存入eax而Intel风格写作mov eax, 1。两种风格差别主要在源操作数和目的操作数的顺序相反。我强烈建议你把gdb和objdump统一设置为Intel风格因为绝大多数CTF Writeup用的都是Intel风格看起来也直观很多。具体操作gdb内执行set disassembly-flavor intel或者直接写入~/.gdbinit。objdump用-M intel参数。常用指令我按功能分组照着敲几遍就熟了数据搬运mov dst, srclea dst, [addr]不访问内存只计算地址栈操作push src栈顶指针先减再写入pop dst先读出再增栈顶指针算术/逻辑add dst, src、sub dst, src、xor dst, src清零常用、and/or、shl/shr比较与跳转cmp a, b本质是减法只修改标志位、test a, b按位与只修改标志位、jmp addr、je/jne/jg/jle等过程调用call func把下一条指令地址压栈然后跳转到func、ret从栈弹出地址并且跳转等价于pop rip经典组合拳leave等于mov rsp, rbp; pop rbp一般在函数结尾出现关于call和ret这是Pwn中最重要的两条指令没有之一。call会把当前指令的下一条地址返回地址压到栈上然后跳转ret则是从栈顶弹出这个地址跳回去。缓冲区溢出经典利用手法就是覆盖栈上的返回地址让ret跳到你指定的位置。理解了这两条指令你就理解了几乎所有栈溢出利用的核心原理。1.4 函数调用约定参数怎么传栈帧怎么搭函数调用约定通俗讲就是“函数之间怎么交接数据和栈空间”的规矩。Linux下主要掌握两种x86的cdecl和x64的System V AMD64 ABI以下简称SysV前者32位后者64位目前做题绝大部分遇到的是64位SysV。32位cdecl简单粗暴所有参数按从右往左的顺序压入栈中返回前由调用者清理栈上的参数。这意味着在调用函数之前你会看到连续几个push调用结束后再add esp, X来恢复栈指针。64位SysV的规则我再强调一遍前6个整数/指针参数依次放入rdi、rsi、rdx、rcx、r8、r9第7个及以后才压栈返回值放在rax被调用者负责维护栈上红区red zone和局部变量区。调用结束后通常由调用者来恢复栈顶也就是add rsp, X。为什么这些细节特别重要因为在构造ROP链时你要使用pop rdi; ret这类gadget来设置参数。“这条gadget为什么能设置第一个参数”以及“函数的第几个参数应该出现在哪个寄存器/栈位置”完全是基于调用约定来确定的。如果搞错了payload写得再长函数拿到的参数也是错的。栈帧的建立和销毁典型轨迹如下函数入口如果定义了局部变量会先push rbp; mov rbp, rsp; sub rsp, 0x20开辟局部变量空间函数结尾通常leave; ret。但正如上面说的编译优化之后这条链路不一定完整需要灵活理解。2. Linux内存模型从地址空间到段与区的边界2.1 一个进程的虚拟内存长什么样学Pwn你必须对进程的虚拟内存布局建立一个整体画面。现代操作系统给每个进程一个独立的虚拟地址空间虚拟地址空间被划分为若干不同功能的区域。在x64 Linux下内存布局从低地址到高地址大致是低地址0x0000000000000000只读代码段.text程序指令所在通常不可写避免被篡改数据段.data存放已初始化且初值非零的全局变量/静态变量BSS段.bss存放未初始化或初值为零的全局变量/静态变量堆区heap程序运行时动态分配的内存区向高地址增长由malloc/free管理内存映射区mmap区用于文件映射、动态库、共享内存等通常会映射libc和ld.so向低地址增长栈区stack函数调用、局部变量、返回地址所在向低地址增长内核地址空间用户态无法访问我们平时看到的0x7fffffffffff以下区域才是用户可用的高地址0x7fffffffffff这个布局决定了你从哪里可以控制什么数据全局变量偏移固定栈地址相对靠上堆地址处于中间libc会被映射到靠近栈的地方。做题时经常看到vmmap输出的libc起始地址落在0x7f...开头那就对应mmap映射区。2.2 为什么是“虚拟”内存页、PIE和ASLR虚拟内存概念好多人听了很多遍但没和Pwn对应起来。简单说每个进程看到的是连续、独立的内存地址但实际上物理内存是分页管理的虚拟页到物理页的映射由内核维护的页表决定。程序本身不知道物理地址CPU通过MMU查页表来完成映射。这里有一个对漏洞利用极其关键的推论即使两个进程运行同一个程序它们的虚拟地址分布也可能完全不同因为现代Linux开启了ASLR地址空间随机化。ASLR会随机化栈、堆、共享库、mmap基底等区域的起始地址让攻击者难以确定目标地址。Pwn里常说的“泄露地址”之所以必须做就是绕开ASLR的前提。另一个相关概念是PIEPosition Independent Executable。如果程序编译时开了PIE默认开启代码段、数据段也会被随机化如果关闭PIE程序加载地址固定通常默认为0x400000x64。CTF题目为了降低难度或故意锻炼某种能力常见组合是“不开PIE但开NX、开canary”或者“开PIE但不全开”做题第一步看checksec输出就是明确这些防护的开关状态。内存布局的理解直接决定了checksec结果对应的攻击思路。2.3 段权限与NX、Canary等防护的底层逻辑每个内存区域都有权限属性读R、写W、执行X对应页表项的控制位。NXNo-eXecute就是限制某些页不可执行在Linux上通过PT_GNU_STACK和页属性实现。开启NX后栈和堆默认不可执行传统“把shellcode放在栈上再跳过去”的方式就失效了于是产生了ROP这种“通过复用程序自身可执行代码片段gadget来构造利用链”的技术。Canary栈保护的实现也是建立在栈空间和特殊寄存器之上的函数入口会从fs段的一个随机位置线程局部存储读取一个值放在栈上缓冲区和返回地址之间函数返回前检查这个值有没有被改动变了就触发__stack_chk_fail。这解释了为什么溢出时一旦覆盖到返回地址正常情况下必然先覆盖到canary也解释了为什么需要先泄露canary才能继续覆盖返回地址。RELRO机制则与数据段的重定位表有关。它把GOT表设置为只读防止攻击者改写GOT表项。部分RELRO只在动态链接器处理完重定位后把GOT表部分页设为只读而Full RELRO则更彻底。这些都是“内存布局的理解”直接催生的利用或防御思路基础不打好后面全是空中楼阁。2.4 用 vmmap 和 /proc/pid/maps 打开进程的眼睛纸上谈兵没有用工具帮你对着真实进程验证模型。gdb内有一个极其常用的命令vmmap如果你用pwndbg或等价的info proc mappings能打印当前进程的完整内存映射。它的输出长这样0x400000 0x401000 r-xp 0x0 /path/to/.../demo 0x401000 0x402000 r--p 0x1000 ... 0x402000 0x403000 rw-p 0x2000 ... 0x7ffff7a00000 0x7ffff7bcb000 r-xp ... /lib/x86_64-linux-gnu/libc.so.6 0x7ffff7bcb000 0x7ffff7dcb000 ---p ... 0x7ffff7dcb000 0x7ffff7dcf000 r--p ... /lib/.../libc.so.6 0x7ffffffde000 0x7ffffffff000 rw-p ... [stack]没有gdb图形界面或没用pwndbg时可以退出调试器直接读/proc/进程pid/maps文件输出格式几乎一样。通过vmmap你可以快速确认程序代码段在哪、libc基地址是多少、栈在哪、堆是否已经初始化。绝大多数地址泄露题目的第一步就是要通过某种漏洞比如格式化字符串读出某个libc函数或栈上的残留地址再结合vmmap的偏移反推出基址。我个人强烈建议新手拿到任意一个Pwn题先跑几遍vmmap跟题目源码对应着看把“内存布局不是抽象概念而是一张可以实时查看的地图”这个感觉建立起来。3. 栈原理与缓冲区溢出复现把画面变成实操3.1 栈帧的手工推演栈是个连续内存区域特点是从高地址向低地址增长rsp永远指向栈顶。函数调用时栈帧的形成顺序是这样的首先调用者把参数准备好寄存器或压栈执行callCPU把返回地址压到栈上进入被调用函数后如果被调用者要使用rbp会保存旧rbp并把rsp赋值给rbp然后通过sub rsp, 局部变量大小给局部变量腾出空间。整块区域向上是调用者的栈帧向下是当前函数的栈空间。一个经典的栈帧布局未开优化、未开canary时的样子高地址 ------------------ | 调用者的局部变量 | ------------------ | 返回地址 | - call 压入 ------------------ | 保存的旧 rbp | - push rbp 压入 ------------------ - rbp 指向这里 | 局部变量区域 | - sub rsp, X 后 rsp 指向底部局部变量从上往下分布 ------------------ - rsp 低地址缓冲区溢出发生后比如你往局部变量数组里拷贝了超出边界的数据写入方向是从低地址往高地址走所以会先覆盖掉局部变量区域的更高空间接着覆盖保存的旧rbp再继续就会覆盖到返回地址最终在函数返回ret时跳转到你写入的位置。这也就是经典攻击路径“栈溢出控制返回地址”的来龙去脉。3.2 写一个可以“看见”溢出的Demo空谈很难建立画面我来写一个极简的例子。选择经典的危险函数strcpy它不做边界检查复制到目标缓冲区直到遇到字符串结束符天然适合展示溢出过程。考虑如下C代码#include stdio.h #include string.h void win() { puts(you win!); } void vulnerable(const char *input) { char buf[16]; strcpy(buf, input); } int main(int argc, char **argv) { if (argc 2) { return 0; } vulnerable(argv[1]); puts(normal return); return 0; }用以下命令编译关闭保护以便快速理解原理正式刷题时按题目情况决定gcc -o demo demo.c -fno-stack-protector -no-pie -O0checksec demo输出会显示没有Canary、没有PIE但默认NX很可能是开启的RELRO部分开启。我们暂时不构造漏洞利用先看它在内存里长什么样。反汇编vulnerable函数objdump -d demo你大概率看到这样的内容push rbp mov rbp,rsp sub rsp,0x20 mov rax,QWORD PTR [rbp0x10] mov rdi,rax lea rax,[rbp-0x10] mov rsi,rax call strcpyplt nop leave ret注意几个关键点sub rsp, 0x20表示栈上留了32字节空间。虽然buf声明是16字节但编译器为了对齐x64栈通常要求16字节对齐分配了32字节后面你会看到实际可写的缓冲区其实比声明大这是很多新手的混淆点。lea rax, [rbp-0x10]把缓冲区首地址放到rsi也就是给strcpy的目标地址。一个常见误解是缓冲区一定从rbp-0x10开始但这里分配的栈空间是0x20缓冲区却从rbp-0x10开始意味着缓冲区和保存的rbp之间还有0x10字节的空隙。这其实是编译器为栈对齐和可能的其他局部变量留的空间。调用strcpy之前会把输入的地址从rbp0x10取出来用寄存器传递x64前6个参数传寄存器完全符合上面讲的SysV调用约定。3.3 手工看栈变化gdb实操记录把证据打在屏幕上比讲十遍原理都有用。用gdb启动demo在strcpyplt调用前下断点给程序传一个输入字符串。比如gdb ./demo (gdb) break *vulnerable20 # 在call strcpy那一行附近下断 (gdb) run AAAA...AAAA # 传入一串A (gdb) x/32gx $rsp # 查看rsp到上方32个内存单元你会看到从rsp开始依次能看到局部变量预留区、rbp、返回地址等等。然后单步执行完strcpy再查看相同区域能看到0x41414141...已经一路写到返回地址位置。你还可以用find 0x41414141来定位字符串。实操中最直观的做法是单步到ret观察rip被改写成什么。这种“逐字节看栈被覆盖”的训练我认为是理解所有栈类漏洞的必经之路。跑通这个流程之后再去看各类ROP题目你会更容易想象出payload写入后栈上的完整画面。3.4 从溢出到控制执行流返回地址跳转的原理当你往buf里输入足够多的字符退出的顺序就会是先覆盖到旧rbp再覆盖到返回地址。leave; ret中的leave会把rsp恢复成rbp然后从栈顶弹出旧rbp紧接着ret会从当前rsp弹出返回地址跳转到攻击者控制的位置。回到demowin函数地址如果已知比如编译后通过readelf -s demo或nm demo查到假设它是0x401156实际以你自己编译结果为准那你只要精确算出返回地址离buf首地址的偏移再在偏移处放上win的地址就能让程序在vulnerable返回时跳进win。在x64下如果地址中会包含\x00字节大概率包含因为默认非PIE程序的代码段地址是0x400000起直接用普通字符串输入会截断这时需要借助脚本写入。这个“字节截断”问题就是为什么实战中payload都必须用脚本拼接而不是命令行手打字符串的原因。如果题目开了PIE也没关系思路类似只是你得先泄露一个程序基地址比如从栈上残留的main返回地址计算出基址再算出win的实际地址。这里背后依赖的就是“内存区域相对偏移固定”这个事实相同可执行文件映射下任何两个代码地址的差值固定所以知道一个代码段地址就能算出任意代码段地址。4. 常见问题与排查技巧实录4.1 “偏移量怎么算都不对”你需要先搞清楚边界很多新手计算溢出偏移时会直接用局部变量声明大小比如buf[16]然后认为返回地址离buf是168旧rbp字节共24字节。但在实际反汇编中因为栈对齐、编译器策略或数组位置不同情况会变。前面那个demo里声明buf[16]实际gap是0x10加上旧rbp的0x8返回地址距buf首地址其实是0x1824字节——这里和声明大小碰巧一致但真心不要依赖声明大小来猜。正确做法是在gdb里下断点查看buf首地址和当前rsp及rbp分别计算差异或者使用pattern create生成字符串运行崩溃后用pattern offset算出偏移。pwndbg的cyclic命令也是这个思路。实操里我会用两个方法交叉确认调试器看地址差以及pattern定位两者一致时才算可靠。4.2 “为什么我明明覆盖了返回地址程序还是崩了”常见原因有三个。第一覆盖未对齐或地址写歪了。返回地址必须精确落在函数fun在ret时栈指针指向的位置如果差了一个字节ret会跳到一个错位的地址。第二x64地址包含\x00字节你用printf或命令行传入字符串时被截断导致只写入部分地址。这个要特别注意解决方法是所有地址都通过脚本精确写入字节不建议手打。第三你覆盖了更多数据导致后续代码期望的栈布局被破坏尤其在你使用system这类函数时参数传递错误也会引出新的crash。排查套路我推荐先在gdb中单步跟踪到ret指令查看ret执行前rsp指向的值是不是你预期地址以及该处内容是否完整写入了你想要的地址然后用x/i查看rip将跳转到的位置反汇编是否合理最后执行并观察崩溃时的寄存器、回溯。4.3 汇编指令风格与调试器配置雷点初学阶段最常见的问题就是gdb默认的ATT风格让人心智错乱。如果不统一成Intel风格读mov %eax,0x8(%rbp)时很容易把源和目的理解反。我的建议是在~/.gdbinit里直接写好set disassembly-flavor intel set pagination off另外gdb里地址前后缀格式不同也会带来认知负担。pwndbg默认会高亮地址并输出十六进制和符号信息很直观。如果你坚持原生gdb那么x命令查看内存的方式要熟练x/20gx $rsp中的g表示以8字节为单位gnu“giant”每次显示8字节x/gx $rbp一次显示一个8字节的值。这些命令不熟所有分析都容易卡壳。4.4 新手最该避开的几个“看似正确”的练习姿势第一不看反汇编直接背payload模板。Pwn不是背模板的科目同一个溢出点在不同保护策略和不同调用约定下payload完全不一样。第二只看Writeup不自己动手调。看别人exp的时候好像什么都懂了自己一收gdb输出就傻眼。哪怕复现一个最简单的栈溢出也要亲手走一遍“断点、看内存、算偏移、写payload、调试crash”的完整循环。第三忽略Ubuntu系统版本的libc差异。同一个system函数在不同glibc版本里偏移很可能不同因此刷题一定要确认题目给出的libc版本本地用不同版本运行会造成大量无意义debug。第二点是体验最深的一条我相信所有真正练过Pwn的人都经历过“看十篇不如调一次”的阶段。面对一个新的漏洞点把调试器用熟用“假设-验证-修正”的节奏不断逼近真相这个能力本身才是Pwn里面最值钱的东西。5. 实操建议收尾从这往后怎么走在文章的最后我本来还想把ROP链的构造和libc泄露都展开但那个内容信息量极大放进这篇文章里会稀释掉前面基础部分的浓度更适合作为系列第三、四篇的独立主题。就个人实际体会而言一篇技术分享能把读者带到“能看懂汇编、知道操作系统内存布局、能亲手复现栈溢出”的程度已经很了不起了。剩下的事需要靠你在真实的题目里把概念变成本能。给想继续推进的同学指个方向找几道没有开启任何保护的经典栈溢出题目自己动手做通几遍把每个步骤做到不看任何Writeup也能完整复现然后逐步给编译选项加上canary和PIE感受防护对利用思路的影响接着引入libc泄露和ROP链构造用pwntools把自己从纯手工调试中解放出来。这样一步一步走Pwn的技术树就会在你脑子里自然地生长出来。有一个很小的实操心得值得分享初学者常常高估“理解”的作用低估“熟练”的作用。理解栈溢出原理很容易但要能在五分钟内完成一次“查看保护、找溢出点、算偏移、写payload、本地调通”的流程需要重复几十道题目才做得到。所以如果你现在正卡在某个题目上别急着跳过去把调试器每一条输出慢慢看懂这份复盘本身就是你最重要的技术积累。
返回列表