基本格式化字符串漏洞原理

理解这个漏洞的原理,你需要有汇编层面的函数调用和函数的参数传递知识。如果你不清楚函数的参数是如何传递的,可以看《加密与解密》的逆向分析技术篇。

再说格式化字符串漏洞之前,先了解一下printf函数和利用该漏洞的重要 格式化字符串%n,利用他可以做到几乎任意内存写入。

函数原型

int printf (“格式化字符串”,参量… )
函数的返回值是正确输出的字符的个数,如果输出失败,返回负值。
参量表中参数的个数是不定的(如何实现参数的个数不定,可以参考《程序员的自我修养》这本书),可以是一个,可以是两个,三个…..,也可以没有参数
printf函数的格式化字符串常见的有 %d,%f,%c,%s,%x(输出16进制数,前面没有0x),%p(输出16进制数,前面带有0x)等等。
但是有个不常见的格式化字符串 %n ,它的功能是将%n之前打印出来的字符个数,赋值给一个变量。

除了%n,还有%hn,%hhn,%lln,分别为写入目标空间2字节,1字节,8字节。 注意是指针指向的地方开始起几个字节。不要觉得%lln,取的是8个字节的指针,%n取的就是4个字节的指针,取的是多少字节的指针只跟 程序的位数有关,如果是32位的程序,%n取的就是4字节指针,64位取的就是8字节指针,这是因为不同位数的程序,地址的长度是不同的。

具体实例:

%n之前打印了5个a,所以n的值变成了5。

了解了这些后就可以说下格式化字符串漏洞了。

漏洞成因和基本原理

正确使用printf是这样的:

1
2
3
4
5
6
7
8
#include <bits/stdc++.h>
using namespace std;
int main()
{
int n=5;
printf("%d",n);
return 0;
}

但也有人会懒省事,写成这样:

1
2
3
4
5
6
7
8
#include <bits/stdc++.h>
using namespace std;
int main()
{
char a[]="neuqcsa";
printf(a);
return 0;
}

实参与函数形参的结合顺序是从左往右依次进行的,所以上面的代码也能输出:

上面的代码不会有什么问题,但是如果将字符串的输入权交给用户就会有问题了。看下面的代码:

1
2
3
4
5
6
7
8
9
#include <bits/stdc++.h>
using namespace std;
int main()
{
char a[100];
scanf("%s",a);
printf(a);
return 0;
}

如果用户输入的字符串是”%x%x%x”,则会输出以下结果

输出的结果是 内存中的数据。

看一下调用printf函数后的堆栈图:(cdecl调用方式,参数从右往左依次入栈)

在OD中可以清晰的看到:

这是因为printf函数并不知道参数个数,它的内部有个指针,用来索检格式化字符串。对于特定类型%,就去取相应参数的值,直到索检到格式化字符串结束。

所以尽管没有参数,上面的代码也会将format string 后面的内存当做参数以16进制输出。这样就会造成内存泄露。

任意内存的读取及任意内存写入:

任意的内存的读取需要用到格式化字符串 %s,其对应的参量是一个指向字符串首地址的指针,作用是输出这个字符串。

在说任意内存的读取之前要知道 局部变量是存储在栈中,这点很关键。所以一定可以找到我们所输入的格式化字符串。
例:

1
2
3
4
5
6
7
8
9
10
#include<stdio.h>
#include<stdlib.h>
int main()
{
char a[100];
scanf("%s",a);
printf(a);
system("pause");
return 0;
}

可以得到以下结果

看下堆栈图:
这是调用scanf函数前的堆栈图。

输入字符串后的堆栈图:

调用printf函数的过程:

mov eax,数组首地址
push eax  
call printf

该过程只是将数组的首地址入栈,此时堆栈图如下。

所以在格式化字符串里用很多的%x 就一定可以找到这个AAAA的位置。我们将这个位置记下来,实例中就是第七个%x的位置,即第7个参数。

这里说下可以直接读取第七个参数的方法。(在linux下有用,win下没用)
%< number>$x 是直接读取第number个位置的参数,同样可以用在%n,%d等等。
但是需要注意64位程序,前6个参数是存在寄存器中的,从第7个参数开始才会出现在栈中,所以栈中从格式化串开始的第一个,应该是%7 $n.

图中是第六个参数是41414141。

同样可以得到41414141。这样就方便的多了。

读取内存

有了上面内容的铺垫就可以学任意读取了:
看下面的代码:

从命令行输入字符串后,将该字符串复制到a内,再直接打印a;
输入的字符串的前4个字节如果是一个有效的字符串的首地址,就可以用%s将其打印出来,做到任意内存读取。如果不是有效的字符串,会出现段错误。

如何写入地址,需要用到linux自带的printf命令,将shellcode编码转义为字符。(注意用反引号将printf命令括住,反引号在Tab键的上面,反引号内的内容会被当做命令执行。)
如果是用scanf输入字符串,则无法使用printf命令,只能对照ascii码表,scanf和命令行输入的shellcode编码不能直接被转义。(所以为了方便演示,后面都使用了命令行输入参数)
写入地址实例:

0x41414141这个地址已经成功写入内存,下面只需用%s读取对应位置,就能读取以0x41414141为首地址的字符串。
如果用%n就能将0x41414141这个地址指向的值修改,就能造成任意内存的修改,可以将栈中返回地址修改为想要执行的shellcode的首地址等等。

修改内存

下面写个修改静态变量的例子
例:

测试前,请先关闭内存地址随机化(PIE),否者b在内存中的地址是不确定的。
先运行下,得到b的地址

接着确定偏移量

这里是第九个参数。
接着用shellcode编码将b的地址写入,并查看能否写入成功。

用%n修改其值。

因为%n之前打印了75个字符,所以这里将b的值从0修改为75
你也可以通过%< number >$n 来直接修改第九个参数来修改b的值。注意在命令行输入字符串参数时,要用 “ \ “将 $ 转义,例如:

在%n之前打印了4个字符,所以b的值直接被修改为4了。
你可以通过控制打印的字符个数来修改b的值,达到几乎任意修改。
例如%0xxxxxd,通过打印数字前面补0,进行简化。