一次有趣的格式化字符串漏洞利用
前言
这是一道来自于 第六届强网拟态线下赛 有趣的 PWN 题,考察的是格式化字符串漏洞。我自认为格式化字符串的题目已经很熟练了,可在我的印象中格式化字符串漏洞无法一次向一个不存在的指针中写入数据,这道题颠覆了我的认知🥲。保护全开,只有一次 非栈上 格式化字符串漏洞利用的机会,之后触发 _exit 函数退出,唯一的信息是有栈地址末尾的两个字节。这可能是格式化字符串能利用的极限条件?!😶🌫️
题目链接:https://pan.baidu.com/s/1ouBtIRpfLBhzPQyi5116JA?pwd=y6sy
提取码:y6sy
程序保护

这个 canary 是存在的(通过分析 main 函数的起始汇编代码或者 gdb 调试都能判断出来),但因为 _exit 直接从 main 函数退出的干扰,似乎 checksec 并没有检测到这个保护
程序分析
int __cdecl __noreturn main(int argc, const char **argv, const char **envp) |
程序泄露了一个栈地址的末尾两个字节,随后是一个非栈上的格式化字符串漏洞,printf 函数执行完触发 _exit 函数(该函数只执行退出功能,不会清理 IO 流,这意味着无法劫持 exit 函数那样的内部函数指针)
在线下比赛的时候,我分析出应该是要用 printf 函数来劫持它自己的返回地址,除此之外没有任何一个劫持指针的机会了(可以搜一下 2023年金盾杯线上赛 ,这个比赛的一个 PWN 题和本题有一些像,也是一次非栈上的格式化字符串漏洞的机会然后 exit ,但那个是走的 libc 正常的 exit 函数,里面有位于栈里的 ld 指针可以劫持 )。printf 函数劫持自身返回地址的一个条件就是已知栈地址,如果再用一个栈里的残留栈地址来打的话,已知最后两个字节也够了。
可在我利用格式化字符串漏洞的印象中,是无法向一个不存在的指针中写入数据的。
假设现在有栈指针 A=>B=>C=>D ,我可以用格式化字符通过 B 为跳板修改 C 为 E,那么修改后的链为 A=>B=>E=>F,如果有第二次格式化字符串漏洞的话,我可以找到链 B=>E=>F 通过 E 为跳板,修改原本的 F 为 G。但这个操作无法用一次的格式化字符串漏洞完成,可能因为 B=>E=>F 这条链本身是不存在的,即使用格式化字符串漏洞做出了 B=>E=>F 这条链也无法同时再去改这条链上的指针😅
一次格式化字符串去编辑一个不存在的指针
赛后看到其他师傅的 payload ,我写了一个类似的
payload=b'%p'*9 |
这个 payload 可以泄露出各种地址,并且一次就能劫持掉 printf 函数自身的返回地址
payload 起始的 9 个 %p 好理解,后面 %xxxc%hn ,这个语法缺了 $ 字符。$ 代表可以指定参数,假如 9$ 那么表示参数列表中第九个参数。通常用这一个特性去进行栈中任意指针地址写,但如果不加 $ ,写入数据的参数位置就是按顺序来的,因为 printf 解析参数会根据 % 进行判断,在 hn 前面一共有 11 个 % ,所以这个 %xxxc%hn 会将 xxx 数据加上 %p 泄露的字符个数写入第十一个参数(printf 函数利用格式化字符串减去的应该是后五个寄存器,rdi 本身是一个字符串地址,% 占位符解析的是从 rsi 开始的,五个寄存器加上六个栈内存单元)
上面的 printf 函数执行后,将原本 0x7ffcd65f8988 指向的 0x7ffcd65fa31e 改成了 0x7ffcd65f8878 ,这个 0x7ffcd65f8878 指向的位置就是 printf 函数的返回地址(因为下图的 printf 函数已经执行完毕,所以看的不直观,但观察此时的 rsp 也能推测出 printf 函数的返回地址应该是位于 0x7ffcd65f8878)

再用一个 payload 通过 0x7ffcd65f8878 修改掉 printf 函数的返回地址,这里有一个小细节。因为正常的返回地址是 0x55be41f00250 ,假设我想劫持到 0x55be41f00223 ,只需要修改一个字节即可,但格式化字符串任意地址写的时候,后面写入的字符量一定要比前面的大。假设有 %30c%16$hhn%40c%26$hhn ,这样写到栈顶偏移 20 的位置,写入的值是 70 (30+40 ,并非是 40)。
上面的 payload 为 %p%p%p%p%xxxc%hn 一定是远大于一个字节所能表示的范围,因此后面就要用截断的方式写进去。比如我想写一个字节 0x23 ,就应该写 0x100023-xxx 后面用 %hhn 来截断掉前面的值,就留最后一个字节 0x23 (xxx 为 payload 前面部分的字节数)
明白了上面的分析,将两部分的 payload 结合起来就是这样(后面补全 0x100 个字节是确保 read 不会把我两次发送的 payload 当成一次给读入了 )
payload=b'%p'*9 |
执行过 __vfprintf_internal 函数后,printf 的返回地址已经被改成了 0x561e4dcc7223 ,这里回到了 read 函数之前

随后我重现了比赛时的操作,首先修改栈顶偏移 5 的栈链,将其指向 printf 函数的返回地址。再修改栈顶偏移 0x21 的指针(理论上此时已经指向 printf 函数的返回地址),从而控制 printf 返回地址

payload 如下,在不考虑泄露地址的情况下,这个看起来应该和上文提到的 payload 等效
payload=b'%'+str((value-0xc)).encode()+b'c%11$hn' |
但事实上在 __vfprintf_internal 函数执行后,仅仅是修改了第一部分的指针,确实做出了一个指向 printf 返回地址的指针,但第二部分通过刚刚做出的指针并没有成功修改掉 printf 函数的返回地址
我不能确定是否和 $ 指定参数有关。猜测:任意地址写用 $ 指定写入和按参数顺序写入的操作是先后分开的,先按参数顺序写入指针后,再用 $ 去在刚刚的指针基础上进行修改。注意:这仅仅是个猜测,真相应该去源码中找到答案 (以后有机会的话,我应该会去分析 printf 函数的源码,来探究出这个答案,但可惜不是现在🤔)
后续利用
因为我本身对格式化字符串漏洞的利用较为熟悉,当知道上面的方法可以一次就劫持到 printf 函数返回地址,那么后续的思路就明朗了。首先把 libc 地址泄露出来,然后通过栈链去修改 printf 函数返回地址下面一个地址为 one_gadget (先调试一下,发现第二个 one_gadget 能用),布局好 one_gadget 后,最后一次修改 printf 函数返回地址为 ret 指令的地址,这样通过 ret 将 one_gadget 执行。
值得一提的是,程序中必须要存在两条栈链才能够利用成功。如下图所示,有两条三级栈指针。一条用来每次 printf 函数结束后劫持自身的返回地址,一条用来将 one_gadget 或 system 等地址写入到栈内存中(我最开始用 ubuntu18.04 做的,程序中只有一条栈链,本题如果只有一条栈链的话也是没办法做的)

EXP
from tools import * |

尾声
格式化字符串漏洞的题目做过很多了,从最初的多次栈上格式化字符串的任意泄露,再到多次任意写,再到劫持 fini_array exit_hook 等等的小 trick ,又发展到多次非栈上的格式化字符串漏洞利用,还能再上升到关闭输出流后的多次非栈上格式化字符串利用,最后上升到一次非栈上格式化字符串漏洞加 exit 直接退出的利用。这个过程似乎一直都在挑战有限条件内格式化字符串漏洞利用的极限,此时似乎我又对 PWN 产生了一些新的理解,从最开始对基础漏洞的好奇,到现在逐渐演变成了对漏洞利用极限的好奇,各路的 PWN 师傅们可能也都在探究各种漏洞所利用的极限。
唔,事情好像变的越来越有趣了🤔。不过目前略微遗憾的是没有太充裕的时间去详细分析 printf 函数源码来搞清这一切,暂时先鸽了🧐