ARTICLE DETAIL

资讯详情

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

CTF Pwn前置基础:从栈溢出原理到第一个ret2text远程利用

CTF Pwn前置基础:从栈溢出原理到第一个ret2text远程利用 CTFshow的pwn系列里005到012这八道题被官方标成了“前置基础”。这个命名听着温和实际很迷惑人——很多人在前面几道纯nc连接题上一路绿灯刷到这里突然发现自己“不会做题”了程序能运行、nc连上也有反应可就是不知道下一步该敲什么、该看什么。这八道题的真正作用是让你从“运行二进制文件”过渡到“理解并控制二进制文件”所以我把这段时间踩过的坑、补上的课、以及后来反复验证过的方法整理成这篇博文。文章会围绕这批题最需要的前置能力来写环境搭建、静态分析、栈原理、动态调试、第一个能打远程的exp、以及卡住时最常见的几个问题。适合刚接触pwn、准备系统性刷CTFshow pwn题的选手也适合那些学了一堆理论但还没动手打通第一道栈溢出题的人。1. 这批“前置基础”题到底在考什么1.1 pwn赛道的第一道分水岭web方向的入门题往往是“扫码、改参数、读源码”式的轻量试错pwn不是这样。pwn题给的是一份编译好的二进制文件你要在不知道源码有些题甚至不给libc的情况下找出程序里的漏洞并利用它拿到shell或flag。CTFshow把005到012归入前置基础本质上是在告诉你从这里开始刷题方式要换了——不再靠“试”而是靠“读、算、验”三步循环。我见过不少新手在这一阶段的心态崩掉原因很统一他们以为pwn会像web一样存在某个“必杀参数”于是一个个payload瞎试。真正有效的方式恰恰相反是耐下性子把程序读透。前置基础题通常只会考察一种最朴素的漏洞栈溢出且利用方式大多是ret2text——也就是让程序跳回一个已经存在的好函数。题目不难但它逼着你把“函数的栈帧长什么样”“返回地址存在哪”“数据怎么覆盖过去”这三件事搞明白这就是前置基础真正的价值。1.2 从“运行程序”到“劫持程序”的认知转变在005之前你可能只是学会了“用nc连上端口、看到flag字样就算过”。但005到012这批题几乎没有一个会直接把flag放在交互输出里你需要让程序执行到某个特定分支、甚至执行你指定的代码。这背后是一个认知转变程序不是一个只会“接收输入、给出输出”的黑盒而是一段按地址执行指令的机器码。你往程序里输入的字符串不再只是“数据”它会存进内存的某个位置如果你的输入足够长越过缓冲区边界就会污染相邻的栈数据最终可能覆盖函数返回时要跳转的地址。一旦掌握了这个能力你就从“操作员”变成了“控制者”。这个转变说难不难但必须真的想通。很多人卡在“我照writeup写了payload却看不懂为什么是这个长度、为什么是这个地址”就是因为没完成这个认知升级只是机械抄答案。1.3 这个阶段必须达成的四个目标我在刷完这一批题后给自己总结了一套“过关标准”如果你也在刷建议对照一下拿到任意ELF文件能在5分钟内用file和checksec判断出它是什么架构、开了哪些保护。能用IDA或Ghidra找到main函数、识别程序里存在的危险函数并快速判断“此题的漏洞点大概在哪”。能画出一个函数的栈帧草图说清楚buffer、本函数寄存器保存区、返回地址三者的相对位置。能用pwntools写一个“本地process”的exp再花10秒钟改成“远程remote”并打通。如果这四件事你都已经没问题那005到012对你来说是热身如果其中任何一件还含糊说明你还没有真正建立pwn入门的地基后面做更复杂的题会非常痛苦。2. 动手前的环境与工具链2.1 一台够用的Linux环境pwn的所有调试和利用脚本基本都是围绕Linux的进程模型和ELF格式来的所以强烈建议所有练习都在Linux环境下做。我自己用的是Ubuntu 22.04的虚拟机内存在2GB到4GB就很够用磁盘20GB也足够了。如果用Windows双系统或者WSL2也行但后续涉及gdb调试、多架构模拟的时候原生Linux的体验会好很多少一些“环境问题”带来的挫败感。提示新手最容易犯的错就是装了环境却从不验证。到后面发现工具装了、一运行却报一堆依赖错误。环境搭建完务必用一个小程序从头到尾跑一遍工具链再开始刷题。2.2 必装工具清单与安装命令以下工具是pwn入门阶段的“标准六件套”。我之前整理过一张表你可以直接对照检查工具用途重要程度file查看ELF架构、位数、是否去除符号极高checksec检查RELRO、Canary、NX、PIE等保护机制极高IDA Free / Ghidra反汇编、伪代码还原极高gdb pwndbg动态调试、观察寄存器与栈极高pwntools写exp、发送payload、远程交互极高nc手工连接题目端口、验证交互高安装命令我放在这里在Ubuntu下可以直接执行sudo apt update sudo apt install -y python3 python3-pip python3-dev gdb binutils file git vim python3 -m pip install --upgrade pwntoolsgdb的插件pwndbg建议直接装它能自动展示寄存器、栈内容和反汇编比原版gdb的体验好太多git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh装完记得把gdb重开一次看到pwndbg的banner说明装好了。2.3 关于逆向工具先别迷信“一键F5”很多新手学pwn时第一反应是“我要先学会破解/逆向然后才能pwn”于是把大量时间花在学IDA的各种奇技淫巧上。实际上入门pwn阶段你根本用不上那些复杂的逆向技巧你只需要三个能力找到main和相关函数、看懂伪代码中的逻辑、识别危险调用。Ghidra是免费且跨平台的IDA也有免费版我个人的建议是手头至少有一个能用F5/Ghidra反编译看伪代码的工具但不要过度依赖伪代码。伪代码能帮你快速理解逻辑但栈布局、偏移计算这类关键信息必须回到汇编或调试器里确认。特别是前置基础题里的“偏移到底是多少”看伪代码是看不出来的得上gdb实测这一点后面会细讲。2.4 一个简单但完整的环境自测工具装完建议找一个最简单的ELF文件甚至可以自己编译一个跑一遍下面这套命令确认工具链是通的file ./test checksec --file./test然后写一个最简单的pwntools脚本from pwn import * io process(./test) io.interactive()这样能快速判断pwntools是否安装成功、能否与程序正常交互。我自己刚入坑时因为老拿“教程里那种完整环境”去对比自己的机器老怀疑自己“是不是还缺了什么”后来发现只要上面这串能跑通环境就完全够用了。3. 静态分析拿到小题目先做这四件事3.1 file与checksec5秒钟给目标“体检”每一道pwn题拿到手第一件事不是丢进IDA而是先做“体检”。我习惯前三连是file pwn checksec --filepwn ./pwnfile能告诉你这是32位还是64位是动态还是静态链接有没有去除符号。很多人只关心“32位还是64位”这一项但“not stripped”这一点会直接影响你逆向的难度。所谓stripped就是去除了符号表函数名会变成sub_401000这种分析难度陡增前置基础题大多not stripped函数名一目了然等于半开卷考试。checksec看的是四大保护。对入门题来说你只需要关注两件事第一有没有开启栈Canary如果开了入门用的栈溢出直接覆盖返回地址会先触发保护退出需要另想办法第二有没有开启PIE地址随机化如果没开程序里的函数地址是固定的你从IDA里看到的地址可以直接写进payload。前置基础阶段的题目大部分是“NO PIE 无Canary”这就是给你练手的“活靶子”。3.2 用反汇编工具找入口与漏洞函数体检完把文件拖进IDA或Ghidra。由于not stripped左侧函数列表会直接出现main、init、还有各种自定义函数。我见过太多新手一上来就去分析那些看起来很像的函数结果绕了半小时弯路。推荐顺序是双击main按F5看伪代码通读程序逻辑。在伪代码里搜索所有“读入输入”的函数比如gets、read、scanf、strcpy。同时搜索system、execve、/bin/sh、“flag”字符串看程序里是否已经预留了后门或flag打印函数。如果没有现成后门去看自定义函数列表重点看名字听起来可疑的比如win、backdoor、shell、flag等。这一个流程走下来八成的前置基础题已经能定位漏洞点。真正需要深入逆向的函数在这个阶段几乎没有。3.3 危险函数识别清单在pwn栈溢出入门阶段下面这几个函数出现的概率极高它们的共同特点是你给的输入长度不受控或者长度参数由代码写死但明显偏大足以越过缓冲区边缘。gets经典中的经典完全无限制输入多少读多少溢出它最舒服。strcpy / strcat拷贝/拼接字符串直到遇到\0源字符串比目标缓冲区长就会溢出。scanf(%s, buf)以非空白字符为输入同样不受长度约束是gets的“变种”。read(fd, buf, n)虽然指定了读取长度但如果n比buf所占用栈空间大同样会溢出。前置题里经常出现read(0, buf, 0x100)这种buf只开了0x20字节的写法。当你识别到这些函数后思路就非常清晰了把它当作“输入入口”然后在它的栈帧里构造溢出。3.4 这个阶段题目的“标准脸”如果你把005到012当作一个整体来看会发现它们几乎共享同一张脸一个不设防的缓冲数组一个危险读入函数一个藏在程序里的后门或者能直接拿到shell的函数。这类题的结构往往是“main里调用某个函数该函数有一个短栈缓冲且有gets/read机会程序某处又存在一个system(/bin/sh)”的组合。所以做题时你基本不需要“从零发明利用”你只需要回答三个问题入口在哪、目标地址是多少、偏移是多少。三个问题都答上exp就出来了。这种“程序自己埋好后门、只等你跳过去”的考察方式就是ret2text也就是pwn入门第一节必修课。4. 栈溢出入门理解你在利用什么4.1 程序运行时的内存“四大区”要说清栈溢出先得知道程序在内存里是怎么布局的。一个进程在运行时通常把自己的内存空间分成若干区段其中四个最常挂在嘴边的.text代码区存放机器指令程序执行就是在这里取指令权限通常是可读可执行、不可写。.data和.bss数据区存放全局变量和静态变量有初始值的放.data没有的放.bss。heap堆区通过malloc/new申请的动态内存由低地址向高地址增长。stack栈区函数调用过程中用到的临时内存区域由高地址向低地址增长。栈溢出发生在stack区所以前面几个区可以先不用深究。你只需要记住栈是一块以后进先出方式使用的临时内存每调用一个函数就多一块“栈帧”函数返回时这块栈帧被回收。4.2 函数调用栈与返回地址拿x86_64举例子。当main调用vulnerable时CPU会执行一条call指令。call做两件事第一把call指令的下一条指令地址压入栈这个地址就是“返回地址”第二跳转到vulnerable的起始地址。然后vulnerable建立自己的栈帧保存上一层的栈基址rbp为局部变量分配空间。于是vulnerable的栈帧从高地址到低地址大致是高地址 [ 返回地址 ] - call压栈 [ saved rbp ] - push rbp 局部变量所在区 [ buffer 等 ] - 低地址buffer相对返回地址的位置中间隔着“buffer本身占用的空间”和“saved rbp”两部分。你输入数据时是往buffer里写也就是往“低地址方向”开始写而数据越长就往高地址方向漫过去先是填满buffer接着覆盖saved rbp再往后就是返回地址。4.3 溢出的本质就是篡改“函数下一步去哪”那么当返回地址被你的输入覆盖成任意值后函数执行到ret指令时就再也不是“回到main里call的下一条指令”而是会跳到你指定的那个地址。如果你把这个地址设成已在程序里存在的system函数、某个后门函数CPU就乖乖听你的话去执行那里。这就是栈溢出利用的最朴素的形态。用一张草图画一下payload布局就会很清楚payload bA*填充长度 p64(saved rbp可覆盖) p64(目标地址)这个“填充长度”就是4.2里说的“buffer大小 saved rbp大小”。在32位程序上saved rbp其实叫saved ebp占4字节64位程序上占8字节。每道题的buffer大小不同需要用调试器实测不能靠猜。提示计算偏移时有个常用技巧用一个像AAAABBBBCCCC逐渐重复的pattern生成输入比如pwntools的cyclic然后在崩溃时查看$rsp或$rip里的值用cyclic_find反查偏移。这比自己在心里数字节快得多也比较不容易错。4.4 32位与64位的差异现在就要养成的“双轨思维”前置基础题32位和64位都会出现我建议从第一次写exp开始就养成“双轨思维”。两者的差异主要在这几点地址宽度不同32位用p32()、长度4字节64位用p64()、长度8字节。参数传递方式不同32位程序在调用函数时参数全部压栈64位程序则先把前6个整型/指针参数放入寄存器rdi、rsi、rdx、rcx、r8、r9多余的才压栈。栈帧寄存器不同32位用ebp64位用rbp。64位程序有“栈对齐”要求调用system这类函数时如果栈没有按16字节对齐可能导致崩溃。入门题里有时你在返回地址前面多塞一个retgadget就能解决问题这个技巧后面会经常用到。虽然前置基础阶段可能还不会让你构造复杂ROP但“64位传参靠寄存器”这一点必须提前建立意识。因为后面所有高级利用技巧几乎都是在围绕寄存器做文章。5. 从本地到远程第一个exp的完整流程5.1 动态调试亲眼确认崩溃点静态分析能告诉你“应该有洞”但“偏移到底多少”必须靠gdb确认。拿一个典型的ret2text场景来说假设程序里有如下反汇编示意0040114d lea rax, [rbp-0x20] 00401151 mov rdi, rax 00401154 call gets ... 00401160 leave 00401161 ret这里的[rbp-0x20]就是要分析的buffer地址。于是buffer到保存的rbp之间是0x20字节再加上8字节saved rbp偏移就是0x2080x28。这是静态读汇编算出的偏移但在真实题目里我会先用gdb验证gdb pwn b *0x401160 # 在leave之前下断点 run # 输入一串 pattern然后查看x/20gx $rsp或者直接看pwndbg自动展示的栈布局确认输入内容落在栈的哪个位置。第一次亲眼看到自己的输入“填满”返回地址区域时对栈的感觉会有质变。强烈建议不要跳过这一步哪怕你已经会算偏移了也花30秒验证一下。5.2 手写第一个pwntools脚本pwntools的脚本结构非常固定拿ret2text的标准模板来说from pwn import * context.arch amd64 context.log_level debug elf ELF(./pwn) # 加载ELF这样可以自动解析地址 # io process(./pwn) # 本地调试 io remote(xxx.ctf.show, 10000) # 打远程时切换 offset 0x28 # 用gdb或者cyclic实测出来的偏移别猜 target 0x4011a0 # 程序里后门函数/目标地址从IDA里拿 payload bA * offset p64(target) io.sendline(payload) io.interactive()这是pwn入门阶段最重要的“骨架脚本”。有几个细节值得唠叨context.arch必须和题目架构一致否则p64字节序可能不对。io.interactive()一定要调用拿到shell之后它的作用是把你当前的终端输入转发给程序、把程序输出打印到终端没有它你拿到shell也操作不了。用sendline还是send取决于目标读入函数。gets以换行为终结用sendline没问题如果目标用的是read并指定长度且你不想多带那个换行可能需要用send。5.3 本地与远程的统一流程打通一个远程题的标准动作是先在本地验证exp成功能看到shell交互再把process换成remote。这一步之所以容易出问题是因为本地环境与远程题目环境并不完全相同。最常见的是本地libc和远程libc版本不同但前置基础题很少用到libc地址。需要注意的反而是一些小事比如远程程序在接收payload前会先打印一行提示如果你的脚本上来就sendline而程序还在等接收提示那就不会触发漏洞。稳妥的做法是配合recvuntil或recvline先接收提示再发送payloadio.recvuntil(binput:) # 收到提示后再发 io.sendline(payload)这样写出来的exp既稳又不依赖手速。5.4 第一次打远程时的“玄学”问题我见过很多新手本地一把过、远程却怎么都不通然后开始怀疑人生。排查顺序应该是这样的确认remote的地址和端口真的写对了CTFshow的题目每次连接给的端口可能不同要填当次平台的端口。确认交互顺序远程程序有没有额外提示有没有膜、banner是否要先输一个选择项确认payload发送的方式有些题目用read读取固定长度如果你用sendline多塞了一个\n可能把整个payload结构破坏。确认程序路径问题如果你在payload里写死了/bin/sh等字符串本地可能因为环境不同而表现不同但正常ret2text不会依赖路径。从不那么“玄学”的角度说绝大多数远程不通都是上面四类原因。只要静态分析没错、本地exp能拿到shell远程通常只是时间问题。6. 做题习惯与卡点排查6.1 一套我自己稳定复用的做题流程刷完005到012后我把做题流程固定成了下面八步后面刷所有基础pwn题我都沿用这套节奏filechecksec看架构和防护。运行程序一次感受交互方式是纯读入还是先打印提示。IDA看main定位危险函数和后门函数。回到gdb用pattern确定偏移确认目标地址。写pwntools本地exp跑通。切换remote打通并拿到flag。记录wp尤其记下“偏移怎么算的、目标地址哪来的”。复盘这题能不能用别的方法解为什么会想到这个方法这套流程的核心是“先理解再利用”它尤其能避免新手最常犯的“拿到题就想着怎么打还没搞清楚程序在干嘛”的毛病。我在刷前置基础题的后半段时基本不看writeup就是靠这个流程硬推下来的成就感完全不一样。6.2 地址“对不上”的几类原因如果你在某个题目里发现payload用的目标地址和程序实际跳转地址对不上最可能的原因有三个PIE没关checksec输出里有PIE enabled时程序每次启动地址都会变你从IDA看到的地址是“偏移基址”不是实际运行地址。前置基础题如果开了PIE一般都会同时提供一个泄漏地址的入口或者要求你先泄漏、再二次利用这已经属于进阶玩法了。字节序写反x86和x86_64都是小端序p64/p32会自动处理但如果你手工拼字节很容易把地址倒着写。比如0x4011a0要写成\xa0\x11\x40\x00\x00\x00\x00\x00。直接用p64别手拼。覆盖长度不够这是偏移算错的另一面。你以为偏移是0x28实际是0x30你的payload覆盖到了返回地址的前面但没完全覆盖程序跳到了一个被截断的地址。遇到地址问题第一个动作永远是回gdb里实际看崩溃后的$rip和栈内容别在空中猜测。6.3 远程打不通的排查链路前面5.4提过排查顺序这里我再给一个更完整的决策树本地是否已经打通如果本地根本没拿到shell不要直接浪费时间看远程。端口是否正确CTFshow这类平台每次开启题目都有一组专属的ip和port复制的时候注意不要复制到“示例端口”。是否做了交互接收有提示就先recv没提示直接发也无妨发完再用interactive等待。是否产生了额外进程/上下文错误如果payload里用了sendline而目标是read试试send。尝试把context.log_level设为debug看脚本每一步收发了什么问题常在一两分钟的自查里暴露。用context.log_level debug是我最推荐的自查手段。pwntools会把“发送了什么、接收到了什么”全部打印出来几乎能替代90%的猜谜。6.4 最后可以按这个清单自查在你准备提交flag之前最后过一遍这个清单能省掉大量“为什么别人能过我过不了”的烦恼[ ] 本地exp是否成功interactive里是否真的出现$符号或flag[ ] 目标地址摘自哪个工具checksec显示PIE是否开启[ ] 偏移是实测的还是猜的用cyclic验证了吗[ ] payload结尾是否正确包裹换行read场景下是否多发了换行[ ] 远程是否成功拿到交互flag是否已经读出如果每个问题都能确认那么这道题基本没有理由过不去。这一批题做完之后我自己最大的体会是pwn入门最难的从来不是某道题的某个知识点而是“终于开始用工程化的方式对待做题”——先体检、再分析、再实测、最后利用每一步都有目的。你能在一道前置题上把这个流程跑顺后面无论是ret2libc还是shellcode都只是在这个骨架上填新的知识而已。如果你在005到012之间卡住了不用急着看writeup回到这篇文章把第3、4、5节里的方法照着做一遍多半就能自己解出来。
返回列表