CTF PWN入门:格式化字符串漏洞原理与利用实战详解
1. 项目概述从零开始理解格式化字符串漏洞如果你刚开始接触PWN看到“格式化字符串漏洞”这个词可能会觉得有点抽象甚至有点吓人。别担心我第一次接触时也是这种感觉。简单来说这就像你让一个程序帮你打印一段话但你告诉它的“打印规则”里藏了点私货结果它不仅把话打印出来了还顺带把一些不该让你知道的“小秘密”比如程序内存里的关键数据也给泄露了甚至还能让你偷偷修改这些秘密。在CTF的PWN题目里尤其是像CTFshow PWN91-100这个系列格式化字符串漏洞是一个非常经典且必须掌握的考点。它不像栈溢出那样需要精心构造一长串数据去覆盖返回地址它更像是一种“四两拨千斤”的技巧通过一个看似无害的printf函数就能实现信息泄露和内存篡改。这个系列题目之所以适合零基础入门是因为它通常将漏洞场景设置得非常清晰。你面对的程序往往直接使用了printf(user_input)这样危险的代码其中user_input就是你能控制输入。你的目标就是从这里切入先“读”出内存布局找到关键信息比如某个函数的地址从而算出系统库的基地址然后再“写”入你需要的数据比如把某个函数的地址改成你想要的。整个过程就像在玩一个解谜游戏而调试器就是你手中的放大镜和手术刀让你能看清每一步内存的变化。我写这篇解析就是想把我自己当初摸索时踩过的坑、总结的技巧以及调试过程中的关键观察点系统地分享给你。我们不会停留在“照着exp脚本跑一遍”的层面而是会深入到栈帧布局、参数传递、GOT/PLT机制以及如何使用GDB配合Pwntools一步步观察和验证你的利用链是否生效。相信我当你亲手通过格式化字符串漏洞拿到第一个shell时那种成就感是无与伦比的。2. 漏洞原理深度拆解printf 如何成为突破口要利用一个漏洞首先得彻底明白它为什么会产生。格式化字符串漏洞的核心在于C语言标准库函数printf、sprintf、fprintf等对格式化字符串的处理机制。2.1 格式化字符串函数的工作原理printf函数的正常工作方式是这样的它的第一个参数是一个格式化字符串例如Hello, %s! You have %d messages.。这个字符串里的%s、%d叫做格式说明符。printf会解析这个字符串当遇到%s时它就认为在栈上或者根据调用约定在寄存器里的下一个参数是一个字符指针然后去那个地址读取字符串并打印遇到%d就认为下一个参数是一个整数并将其打印。关键点来了printf函数本身并不知道也从不验证它后面到底有多少个参数对应格式化字符串中的格式说明符。它完全信任第一个参数格式化字符串的指示去“索取”数据。举个例子正常代码printf(Number: %d, String: %s, 100, test);栈上布局大致是返回地址 - 格式化字符串地址 - 整数100的地址 - 字符串“test”的地址。printf解析%d就从栈上取一个参数100打印解析%s再取下一个参数指向“test”的指针打印。一切正常。2.2 漏洞的产生用户控制格式化字符串现在看漏洞代码char user_input[100]; gets(user_input); // 或 read(0, user_input, 100) printf(user_input); // 危险如果用户输入的不是普通字符串而是包含了格式说明符比如%p、%x、%s会发生什么假设用户输入%p.%p.%p.%p。程序执行printf(“%p.%p.%p.%p”)。printf开始解析遇到第一个%p它默认去栈上“它认为”是第一个参数的位置注意在x86-64调用约定下前几个参数可能在寄存器但这里我们简化理解读取数据并当作指针打印。遇到第二个%p再去栈上“下一个”位置读取。...问题在于这些被读取的“参数”位置原本并不是程序调用printf时传递的真实参数因为这里只有一个参数即格式化字符串本身而是栈上printf函数调用帧之后的数据这些数据可能包括调用printf后的返回地址、printf的栈帧信息、甚至更早的函数调用留下的数据。通过这种方式攻击者就能像“窥探”一样泄露栈内存的内容。注意在x64架构下前6个整型或指针参数通过寄存器RDI, RSI, RDX, RCX, R8, R9传递。对于printf(user_input)user_input的地址放在RDI寄存器作为第一个参数格式化字符串。当格式化字符串需要第一个“变量参数”时printf会从RSI寄存器即第二个参数的位置开始读取但因为我们没有传递第二个参数RSI里是调用前的残留值。之后更多的参数则会从栈上读取。这使得利用时计算偏移offset变得稍微复杂需要通过泄露来确定。这是调试中的一个关键点。2.3 从读到写%n 格式符的威力泄露信息只是第一步更重要的是写入。这就要用到另一个特殊的格式说明符%n。%n的功能是不输出任何字符而是将截至目前已成功输出的字符总数写入到一个指定的整数指针参数所指向的内存地址中。例如int count; printf(Hello%n, count); // 执行后count的值将被设置为5因为“Hello”有5个字符。结合漏洞如果我们能控制格式化字符串并让%n对应的“参数”是一个我们想要修改的内存地址比如某个函数的GOT表项那么我们就可以通过控制已输出的字符数来向那个地址写入一个特定的数值。如何控制“已输出的字符数”我们可以使用%numberc这样的格式。%100c会输出100个字符前面填充空格。通过组合%numberc和%n我们就能实现向任意地址写入任意值理论上。例如构造%100c%n如果%n对应的参数被我们操控为指向地址A那么数字100就会被写入地址A。2.4 利用链路的典型构建在CTF的PWN题中利用格式化字符串漏洞的攻击链路通常遵循以下逻辑信息泄露使用%p、%lx、%s配合特定地址等泄露栈上的数据。目标是找到Libc地址泄露某个已知函数如__libc_start_main、puts在栈上残留的返回地址从而计算出libc的基地址。程序地址泄露程序本身的代码段地址如.text段地址用于计算偏移或绕过PIE地址随机化。栈地址泄露栈上的指针用于定位我们输入的字符串本身在栈上的位置从而构建“地址-值”对供%n写入。计算关键地址根据泄露的libc地址和已知的libc中函数的偏移计算出system函数和字符串/bin/sh的真实地址。内存篡改通常选择覆盖GOTGlobal Offset Table表。GOT表存储了外部函数如printf、system的地址。如果我们把printf的GOT表项覆盖为system的地址那么下次程序调用printf时实际上就会调用system。如果此时我们能控制传给“printf”实为system的参数就能执行任意命令。触发执行完成覆盖后让程序流程执行到被篡改的GOT表项对应的函数调用从而获得shell或读取flag。3. 调试环境搭建与核心工具链工欲善其事必先利其器。面对PWN题尤其是需要精细调试的格式化字符串漏洞一个顺手的调试环境至关重要。3.1 基础工具安装以下是我在Linux推荐Ubuntu 20.04/22.04下使用的核心工具链你可以通过apt一键安装sudo apt update sudo apt install -y gdb python3 python3-pip git sudo pip3 install pwntoolsGDB GNU调试器是动态分析的基石。我们后面会搭配插件使用。Pwntools Python写的CTF框架及漏洞利用开发库。它提供了极其方便的本地/远程交互、数据打包/解包、日志记录等功能是写exp脚本的标配。Git 用于下载一些插件和工具。3.2 GDB增强插件Pwndbg/GEF原生GDB功能强大但不够友好。强烈建议安装一个增强插件它们能自动显示寄存器、栈、代码、内存映射等信息极大提升调试效率。Pwndbg和GEF是两个最流行的选择。我个人更偏爱Pwndbg因为它对堆和格式化字符串的显示更直观。安装Pwndbgcd ~ git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh安装完成后启动GDB就会自动进入Pwndbg模式。你会看到彩色的界面和丰富的上下文信息。3.3 关键调试技巧与命令在调试格式化字符串漏洞时以下几个GDBPwndbg命令和技巧是每天都要用到的查看内存映射vmmap或info proc mappings这能告诉你程序代码段.text、数据段.data、.bss、堆heap、栈stack以及libc库的加载地址范围。这是计算偏移的基础。在printf处下断点b printf或b *printf的plt地址在漏洞函数通常是main或vuln函数调用printf的地方下断点。运行程序r并输入测试字符串如AAAA%p.%p.%p在断点处停下后单步步入si进入printf的PLT和最终的GOT跳转。观察栈帧stack或x/30gx $sp进入printf函数内部后查看栈顶附近的内存。你的输入字符串格式化字符串的地址通常就在栈上。你需要找到它并观察它后面高地址方向的内存内容这些就是可以被%p泄露出来的数据。确定偏移 输入一个包含多个%p和一段易识别地址的字符串例如python3 -c print(AAAA .%p*20)输出后作为输入。 在GDB中运行观察输出。假设输出是AAAA.0x7ffd... .0x400... .0x7f... .(nil).0x41414141...看到0x41414141‘AAAA’的十六进制出现在第n个位置从1开始数那么这个n就是你的格式化字符串中第一个%p对应的、从栈上读取的“第一个参数”的偏移。在后续利用中如果你想通过栈上的某个地址比如你输入的地址来读写就需要使用%offset$p这样的位置参数语法来指定。检查GOT表got或x/10gx got_addressPwndbg的got命令可以直接列出GOT表项及其指向的地址。在泄露libc地址后你可以验证计算出的system地址是否正确。修改内存后验证在exp脚本中使用%n进行覆盖后在GDB中再次使用got命令查看目标GOT表项确认是否已被修改为system的地址。实操心得调试时我习惯把GDB窗口和写exp的终端窗口并排。在exp脚本中我会在关键步骤前加入pause()Pwntools函数或raw_input(‘’)让脚本暂停然后切换到GDB窗口进行检查。确认内存状态符合预期后再回到终端继续执行脚本。这种“步进式”调试对于理解利用链的每个环节非常有效。4. CTFshow PWN91-100 题型实战逐步拆解这个系列的题目难度是递进的非常适合用来构建知识体系。我们选取其中最具代表性的几道题来演示完整的分析、调试和利用过程。请注意实际题目细节如函数名、偏移可能略有不同但思路完全一致。4.1 PWN91基础栈地址泄露与偏移计算题目特征通常是一个简单的、没有开启PIE程序地址随机化的32位或64位程序。只有一个printf(user_input)漏洞点。利用目标泄露栈上的某个返回地址计算出libc基址然后通过覆盖GOT表中的某个函数如printf为system并传递/bin/sh字符串作为参数。步骤详解静态分析用file、checksec查看程序架构和保护。用IDA或Ghidra进行反编译找到漏洞函数。确认是printf(user_input)且user_input在栈上可能是局部变量。动态调试确定偏移编写一个简单的Python脚本使用Pwntools连接本地程序process(‘./pwn91’)。发送payload:bAAAA b%p.*20。接收输出寻找0x41414141AAAA出现的位置。假设出现在第8个%p的输出那么偏移就是8。这意味着在格式化字符串中%8$p将会读取栈上对应我们输入的AAAA这个地址所在位置的内容即0x41414141本身。但更重要的是我们可以把AAAA替换成一个我们想读取的目标地址然后用%8$s去读取那个地址指向的字符串。泄露libc地址我们需要泄露一个已知函数在libc中的地址。常见目标是__libc_start_main的返回地址它通常在栈上较深的位置。通过调试我们发现第12个%p输出的是一个看起来像代码段的地址比如0x7fxxxxxxxxxx。我们可以用%12$p来稳定泄露它。在GDB中在这个地址上下断点或反汇编确认它是什么函数例如__libc_start_main243。记下这个偏移libc.symbols[‘__libc_start_main’] 243。计算system和/bin/sh地址假设我们使用的libc版本是libc6_2.31-0ubuntu9.15_amd64.so题目可能提供也可能需要你根据泄露的地址特征去匹配。libc_base leak_addr - (libc.symbols[‘__libc_start_main’] 243)system_addr libc_base libc.symbols[‘system’]bin_sh_addr libc_base next(libc.search(b’/bin/sh’))选择覆盖目标查看GOT表找一个在漏洞点之后会被调用的函数比如printf。因为我们的漏洞就在printf覆盖它之后下一次程序调用printf时就会跳转到system。构造写payload我们需要将printf_got地址写入栈上并让%n指向它。根据第2步我们知道我们的输入字符串在栈上的第8个位置。所以payload结构为p64(printf_got) p64(printf_got1) ... 格式化字符串。为什么有多个地址因为一次%n通常只能写入4字节32位或2字节64位小端序下可控写入。要写入一个完整的64位地址如system_addr我们需要将其拆分为多个2字节或1字节的写入使用%hhn写1字节或%hn写2字节来精确控制。计算需要输出的字符数使其等于system_addr的各个字节值。这涉及到复杂的字节对齐和计数通常用Pwntools的fmtstr_payload函数自动生成。from pwn import * context.log_level ‘debug’ p process(‘./pwn91’) # ... 省略前期泄露和计算步骤 ... # 假设 offset 8, printf_got 0x601020, system_addr 0x7ffff7e12345 payload fmtstr_payload(offset, {printf_got: system_addr}) p.sendline(payload) p.interactive()触发发送payload后程序执行到被覆盖的printfGOT项此时再调用printf可能需要程序循环再次进入漏洞函数或者原漏洞调用后还有一次printf实际执行system如果此时栈上或寄存器中恰好有指向/bin/sh的指针就能getshell。4.2 PWN95应对PIE与地址随机化题目特征开启了PIEPosition-Independent Executable。这意味着程序代码段.text和部分数据段的基地址每次运行都会变化。新挑战你无法在写exp时硬编码任何程序本身的地址比如GOT表地址.got.plt节区或某个函数的PLT地址。解决方案泄露程序基址虽然地址随机化但程序内部的相对偏移是固定的。我们可以利用格式化字符串泄露一个程序代码段内的地址例如main函数的返回地址或者__libc_csu_init之类的函数地址。这个地址在每次运行中是随机的但减去它在二进制文件中的偏移可以通过readelf -s ./pwn95 | grep main或IDA查看就能得到本次运行的程序基址program_base。program_base leak_code_addr - elf.symbols[‘main’](假设泄露的是main地址)计算GOT地址得到程序基址后GOT表的地址就不再是神秘的了。got_address program_base elf.got[‘printf’]。elf.got[‘printf’]是printf的GOT项相对于程序基址的偏移这是一个固定值。后续步骤与PWN91相同用计算出的真实GOT地址进行覆盖。注意事项在泄露程序地址时要确保泄露的是代码段的地址而不是栈地址或libc地址。通常代码段地址的范围是0x55...或0x56...64位而libc地址是0x7f...。在调试时用vmmap命令可以清晰地区分。4.3 PWN98无循环的一次性利用与栈布局操控题目特征程序只给你一次输入机会执行一次printf后就退出。没有循环让你先泄露再写。新挑战你必须在一次payload中同时完成信息泄露和内存覆盖。解决方案组合型payload格式化字符串是顺序解析的。我们可以构造这样的payload泄露部分 写入部分。泄露部分使用%offset$p来泄露栈上的libc地址。但这里有个问题printf在解析到%n进行写入后还会继续执行吗答案是会的它会继续解析后面的格式符直到字符串结束。所以我们可以把泄露放在前面。写入部分使用%valuec%offset$n或更精细的%hhn写入。精准控制栈布局这是难点。我们的payload本身在栈上。我们需要让%n对应的“参数位置”正好是我们想覆盖的地址比如printf_got。这通常意味着我们需要把目标地址放在payload的合适位置并精确计算这个地址在栈上相对于格式化字符串参数的位置即偏移。通过调试确定我们输入的字符串起始地址在栈上的位置以及它相对于格式化字符串参数第一个%p读取的位置的偏移。假设这个偏移是n。那么payload的开头部分放置我们想写入的地址例如printf_got这个地址本身会在栈上占据一个或多个单元8字节。我们需要计算这个地址单元位于第几个“参数”位置。假设是第m个位置。在格式化字符串中我们用%m$n来指向这个地址单元从而向printf_got写入数据。利用已输出字符数泄露部分%p也会输出字符影响%n写入的值。需要精确计算在%n之前总共输出了多少字符并通过%numc来微调使总字符数等于system_addr的低位字节值。这通常需要编写脚本进行动态计算和构造。一个简化的payload结构示例[printf_got地址] [printf_got1地址] ... [%p用于泄露][%c调整字符数][%hhn写入低字节][%c调整][%hhn写入高字节]...在实际操作中强烈推荐使用pwntools的fmtstr_payload函数它封装了所有这些复杂的计算。你只需要告诉它偏移、要写的地址和值它就能生成正确的payload。# 假设在一次调用中完成泄露和写入 payload b‘%15$p’ # 先泄露假设第15个位置是libc地址 payload fmtstr_payload(offset, {printf_got: system_addr}, write_size‘short’) # fmtstr_payload会自动将地址嵌入payload并生成正确的格式化字符串 p.sendline(payload) leak int(p.recvuntil(‘\n’, dropTrue), 16) # 接收泄露的地址 # 注意这里需要根据泄露的内容实时计算system_addr但一次调用中难以实现除非泄露的是固定偏移值。更常见的是一次性利用需要提前知道或猜测libc版本或者题目环境固定。对于一次性利用如果libc版本未知有时需要采用**部分写partial write或栈链stack chain**等更高级的技巧这超出了入门范围但PWN98通常会给一个固定的libc环境。4.4 PWN100综合挑战与高级技巧初探题目特征可能结合了之前的所有保护PIE、Canary等并且漏洞利用条件可能更苛刻比如输入长度受限或者需要多次触发不同漏洞点配合。解题思路绕过Canary格式化字符串漏洞可以读取栈上任意内容自然也可以读取到栈上的Canary值。一旦读取到在后续的栈溢出利用中就可以在覆盖返回地址时在正确位置填入正确的Canary值从而绕过检查。输入长度限制如果缓冲区很小无法容纳完整的复杂payload。可以考虑以下策略短payload泄露先发送一个极短的payload如%p来泄露关键地址。多次触发如果程序有循环或多次输入机会可以分阶段进行第一次泄露地址第二次发送覆盖payload。精确计算使用%hhn单字节写入可以减少用于调整输出字符数的%c的长度。fmtstr_payload函数生成的payload有时较长可以尝试手动构造更精简的payload。利用其他漏洞配合有时题目会有一个明显的格式化字符串漏洞用于泄露另一个轻微的缓冲区溢出用于覆盖返回地址。需要将两者结合用格式化字符串泄露出的地址如libc地址、程序地址、canary作为缓冲区溢出的“弹药”。5. 利用脚本编写与Pwntools实战理论说再多不如动手写一行代码。这里我以一个假设的、综合了PIE和一次调用的CTFshow题目为例展示一个相对完整的exp脚本框架和编写思路。#!/usr/bin/env python3 from pwn import * # 设置上下文自动处理二进制文件架构 context(arch‘amd64’, os‘linux’, log_level‘debug’) # 切换本地/远程模式 LOCAL 1 if LOCAL: p process(‘./pwn100’) # 加载ELF和Libc用于获取符号和节区信息 elf ELF(‘./pwn100’) # 假设题目提供了libc.so或者使用本地libc libc ELF(‘/lib/x86_64-linux-gnu/libc.so.6’) else: p remote(‘pwn.challenge.ctf.show’, 9999) # 远程题目通常需要你根据泄露动态匹配libc这里假设已知 libc ELF(‘./libc6_2.31-0ubuntu9.15_amd64.so’) # 1. 第一步泄露程序基址绕过PIE log.info(“Step 1: Leaking program base address”) # 构造泄露payload假设通过调试我们知道偏移6处有一个指向main函数的指针 payload_leak b‘%6$p’ p.sendlineafter(b‘Input: ‘, payload_leak) # 等待提示词后发送 # 接收泄露的地址 leak_main int(p.recvline(keependsFalse), 16) log.success(f“Leaked main address: {hex(leak_main)}“) # 计算程序基址。注意泄露的地址可能是main函数内的某个指令地址如mainxx。 # 我们需要知道这个偏移。通过反汇编或调试假设泄露的是 main42 main_offset elf.symbols[‘main’] 42 # 这个42需要根据实际调试确定 program_base leak_main - main_offset log.success(f“Program base: {hex(program_base)}“) # 计算关键GOT地址 printf_got program_base elf.got[‘printf’] log.info(f“printf GOT {hex(printf_got)}“) # 2. 第二步泄露libc地址 log.info(“Step 2: Leaking libc address”) # 假设偏移12处有一个libc地址例如__libc_start_main_ret payload_leak_libc b‘%12$p’ p.sendlineafter(b‘Input: ‘, payload_leak_libc) leak_libc int(p.recvline(keependsFalse), 16) log.success(f“Leaked libc address: {hex(leak_libc)}“) # 计算libc基址。同样需要知道泄露点的符号偏移。 # 假设泄露的是 __libc_start_main243 libc_start_main_offset libc.symbols[‘__libc_start_main’] 243 libc_base leak_libc - libc_start_main_offset log.success(f“Libc base: {hex(libc_base)}“) # 计算system和/bin/sh地址 system_addr libc_base libc.symbols[‘system’] bin_sh_addr libc_base next(libc.search(b’/bin/sh’)) log.info(f“system {hex(system_addr)}“) log.info(f“/bin/sh {hex(bin_sh_addr)}“) # 3. 第三步确定格式化字符串的偏移 # 我们需要知道我们输入的字符串中我们放置的地址在栈上是第几个参数。 # 这通常通过发送 ‘AAAA%p%p%p...’ 来探测。这里假设我们已经知道偏移是8。 fmt_offset 8 # 4. 第四步构造覆盖payload将printf_got覆盖为system_addr log.info(“Step 3: Constructing write payload”) # 使用pwntools的强大函数自动生成payload # 参数偏移 {要写的地址: 要写的值} 写入字节大小byte/short/int write_payload fmtstr_payload(fmt_offset, {printf_got: system_addr}, write_size‘short’) # 注意一次写入8字节的system_addr可能需要拆分多次2字节写fmtstr_payload会处理好。 # 5. 第五步发送最终payload并触发 log.info(“Step 4: Sending final payload”) p.sendlineafter(b‘Input: ‘, write_payload) # 6. 第六步尝试获取shell # 覆盖完成后程序下一次调用printf时实际会调用system。 # 我们需要确保调用时RDI寄存器第一个参数指向“/bin/sh”字符串。 # 这通常需要提前布局比如在输入中传入“/bin/sh”并希望它留在栈上某个能被RDI指向的位置。 # 或者如果原程序是 printf(user_input)那么user_input本身就会作为第一个参数传给system。 # 所以我们在最后的payload里在格式化字符串之前或之后加上“/bin/sh”。 # 更可靠的方法是覆盖其他函数的GOT比如strlen并布局好参数。 # 这里假设漏洞函数是 printf(user_input)且我们的write_payload发送后程序会再次调用printf(某处)。 # 我们可以在write_payload后面拼接上“;/bin/sh”或“||/bin/sh”利用system执行命令的特性。 # 但更通用的方法是覆盖exit或puts的GOT并提前在栈上布置好参数链。 log.info(“Attempting to get shell...”) p.interactive()脚本编写心得灵活使用loglog.info,log.success,log.warning能让你的脚本输出更清晰便于调试。善用sendlineafter/recvuntil这些交互函数能稳定地处理程序输入输出避免时序问题。偏移是核心fmt_offset和泄露用的偏移如%6$p中的6可能不同需要仔细区分。前者是“我们输入的地址在栈参数中的位置”后者是“我们想读的栈数据在栈参数中的位置”。fmtstr_payload是神器对于大多数题目它都能生成可用的payload。但理解其原理在它失效时如长度限制能手动构造才是真本事。动态调试在脚本关键节点如发送泄露payload后、发送写payload前加入pause()配合GDB观察内存是验证思路、排查错误的不二法门。6. 常见问题与调试排错实录即使理解了原理照着教程做实际操作中还是会遇到各种“妖魔鬼怪”。下面是我在实战中遇到的一些典型问题及解决方法。6.1 泄露的地址不对或程序崩溃症状发送%p序列后输出的地址看起来杂乱无章或者程序直接段错误Segmentation Fault。可能原因与排查偏移计算错误这是最常见的原因。你使用的偏移%n$p可能没有对准有效的栈地址。解决增加探测范围如发送%1$p.%2$p....%50$p观察输出规律找到看起来像代码/库/栈的地址区域如0x55...,0x7f...,0x7ffd...。格式化字符串包含空字节如果你用p64(address)将地址嵌入payloadx64地址高位可能是\x00printf遇到空字节会终止字符串读取导致后面的格式符无效。解决调整顺序将地址放在payload末尾或者使用%n$p直接读取栈上已有的地址避免自己嵌入带空字节的地址。对于写入空字节问题更复杂通常需要利用%hhn分批写来避免在地址中出现\x00。输入缓冲区大小限制你的payload可能太长覆盖了栈上的其他关键数据如返回地址导致返回时崩溃。解决精简payload优先使用%hhn或尝试将地址放在payload靠后的位置。6.2 覆盖GOT后没有成功执行system症状exp脚本执行到最后没有弹出shell程序可能正常退出或报错。可能原因与排查GOT表项写错了用GDB在覆盖后下断点查看目标GOT表项的内容是否真的变成了system的地址。命令x/gx printf_got。system地址计算错误libc版本不匹配。你泄露的libc地址计算的基址可能不对。解决使用泄露的地址去匹配libc数据库如https://libc.blukat.me/或libc-database。在本地调试时确保使用的libc文件与远程环境一致。参数传递问题system函数需要一个指向命令字符串的指针作为参数。当你覆盖printf的GOT后下一次调用printf(arg)时arg会作为第一个参数传给system。你需要确保这个arg是你能控制的、且内容为如/bin/sh的字符串指针。如果程序调用的是printf(format, ...)那么format字符串可能就是你的部分payload会成为system的参数。解决在payload中嵌入/bin/sh字符串并确保它位于能被作为第一个参数访问到的位置通常是栈上某个点。有时需要覆盖其他函数如strlen的GOT并精心布局栈帧。保护机制某些保护如FULL RELRO会使GOT表只读无法覆盖。checksec查看。如果开启则此路不通需寻找其他利用方式如覆盖栈上的返回地址或函数指针。6.3 使用pwntools的fmtstr_payload失败症状生成的payload发送后无效果或程序输出异常。可能原因与排查偏移参数错误fmtstr_payload的第一个参数offset是写偏移即你嵌入的地址在格式化字符串参数列表中的位置。这个值必须通过调试精确获得不能使用泄露的偏移。它们通常是不同的。写入大小不匹配write_size参数默认为‘byte’%hhn。如果系统要求对齐或你的值比较大使用‘short’%hn或‘int’%n可能更合适。查看生成的payload确认使用的格式符是否符合你的预期。自动生成的地址顺序fmtstr_payload会将需要写入的地址放在payload开头。如果这些地址包含空字节可能会被printf提前截断。解决可以尝试设置write_size‘short’来减少空字节或者手动构造payload。6.4 调试时断点不停或无法跟进libc症状在printfplt下断点但程序直接运行过去了。解决尝试在printf的GOT表项地址got.printf下硬件断点hb *printf_got_address。当程序跳转到libc的printf时会触发。在漏洞函数调用printf的那条call指令处下断点b *vuln_functionoffset。确保GDB加载了程序的调试符号file ./pwn100并且使用start命令启动程序而不是run这样会在main函数入口停下方便你下断点。6.5 一次攻击不成功怎么办格式化字符串漏洞利用有时需要多次尝试和调整。建立一个清晰的调试循环信息收集用简单的%p链尽可能多地泄露栈内存画一个简单的栈布局图。偏移校准确定用于读和写的精确偏移。小范围测试先尝试写一个无关紧要的内存位置比如一个全局变量验证写操作是否成功。分阶段攻击如果可能先泄露再根据泄露的信息计算第二次再写。善用工具pwntools的fmtstr模块from pwn import fmtstr提供了更底层的格式化字符串漏洞利用类可以给你更多控制权。格式化字符串漏洞的学习曲线起初可能比较陡峭但一旦你掌握了栈帧、参数传递、GOT/PLT这些底层概念并熟练运用GDB进行动态跟踪你会发现它是一门非常精妙且强大的艺术。CTFshow PWN91-100这个系列就像一套精心设计的训练关卡逐层递进地帮你夯实基础。多动手、多调试、多思考每一个“为什么”你就能从“看懂了”进化到“会用了”最终达到“精通了”的境界。