纯新手一步步解析-格式化字符串漏洞原理(一) – 作者:参宿七的风华

网上一搜的文章都很浅,越看疑惑越多,于是自己写了个和网上一样的程序,一步一步调试。

给绝对萌新的说明:黑漆漆的图片里的数据代表了栈结构。

正常程序:
20210130205232

gcc -m32 -o normal_stringpwn normal_stringpwn.c -no-pie

漏洞程序:
20210130204009

在调试之前,用checksec看了一下,发现程序默认开启了PIE保护,于是就顺其自然,分别调试有PIE保护与无保护的程序。

编译两个版本:

gcc -m32 -o stringpwn stringpwn.c

gcc -m32 -o stringpwn1 stringpwn.c -no-pie

进入gdb,在printf处下断点,运行。

任意读

32位正常程序栈底层分析

萌新解惑:加入“aaaa”是为了方便定位我们的数据在栈上的位置,不然很难从一大堆二进制识别出字符串的位置。

正常程序输入aaaa%x%x,进入printf时的栈情况:
20210130214810

参数从图中栈第二个开始,分别是格式化字符串、%s、%d(0x400==1024)、

%f(100 0000 0100 0100 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000==0x4044 0000 0000 0000)


用之前写过的《浮点数底层验证》,可以计算得出此二进制串解析为double为1.25*(100 0000 0100)-2^(11-1)-1=1.25*32=40

正好就是我们输入的小数40,其中,double形参占用两个栈单位,其值分别为0x00000000与0x40440000


而接下来又一个%s,那是因为调用printf前和调用中会调用很多字符串处理函数,于是此格式化字符串会在栈上重复出现。


32位漏洞程序栈底层分析

PIE漏洞程序:
20210130205930

无保护程序:
20210130205945

本文无关:
顺便打印了此时程序的esp下50个数据,感觉onegadget的条件其实也不会太难符合,栈上NULL很多。

对比正常程序的栈可以得知参数从图中第二个栈单位开始。

从两个程序的栈来看,漏洞程序并不会如正常程序一样,将格式化参数压栈,只会压入漏洞字符串,也就是,程序在读取到printf函数的可变参数时,才会解析并压入栈,而不是读取到格式化字符串中的格式字符就压栈。

这对我们接下来判断偏移量提供了一些信息。

32位验证阶段

从上面的图可得,一个程序开启和不开启PIE,运行某一个程序位置时栈上对应的数据用途都是相同的,如存储参数的栈位置,开启PIE后同位置还是存储参数。

所以,接下来验证前面的判断时两个漏洞程序的结果是等效的。

我们试着运行漏洞程序:

无保护漏洞程序:
20210130231352分别输出ff84c826与0,
第一个%x不固定是因为常量地址不固定,而不是PIE。

对比
20210130231548可以发现格式化字符从第一个压栈参数开始读,第二个%x输出0,而参数部分栈的第二个地址确实也是0。

再运行PIE漏洞程序加强验证:
20210130232136

虽开启了PIE但输出的两个值确实和前面的图中栈的数据格式相对应。

所以,理论上们可以读取格式化字符串前每一个地址的数据

但是,如果要读取第100个地址的内容,那就相当麻烦了,不说我们要输入100个%x,若字符串变量长度不足,也会崩毁栈致使程序崩溃。

20210130233006(鼠标滚到开头你会发现字符串数组只有10个字节)

鉴于此,另辟蹊径,我们可以利用%{n}$x来快捷打印离离第一个参数开始第n-1个地址的数据

如观察20210130233620

w们发现字符串后面四个字符在0xffffd2e8开始,而%1$x就是压入栈的格式化字符串参数且位于0xffffd2d0,以此为基础类推,%6$x就是“aa%x%x”的地址,来试试看:

(注意,我们上面的分析都是基于输入“aaaa%x%x”,而接下来我们会改变输入,比如“aaaa%6$x”,虽然参数不一样了,但是经过调试,除了栈上字符串变化之外其他数据都一致。)

%1$s打印栈上以第一个参数(也就是格式化字符串参数)第1-1个(也就是参数本身)地址对应的字符串:
2021013100090920210130235143

成功!

%6$x以十六进制打印以第一个参数(也就是格式化字符串参数)第6-1个地址存放的数值:
20210131005159结果:
20210130235828
20210131001808
20210131001846

所以0x25和0x36确实是‘%’和‘6’的ascii编码。

这就是任意读。

64位栈与寄存器分析

而64位程序分析也是类似,仍然是同一份代码,但是这次不指定-m32,而是直接编译成64位。

需要注意的点是64位中函数前6个参数不是压栈而是存入寄存器(格式化字符串作为第一个参数传入rdi):
20210131004208栈:
20210131004249对比:
20210131001445

发现了没有?

可知,函数参数并没有入栈,栈上的字符串是因为字符串变量是局部变量,本身就在栈上,32位也是如此,输入的字符串存放于栈上,而参数则是字符串在栈的地址,很合理。

有了之前的经验,这次%1$s打印出来的应该是栈上第一个参数吧?
也就是图中rdi-6那个位置,对吗?
20210131005520很明显不是,这个a哪来的?
网上一番搜罗与调教调试,偶然瞟到:
20210131005647

回想一番,按逻辑推导,函数读取第二个参数应该是从rsi读取,所以%1$x应该是读取第二个参数寄存器rsi!
类似的,
20210131010904当参数少于7个时, 参数从左到右放入寄存器: rdi, rsi, rdx, rcx, r8, r9。
如上两图所示,完美契合。

而从第六个偏移开始,相当于“第七个参数”,于是操作系统把目光放回了栈:
20210131011120因为默认开了PIE的缘故,栈上除了我们输入的字符外其他大多都在变化,这也就是为什么要加个前缀“aaaa”了,对眼睛好一点。

此时,printf栈帧前很干净,除了我们输入的局部变量s的数据外,什么都没有(我知道上面还有一个main+49,那是printf栈帧的……),于是栈上对应第七个参数就是printf栈帧前的数据,也就是我们的唯一的局部变量s:
20210131011511

除了栈上字符串变量“上面”的一些参数没了,其他都和32位差别不算很大(64位printf一样会调用其他函数且需要存放一样数量的参数,但是64位下,printf调用的每个底层函数的参数个数都没有超过6个,所以栈上除了那唯一的局部变量数据外什么参数都没有,没有压进来)。

总结一下就是

32位:
从栈帧第一个参数(格式化字符串)往高地址,%x以4字节为单位

64位:
%1-5$lx:

rsi,rdx,rcx,r8,r9

%6-…$lx: 从printf族函数栈帧前,也就是主调函数的局部变量们开始,%lx以8字节为单位(一般情况是这样,毕竟系统函数参数很少超过6个,我太菜了还没见过)。

可能会有人问那是怎样定位“第七个参数”的呢?答案就是ebp。

任意写概述

任意写威力很大,比如覆盖got表,但是出现的情况很少。

还是printf,它有一个格式字符%n,可以把%n之前打印成功的字符个数赋值给某个变量:

int a = 0; 
	printf("%.44d%n\n", a,&a);
	printf("a: %d\n", a);
    //a为44

基础知识就只是如此,任意写的基本思路就是,先任意读取到我们输入的格式化字符串的位置(不是格式化字符串参数所在位置!是所存放在局部变量的位置)。

%n格式符会取变量地址并且存入数据,就如同上面任意写时%7$x可以读到我们的局部变量所在位置一样(相当于直接“接触字符串”)并且打印出对应字符的ascii码一样:

我们可以在字符串中写入地址,并且用任意读的原理调试出%{偏移量}$n,而%n会将当前字符个数存进字符串中的指定地址。

但是若需往该地址写入10000怎么办?这种情况下会打印出10000个字符,可能有人会说,也许%.44d会将44个0压栈,这个倒是不会的。

搜罗搜罗一些exp,发现都是将数据一字节一字节写入,而%n也有限制符:
%$hn写入2字节,%$hhn写入1字节,%$lln写入8字节,在32位和64位环境下一样。

直接写4字节会导致程序崩溃或等候时间过长,比如我开着wsl2调试任意读的时候,作死objdump了一个libc库,在川流不息的汇编流下,我用gdb调试完成并且打算截图的一片良好光景,崩了。

当然若缓冲区长度不够就不能如此浪费了,有一点是一点,但是有时候可以结合栈溢出循环调用。

需要结合ida+pwntools调试才能直观,然鹅有砖要搬,等下回再开新篇。

来源:freebuf.com 2021-02-08 16:43:59 by: 参宿七的风华

© 版权声明
THE END
喜欢就支持一下吧
点赞0
分享
评论 抢沙发

请登录后发表评论