由printf引起的格式化字符串漏洞(一)
本系列文章实验平台:
x86 Fedora 7
$ gcc -v
使用内建 specs。
目标:i386-redhat-linux
配 置为:../configure –prefix=/usr –mandir=/usr/share/man –infodir=/usr/share/info –enable-shared –enable-threads=posix –enable-checking=release –with-system-zlib –enable-__cxa_atexit –disable-libunwind-exceptions –enable-languages=c,c++,objc,obj-c++,java,fortran,ada –enable-java-awt=gtk –disable-dssi –enable-plugin –with-java-home=/usr/lib/jvm/java-1.5.0-gcj-1.5.0.0/jre –enable-libgcj-multifile –enable-java-maintainer-mode –with-ecj-jar=/usr/share/java/eclipse-ecj.jar –with-cpu=generic –host=i386-redhat-linux
线程模型:posix
gcc 版本 4.1.2 20070502 (Red Hat 4.1.2-12)
*************************************************
在c语言中有些库函数以含格式化字符串作为其参数之一,比如*prinf家族函数,syslog等类似函数。这些函数如果使用错误,将会带来灾难性的软件缺陷 。
为了更加清楚的理解格式化字符串缺陷的形成原因与攻击方法,我们有必要对程序在内存中的布局,堆栈,函数调用等基础知识有较清晰的理解,汇编语言也是必不可少的。这些基本知识可以很容易在网络上面找到,所以在这里就不啰嗦了。
下面我们以一个例子来分析一下在c中变参数函数的实现原理,其实查看glibc库中的源代码更容易些,但是我了锻炼汇编语言能力,我们通过反汇编来分析。
$ cat var_args.c
#include <stdio.h>
#include <stdarg.h>
int func(int argc, …);
int main(int argc, char *argv[])
{
int i;
int j;
int ret;
i = 0;
j = 1;
ret = func(2, i, j);
printf(”%d\n”, ret);
return 0;
}
int func(int argc, …)
{
int i;
int arg;
va_list va;
va_start(va, argc);
for (i = 0; i < argc; i++) {
arg = va_arg(va, int);
}
va_end(va);
return arg;
}
$ gcc var_args.c -o var_args
$ ./var_args
1
我们用gdb来分析一下这个程序,首先来看main函数
$ gdb var_args -q
(no debugging symbols found)
Using host libthread_db library “/lib/libthread_db.so.1″.
(gdb) disass main
Dump of assembler code for function main:
0×080483a4 <main+0>: lea 0×4(%esp),%ecx //此时esp指向main的返回地址
0×080483a8 <main+4>: and $0xfffffff0,%esp //堆栈指针按16字节对齐
0×080483ab <main+7>: pushl 0xfffffffc(%ecx) //把main的返回地址再次压栈,实际上没什么用,可以不要
0×080483ae <main+10>: push %ebp //此句和下一句构造栈帧
0×080483af <main+11>: mov %esp,%ebp
0×080483b1 <main+13>: push %ecx
0×080483b2 <main+14>: sub $0×24,%esp //为main函数局部变量和调用func的参数分配地址空间,36字节
0×080483b5 <main+17>: movl $0×0,0xfffffff0(%ebp) //i = 0
0×080483bc <main+24>: movl $0×1,0xfffffff4(%ebp) //j = 1
0×080483c3 <main+31>: mov 0xfffffff4(%ebp),%eax //把j的值放入eax寄存器中
0×080483c6 <main+34>: mov %eax,0×8(%esp) //func的最后一个参数入栈,也就是j
0×080483ca <main+38>: mov 0xfffffff0(%ebp),%eax
0×080483cd <main+41>: mov %eax,0×4(%esp) //func的倒数第二个参数入栈,i
0×080483d1 <main+45>: movl $0×2,(%esp) //func的倒数第三个参数也是第一个参数2入栈
0×080483d8 <main+52>: call 0×8048401 <func> //调用func函数
0×080483dd <main+57>: mov %eax,0xfffffff8(%ebp)//func函数的返回值赋给ret
0×080483e0 <main+60>: mov 0xfffffff8(%ebp),%eax
0×080483e3 <main+63>: mov %eax,0×4(%esp) //printf函数最后一个参数就是ret入栈
0×080483e7 <main+67>: movl $0×8048510,(%esp) //printf的第一个参数就是字符串”%d”入栈
0×080483ee <main+74>: call 0×80482b8 <printf@plt> //调用printf函数
0×080483f3 <main+79>: mov $0×0,%eax //准备main函数的返回值
0×080483f8 <main+84>: add $0×24,%esp //调整堆栈指针
0×080483fb <main+87>: pop %ecx
0×080483fc <main+88>: pop %ebp //恢复基址寄存器
0×080483fd <main+89>: lea 0xfffffffc(%ecx),%esp //esp指向main函数的返回地址
0×08048400 <main+92>: ret //返回到libc库的启动例程
End of assembler dump.
(gdb)
从上面可以看出传递给func函数的参数都是以4个字节为单位压入堆栈的,然后call指令完成两个任务,一是把call指令的下一条指令的地址也就是 0×080483dd压入堆栈,而是跳转到0×8048401执行func函数。这时堆栈寄存器esp就指向func函数的返回地址。大概样子如下两个图
图一:在程序执行到0×080483c3时堆栈布局
------------------内存低地址
垃圾数据 <—–esp 虚拟地址X
------------------
垃圾数据 <—–esp + 4 X+4
------------------
垃圾数据 <—–esp + 8 X+8
------------------
垃圾数据 <—–esp + 0xc X+0xc
------------------
垃圾数据 <—–esp + 0×10 X+0×10
------------------
垃圾数据 <—–ebp-0×14 esp+0×14 X+0×14
------------------
0(变量i) <—–ebp-0×10 esp+0×18 X+0×18
------------------
j(变量i) <—–ebp-0xc esp+0×1c X+0×1c
------------------
垃圾数据 <—–ebp-8 esp+0×20 X+0×20
------------------
ecx <—–ebp -4 esp+0×24 X+0×24
------------------
glibc启动例程的ebp <—–ebp esp +0×28 X+0×28
------------------
main函数的返回地址 <—–ebp+4 esp +0×2c X+0×2c
------------------
0×080483a8处指令调整空间
------------------
main函数的返回地址
------------------
main函数的参数
glibc堆栈
环境变量与命令行参数等
------------------ 内存高地址
图二:在程序执行完0×080483d8的call这条机器指令后堆栈布局
------------------内存低地址
func函数的返回地址 <—–esp X-4 /*call压入*/
------------------
2(printf函数的第一个参数) <—–esp+4 虚拟地址X
------------------
0 (变量i 的值) <—–esp + 8 X+4
------------------
1 (变量j的值) <—–esp + 0xc X+8
------------------
垃圾数据 <—–esp + 0×10 X+0xc
------------------
垃圾数据 <—–esp + 0×14 X+0×10
------------------
垃圾数据 <—–ebp-0×14 esp+0×18 X+0×14
------------------
0(变量i) <—–ebp-0×10 esp+0×1c X+0×18
------------------
j(变量i) <—–ebp-0xc esp+0×20 X+0×1c
------------------
垃圾数据 <—–ebp-8 esp+0×24 X+0×20
------------------
ecx <—–ebp -4 esp+0×28 X+0×24
------------------
glibc启动例程的ebp <—–ebp esp +0×2c X+0×28
------------------
main函数的返回地址 <—–ebp+4 esp +0×30 X+0×2c
------------------
0×080483a8处指令调整空间
------------------
main函数的返回地址
- -----------------
main函数的参数
glibc堆栈
环境变量与命令行参数等
------------------ 内存高地址
-----------------------------------
我们再来看看func函数
(gdb) disass func
Dump of assembler code for function func:
0×08048401 <func+0>: push %ebp
0×08048402 <func+1>: mov %esp,%ebp
0×08048404 <func+3>: sub $0×10,%esp //以上三句构造func函数的栈帧
0×08048407 <func+6>: lea 0xc(%ebp),%eax
0×0804840a <func+9>: mov %eax,0xfffffff4(%ebp)//把传递给此函数的第二个参数的地址(ebp+0xc)保存在地址(epb – 0xc)处,地址ebp-0xc处专门用于保存将要处理的参数的地址
0×0804840d <func+12>: movl $0×0,0xfffffff8(%ebp)//i = 0
0×08048414 <func+19>: jmp 0×804842a <func+41>
0×08048416 <func+21>: mov 0xfffffff4(%ebp),%edx//edx保存此时正在处理的参数的地址
0×08048419 <func+24>: lea 0×4(%edx),%eax //此时eax保存的是传递给本函数的下一个参数的地址,从上面那个图我们可以看出第三个参数存放在第二个参数地址加4的地方
0×0804841c <func+27>: mov %eax,0xfffffff4(%ebp)//更新地址ebp-0xc中的值,使其指向下一个待处理参数
0×0804841f <func+30>: mov %edx,%eax
0×08048421 <func+32>: mov (%eax),%eax//取参数值到eax
0×08048423 <func+34>: mov %eax,0xfffffffc(%ebp)//给变量arg赋值
0×08048426 <func+37>: addl $0×1,0xfffffff8(%ebp) //变量i++
0×0804842a <func+41>: mov 0xfffffff8(%ebp),%eax //把变量本函数局部变量i的值放入eax
0×0804842d <func+44>: cmp 0×8(%ebp),%eax //变量i与传递给本函数的第一个值比较
0×08048430 <func+47>: jl 0×8048416 <func+21> //小于则跳转
0×08048432 <func+49>: mov 0xfffffffc(%ebp),%eax //变量arg的值存入eax作为返回值
0×08048435 <func+52>: leave
0×08048436 <func+53>: ret
End of assembler dump.
(gdb)
图三 0×08048404执行完毕后堆栈如下图所示
------------------ 内存低地址
X-0×18 <--------esp
------------------
X-0×14
------------------
X-0×10
------------------
X-0xc
------------------
ebp <——ebp X-8
------------------
func函数的返回地址 <—–esp X-4 /*call压入*/
------------------
2(printf函数的第一个参数) <—–esp+4 虚拟地址X
------------------
0 (变量i 的值) <—–esp + 8 X+4
------------------
1 (变量j的值) <—–esp + 0xc X+8
------------------
垃圾数据 <—–esp + 0×10 X+0xc
------------------
垃圾数据 <—–esp + 0×14 X+0×10
------------------
垃圾数据 <—–esp+0×18 X+0×14
------------------
0(变量i) <—–esp+0×1c X+0×18
------------------
j(变量i) <—–esp+0×20 X+0×1c
------------------
垃圾数据 <—–esp+0×24 X+0×20
------------------
ecx <—–esp+0×28 X+0×24
------------------
glibc启动例程的ebp <—–esp +0×2c X+0×28
------------------
main函数的返回地址 <—–esp +0×30 X+0×2c
------------------
0×080483a8处指令调整空间
------------------
main函数的返回地址
- -----------------
main函数的参数
glibc堆栈
环境变量与命令行参数等
------------------ 内存高地址
当从func函数返回后堆栈堆栈指针esp就指向上图中的虚拟地址X,可以看出函数在执行返回后堆栈恢复到调用函数之前的状态。
从 以上的分析可以看出,编译器把传递给函数的第一个参数在ebp+8的地方,编译器把第二个参数放在第一个参数相邻的地址中,依次类推。在可变参数函数中, 编译器无法确定函数的参数个数,因此参数个数完全由该函数自己决定,比如在我们的例子中该函数用第一个参数来确定后面还带有几个参数,又如printf利 用%符号来确定参数的个数与类型,但是如果我们传入的参数少于格式化字符串提示的参数个数会发生什么情况呢,比如printf(”[%d][%d][% d][%d]“, 1, 2)这样调用printf函数到底会发生什么情况,我们在下篇文章中来分析。
