ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PWN入门实战:从BUUCTF rip题目理解栈溢出与返回地址劫持

PWN入门实战:从BUUCTF rip题目理解栈溢出与返回地址劫持 我第一次刷 pwn 题的时候对着 BUUCTF 上那排题目列表完全不知道该点哪一个。后来把rip这道题打通的那一刻才终于明白网上那些 writeup 里反复说的“控制 rip”到底是什么意思。这篇文章就把这道题的完整链路写一遍拿题之后怎么看、栈溢出的原理是什么、exp 为什么这么写、以及从题目里沉淀下来的 pwn 基本思路。适合已经装好 Ubuntu/kali、会用一点 Python、但对“pwn 到底在干嘛”还一脸迷茫的新手如果你已经能独立打通两三道栈溢出题可以直接跳到第 5 章对照一下自己的判断流程。1. 正式动手前先把“打 pwn”这件事想明白1.1 pwn 题到底在做什么CTF 里的 pwn本质上就干一件事让程序执行它原本不该执行的代码。一个正常程序从入口函数开始一步一步按开发者写的逻辑走读文件、算数据、打印结果最后退出。pwn 想打破的就是这条固定轨道通过输入、环境变量、内存布局等手段把程序掰到攻击者期望的方向上。为什么能掰因为程序处理数据和控制执行流用的是同一套内存。开发者以为你给它的是一串普通字符串但实际上这串字符串里藏着下一跳要去的地址。拿 rip 这道题来说程序读入了一串乱写的数据我们让其中某一段变成后门函数的地址程序返回时就真的跳过去了。整个过程不是魔法而是顺着程序自身机制走了一遍。新手最容易犯的毛病是急着看 exp、背 payload。其实 pwn 题的思路链条非常固定输入点在哪、能否覆盖关键数据、目标地址有没有。把这三个问题搞清楚exp 只是最后一小步。后面你会发现哪怕题目换成堆、栈迁移、格式化字符串底层问的还是这三件事。1.2 为什么第一道题通常选 ripBUUCTF 上这道rip是很多人接触的第一道 pwn 题它的特点非常“新手友好”64 位 ELF编译时几乎没有开常见保护攻击条件非常宽松程序里已经写好了一个后门函数fun()里面直接执行system(/bin/sh)等于把攻击目标摆在你面前输入点用的是gets没有任何长度限制栈溢出轻轻松松题目名叫 rip本身就在暗示考点——x86-64 下控制执行流的寄存器叫RIP你要做的就是控制它。这不是一道难题目但它把 pwn 里最核心的一个概念讲透了程序跑的每一步都取决于当前要执行的指令地址而这个地址有时候是可以由用户输入决定的。哪怕后面打 ROP、ret2libc、堆利用本质上都是在用不同的技巧去做同一件事——把程序计数器改到你想让它去的地方。所以我的建议是别急着跳题先把 rip 吃透。就算你已经过了新人阶段回来看这道题也能帮你梳理清楚基础模型。1.3 工具准备能跑起来的最小环境打 pwn 不需要装备拉满但有几样东西必须有。我用的是 Ubuntu 虚拟机加一套基础工具链完全够用工具用途备注Ubuntu / kali 虚拟机运行题目、调试不建议用真机直接跑陌生二进制python3 pwntools写 exploit 脚本CTF 打 pwn 事实标准gdb动态调试、看崩溃现场建议装 pwndbg / gef 插件file / checksec看文件类型与保护机制checksec 在 pwntools 里也带IDA Pro / Ghidra / objdump静态反汇编没有 IDA 就用后面两个够看这道题装好基础环境后建议先把 pwndbg 配好。pwndbg 能直接显示崩溃时的寄存器、栈内容、反汇编对新手理解栈溢出帮助极大。命令行里执行gdb ./rip如果提示找不到命令先装 gdbsudo apt update sudo apt install gdb python3 -m pip install pwntools如果 pwndbg 还没配也行裸 gdb 配合x、bt、info registers这几个基础命令也能调试只是效率稍低。别在这上面花太多时间题目要先跑起来。2. 拿到题目以后的第一轮侦察file、checksec、反汇编2.1 file 与 checksec先看对手长什么样拿到题目文件第一件事不是急着开 IDA而是先看它是什么物种。打开终端对题目文件执行两句话file rip checksec ripfile输出会告诉我们这是一个 64 位 ELF 可执行文件动态链接有没有去符号等。checksec则是 pwn 玩家的“体检报告”每一项保护都决定了一种攻击路径是否可行保护项作用如果开启了会怎样Canary栈上放随机数检查是否被改写直接覆盖返回地址会触发崩溃得先泄露 canaryNX栈不可执行不能把 shellcode 放在栈上跑得用 ROP 或复用已有代码PIE程序地址随机化后门函数地址不固定得先泄露基址RELRO重定位表只读/延迟绑定控制全开时改 GOT 表方案失效部分 RELRO 还能打 GOTrip 这道题正常情况下checksec输出大概是Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x400000)意思就是没有 canary、栈可执行、程序地址固定。三个大麻烦一个都没开。这对新手来说简直是天胡开局——你不需要考虑绕过随机化不需要提前泄露任何东西只需要把返回地址改成后门函数地址就行。顺便说一句checksec这个命令在 pwntools 里也内置了如果系统里没有独立命令可以进 python3 后执行from pwn import * print(ELF(./rip).checksec())2.2 反汇编在 IDA 里找漏洞点和攻击目标接下来用 IDA 打开ripF5 一把梭。如果没有 IDA用 Ghidra 也一样。再或者直接objdump -d rip也能看出个大概。我用 IDA 打开之后反编译结果非常短int fun() { return system(/bin/sh); } int main() { char buf[?]; gets(buf); return 0; }程序逻辑干净得让人感动main里就一个gets(buf)然后在栈上定义一个局部变量 buf读进来的数据直接往栈上塞完全没有长度检查。而fun函数里已经帮你调好了system(/bin/sh)只要程序跳进去就直接弹 shell。这里有一个新手容易误解的地方pwn 题里所谓的“后门函数”不是程序预留的隐藏菜单而是开发者写了一段危险逻辑或者程序里恰好存在system这类关键函数。fun这种叫“后门”因为它是作者故意留下、或者因为某种原因存在的可执行危险代码。rip 就是非常经典的“后门函数 栈溢出”组合题。在反汇编界面里你要做的关键动作只有两个一是确认gets的缓冲区地址二是记下fun函数的地址。这两个值后面都要用。地址以你自己反汇编到的为准不同版本可能不一样不要死记我文章里的数字。2.3 关键问题为什么目标是 fun 而不是 main你可能想为什么目标是fun而不是让程序重新执行main或者直接跳向某个系统调用因为执行流的走向完全取决于你能控制到哪个地址、那个地址有什么代码。fun里已经有现成的system(/bin/sh)等价于一个已经写好的“打开 shell”按钮。程序里虽然也有system的 PLT 入口但直接跳 PLT 会遇到参数问题——system需要一个指向/bin/sh字符串的指针不能随随便便蹦过去。fun好就好在它把所有准备工作都做完了参数在函数内部已经传好。你只需要把 RIP 指向它等着拿 shell 就行。pwn 里管这种手段叫 ret2text也就是“返回到程序里原本就有的代码片段”。这套思路在简单题里是全部在难题里是基本功。3. 栈溢出到返回地址劫持攻击链是怎么成立的3.1 函数调用与栈布局要理解为什么字符串能变成返回地址得先知道函数调用时栈是怎么布局的。x86-64 下每次函数调用大致会发生这些事调用方把参数放进寄存器或栈执行call指令这会自动把“返回地址”当前指令的下一条压入栈被调用函数开头通常push rbp保存上一个函数的栈底然后mov rbp, rsp建立新的栈帧函数局部变量分配在 rbp 往下低地址的空间。一个函数在运行时的栈布局大概长这样高地址 -------------------- | 调用方栈帧 | -------------------- | 返回地址 (ret) | - call 压入函数返回时执行 -------------------- | 保存的 rbp | - push rbp 存入 -------------------- | 局部变量区 buf | - rbp 减去一段偏移 -------------------- 低地址gets(buf)读入数据时是从buf开始往高地址方向一路写。正常情况下写到buf区域就停了但gets没有边界检查它会一直写直到遇到换行符或 EOF。于是你可以把保存在栈上的rbp覆盖掉再往下把返回地址也覆盖掉。函数执行到尾部编译器会生成leave; ret两条指令。leave等价于mov rsp, rbp; pop rbp先把栈指针恢复到当前函数栈帧底部弹出一个值作为上一轮rbpret再弹出一个值作为下一条指令地址。我们在返回地址位置填了fun的地址那ret弹出的就是它程序下一跳就进了fun。这个过程可以粗浅地类比成盖楼时设计图放在高层但你从地下室的排水管往里灌水水一直灌到高层把设计图上的箭头涂改成了另一条路线。水就是gets读进来的数据路线指向就是返回地址。3.2 偏移量计算从 buf 到 rip 到底隔了几字节理解了攻击原理接下来最关键的实操是计算“填充多少字节才能刚好覆盖到返回地址”。这道题的常见版本里main反汇编大概长这样push rbp mov rbp, rsp sub rsp, 0x10 lea rax, [rbp-0x10] mov rdi, rax mov eax, 0 call gets mov eax, 0 leave ret可以看到buf在rbp-0x10也就是相对于栈底偏移 0x1016 字节。栈从buf到rbp有 16 字节后面还有保存的旧rbp占 8 字节再后面才是返回地址。所以要覆盖返回地址需要填充长度 buf 到 rbp 偏移 (0x10) 旧 rbp 占位 (0x8) 0x18 (24 字节)payload 开头 24 字节可以是任意内容第 25 到第 32 字节覆盖返回地址。这里有个非常重要的提醒偏移量必须以你反汇编看到的实际值为准。如果你拿到的 rip 是sub rsp, 0x18的版本那偏移就是 0x18 0x8 0x20。不同平台或不同编译选项可能会不同盲目抄别人文章里的数字会踩坑。验证偏移最保险的方法是动态调试用 gdb 配合cyclic生成随机串gdb ./rip run (python3 -c from pwn import *; print(cyclic(80)))崩溃后看$rsp或寄存器里的值再用cyclic_find反查偏移。不过 rip 这道题比较简单直接从反汇编算偏移就行动态调试可以作为确认手段。3.3 为什么要用“返回地址”而不是改函数指针写 exp 多了你会发现pwn 里劫持执行流的方式很多改函数指针、改 GOT 表、改栈上返回地址、改 vtable……但 rip 这道题选的是最基础也最稳定的路径——修改栈上返回地址。为什么大家都在返回地址上做文章因为函数返回时必然要从栈上取一个地址而这个地址的位置和格式是固定的。只要我们能写到栈上对应位置就等于拿到了一个“必定会被执行一次跳转”的机会。相比之下改函数指针要求程序后面确实会调用那个指针链路更长、条件更多。这也是为什么学习 pwn 都从栈题起步栈上布局简单、规则明确、对新手友好。理解了这一个窗口后面看堆题、格式化字符串题时会轻松许多。4. 写 exp从本地打通到远程连接4.1 最小 exp 脚本现在所有信息都齐了偏移 0x18返回地址改成fun的地址。用 pwntools 写出第一个完整的 expfrom pwn import * context(oslinux, archamd64, log_leveldebug) p process(./rip) # 以你实际反汇编到的地址为准 fun_addr 0x401186 payload bA * 0x18 payload p64(fun_addr) p.sendline(payload) p.interactive()逐行解释一下context(oslinux, archamd64)告诉 pwntools 目标是 64 位 Linux 程序后面p64才会按小端序打包process(./rip)在本地起一个子进程来跑程序bA * 0x18是 24 个填充字节覆盖到返回地址前的位置p64(fun_addr)把fun地址打包成 8 字节小端序覆盖返回地址位置sendline发送时自带换行gets收到换行就停止读入interactive()把当前终端交给程序等 shell 弹出来。运行后如果看到$符号说明拿到了目标程序的 shell可以愉快地执行ls、cat flag了。如果本地直接崩掉别慌八成是下一节要说的栈对齐问题。4.2 常见的第一个坑栈对齐movaps新手第一次打这道题最容易遇见的诡异现象是payload 明明看起来没毛病地址也对但本地直接 Segmentation fault或者本地能通、远程不通。这种时候首先要怀疑 64 位下的栈对齐问题。为什么会崩因为 glibc 从某个版本开始system内部有些路径会用到movaps这类要求栈 16 字节对齐的指令。如果程序跳进fun时的栈指针没有按照 16 字节边界对齐system一执行到movaps就会直接段错误。用 gdb 跑一下崩溃位置经常就在 system 内部的某一处 movaps 上。解决办法也简单不要直接ret到fun先ret到一条裸的ret指令上让栈指针先整体往下挪 8 字节再回到fun。这样对齐状态就变了。去找一条ret指令地址可以用 pwntools 的 ROP 模块from pwn import * context(oslinux, archamd64) elf ELF(./rip) rop ROP(elf) # 找一条 ret 指令的地址 ret_addr rop.find_gadget([ret]).address print(hex(ret_addr))然后在 payload 里把 ret 垫在 fun 前面payload bA * 0x18 payload p64(ret_addr) # 先执行一次 ret调整对齐 payload p64(fun_addr) # 再进入后门函数执行逻辑变成main返回时ret到 ret 指令ret 指令还没有做实质工作只是把下一条地址弹出来、跳转到fun。但关键在于多执行这次 ret 会让rsp继续向高地址走 8 字节对齐状态刚好可能被纠正。这道题是否需要垫 ret和你本地的 glibc 版本、题目编译方式都有关系。我个人的习惯是第一轮先不垫 ret 直接打崩了再加 ret 再试。这也是 pwn 里很常用的调试思路——每次只改一个变量看程序反应。4.3 本地通、远程连接的最终形态本地打通之后BUUCTF 在线平台会提供一个远程实例和端口把 exp 里的process换成remote即可from pwn import * context(oslinux, archamd64, log_levelerror) p remote(node4.buuoj.cn, 你的实例端口) # ret 地址和 fun 地址以实际反汇编为准 ret_addr 0x401016 fun_addr 0x401186 payload bA * 0x18 p64(ret_addr) p64(fun_addr) p.sendline(payload) p.interactive()这里再强调一次两个小细节。第一sendline要带换行因为gets遇到换行才停止读入如果你用send且末尾不加换行服务端会一直等你输入。第二有的用户会在p64打包时忘了字节序导致地址倒过来程序照样崩。这是新手非常高频的错误确认一下你的地址是不是以\x86\x11\x40...的形式出现在 payload 里。远程打通后interactive()拿到的就是远程程序的 shell直接cat flag就能拿到 flag。这一瞬间的成就感是很多 pwn 玩家入坑的起点。4.4 动态调试不会也没关系但会了少走一半弯路虽然 rip 简单到可以直接算偏移但我还是建议借这道题把 gdb 的基本流程过一遍因为后面的栈题几乎都要靠调试定位问题。用 pwndbg 插件时崩溃后它会自动高亮报错的指令、寄存器和栈内容看得非常清楚。调试栈溢出题的通用流程大概是这样用cyclic 100生成 100 个随机字符在 gdb 里运行程序把这串字符作为输入程序崩溃后查看$rip记下那 4 个或 8 个字符用cyclic_find 那串字符得出偏移。这个流程能解决任何栈题的“填充多少字节”问题不需要每次去数反汇编偏移。而且它让你真正看到程序崩溃是因为ret弹出了一个由你输入的地址而不是魔法。理解到这个层面栈溢出的攻击模型就建立起来了。5. 从 rip 下一步pwn 基本思路里值得记住的判断框架5.1 拿到新题先问五个问题打通 rip 之后你会遇到越来越多题目每道题的长相都不一样但是分析思路高度相似。我把自己的判断流程总结成五个问题每次拿到新题先问一遍问题对应的动作rip 题的回答1. 输入点在哪里找到 read/gets/scanf 等函数main 里的 gets2. 用什么危险函数 / 是否可覆盖返回地址看反汇编、算偏移gets 无长度限制偏移 0x183. 保护机制开了哪些checksec基本全关4. 有没有现成的后门或可用函数搜索 system/execve/fun 等fun 里有 system(/bin/sh)5. 要控制的目标是什么确定返回地址 / 函数指针 / GOT 表返回地址改成 fun 地址这五问看起来简单但实际做题时很多人会漏掉其中一环。比如忽略了 checksec不知道开了 PIE直接写死地址比如没注意输入的函数是read而不是gets导致发送时没有换行时机不对。rip 的好处就是五问全部有清晰的答案练熟这套流程比多做十道重复题更有价值。5.2 保护机制决定了你的攻击路线pwn 的世界里保护措施是分层的。每开一个保护就相当于关了一扇门但同时也逼你换一扇窗。理解这层关系你看到 checksec 输出时就不会慌保护开关常规应对思路Canary 开启动态泄露 canary或想办法绕过检查PIE 开启先泄露程序基址再重新计算所有地址NX 开启栈上不能跑 shellcode改用 ROP 链或 ret2libcRELRO 全开不能改 GOT考虑其他写入点rip 之所以简单是因为这些保护全部没有开相当于门都开着。但你也要意识到rip 属于“栈溢出 ret2text”这个模型里的特例——它有个现成后门函数。如果后门函数不存在但程序里能调用system且能找到/bin/sh字符串那就是 ret2libc / ret2csu 等更进阶的手段了。从 rip 往后走你会在越来越多的题目里看到 NX 开启。那种情况下即便你能控制返回地址也不能执行栈上代码只能去调用程序里已有的 PLT 函数沿着 ROP 链一步步执行。这就是为什么我在前面反复强调要理解 rip 的地址跳转——它是所有 ROP 技巧的地基。5.3 下一步建议rip 之后怎么走打通 rip 回到题目列表之后我建议你按这个顺序继续刷下一阶段再找几道“ret2text gets 栈溢出”的题目专门练偏移计算和 gdb 调试做一两道“栈可执行、无 PIE”的题学 ret2shellcode体会 shellcode 的写法与 NX 的意义开始接触开启了 NX 的题目学 ret2libc理解为什么要泄露 libc 基址、为什么system参数要自己构造接触 PIE 开启的题目学地址泄露的方法最后尝试带 canary 的题目把这个保护机制彻底搞清楚。BUUCTF 上类似难度的题目还有不少名字不一定完全一样但思路都可以套用。刷题时别只追求次数我更建议每次打完一道题在笔记里按“漏洞成因、利用原语、绕过保护、exp 写法”四个点记录。我自己就是这个习惯前期看不出什么但到后期面对复杂题时这些记录会变成你快速定位问题的索引。另外再分享一个小习惯打远程题之前永远先在本地用相同 exp 测一遍。本地通了再打远程能排除大量低级问题本地不通时用 gdb 看崩溃位置比盲改 payload 高效得多。很多新手远程打不通就开始瞎猜地址其实只要回到本地调试问题一两分钟就能定位出来。
返回列表