CTF必备技能丨Linux Pwn入门教程——格式化字符串漏洞

Linux Pwn入门教程系列分享如约而至,本套课程是作者依据i春秋Pwn入门课程中的技术分类,并结合近几年赛事中出现的题目和文章整理出一份相对完整的Linux Pwn教程。

教程仅针对i386/amd64下的Linux Pwn常见的Pwn手法,如栈,堆,整数溢出,格式化字符串,条件竞争等进行介绍,所有环境都会封装在Docker镜像当中,并提供调试用的教学程序,来自历年赛事的原题和带有注释的python脚本。

课程回顾>>

Linux Pwn入门教程第一章:环境配置

Linux Pwn入门教程第二章:栈溢出基础

Linux Pwn入门教程第三章:ShellCode

Linux Pwn入门教程第四章:ROP技术(上)

Linux Pwn入门教程第四章:ROP技术(下)

Linux Pwn入门教程第五章:调整栈帧的技巧

Linux Pwn入门教程第六章:利用漏洞获取libc

今天i春秋与大家分享的是Linux Pwn入门教程第七章:格式化字符串漏洞,阅读用时约15分钟。

 

printf函数中的漏洞

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

char s[20] = “Hello world!\n”;
printf(“%s”, s);

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

char s[20] = “Hello world!\n”;
printf(s);

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

利用格式化字符串漏洞实现任意地址读

首先我们来看一个自己写的简单例子~/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\n\x00)。所以我们可以构造字符串“\x01\x80\x04\x08%x.%x.%x.%x.%s”。这里前面的地址是ELF文件加载的地址08048000+1,为什么不是08048000后面再说,有兴趣的可以自己试验一下。

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

 

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

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

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

 

 

使用格式化字符串漏洞任意写

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

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

 

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

 

我们再次修改payload为“\x8c\x97\x04\x08%2048c%5$n”,成功把0804978c里的内容修改成0x804。

 

现在我们已经验证了任意地址读写,接下来可以构造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 = 0x08049778
system_plt = 0x08048320
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加上四个要修改的字节。

printf_got = 0x08049778
system_plt = 0x08048320
payload = p32(printf_got)
payload += p32(printf_got+1)
payload += p32(printf_got+2)
payload += p32(printf_got+3)

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

payload += “%”
payload += str(0x20-16)
payload += “c%5$hhn”

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

payload += “%”
payload += str(0x83-0x20)
payload += “c%6$hhn”

继续修改0x08049778+4,需要修改为0x04,然而我们前面已经输出了0x83个字节,因此我们需要输出到0x04+0x100=0x104字节,截断后变成0x04。

payload += “%”
payload += str(0x104-0x83)
payload += “c%7$hhn”

修改0x08049778+6

payload += “%”
payload += str(0x08-0x04)
payload += “c%8$hhn”

最后的payload为:

'\x78\x97\x04\x08\x79\x97\x04\x08\x7a\x97\x04\x08\x7b\x97\x04\x08%16c%5$hhn%99c%6$hhn%129c%7$hhn%4c%8$hhn'

当然,对于格式化字符串payload,pwntools也提供了一个可以直接使用的类Fmtstr,具体文档见http://docs.pwntools.com/en/stable/fmtstr.html ,我们较常使用的功能是fmtstr_payload(offset, {address:data}, numbwritten=0, write_size=’byte’)。

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

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

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

 

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

offset = 6
printf_got = 0x00601020
system_plt = 0x00400460
payload = “%” + str(system_plt) + “c%6$lln” + p64(printf_got)

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

 

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

 

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

offset = 8
printf_got = 0x00601020
system_plt = 0x00400460
payload = “a%” + str(system_plt-1) + “c%6$lln” + p64(printf_got)

这回就可以了。

使用格式化字符串漏洞使程序无限循环

从上面的两个例子我们可以发现,之所以能成功利用格式化字符串漏洞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,可用的脚本同样见于附件。

一些和格式化字符串漏洞相关的漏洞缓解机制

在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项为Partial RELRO。

而开头的图中显示的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函数的存在。

 

以上是今天的内容,大家看懂了吗?后面我们将持续更新Linux Pwn入门教程的相关章节,希望大家及时关注。

posted @ 2019-07-31 11:01  i春秋学院  阅读(653)  评论(1编辑  收藏