2016Fall虽然只上了两门课,但是课程负担还是蛮大的,所以18600有一个仅当练习不计分数的Attack Lab一直没有时间做。要成为一名合格的白帽,技能树还是得点地全面一些。对于Attack Lab里的两种攻击方式,虽然为了准备Final已经了解了大概,但是纸上得来终觉浅,还是要亲自实战一番。
缓冲区溢出(Buffer Overflow)是攻击的核心和原理,其本质就是利用臭名昭著的不检查输入字符串长度的gets函数来造成缓冲区溢出,用攻击者精心设计的代码覆盖掉原有内容,从而完成各种形式的攻击。
Virtual Address Space
操作系统的核心抽象之一就是Virtual Address Space(虚拟地址空间)。一般来说,一台计算机只有一个物理内存,那么如何将这一个有限的物理内存资源分配给操作系统中的所有进程呢?答案就是给每一个进程设计一个虚拟地址空间。

这张来自wikipedia的图比较清楚地展示了进程的虚拟地址空间到物理内存地址空间的映射关系。每个在虚拟内存空间的页块(Virtual Page)会通过一定的机制(内存管理单元MMU或多级页表)转换为物理内存中的物理页块(Physical Page),原有的顺序会被打乱。这种设计最大的好处就是将进程空间看成是一个连续的整体,从而大大简化了程序的编写和运行。这张图只显示了进程指令、数据和栈的部分,实际上整个虚拟地址空间从地址0开始依次为:text, data, run time heap, shared libraries, stack, kernerl。其中,heap就是传说中用于在runtime动态分配内存的堆,向地址高处增长;stack就是每次函数调用所依赖的栈,向地址低处增长。
Function Call On Stack
程序调用的相关操作(如存储函数参数,返回地址,本地变量等)都在栈上完成。

还是用wikipedia上的图来解释。蓝色部分的函数是DrawSquare,它调用了绿色部分的函数DrawLine。当DrawSquare内部调用DrawLine前,调用的实际参数会被首先压入栈顶。在汇编语言的CALL DrawLine执行完成后,该指令的下一条指令的地址会被当作DrawLine的返回地址压在参数上方。当DrawLine定义本地变量时,它们也会继续压栈。寄存器%rsp始终维护着栈顶地址。试想一下,如果我们可以利用某些办法更改函数的返回地址,那么就可以篡改原有的执行流程,达到我们的攻击目的。
Code Injection
如果说缓冲区溢出是攻击的基本原理,那么代码注入(Code Injection)就是利用缓冲区溢出来达到执行自定义指令的目的。首先,将事先设计好的字符串作为参数调用gets函数,若字符串不断在栈上堆积以至于覆盖掉栈上原有的内容。如将原本函数的返回地址覆盖为自定义指令的地址,则在函数返回时接下来执行的就不是原来的下一条指令,而是攻击者指定的指令。
代码注入部分一共有三个关卡,攻击目标都是一个叫做ctarget的可执行程序。
Phase1
首先我们来看第一关。第一关是最简单的,并不要求我们注入代码,而仅仅要求利用缓冲区溢出完成篡改返回地址而执行程序中的touch1函数。
|
|
先来看一下ctarget中的缓冲区输入部分的代码,对ctarget进行反汇编。
|
|
找到getbuf函数。

给缓冲区预留的空间是0x38,也就是56个字节。我们的目标就是通过写入缓冲区并覆盖掉原来getbuf的返回地址,将其写为touch1的入口地址,从而完成控制的转移。
同样在ctarget.txt中找到touch1函数。

入口地址为0x004017c0。因此,输入的内容包含任意56字节内容(这里设为00)加上8个字节的入口地址。
|
|
注意字节的先后顺序,由于实验机器是little-endian,需要将数量级越小的位数存储在越低的地址上,如0x004017c0从低地址到高地址存储为c0 17 40 00。另外,并不能将这些十六进制字节直接作为输入,需要用Lab提供的hex2raw程序将这些字节用ASCII码转化为字符表示,因为gets读入的是字符串,它将字符的ASCII码存储起来,因此只有以字符的形式输入最终才能以上面的形式保存在栈中。
|
|
这样一来在getbuf函数返回时就跳转到了0x004017c0,成功调用了touch1函数。

Phase2
第二关要求执行并成功验证touch2函数,并在ctarget.txt中找到touch2函数,地址为0x004017ec。
|
|

为了完成攻击,需要令传入的无符号整型参数等于cookie。

因此,这次需要自己编写一段指令来传入值为cookie的参数(x86-64汇编语言中%rdi存储第一个参数),并跳转到touch2的地址。
|
|
首先将cookie的十六进制表示存入%rdi中,再将touch2的地址压栈,RETQ则会把touch2的地址当作返回地址。PUSHQ和RETQ的组合能够跳转到自定义的地址。需要注意的是,不能直接使用CALL或JMP加上目的地址来跳转,因为这些指令的目的地址经过了复杂的编码过程,无法简单地直接构造。之后,将汇编指令转化为机器码构造攻击字符串。
|
|

可以利用缓冲区来存放这些指令,因此需要知道缓冲区的起始地址。利用gdb重跑一遍phase1,可以找到缓冲区的栈顶地址为0x55631248,可以将指令依次从这里往高地址存储。

将指令从0x55631248依次向上存储,直到填满56个字节的缓冲区,继续溢出将0x55631248覆盖掉原有的返回地址,就能够跳转并执行我们编写的指令了。因此,构造如下字节,转化为字符串完成攻击。
|
|
写入攻击字符串后,getbuf函数返回后跳转到了0x55631248处执行指令,传入cookie参数,将touch2的地址压栈,返回后跳转到touch2,成功验证。

Phase3
第三关要求执行并成功验证touch3函数。成功的关键在于hexmatch返回1,hexmatch函数做的事情主要是比较传入的sval字符串和cookie是否相同。注意第二关传入的是无符号整型直接进行比较,而这里传入的是存放cookie字符串的首地址,需要找到缓冲区的某个地方将cookie存入。
|
|
因此,攻击的思路和第二关类似,自己编写指令写入缓冲区,覆盖返回地址指向自定义的指令。不是将cookie的值直接传入%rdi,而是将cookie放置在缓冲区中的地址传入%rdi。直接修改下面第二关的字节。
|
|
下面是修改后的字节。
|
|
由于hexmatch比较的是字符串,需要将cookie(0x2486651c)的每个字符转化为对应的十六进制ASCII码,为32 34 38 36 36 35 31 63。尝试着把它紧跟在指令后面,由于缓冲区栈顶是0x55631248,可以计算出cookie字符串的起始地址是0x55631255,将这个地址替换掉原来的0x2486651c。另外,将touch2的地址0x004017ec替换为0x004018c0。
需要注意的是hexmatch函数会把许多寄存器的值压入栈中,可能有污染我们写入的字节的风险,实际测试一下可以发现,hexmatch污染到的最低地址为0x55631260。而幸运的是,cookie字符串的最高地址只有0x5563125d,躲过一劫,如果把cookie存高一点就会被污染了。

因此,写入攻击字符串后,getbuf函数返回后跳转到了0x55631248处执行指令,将cookie字符串地址0x55631255传入参数,将touch3的地址0x004018c0压栈,返回后跳转到touch3,成功验证。

Return-Oriented Programming
为了防止代码注入,主要有以下几种方法:
- 随机化栈地址,令攻击者无法确定注入代码的位置。
- 设定栈中某些区域为“canary”,一旦代码注入攻击发生且修改了这些canary,系统就会及时发现。
- 令栈不可执行,若强行更改返回地址并企图执行自定义指令,则系统会抛出Segmentation Fault异常。
因此,与其注入新的代码,倒不如利用现成的代码,这种攻击技术成为Return-Oriented Programming(面向返回编程?),简称ROP。现成的以RET(十六进制编码为0xc3)结尾的若干条指令称为一个gadget。

利用缓冲区溢出在栈上写入n个gadget的地址,首先控制跳转到第1个gadget的地址并开始执行,当遇到c3便返回,这时第2个gadget的地址作为返回地址,跳转后开始执行,以此类推。总的来说,ROP就是将散落在现存代码各处的许多包含c3的代码片段组织起来,以完成一系列连续的指令。
ROP部分一共有两个关卡,攻击目标都是一个叫做rtarget的可执行程序,且gadget的函数名都在farm.c中可供查询。
Phase4
第四关的要求和第二关一样,执行并成功验证touch2。但是如果尝试用代码注入的方法,会发现出现了Segmentation Fault,原因在于栈采用了随机化和不可执行的防御措施。因此,这里只能使用ROP的方式进行攻击,题目建议使用2个gadget。首先对rtarget进行反汇编。
|
|
先尝试构造两条汇编语句完成参数的传入,其中cookie的值和touch2的地址可以和gadget的地址组合起来作为rtarget的输入字节。
|
|
经过查表,POPQ %rax的指令编码为58,还需要加上RETQ的c3。在rtarget.txt中搜索到addval_245函数中有58 90 c3这样的片段,其中90是nop指令的编码,夹在中间没有影响。这个gadget的入口地址为0x00401973。

同理,MOVQ %rax,%rdi的指令编码为48 89 c7 c3,在setval_441中可以找到,入口地址为0x00401957。

因此,可以构造如下的栈结构。
|
|
转化为字节,注意指令从0x55631280的返回地址开始覆盖以上栈结构。
|
|
当getbuf返回时,控制跳转到第一个gadget即POPQ函数处开始执行,随后cookie的值被POP进%rax中,遇到c3返回后又跳转到第二个gadget即MOVQ函数处开始执行,%rax的值被赋给了%rdi,又遇到c3返回后跳转到touch2函数并成功验证。

Phase5
第五关的要求和第三关一样,执行并成功验证touch3。题目建议可以使用8个gadget完成攻击。
成功验证touch3需要传入存储cookie的首地址而不是cookie的值,但是栈的地址是随机产生的,就需要每次通过%rsp加上offset的方法来获得cookie的首地址。8个gadget的数据流比较复杂,因为没有直接定位cookie并赋值给%rdi的gadget,需要一步一步来构造。找gadget的流程和第四关类似,这里直接给出栈结构。
|
|
转化为如下字节。
|
|
当getbuf返回时,当时的%rsp赋值给了%rdi,offset赋值给了%rsi,(%rsp+%rsi)就是cookie字符串的首地址,传入%rdi即可成功验证touch3。

Conclusion
不论是代码注入,还是ROP,都是通过缓冲区溢出覆盖了原来getbuf的返回地址而完成攻击。要彻底修补该漏洞,只需要将gets函数替换为fgets函数,它能够规定每次读入的字符串长度,从而杜绝了溢出的可能。