printf的漏洞听过好多次,但是总是没有成功利用过,这里记录学习过程。
printf格式化字符串漏洞
1.简单介绍
printf的正确使用方式应该是:
1 | printf(format_string, arg0,arg1...) |
由于C允许函数的参数不固定,这就使printf的参数在编译过程中不会特意的检查参数的数量。而格式化字符串漏洞为:
1 | printf(user_str) |
也就是【由用户来输入格式化字符串从而导致的漏洞】。
2.格式化字符串
常用的格式化字符串类型有以下
1 | | 符号 | 作用 | |
- 其中,在%之后紧跟数字的话,可以指定输出的长度。如%7f,表示输出的浮点数宽度为7(包括小数点)。
- 还可以指定输出的填充,比如%08x,表示如果输出长度不足8,则用0来填充。
- 如果在%之后添加l,表示此时使用【长】类型数,比如%ld:长整形,%lf:双精度浮点
- 如果在%之后紧跟者
$num
的话,表示是【作用于第num个参数】
**【备注】**对于snprintf这类,会限制输入长度的printf而言,形如下列的例子:
1 | input = "%025x%n" |
这类题目,0最多会输出18个到tmp中,但是value2的值依然为25,因此可以断定,格式化字符串%n对于输出长度的计算不是真的会有多少个字符输出,而是理论上有多少个字符将会存在
3.格式化字符串漏洞
其中利用%x可以轻易的泄漏指定地址的信息,如:
1 | printf("%12d%x") |
注意到之后并没有紧跟着其他的参数,但是printf本身不会判断后面是否有跟着其他参数,而是会模拟一个取参数的过程
可以看到,printf是通过【读取format_str】中指定的参数,然后直接操作栈,从栈中紧临着格式化字符串上的区域中取出参数,并且输出相关数据。所以当用户来指定format_str的时候,就可以通过这一点将栈中的一些信息泄漏出来。
这里假设我们的代码形式入下
1 | int main(int argc, char *argv[]) |
此时可以看到,用户并没有放入足够的参数,于是此时printf会将地址08480110输入到字符串中,同时,使用了%x使得栈的指针向后移动。由于参数是从右往左压入的,所以最左边的格式化字符串就位于栈最下的位置。四个%x总共就会将栈上移4*4 = 16字节,此时正好会将字符串中指定区域的数据输出。
同样的,如果将此时的%s更换成%n的话,就能够像指定的地址08480110中写入我们指定的数据。
这里有一个来自实验楼的测试:
1 | /* vul_prog.c */ |
实验目的是修改secret[1]的值为指定值,这里我们先分析一下:
思路:已知存在一个格式化字符串输出漏洞,那么我们可以构造字符串,像指定地址中写入数据。
首先我们需要知道字符串开始的地址,通过gdb调试,我们能够得到开始的地址为[ebp-0x6c],而此时的栈头部为[ebp-0x88]
此时可以算出,期间相间的地址为6个空间大小,那么此时可以通过6个%x将printf的指针指向字符串的开头,在这个位置写下secret[1]的地址的话,就可以修改目标secret[1]的值。(后来发现,由于有输入参数int_input,可以往此种写入地址,然后直接让printf的%n作用在这个地址指向的位置即可)此地址为[ebp-0x74],那么距离参数只间隔4个%x,从而可以构建参数:
%x,%x,%x,%x,%s
结果如下
显然,U的ascii码为55,所以此时的%s的确是将数据成功的泄漏了。那么通过将%s修改为%n的话,就能够修改指定值。
成功。
snprintf的特殊做法:
1 | snprintf(dst, size, fmt, ..); |
格式化字符串攻击是动态的。也就是说,在运行过程中,dst中的值如果能够根据fmt发生变化,那么就可以直接利用dst进行修改,而不是fmt。
例子:
1 | snprintf(dst, sizeof(fmt), argv[1]); |
上述题目要实现任意位置写,所以我们需要在某个可控位置放入addr,然后使用%n
进行攻击。
直接使用argv[1]?
如果想要在argv[1]中写入地址,并且直接利用的话,其实非常困难,因为argv[1]的内容会让栈发生动态变化,这一点是很要命的。。。因为你可能计算好了偏移量,但是exp的长度变化后整个长度都会变化。
使用dst
由于snprintf这个函数没有缓冲,是立即生效的,所以如果我们输入的内容是:
1 | '\xef\xbe\xad\xde' |
那么其实在栈中,dst就会有这个地址:
1 | 0xbffffac0: 0xbffffaec 0x00000080 0xbffffd5a 0xb7fdcb48 |
这样的话,我们就不必巧妙的计算exp的长度,从而防止偏移变化,而是直接写入到fmt的对应位置上即可。
POSIX标准
POSIX标准中新增加了如下的标准:
1 | %[parameter][flags][field width][.precision][length]type |
格式和原先差不多,但是可以使用【占位符】来指定【要操作的变量】。
n$
n是用这个格式说明符的第几个参数。
其中可以使用功能将数字扩栈:
hhn$
– 将char类型扩展成inthn$
– 将short类型扩展成int
如:
1 | printf("%2$d %2$#x; %1$d %1$#x",16,17) |
下面用一个例题来说明具体利用:
ISCC pwn 100
虽然这个比赛有一些很容易的题目,但是pwn似乎出的还是蛮适合新手的(?)
程序大致流程如下:
程序给了一个libc,漏洞有两个:gets和printf。
然而仔细看,发现这个程序他。。。没有return,那这个canary就没有什么意义了。。。
所以,我们的只能利用这个printf了。我们的第一步就是通过printf找到puts函数的真实地址,然后计算出libc的地址,并且计算出system的地址,并且替换掉printf的.plt表,让其执行system:
为了实现这个思路,首先要能够泄露出puts的地址,这里我们可以用上文提到过的方法,在printf输入的字符串中写入puts.plt的地址,从而将plt的内容输出:
1 | "p32(puts_got) + "%6$s" |
这个%6$s
是【计算出了当前字符串的开始地址】然后独处这个地址中的内容,也就是puts的内容。:
我们得到了puts的地址后,再次构造,利用%n往指定的位置写入数据。这里有一个技巧:一个地址的一般大,不可能说写入0xb000cf00个字符串(因为printf是真的向stdout中写出那么多个字符串,而这个数字很大),所以这里我们分成两个部分,分别写入0xb000和0xcf00-0xb000个字符串(此时我们指定用hn,也就是按照两个字节写入),这样的话就能够写入字符串。
第二个技巧是,当我们计算这个%x$hn
的时候,x的数值是根据字符串的起始位置来确定的,而我们写入的字符出长度也是要精确计算过的,所以这个就导致我们需要知道我们写入的地址有没有被计算在输出的字符串长度中。因此我们可以将地址放在字符串的尾部,这样的话我们的输出长度x就不需要继续调整,直接就是我们之前计算好的地址的两部分。
如果按照上述做法的话,可能会有疑惑,这个x的长度应该也会影响字符串,那又如何确定printf.got在第几个变量呢,这里要用到一个进程运行时动态库的位置的知识:动态链接库的程序所映射的地址空间是介于栈和堆之间,或者说更加接近于栈方向,也就是说此时地址的一般是0xbxxxxxxx,也就是说是数字0xb000~0xbfff(45056-49151),而后面那段就算是0xffff,答案也是65535,长度依然是5,也就是大概率这个数字的长度是5,所以我们计算参数所在位置的时候,这个字符串的长度我们就默认为5计算,应该是不会错的。
于是我们可以得到字符串为
1 | %(5)x%13$hn%(5)x%14$hnaa+printfs_got+printf_got+2 |
通过计算,能够发现这个字符串的长度为28(本来是26,我们在第二个$hn后长度为26,为了对其填补两个aa作为padding),然后计算后可以得到,此时的printf_got(注意这个要是got!因为我们是修改got表),最后计算出这个printfs_got的地址为第13个变量的位置。
最后就能够计算出exp:
1 | exp = "%" + str(c2) + "x" + "%13$hn" + "%"+ str(c1-c2) + "x" + "%14$hnaa" + p32(printf_got) + p32(printf_got + 2) |
下面给出poc:
1 | # -*- coding:utf-8 -*- |