ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

pwn入门基础三题:栈溢出、shellcode与ROP实战

pwn入门基础三题:栈溢出、shellcode与ROP实战 前言这组题目是pwn入门路上最该先刷的三道题直接说结论如果你刚接触CTF准备往pwn方向走CTFshow平台上的pwn32、pwn33、pwn34这三道前置基础题是性价比极高的一组起手式。很多人一开始就被各种复杂保护机制劝退折腾半天也不知道自己到底在干什么。而这组题的意义在于它帮你把pwn入门最核心的那件事讲明白了——你如何让一个看起来完全受控的程序跳到你希望它执行的地方去。不夸张地说刷完这三道题你对栈溢出、返回地址、shellcode、ROP这些概念会有从听说到上手的质变。我当年就是在这三道题上第一次完整跑通了自己的exp第一次在终端里弹出shell、拿到flag那种成就感是后面做任何难题都比不了的。这篇文章不是把writeup抄一遍而是把我从这三道题里真正学到的东西、踩过的坑、以及之后做更多题时反复用到的思路一次性整理给你。不管你是零基础小白还是已经能看懂一点汇编但不知道怎么下手跟着这篇文章的思路走一遍pwn入门这道坎基本就算迈过去了。1. 前置基础三连这三道题到底在设计什么1.1 从pwn32到pwn34是一套递进的思维训练先说一个大前提pwn方向的题目不管包装得多花哨本质都是程序存在某个漏洞让你有机会劫持它的控制流也就是让程序执行你指定的代码。对入门来说最常见的漏洞载体就是栈溢出。CTFshow的pwn32到pwn34三道题正好对应了三个递进层次pwn32程序里已经故意留好了后门函数你只需要想办法跳过去。这种题考察的是最基础的栈溢出利用——覆盖返回地址。pwn33程序没有后门函数了但你的输入会被执行。这种题引入了一个新概念shellcode你自己注入一段机器码去执行。pwn34更进一步程序开启了NX保护栈不可执行shellcode这条路被堵死了。这时候你必须学会ROP利用程序自己的代码片段拼出攻击链。我当时刷的时候还没意识到这三道题对应的恰恰是真实漏洞利用中最常见的三种情况有现成函数可跳、需要自己写代码、代码被限制后靠拼积木绕过。出题人把这三个层次压在入门阶段是想让你在还没有被各种复杂的防护机制淹没之前先把最核心的攻击模型建立起来。1.2 为什么说它是前置基础而不是入门实战平台把这组题归为前置基础是有原因的。到了后面真正的pwn入门题你会遇到canary保护——栈上放一个随机值函数返回前检查这个值有没有被改被改就直接崩溃。PIE保护——程序每次运行时内存加载地址都随机化你没法硬编码一个固定地址。RELRO / Full RELRO——程序自己的全局函数指针表被置为只读没法通过改GOT表劫持流程。但pwn32到pwn34这三道题几乎把这些复杂保护都关掉了。你可以把全部注意力放在最核心的利用逻辑上不用一上来就被五六个防护机制同时折磨。等这三道题玩透彻了你再去面对canary、PIE脑袋里已经有一张清晰的攻击地图剩下的只是见招拆招。这个设计非常像我们学车时先在空旷场地练倒库而不是直接开上复杂路况。很多自学的朋友卡在pwn门口往往不是理解不了漏洞原理而是一步到位直接上综合题被各种保护叠加搞懵了。如果你现在就有这种感觉回头把这组前置题扎扎实实做一遍反而比硬啃难题效率高得多。2. 环境和工具准备刷题前先把这杯地基打牢2.1 本地环境搭建做pwn题一套顺手的本地环境比什么都重要。我的建议是直接用Ubuntu20.04或22.04都行虚拟机也可以但内存最好给到4GB以上因为后面调试程序gdb多个终端来回切内存不够会很卡。几个核心工具的安装sudo apt update sudo apt install python3 python3-pip gdb binutils file pip3 install pwntoolspwntools是pwn领域最常用的Python库写exp、连远程、发payload全靠它。安装完可以用下面命令验证python3 -c from pwn import *; print(ok)gdb的调试插件我建议装pwndbg它对堆结构、栈布局的展示比原版gdb清晰太多。装好后打开程序、查看地址、观察栈上数据效率完全不一样。装pwndbg可以用官方脚本也可以直接按项目README手动配置网上教程很多这里不展开。注意装pwndbg时不要一次装太多插件比如peda、gef混着来容易引发配置冲突。我一开始图省事全装了结果gdb启动报错花了一晚上排查才发现是插件互相覆盖了初始化文件。认准一个插件用到底就行。2.2 读懂checksec的输出这是题目体检报告每拿到一个二进制我做的第一件事永远是checksec。这个工具在pwntools里自带它会检查程序开启了哪些安全机制。checksec pwn32输出通常长这样不同版本显示略有差异Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这个输出到底在说什么逐行翻译给你Arch架构是32位还是64位。x86是32位amd64是64位。这决定了payload的构造方式——32位返回地址是4字节64位是8字节参数传递规则也不一样。pwn32这个题名虽然带32但具体架构要看checksec有些版本可能就是64位的别被题名带偏。RELRO和修改GOT表相关。入门阶段不太关心但你要知道Partial RELRO意味着程序的GOT表可写这在后面的题目里会用到。Stackcanary栈保护。如果显示No canary found说明函数返回前不会检查栈布局有没有被破坏这是栈溢出利用的绿灯信号。NX栈上代码能否执行。NX enabled表示栈不能执行shellcode直接放在栈上会被拒绝执行NX disabled则意味着你可以在栈上放shellcode并跳过去执行。PIE地址随机化。No PIE表示程序的代码段地址是固定的你可以硬编码函数的地址如果开了PIE函数地址每次加载都不一样入门阶段处理起来会痛苦很多。做pwn32到pwn34这三道题你大概率会看到的是No canary foundNo PIE有可能会碰到NX关闭或开启。这恰恰是出题人精准控制好的梯度——先用最宽松的环境让你学会基础利用再逐步收紧条件逼你换思路。3. 逐题拆解pwn32到pwn34从ret2text到ROP的完整样本说明这个系列的具体文件和源码在平台每个批次里可能略有差异但核心考察点非常一致。下面我按最常见的命题方式来讲思路和流程你拿到手上的题目如果细节不一样用同样的方法排查一遍即可。3.1 pwn32覆盖返回地址跳进程序自己留的后门3.1.1 拿到题目先做静态分析第一步永远是checksec然后看它是什么架构。假设我们拿到的是一个64位ELF栈上无canary、无PIE。接下来就是用objdump或readelf看程序的函数列表objdump -d pwn32 | less在这里面我一般先搜两个东西一是有没有system二是有没有看起来像后门的自定义函数名字可能是win、backdoor、shell之类的。入门题经常直接用system(/bin/sh)作为后门有的题甚至直接用execve(/bin/sh,0,0)。假设找到了一个win函数地址是0x401186里面调用了system(/bin/sh)那这道题的逻辑就很清晰了程序里存在栈溢出点你让它原本要返回的地址变成win的地址程序执行完当前函数后就会直接跳过去弹出shell。3.1.2 找到栈溢出点和偏移量静态分析之后把程序丢进gdb里跑一下或者直接看反汇编。常见的漏洞点是用gets、scanf、read之类的函数读入用户输入却不检查长度。反汇编里你可能会看到类似这样的代码lea rax, [rbp-0x30] mov rdi, rax call gets这段汇编的意思是把一个栈上的缓冲区地址rbp-0x30传给gets往里面读入字符串。问题在于缓冲区距离栈底有0x30字节而gets不限制读入长度你往里塞足够多的数据就能一路覆盖到返回地址。计算偏移量有两种方式一种是数汇编0x30再加上rbp本身占的8字节就是返回地址相对于缓冲区开头的偏移也就是0x3856字节。另一种是用pwntools的cyclic这也是我推荐的方式省去手数汇编的麻烦cyclic 200把生成的乱序字符串作为输入喂给程序程序崩溃后gdb会告诉你rip寄存器里的值然后用cyclic -l反查这个值在序列里的位置就是偏移量。比如cyclic -l 0x6161616c # 输出 56注意cyclic生成的是4字节为单位的模式串64位下rip会把乱序数据当作地址处理所以崩溃后rip里的值可能被截断成8字节里的一部分。网上有人用cyclic -l查不到多半是没注意大小端序或者gdb显示的地址里混了别的字节。遇到这种情况直接看崩溃时$rsp和缓冲区起始地址的差也能算出偏移。3.1.3 编写第一个exp有了偏移量和win函数地址exp就非常简单了from pwn import * context.arch amd64 context.log_level debug # 本地调试时用process打远程时换成remote p process(./pwn32) payload bA * 56 payload p64(0x401186) # win函数地址 p.sendline(payload) p.interactive()很多第一次写exp的人会犯一个错误直接bA*56 \x86\x11\x40不打包。在64位程序里地址必须是8字节而且是小端序所以必须用p64()把整数地址转成字节串。这一点特别容易漏漏了之后程序要么崩溃要么跳到一个乱七八糟的地址。跑起来之后如果成功你会看到shell提示符然后随便输个cat flag就能出flag。那句话怎么说来着——第一次弹出$的那一刻真的会上瘾。3.2 pwn33没有后门那就自己塞一段shellcode3.2.1 思路转变从跳到写做pwn32的时候你可能觉得这也太简单了不就是打过去吗到了pwn33出题人把后门函数去掉了。你再静态分析时找不到任何能直接getshell的函数。这时候的破局点是程序允许你把代码放到一个可执行的区域也就是shellcode注入。检查checksec时你可能会看到类似这样的输出NX: NX disabled这说明程序没有开启栈不可执行保护。换句话说你把机器码写到栈上的缓冲区里再把返回地址指向这片缓冲区CPU就会直接开始执行你写入的机器码。这个思路的转变很关键。pwn32我们做的是控制程序跳到已有的代码pwn33则升级成程序没有现成的可用代码但允许我自己造一段然后跳过去。这就是shellcode的核心你写入的不仅是数据更是代码本身。3.2.2 确定可利用的输入点用objdump或者gdb找到读取输入的函数确认缓冲区的位置和大小。有些不负责任其实是有意为之的程序还会直接把jmp rsp或者call rsp之类的指令留在代码里更精妙一点的题目会有read把你读到栈上然后执行一段jmp rsp的gadget跳到栈上。注意栈地址每次运行都可能不同尤其开了ASLR的现代系统所以直接把shellcode放在栈深处然后用固定地址跳过去并不总是那么可靠。通常的做法是找一个jmp rsp或call rsp的gadget先把返回地址改成这个gadget的地址然后当CPU执行完例程跳到rsp指向的位置时你的shellcode正好就在rsp之后。这样不管栈地址怎么变rsp的位置总是跟返回地址位置强相关相当于相对寻址稳得很。一条常用的64位shellcodeexecve(/bin/sh,0,0)大概是这样的xor rdx, rdx xor rsi, rsi mov rbx, 0x68732f6e69622f2f ; //bin/sh 的小端表示 push rbx mov rdi, rsp xor rax, rax mov al, 59 syscall用汇编器转成字节串或者直接网上找一份经典的\x31\xf6\x48\xbb...之类的shellcode网上资源很多这里不贴完整字节串了。写exp时可以直接用pwntools的asm()帮你汇编from pwn import * context.arch amd64 context.os linux jmp_rsp 0x401040 # 通过搜索得到的jmp rsp地址 shellcode asm( xor rdx, rdx xor rsi, rsi mov rbx, 0x68732f6e69622f2f push rbx mov rdi, rsp xor rax, rax mov al, 59 syscall ) payload bA * offset payload p64(jmp_rsp) payload shellcode p process(./pwn33) p.send(payload) p.interactive()一个容易踩的坑是你得知道偏移量之后的那8字节到底是返回地址而shellcode要放在返回地址之后这样jmp rsp之后CPU才会执行到shellcode。如果你把shellcode放在返回地址前面jmp rsp跳过去执行的全是垃圾数据程序必崩。3.2.3 shellcode的坏字节与长度限制pwn33这类题目经常对输入长度有严格限制。如果缓冲区只有几十字节你的shellcode就得压缩得很短。经典的短shellcode通常用pushmovsyscall的组合尽量避免多余指令。如果长度不够还可以考虑分两次输入第一次用短shellcode调用read读取第二段到固定地址第二段再放完整的主shellcode——这个技巧在后面更复杂的题目里也经常用到属于pwn里的基础拆解思路。另外要注意坏字节。很多输入函数遇到\x00或\n就截断了如果你的shellcode里含这些字节会被程序卡掉。解决方法是选一个不含坏字节的shellcode版本或者用编码器pwntools的encode()可以帮你做shellcode编码和解码。入门阶段不建议一上来就搞编码先会挑一个好的shellcode就够了。3.3 pwn34NX开启之后用ROP拼一个攻击链3.3.1 条件变严了思路要跟着升级到pwn34出题人把NX打开了NX: NX enabled这意味着栈上不能执行代码。你写再完美的shellcode到栈上CPU一拍就会直接Segmentation Fault。那怎么getshell答案是程序自己的代码段里有现成的机器指令序列gadget你把它们串联起来拼出一个攻击链让程序按你的剧本一步步执行。这个技术就是ROPReturn-Oriented Programming。可能有人觉得这很难其实换个角度很好理解。想象你拼乐高每个gadget就是一块积木有的积木能把某个寄存器设为固定值有的积木能把栈上某个值弹进寄存器再返回有的积木能调用syscall我们要做的就是在程序的代码段里找出这些积木按顺序排列好每次让程序执行完一个积木后通过ret指令自动跳到下一个积木。这就是ROP链的底层逻辑。3.3.2 用ROPgadget找积木现在工具已经很成熟了。以最经典的ret2syscall利用为例适用于32位程序如果题目里能拼出int 0x80的系统调用配合/bin/sh字符串和寄存器设置就能getshell。用工具搜一下ROPgadget --binary pwn34 --only pop|ret这个命令会列出程序里所有的pop xxx; ret型gadget比如0x0804901e : pop eax ; ret 0x08049020 : pop ebx ; ret32位下execve(/bin/sh, 0, 0)系统调用号为11需要设置eax11、ebx/bin/sh地址、ecx0、edx0。所以你要找的是能连续pop好几个寄存器的gadget最好是pop eax ; pop ebx ; pop ecx ; pop edx ; ret这种一网打尽的省事很多。如果找不到这种全能的就退而求其次用单个gadget拼接。比如先pop eax; ret把eax设为11再pop ebx; ret把ebx设为字符串地址以此类推。每条gadget后面跟一个参数payload按这个顺序排布padding pop eax; ret 0xb ; eax 11 pop ebx; ret /bin/sh地址 ; ebx 字符串地址 pop ecx; ret 0 ; ecx 0 pop edx; ret 0 ; edx 0 int 0x80注意每个ret后面紧跟的栈数据会成为下一条gadget要pop的值。所以ROP链本质上就是一段精心编排的栈上剧本。只要排布顺序和gadget的pop个数匹配CPU就会像火车沿着轨道一样一节一节跑完。64位下情况略有不同。64位的execve系统调用约定是rax59、rdi/bin/sh、rsi0、rdx0参数不再全部从栈上取而是先放进寄存器。但思路完全一样找能设置这些寄存器的gadget把值传进去最后触发syscall。3.3.3 把payload组装出来假设我们最终选定了几个gadgetfrom pwn import * context.arch i386 # 以32位为例 p process(./pwn34) pop_eax_ret 0x0804901e pop_ebx_ret 0x08049020 int_0x80 0x08049040 # 先在程序里找/bin/sh字符串可以用ROPgadget --binary pwn34 --string /bin/sh binsh 0x0804a024 payload bA * offset payload p32(pop_eax_ret) payload p32(11) payload p32(pop_ebx_ret) payload p32(binsh) payload p32(int_0x80) p.sendline(payload) p.interactive()这只是一个最小可用的示例。真正的pwn34可能没有现成的int 0x80你可能得用多个gadget把eax、ebx都设好。也可能出现某个关键的pop组合找不到这时候可以用ROPgadget --binary pwn34 --only pop|pop|ret找两个参数的组合多拼几步。我在做这题时最大的体会是ROP与其说是拼技术不如说是拼耐心。先列清楚你需要设置哪些寄存器再一个个找对应的gadget最后把它们串起来。整个过程像做数学证明每一步都有据可循。等payload第一次成功跑通那种成就感比pwn32拿到shell时还要强因为这次是你亲手造了一条路出来。4. 常见问题与排查技巧实录4.1 入门阶段的高频报错速查表这三题刷下来犯错不可怕可怕的是不知道错在哪。我把自己和周围同学踩过的坑整理成一张表现象大概率原因解决方法payload发送后程序直接崩溃偏移量算错覆盖到了错误的栈位置重新用cyclic计算偏移别手数不弹shell但程序也不崩返回地址写错或者函数没有被当成代码入口执行用gdb下断点看ret指令执行前的rsp值本地成功远程失败远程环境架构/libc版本不同或地址不同连接remote后用ELF模块解析远程目标的动态库或检查x86/x64地址里含有\x00被截断用的函数是strcpy/gets读入遇\x00截断用read函数读取payload或把地址放到payload靠后位置ROPgadget搜不到想要的gadget程序太小或者gadget被PIE打乱了检查是否开了PIE开了就用动态解析地址或换用动态库里的gadgetgdb调试时地址和正常运行时不一样ASLR开了地址随机化调试时set disable-randomization on或用jmp rsp这类相对寻址绕过4.2 三个值得养成的debug习惯第一个习惯先本地后远程。任何payload都先在本地跑通拿到shell再切远程。我见过很多人一上来就怼远程payload出错了也没法看内核信息全靠猜。本地通了远程基本就是改个IP端口的事。第二个习惯用gdb在关键位置打断点。比如你想确认偏移量算得对不对就在函数末尾的ret指令处下断点看执行到ret之前$rsp指向的8字节是不是你期望的返回地址。很多时候问题就出在你以为算对了、其实差了一两个字节gdb一眼就能看出来。第三个习惯把日志级别打开。pwntools里context.log_level debug会打印出发送的每个字节看起来有点吵但对排查payload发出去对不对特别有用。有一次我老是打不通最后发现是p.sendline自动加了换行符而程序用的是gets——换行符直接被当成终止符吃掉了缓冲区里的payload少了一截。这种问题不看日志很难定位。4.3 一个容易忽略的栈平衡问题做64位ROP时有一个特别经典的坑用system这类libc函数时栈必须16字节对齐。很多环境里system内部会用到movaps指令这条指令要求栈地址按16字节对齐否则直接崩溃。针对这个问题的经典解法是在system地址之前加一个retgadget让栈指针先多走一步人为调整对齐。我第一次遇到这个问题时在本地反复测试都正常一上远程就崩排查了两天才发现是libc版本差异导致的栈对齐要求不一样。从那以后我在payload里只要调用libc函数就会先加一个retpadding这已经成了我的固定习惯。对pwn34这个量级的题目可能用不太上但提前知道这个坑后面做系统型题目能省很多时间。5. 刷完这三题之后下一步往哪走把pwn32到pwn34吃透之后你的知识树上已经长出了几个最重要的枝干栈溢出点的识别、返回地址的精确覆盖、shellcode的编写与执行、ROP链的基本构造。这个时候再回头看那些带canary、带PIE的真·入门题你会发现其实没那么可怕因为你已经懂核心了剩下的防护机制不过是一门门新的术。我个人建议的下一步顺序是没做ret2libc的先补上。入门阶段最核心的另一种模型是动态链接库里的函数本身不可控靠system这种平时不用的libc函数弹shell。理解清楚它和ret2text的区别对后面帮助很大。做两道带canary的题。学会泄露canary、把它原样填回去你会发现保护机制不是用来让人绝望的而是用来逼你想办法的。自己试着把这三道题的防护开关逐个打开。比如用gcc -fstack-protector -z noexecstack -pie重新编译一遍pwn32的源码然后尝试用你现有的知识破解它。这一步非常锻炼人比一味刷题更能建立体系感。多去别的平台BUUCTF、NSSCTF等刷同类型题。每道题都换个花样换个坑见过的坑多了后面的路自然顺。Pwn方向最忌讳的就是只看writeup不动手一定要自己把exp敲出来、跑起来、拿到shell。最后说一句掏心窝的话pwn入门最难的不是技术而是那种面对黑盒完全无从下手的挫败感。这三道题的价值恰恰在于它们的难度曲线设计得足够平滑让每一个普通人都能亲手完成从debug不了到居然搞定了的跨越。你只要把这三题完整跑通一遍之后的学习节奏就完全不一样了。
返回列表