首页 / 资讯中心 / 文章详情

从零开始Pwn:get_started_3dsctf_2016栈溢出入门实战

从零开始Pwn:get_started_3dsctf_2016栈溢出入门实战 ★ FEATURED ARTICLE
练过CTF的人十有八九都见过这个名字get_started_3dsctf_2016。这是3DSCTF 2016年的一道入门pwn题题目起名就很诚实——get started就是让你从这里开始。在CTF圈子里这道题几乎被当成栈溢出的hello world很多练习平台都收录了它。如果你正打算入门pwn方向又不知道该从哪道题下手拿它当第一道题基本不会踩坑。这道题的核心考点非常单一一个典型的栈溢出配合程序里预留好的后门函数最后构造ROP链拿到flag。它不涉及堆、格式化字符串、整数溢出这些进阶内容只要你能理解函数调用时栈是怎么变化的、返回地址存在哪里、gets为什么危险就能解出来。这篇文章我会从环境准备、题目分析、漏洞定位、exp编写到常见问题排查完整走一遍顺便把新手最容易被卡住的几个点摊开讲明白。1. 先搞清楚这道题在考什么1.1 CTF五大方向与get_started的定位CTFCapture The Flag夺旗赛常见的题目方向大体分五块WebWeb安全、Pwn二进制漏洞利用、Reverse逆向工程、Crypto密码学、Misc杂项包含流量分析、取证、隐写等。每个方向考察的思路完全不同但pwn一直被认为是门槛比较高、学起来最底层的一块因为它要求你直面程序在内存里的真实行为。get_started_3dsctf_2016就属于Pwn方向。Pwn题的做题逻辑和别的方向不太一样你拿到的通常是一个已编译好的二进制程序它可能运行在一个服务上也可能就是本地一个可执行文件。你的任务不是找漏洞代码而是通过输入数据让程序自己犯错误——比如把输入写到栈上然后把程序的执行流程劫持到你想要的地方。为什么我强烈推荐从这道题入手因为它是名副其实的一道题只考一个点栈溢出后跳转到后门函数。你不需要会堆利用不需要理解glibc的分配器甚至连ROP的复杂变体都不需要。Pwn入门最怕的不是题目难而是题目里塞了太多新东西让你分不清到底是哪一步出了问题。这道题恰好把干扰项全砍掉了你可以把全部精力放在返回地址这一个概念上。1.2 解题的目标到底长什么样第一次拿到一个pwn题很多人会懵我该干嘛先说结论——你的最终目标就是构造一段输入通常叫payload让程序打印出flag或者直接给你一个Shell。get_started_3dsctf_2016这道题的出题人很贴心你会在程序里找到一个专门输出flag的辅助函数这类函数在pwn里常被叫win函数、backdoor函数。它不会通过正常的main函数被调用而是藏在代码里等你用栈溢出把程序执行流扭过去。你唯一要做的就是找到这个函数的位置、搞清它需要什么条件然后往栈上精确写入数据。整个分析逻辑就是出题人给你留了一条暗线任务路径正常操作走不到这条路但一旦你控制了返回地址就能让程序从正常路径穿越到暗线上去。理解了这一点后面所有的步骤都是围绕怎么把返回地址改成目标函数地址展开的。2. 开打之前环境、工具和必须懂的底层知识2.1 环境准备清单工欲善其事必先利其器。做pwn题前期最折腾人的不是题目本身而是环境搭建。我先给一份通用清单这些都是pwn方向的标配。操作系统我建议直接用Ubuntu 20.04或22.04Kali也可以核心是Linux环境。Windows虽然能写exp但调试和运行32位ELF会很别扭。然后是Python3环境因为社区最常用的pwntools是基于Python的用它来构造payload、发送输入、接收输出都极其方便安装命令一行搞定pip install pwntools还需要一个调试器。gdb是必须的但原生gdb看栈信息太痛苦强烈建议装一个插件常见的有pwndbg、peda、gef我用得最多的是pwndbg它对pwn题的支持非常友好能直接高亮出返回地址、偏移量等关键信息。静态分析工具方面IDA Pro是很多人首选如果不想折腾破解用Ghidra也完全够用。如果你连图形界面都懒得开那至少要学会用objdump和readelf这两个命令行工具后面我会具体演示。这里有个很容易卡住的坑get_started_3dsctf_2016是32位程序。如果你在64位Ubuntu上双击运行大概率会报错说找不到文件或无法执行。解决办法是确认系统支持32位运行库装上类似libc6:i386之类的包即可。这一步提前做完后面能省掉大量折腾时间。2.2 栈溢出到底是怎么发生的很多新手对栈溢出只有个模糊概念我尽量用大白话把它讲清楚。程序里调用一个函数时CPU会在栈上做一系列操作。栈可以理解成一块从上往下压的内存区域。调用函数前调用者会把参数压进栈然后执行call指令call指令会把返回地址也就是函数执行完后该回到哪一行也压进栈进入被调函数后函数会保存上一个函数的栈底指针然后给局部变量腾出一块空间。整个过程在栈上依次排布局部变量区 → 保存的ebp → 返回地址 → 调用者的参数等等。关键在于局部变量和返回地址在内存里挨得很近中间只隔了保存的ebp那4个字节32位程序里是4字节。gets这个函数非常危险因为它读取输入时完全不知道缓冲区有多长它会一直读到换行符为止把你输入的所有内容原封不动地往缓冲区里面塞。如果你输入的内容超过了缓冲区大小多出来的部分就会继续往上覆盖——先覆盖保存的ebp再覆盖返回地址。而程序函数结束时会执行ret指令它的作用就是从栈顶弹出返回地址并跳转过去。所以只要你能精确控制返回地址的位置就能让CPU跳到你指定的任意地址去执行。这就是栈溢出的基础模型。你会听到的ret2textret2shellcoderet2libc这些名词本质上都是同一件事控制返回地址跳转到你想要的地方差别只在于跳去哪、怎么跳。2.3 checksec 输出怎么读拿到任何一个ELF文件第一个动作永远是跑一遍checksec。它是pwntools自带的工具用来查看程序开启的安全防护机制。这些机制直接决定你的利用可行性和写法。检查项含义常见状态对利用的影响RELRO重定位表只读保护Partial RELRO / Full RELRO影响你是否能修改GOT表Canary栈金丝雀保护开启/关闭关闭时可以直接覆盖返回地址NX栈不可执行开启/关闭开启时无法直接在栈上执行shellcodePIE地址随机化开启/关闭关闭时程序加载地址是固定的可以写死函数地址get_started_3dsctf_2016这类入门题通常Canary是关闭的、PIE是关闭的这正好给新手降低了难度Canary关闭意味着没有人会在你覆盖返回地址前做校验PIE关闭意味着函数地址是一个固定的数字你可以放心地把后门函数地址写进payload。NX可能会开启但这道题不需要担心因为我们跳转到的后门函数是程序本身的一部分属于代码段不是栈上数据。代码段本来就允许执行NX只管栈管不到代码段。3. 完整实战从拿到文件到拿到flag3.1 第一步file 和 checksec 确认目标拿到题目文件后先不要急着运行先在终端里执行两条命令file get_started_3dsctf_2016 checksec --fileget_started_3dsctf_2016file输出会告诉我们这是一个32位的ELF可执行文件能看到AAPCS32-bitIntel 80386之类的关键字。这里有个细节如果是32位程序后面你写exp时地址要用p32打包而不是p64这个搞错的话payload会完全对不上。checksec的输出类似下面这样不同版本工具输出格式会有差异但字段大同小异RELRO: Partial RELRO STACK CANARY: No NX: Yes PIE: No看到No的地方就是我们可以利用的点没有Canary自由覆盖返回地址没有PIE函数地址可以直接写死。NX开启不影响这题因为我们的目标是代码段里的现有函数。如果到这里你发现程序跑不了报错信息提到no such file或者动态链接器错误那多半是缺少32位运行库回到2.1节那条补上就行。3.2 第二步静态分析找漏洞和后门函数静态分析的目标非常明确找出危险函数的位置以及后门函数在哪里、需要什么条件。最常见的危险函数就那么几个gets、strcpy、sprintf、read。这道题的主角是gets。用objdump反汇编看main函数objdump -d get_started_3dsctf_2016 | grep -A 100 main:你会看到main函数里调用了gets传入的参数是某个缓冲区地址。这就是溢出点。接下来看后门函数。用nm列出符号表nm get_started_3dsctf_2016如果文件里保留了符号信息你会看到类似win、get_flag、flag这样的函数名。记下它的地址。如果符号没保留别慌用IDA或Ghidra打开在函数列表里翻一翻找到那种看起来没什么人调用但内部有打印逻辑的函数十有八九就是。找到后门函数后重点看两件事第一它有没有检查某些条件第二它的参数是什么。用objdump或者反汇编器看这个函数的代码。如果它接收一个int参数并和某个固定值比较相等才打印flag那你就需要在payload里把这个参数带上。如果它检查一个全局变量那可能需要你用ROP去修改那个全局变量或者先调用另一个设置函数。这里我要强调不要凭记忆套答案。每个人的wp里写的后门函数名和条件值可能都不一样因为网上流传的版本可能存在差异。正确做法是打开你手里的真实文件把函数名、条件值、偏移全部自己确认一遍。思路是通用的但参数是具体的。3.3 第三步用 cyclic 确定溢出偏移知道漏洞点之后下一步就是算清楚缓冲区和返回地址之间到底隔了多少字节。这个偏移量是所有后续操作的基础算错了后面一切都白搭。强烈不建议自己一个个数用pwntools自带的cyclic命令最靠谱。它会生成一串循环的模式串每个4字节32位程序在整串里都是唯一的。操作流程是这样先启动程序把生成的模式串作为输入发进去python3 -c from pwn import *; p process(./get_started_3dsctf_2016); p.sendline(cyclic(200)); p.wait(); print(p.corefile)程序收到超长输入后会段错误崩溃生成一个core dump文件。用gdb加载这个core文件或者直接在gdb里运行到崩溃然后查看程序崩溃时EIP32位程序的指令指针的值。这个值就是被覆盖进去的模式串的一部分。然后把这个值交给cyclic -l反向查偏移cyclic -l 0x6161616c它会直接告诉你这个模式值对应的偏移量也就是padding长度。比如结果是0x58那就意味着缓冲区共0x58字节从第0x58字节开始就是返回地址的位置。新手最容易犯的错是没等程序崩溃就急着查或者跑的不是同一个二进制文件。注意每次编译版本不同偏移可能不同一定要用自己手头的文件实测出来的结果。3.4 第四步写第一个exp知道了偏移量、后门函数地址和参数要求后就可以写完整exp了。我用的是pwntools脚本骨架如下from pwn import * context.binary ./get_started_3dsctf_2016 context.log_level info p process(./get_started_3dsctf_2016) elf ELF(./get_started_3dsctf_2016) offset 0x58 # 用cyclic实测出来的偏移 win elf.symbols[win] # 换成你实际文件里的后门函数名 need_arg 0x2af4 # 后门函数要求的条件参数值从反汇编确认 payload ba * offset payload p32(win) # 覆盖返回地址 payload p32(0xdeadbeef) # win函数返回后去哪随意填避免崩溃而已 payload p32(need_arg) # win函数的第一个参数 p.sendline(payload) p.interactive()如果你面对的是无参数后门函数那payload里就不需要最后那4字节参数或者随便填充。关键是理解为什么是这种排列顺序。这就要说到32位函数的调用约定调用者先压参数再执行call指令把返回地址压栈。所以被调函数回头从栈上取参数时参数正好在返回地址的下方也就是更高地址。你覆盖返回地址为win函数后程序ret跳过去但此时栈顶的值是你填充的下一个4字节CPU会把它当成win函数的返回地址再下一个4字节才是win函数的第一个参数。所以payload的布局顺序必须是padding win地址 垃圾返回地址 参数1如果有多个参数就继续往后放。脚本写完后先本地跑一遍。如果思路正确你会看到程序打印出flag或者拿到一个交互式shell。看到shell时输入cat flag就能读取flag文件。3.5 第五步从本地到远程本地打通只是第一步很多练习平台的题目可以通过远程服务连接flag在服务端。这时把process换成remote即可p remote(目标IP, 端口)有个细节需要留意本地和远程程序通常是一模一样的文件所以偏移和地址直接复用。但偶尔会出现环境差异比如libc版本不同导致额外功能失效不过对于这道入门题一般不存在这个问题。远程打的时候要注意交互节奏。平台上常见的模式是程序启动后等待输入发送payload然后立刻接收输出。有的平台加了那么一秒两秒的延迟你的exp里如果用了recvuntil等特定字符串可能会因为时机问题卡住。处理办法是给接收逻辑加个超时或者干脆用interactive()手动交互。等你自己写过几次远程题就会习惯这些差异了。4. 新手最容易踩的坑问题与排查实录4.1 崩溃点不在预期位置偏移算错了症状跑exp时程序崩了但gdb里看到的崩溃地址不是后门函数地址乱七八糟的或者cyclic -l算出来的偏移根本不对。排查思路先在gdb里跑一遍崩溃流程输入模式串看崩溃时EIP的值是什么。如果EIP是0x6d616161这样的模式字符说明偏移位的值还是模式串的一部分说明你覆盖到的位置和预期不对。重新确认输入从哪个字节开始覆盖返回地址最靠谱的做法是先用cyclic把偏移算死再手写padding。还有一种情况是You输入里带了sleep或者多余字符导致gets读到的内容和预期不一致。我自己曾经犯过一个很低级的错用p.send发payload而不是p.sendline。gets要读到换行符才会结束你只send不发换行程序就一直卡在读取状态payload根本没被消费自然看不到任何输出。这种问题表现是程序卡住不动优先检查sendline和send的用法。4.2 返回地址后到底要不要加参数这个问题我见很多人问过包括以前的我。在32位程序里函数参数通过栈传递参数确实放在返回地址后面但准确地说是放在被调用函数视角下的返回地址与参数区。当你直接改返回地址跳到后门函数时CPU执行ret后ESP已经指向返回地址的下一个位置。此时栈顶是垃圾返回地址后门函数执行时从栈上取参数就直接取到垃圾返回地址后面的内容。所以payload里那块垃圾返回地址不能省略它不是可选项而是充当了一个占位符真正的参数要从它后面开始放。如果把参数直接放在返回地址后面函数会把参数误认为返回地址然后从下一个4字节取参数导致所有数据错位。4.3 为什么有时候还要加一层ret gadget这个问题等做64位题时会经常遇到。在64位系统上函数参数通过寄存器传递栈布局和32位完全不同而且System V ABI要求调用时栈要对齐到16字节否则某些glibc函数会出问题典型表现是movaps指令触发段错误。所以64位exp里经常能在payload里看到ret地址它的作用不是跳转到后门而是先让栈多弹出一次让真正的后门函数调用时栈满足对齐要求。get_started_3dsctf_2016是32位题一般不用考虑栈对齐问题。但你如果是从这道题往后续做先有个印象后面遇到诡异崩溃不至于完全懵。4.4 本地能打远程就是不出flag这类情况多半跟交互细节有关而不是利用思路问题。远程服务启动时可能有欢迎信息、提示语你的exp里如果有sendline(payload)之前还发了别的内容或者接收逻辑用错了关键字都会导致预期外的行为。还有一个常见原因是接收区处理不当。有些平台flag是直接打印在标准输出里的但有些需要等shell出现后再执行命令。新手容易在p.interactive()前加了一堆recv把自己搞乱。最简单的做法本地和远程都直接interactive()手动看看发生了什么再回头改脚本。4.5 合规提醒只能在授权环境里玩最后必须提醒一句栈溢出、ROP这些技术全部是漏洞利用的通用手段在CTF比赛、练习平台、你自己搭的虚拟机靶机里用完全没问题这也是安全行业训练的重要方式。但绝对不要把学到的东西拿到未经授权的真实系统上去试。学习二进制安全第一课不是技巧是边界感。所有练习都在自己可控的、明确授权的环境里进行这是从业者最基本的职业素养。5. 做完这道题之后下一步怎么走5.1 一条我从实战中捋出来的路线把get_started_3dsctf_2016吃透后说明你已经理解了栈溢出的基本模型、返回地址控制、pwntools的基本使用。这时可以顺着难度梯度往下走我的建议是先从ret2text系列的其他入门题开始比如对应的32位同类题巩固固定地址跳转然后做ret2shellcode理解NX关闭时怎么把shellcode放到栈上执行再做ret2libc这时候你就会遇到地址随机化、64位参数传递、栈对齐等一堆新问题每一步都会逼你补知识。这条路线走完你对栈利用的常见模型就有了整体认识到时候再回头看看那些云里雾里的高级教程会发现很多内容突然变得顺理成章了。中间还可以穿插着做几道简单的Reverse题练练静态分析手感因为Pwn和Reverse本来就经常共用同一套工具链。5.2 多刷题也别忘了写自己的笔记ctf练习网站很多我自己的习惯是做完一道题、理解透彻后立刻写一篇自己的复盘笔记不要求长篇大论但必须把下面几个要素记录清楚漏洞类型、偏移量、关键函数地址、payload的构造逻辑、遇到的问题、从坑里爬出来的过程。为什么强调写笔记因为pwn题本质是熟练工种思路需要反复强化。你看十篇writeup不如自己从头到尾动手做一遍、卡一遍、修一遍。而且很多解法不是一次到位的我在实战中就经常遇到第一次用这个payload崩了修改一个字节通了的情况这种细节恰恰是writeup里不会写的但它最有价值。自己记录一遍就成了自己真正掌握的东西。最后分享一点个人体会我最初接触这道题时看了三篇writeup还是没完全看明白硬着头皮把exp抄了一遍跑通了但脑子里一团浆糊。第二天不看任何参考凭记忆把exp重写了一遍写到一半就卡住了——我当时甚至搞不清为什么要用p32而不是p64。后来静下心把栈上每个字节的位置画了一遍才真正开窍。所以我对想入pwn方向的朋友的建议是不要怕抄但更不要只抄。第一次照着writeup抄是为了把流程跑通第二次一定要闭上参考书自己重新推一遍推不动的地方就是你知识体系的漏洞所在。这道题恰好是最适合用来干这件事的素材。把它彻底弄明白之后你会发现后面很多栈题的本质都不过是这一个模型的变体。
阅读完成 · 觉得有帮助?
咨询建站