Linux pwn入门教程——格式化字符串漏洞

Posted i春秋网络安全学院

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux pwn入门教程——格式化字符串漏洞相关的知识,希望对你有一定的参考价值。

本文作者:[email protected]

原文来自:https://bbs.ichunqiu.com/thread-42943-1-1.html

0×00 printf函数中的漏洞printf函数族是一个在C编程中比较常用的函数族。通常来说,我们会使用printf([格式化字符串],参数)的形式来进行调用,例如

技术分享图片

然而,有时候为了省事也会写成

技术分享图片

事实上,这是一种非常危险的写法。由于printf函数族的设计缺陷,当其第一个参数可被控制时,攻击者将有机会对任意内存地址进行读写操作。

0×01 利用格式化字符串漏洞实现任意地址读首先我们来看一个自己写的简单例子~/format_x86/format_x86

技术分享图片

这是一个代码很简单的程序,为了留后门,我调用system函数写了一个showVersion().剩下的就是一个无线循环的读写,并使用有问题的方式调用了printf().正常来说,我们输入什么都会被原样输出

技术分享图片

但是当我们输入一些特定的字符时输出出现了变化。

技术分享图片

可以看到,当我们输入printf可识别的格式化字符串时,printf会将其作为格式化字符串进行解析并输出。原理很简单,形如printf(“%s”,“Hello world”)的使用形式会把第一个参数%s作为格式化字符串参数进行解析,在这里由于我们直接用printf输出一个变量,当变量也正好是格式化字符串时,自然就会被printf解析。那么后面输出的内容又是什么呢?我们继续做实验。

我们直接在call _printf一行下断点然后以调试方式启动程序,然后输入一大串%x.,输出结果如图

技术分享图片

此时的栈情况如图

技术分享图片

我们很容易发现输出的内容正好是esp-4开始往下的一连串数据。所以理论上我们可以通过叠加%x来获取有限范围内的栈数据。那么我们有可能泄露其他数据吗?我们知道格式化字符串里有%s,用于输出字符。其本质上是读取对应的参数,并作为指针解析,获取到对应地址的字符串输出。我们先输入一个%s观察结果。

技术分享图片

我们看到输出了%s后还接了一个换行,对应的栈和数据如下:

技术分享图片

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

通过刚刚的调试我们可以发现,我们的输入从第六个参数开始(上图从栈顶往下数第六个‘000A7325’ = %s x00)。所以我们可以构造字符串”x01x80x04x08%x.%x.%x.%x.%s“。这里前面的地址是ELF文件加载的地址08048000+1,为什么不是08048000后面再说,有兴趣的可以自己试验一下。

由于字符串里包括了不可写字符,我们没办法直接输入,这回我们用pwntools+IDA附加的方式进行调试。

技术分享图片

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

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

格式化字符串可以使用一种特殊的表示形式来指定处理第n个参数,如输出第五个参数可以写为%4$s,第六个为%5$s,需要输出第n个参数就是%(n-1)$[格式化控制符]。因此我们的payload可以简化为”x01x80x04x08%5$s”

技术分享图片

0×02 使用格式化字符串漏洞任意写虽然我们可以利用格式化字符串漏洞达到任意地址读,但是我们并不能直接通过读取来利用漏洞getshell,我们需要任意地址写。因此我们在本节要介绍格式化字符串的另一个特性——使用printf进行写入。

printf有一个特殊的格式化控制符%n,和其他控制输出格式和内容的格式化字符不同的是,这个格式化字符会将已输出的字符数写入到对应参数的内存中。我们将payload改成“x8cx97x04x08%5$n”,其中0804978c是.bss段的首地址,一个可写地址。执行前该地址中的内容是0

技术分享图片

printf执行完之后该地址中的内容变成了4,查看输出发现输出了四个字符“x8cx97x04x08”,回车没有被计算在内。

 

技术分享图片

技术分享图片

我们再次修改payload为“x8cx97x04x08%2048c%5$n”,成功把0804978c里的内容修改成0×804

技术分享图片

技术分享图片

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

由于我们可以任意地址写,且程序里有system函数,因此我们在这里可以直接选择劫持一个函数的got表项为system的plt表项,从而执行system(“/bin/sh”)。劫持哪一项呢?我们发现在got表中只有四个函数,且printf函数可以单参数调用,参数又正好是我们输入的。因此我们可以劫持printf为system,然后再次通过read读取”/bin/sh”,此时printf(“/bin/sh”)将会变成system(“/bin/sh”)。根据之前的任意地址写实验,我们很容易构造payload如下:

 

printf_got = 0×08049778

system_plt = 0×08048320

payload = p32(printf_got)+”%”+str(system_plt-4)+”c%5$n”           #p32(printf_got)占了4字节,所以system_plt要减去4

 

 

将payload发送过去,可以发现此时got表中的printf项已经被劫持

技术分享图片

此时再次发送”/bin/sh”就可以拿shell了。

但是这里还有一个问题,如果读者真的自己调试了一遍就会发现单步执行时call_printf一行执行时间额外的久,且最后io.interactive()时屏幕上的光标会不停闪烁很长一段时间,输出大量的空字符。使用io.recvall()读取这些字符发现数据量高达128.28MB。这是因为我们的payload中会输出多达134513436个字符

技术分享图片

由于我们所有的试验都是在本机/虚拟机和docker之间进行,所以不会受到网络环境的影响。而在实际的比赛和漏洞利用环境中,一次性传输如此大量的数据可能会导致网络卡顿甚至中断连接。因此,我们必须换一种写exp的方法。

我们知道,在64位下有%lld,%llx等方式来表示四字(qword)长度的数据,而对称地,我们也可以使用%hd, %hhx这样的方式来表示字(word)和字节(byte)长度的数据,对应到%n上就是%hn,%hhn。为了防止修改的地址有误导致程序崩溃,我们仍然需要一次性把got表中的printf项改掉,因此使用%hhn时我们就必须一次修改四个字节。那么我们就得重新构造一下payload

首先我们给payload加上四个要修改的字节

技术分享图片

然后我们来修改第一位。由于x86和x86-64都是小端序,printf_got对应的应该是地址后两位0×20

技术分享图片

这时候我们已经修改了0×08049778处的数据为0×20,接下来我们需要修改0×08049778+2处的数据为0×83。由于我们已经输出了0×20个字节(16个字节的地址+0×20-16个%c),因此我们还需要输出0×83-0×20个字节

技术分享图片

继续修改0×08049778+4,需要修改为0×04,然而我们前面已经输出了0×83个字节,因此我们需要输出到0×04+0×100=0×104字节,截断后变成0×04

技术分享图片

修改0×08049778+6

技术分享图片

最后的payload为[C] 纯文本查看 复制代码

 

‘x78x97x04x08x79x97x04x08x7ax97x04x08x7bx97x04x08%16c%5$hhn%99c%6$hhn%129c%7$hhn%4c%8$hhn’

 

当然,对于格式化字符串payload,pwntools也提供了一个可以直接使用的类Fmtstr,具体文档见http://docs.pwntools.com/en/stable/fmtstr.html,我们较常使用的功能是

技术分享图片

。第一个参数offset是第一个可控的栈偏移(不包含格式化字符串参数),代入我们的例子就是第六个参数,所以是5。第二个字典看名字就可以理解,numbwritten是指printf在格式化字符串之前输出的数据,比如printf(“Hello [var]“),此时在可控变量之前已经输出了”Hello”共计六个字符,应该设置参数值为6。第四个选择用 %hhn(byte), %hn(word)还是%n(dword).在我们的例子里就可以写成

技术分享图片

 

获取本例子shell的脚本见于附件,此处不再赘述。

0×03 64位下的格式化字符串漏洞利用学习完32位下的格式化字符串漏洞利用,我们继续来看现在已经变成主流的64位程序。我们打开例子~/format_x86-64/format_x86-64。

 

技术分享图片

事实上,这个程序和上一节中使用的例子是同一个代码文件,只不过编译成了64位的形式。和上一个例子一样,我们首先看一下可控制的栈地址偏移。

技术分享图片

根据上个例子,我们的输入位于栈顶,所以是第一个参数,偏移应该是0.但是问题来了,栈顶不应该是字符串地址吗?别忘了64位的传参顺序是rdi, rsi, rdx, rcx, r8, r9,接下来才是栈,所以这里的偏移应该是6.我们可以用一串%llx.来证明这一点。

技术分享图片

有了偏移,got表中的printf和plt表中的system也可以直接从程序中获取,我们就可以使用fmtstr_payload来生成payload了。

技术分享图片

然而我们会发现这个payload无法修改got表中的printf项为plt的system

技术分享图片

然而查看内存,发现payload并没有问题

技术分享图片

那么问题出在哪呢?我们看一下printf的输出

技术分享图片

 

可以看到我们第一次输入的payload只剩下空格(x20),x10和`(x60)三个字符。这是为什么呢?

我们回头看看payload,很容易发现紧接在x20x10x60三个字符后面的是x00,而x00正是字符串结束符号,这就是为什么我们在上一节中选择0×08048001而不是0×08048000测试读取。由于64位下用户可见的内存地址高位都带有x00(64位地址共16个16进制数),所以使用之前构造payload的方法显然不可行,因此我们需要调整一下payload,把地址放到payload的最后。

技术分享图片

由于地址中带有x00,所以这回就不能用%hhn分段写了,因此我们的payload构造如下

技术分享图片

这个payload看起来好像没什么问题,不过如果拿去测试,你就会发现用io.recvall()读完输出后程序马上就会崩溃。

 

技术分享图片

这是为什么呢?如果你仔细看右下角的栈,你就会发现构造好的地址错位了。

技术分享图片

因此我们还需要调整一下payload,使地址前面的数据恰好为地址长度的倍数。当然,地址所在offset也得调整。调整后的结果如下:

技术分享图片

这回就可以了。

 

0×04 使用格式化字符串漏洞使程序无限循环从上面的两个例子我们可以发现,之所以能成功利用格式化字符串漏洞getshell,很多时候都是因为程序中存在循环。如果程序中不存在循环呢?之前我们试过使用ROP技术劫持函数返回地址到start,这回我们将使用格式化字符串漏洞做到这一点。

我们打开例子~/MMA CTF 2nd 2016-greeting/greeting

技术分享图片

同样的,这个32位程序的got表中有system(看左边),而且存在一个格式化字符串漏洞。计算偏移值和详细构造payload的步骤此处不再赘述。这个程序主要的问题在于我们需要用printf来触发漏洞,然而我们从代码中可以看到printf执行完之后就不会再调用其他got表中的函数,这就意味着即使成功触发漏洞劫持got表也无法执行system。这时候就需要我们想办法让程序可以再次循环。

之前的文章中我们就提到过,虽然写代码的时候我们以main函数作为程序入口,但是编译成程序的时候入口并不是main函数,而是start代码段。事实上,start代码段还会调用__libc_start_main来做一些初始化工作,最后调用main函数并在main函数结束后做一些处理。其流程见于链接http://dbp-consulting.com/tutorials/debugging/linuxProgramStartup.html

大致如下图

技术分享图片

简单地说,在main函数前会调用.init段代码和.init_array段的函数数组中每一个函数指针。同样的,main函数结束后也会调用.fini段代码和.fini._arrary段的函数数组中的每一个函数指针。

技术分享图片

技术分享图片

 

而我们的目标就是修改.fini_array数组的第一个元素为start。需要注意的是,这个数组的内容在再次从start开始执行后又会被修改,且程序可读取的字节数有限,因此需要一次性修改两个地址并且合理调整payload。可用的脚本同样见于附件。

0×05 一些和格式化字符串漏洞相关的漏洞缓解机制       在checksec脚本的检查项中,我们之前提到过了NX的作用,本节我们介绍一下另外两个和Linux pwn中格式化字符串漏洞常用的利用手段相关的缓解机制RELRO和FORTIFY

技术分享图片

       首先我们介绍一下RELRO,RELRO是重定位表只读(Relocation Read Only)的缩写。重定位表即我们经常提到的ELF文件中的got表和plt表。关于这两个表的来源和作用,我们会在介绍ret2dl-resolve的文章中详细介绍。现在我们首先需要知道的是这两个表,正如其名,是为程序外部的函数和变量(不在程序里定义和实现的函数和变量,比如read。显然你在自己的代码里调用read函数的时候不用自己写一个read函数的实现)的重定位做准备的。由于重定位需要额外的性能开销,出于优化考虑,一般来说程序会使用延迟加载,即外部函数的内存地址是在第一次被调用时(例如read函数,第一次调用即为程序第一次执行call read)被找到并且填进got表里面的。因此,got表必须是可写的。但是got表可写也给格式化字符串漏洞带来了一个非常方便的利用方式,即修改got表。正如前面的文章所述,我们可以通过漏洞修改某个函数的got表项(比如puts)为system函数的地址,这样一来,我们执行call puts实际上调用的却是system,相应的,传入的参数也给了system,从而可以执行system(“/bin/sh”)。可以这么操作的程序使用checksec检查的结果如下图

技术分享图片

其RELRO项为PartialRELRO.

而开头的图中显示的RELRO: Full RELRO意即该程序的重定位表项全部只读,无论是.got还是.got.plt都无法修改。我们找到这个程序(在《stack canary与绕过的思路》的练习题中),在call read上下断点,修改第一个参数buf为got表的地址以尝试修改got表,程序不会报错,但是数据未被修改,read函数返回了一个-1

技术分享图片

技术分享图片

 

显然,当程序开启了Full RELRO保护之后,包括格式化字符串漏洞在内,试图通过漏洞劫持got表的行为都将会被阻止。

接下来我们介绍另一个比较少见的保护措施FORTIFY,这是一个由GCC实现的源码级别的保护机制,其功能是在编译的时候检查源码以避免潜在的缓冲区溢出等错误。简单地说,加了这个保护之后(编译时加上参数-D_FORTIFY_SOURCE=2)一些敏感函数如read, fgets,memcpy, printf等等可能导致漏洞出现的函数都会被替换成__read_chk,__fgets_chk, __memcpy_chk, __printf_chk等。这些带了chk的函数会检查读取/复制的字节长度是否超过缓冲区长度,通过检查诸如%n之类的字符串位置是否位于可能被用户修改的可写地址,避免了格式化字符串跳过某些参数(如直接%7$x)等方式来避免漏洞出现。开启了FORTIFY保护的程序会被checksec检出,此外,在反汇编时直接查看got表也会发现chk函数的存在

大家可以点击链接下载课后习题哦 >>>> https://bbs.ichunqiu.com/thread-42943-1-1.html

以上是关于Linux pwn入门教程——格式化字符串漏洞的主要内容,如果未能解决你的问题,请参考以下文章

pwn-格式化字符串漏洞

❤️超详细PWN新手入门教程❤️《二进制各种漏洞原理实战分析总结》

Linux pwn入门教程——环境配置

Linux下pwn从入门到放弃

CTF入门

PWN格式化字符串2——例子