深入理解计算机系统(CS:APP) - Attack Lab详解
Attack Lab
实验代码见GitHub
简介
Attack Lab
的内容针对的是CS-APP
中第三章中关于程序安全性描述中的栈溢出攻击。在这个Lab
中,我们需要针对不同的目的编写攻击字符串来填充一个有漏洞的程序的栈来达到执行攻击代码的目的,攻击方式分为代码注入攻击与返回导向编程攻击。本实验也是对旧版本中IA32
编写的Buffer Lab
的替代。
我们可以从CMU
的lab
主页来获取自学者版本与实验讲义(Writeup),讲义中包含了必要的提示、建议与被禁止的操作,从这个lab
开始之后的lab
对讲义中内容的依赖还是很强的。
特别提示 本
lab
的自学者版本需要在运行程序时加上-q
参数来避免程序向不存在的评分服务器提交我们的答案导致报错
前置
讲义中首先给我们展示了导致程序漏洞的关键:getbuf
函数。
1 | unsigned getbuf() |
getbuf
函数在栈中申请了一块BUFFER_SIZE
大小的空间,然后利用这块空间首地址作为Gets
函数的参数来从标准输入流中读取字符。由于没有对读入字符数量的检查,我们可以通过提供一个超过BUFFER_SIZE
的字符串来向getbuf
的栈帧之外写入数据。
在代码注入攻击中就是利用函数返回时RET
指令会将调用方在栈中存放的返回地址读入IP
中,执行该地址指向的代码。栈溢出后,我们可以改写这个返回地址,指向我们同样存放在栈中的指令,以达到攻击的目的。
第一部分:代码注入攻击
Level1
在这个等级中,我们不需要注入任何攻击代码,只需要更改getbuf
函数的返回地址执行指定的函数touch1
(该函数已经存在于程序中)。
那么我们需要做的就是将栈中存放返回地址的位置改为touch1
函数的入口地址,问题在于我们如何将地址精确地写入到原来的地址的位置。
讲义给出了getbuf
的调用函数:
1 | void test() |
如果攻击成功,我们不会执行到第五行,而是跳转到touch1
中执行:
1 | void touch1() |
输出上面的字符串代表我们攻击成功。
下面我们利用objdump -d
命令将程序反汇编来查看getbuf
函数的行为。
1 | 00000000004017a8 <getbuf>: |
代码比较简单,在第2行中将rsp
减了0x28,申请了一块28字节的空间,第3行将rsp
赋给rdi
就是空间的首地址,然后调用了Gets
函数,rdi
就是它的参数。到这里我们可以确定BUFFER_SIZE
的大小为0x28(自学讲义中这个值是固定的,但是真正的实验中这个值是由服务器生成的)。换句话说,在0x28字节的栈被Gets
函数写满之后,多出来的字符会被写入getbuf
函数的栈外。我们用图来说明栈的结构:
下面是低地址,上面是高地址,在getbuf
函数申请的0x28字节内存之外的8个字节存放的就是test
函数call
指令后下一条指令的地址。
现在我们可以知道,我们需要用0x28字节来将栈填满,再写入touch1
函数的入口地址,在getbuf
函数执行到ret
指令的时候就会返回到touch1
中执行。
下面就要利用官方提供的hex2raw
程序来帮助我们生成攻击字符串,这个程序将以空白字符隔开表示的字节转换成真正的二进制字节,注意这个程序只是原样地转换文件中的字符,所以字节序的问题是我们应该考虑的。
最终的答案如下:
1 | 00 00 00 00 00 00 00 00 |
可以看到前0x28个字节都使用0x00来填充,然后在溢出的8个字节中写入了touch1
的首地址0x4017c0
,注意字节序就可以了。
Level 2
这个等级中我们同样需要跳转到指定的函数touch2
中,但是想要通过touch2
需要我们进行一些操作,讲义中给出了touch2
的代码:
1 | void touch2(unsigned val) { |
这里cookie
是服务器给我们的一个数值,存放在cookie.txt
文件中,自学者材料中的这个值应该都是一样的。
可以看到touch2
拥有一个参数,只有这个参数与cookie
的值相等才可以通过这一等级。所以我们的目标就是让程序去执行我们的代码,设置这个参数的值,再调用touch2
完成攻击。
首先要注意的是touch2
的第一个参数存放在寄存器rdi
中,我们就是要设置这个寄存器的值为cookie
。
那么如何让程序去执行我们的代码呢?既然我们可以向栈中写入任意内容并且可以设置返回时跳转到的地址,那么我们就可以通过在栈中写入指令,再令从getbuf
函数返回的地址为我们栈中指令的首地址,在指令中执行ret
进行第二次返回,返回到touch2
函数,就可以实现我们的目的。
所以我决定将指令写入到栈地址的最低处,然后在溢出后将地址设置为这个栈地址。我们能完成这个攻击的前提是讲义中已经告诉我们这个具有漏洞的程序在运行时的栈地址是固定的,不会因运行多次而改变,并且这个程序允许执行栈中的代码。
我们利用gdb
在运行时查看栈地址:
停在getbuf
的这里,然后查看rsp
指向的地址:
可以看到首地址为0x5561dc78
,顺便看到第6行也就是0x28个字节之后存放的原返回地址。
由于我们需要在注入的代码中再次返回,就需要将二次返回的地址同样存放在栈中,这里为了避免与我们注入的代码重叠,我选择将touch2
地址放在getbuf
函数栈的最后8字节中。
下面就要生成攻击字符串了,首先我们需要生成攻击代码。我们先将攻击代码用汇编指令的形式写出来:
1 | movq $0x59b997fa,%rdi # rdi = cookie |
下面利用gcc -c
命令将汇编语句编译成机器码,再objdump -d
生成的文件就可以间接地看到最终的机器码。
将指令的机器码作为我们攻击字符串的开头,touch2
的地址放在栈中第0x20-0x28位置,将栈的首地址放在栈外的8个字节,构成我们的攻击字符串:
1 | 48 c7 c7 fa 97 b9 59 48 |
Level 3
该等级同样让我们跳转到touch3
函数中,不过touch3
函数判断有所不同:
1 | /* Compare string to hex represention of unsigned value */ |
仔细阅读上面的代码,我们需要传入touch3
的参数是一个字符串的首地址,这个地址指向的字符串需要与cookie
的字符串表示相同。这里cookie
的字符串表示是cookie
:0x59b997fa
的ASCII
表示的字符串:35 39 62 39 39 37 66 61 00
。
所以我们需要做的是将这串字符串放入栈中,并且将rdi
的值置为字符串的首地址,再进行与上步类似的二次返回操作。
这里我们需要好好考虑目标字符串在栈中的位置,下面是最终结果中的栈结构,先放出来便于讲解。
如果目标字符串存放的位置比touch3
存放地址更低,在最终字符串对比的时候会发现rdi
指向地址的内容发生了改变。分析原因,我们可以查看从getbuf
返回到字符串比对过程中执行的指令:
1 | 00000000004018fa <touch3>: |
1 | 000000000040184c <hexmatch>: |
上面列出的这部分指令都会向栈中压入新的内容,由于栈向下增长,而rsp
一开始的位置在touch3
地址的下一个位置,压入的新内容会覆盖touch3
地址以下的内容,如果把目标字符串放在这部分会导致内容在比较之前就被覆盖。
知道栈中应该存放的内容的结构,攻击字符串的编写就不再困难了:
1 | 48 c7 c7 90 dc 61 55 48 # mov $0x5561dc90,%rdi mov $0x5561dc88,%rsp ret 为寄存器赋值并返回 |
第二部分:返回导向编程攻击
我们在第二部分中需要解决的同样是第一部分的后两个问题,只不过我们要采取不同的方式来进行攻击。
为什么我们之前采取的代码注入的攻击手段无法在这个程序中起作用呢?这是国因为这个程序对代码注入攻击采取了两种防护方式:
- 栈随机化,使得程序每次运行时栈的地址都不相同,我们无法得知我们注入的攻击代码的地址,也无法在攻击代码中硬编码栈中的地址。
- 标记内存中的栈段为不可执行,这意味着注入在栈中的代码无法被程序执行。
尽管这两种手段有效地避免了代码注入攻击,但是我们仍然可以找到方式让程序执行我们想要去执行的指令。
攻击方式
现在我们无法使用栈来存放代码,但是我们仍可以设置栈中的内容。不能注入代码去执行,我们还可以利用程序中原有的代码,利用ret
指令跳转的特性,去执行程序中已经存在的指令。具体的方式如下:
我们可以在程序的汇编代码中找到这样的代码:
1 | 0000000000400f15 <setval_210>: |
这段代码的本意是
1 | void setval_210(unsigned *p) |
这样一个函数,但是通过观察我们可以发现,汇编代码的最后部分:48 89 c7 c3
又可以代表
1 | movq %rax, %rdi |
这两条指令(指令的编码可以见讲义中的附录)。
第1行的movq
指令可以作为攻击代码的一部分来使用,那么我们怎么去执行这个代码呢?我们知道这个函数的入口地址是0x400f15
,这个地址也是这条指令的地址。我们可以通过计算得出48 89 c7 c3
这条指令的首地址是0x400f18
,我们只要把这个地址存放在栈中,在执行ret
指令的时候就会跳转到这个地址,执行48 89 c7 c3
编码的指令。同时,我们可以注意到这个指令的最后是c3
编码的是ret
指令,利用这一点,我们就可以把多个这样的指令地址依次放在栈中,每次ret
之后就会去执行栈中存放的下一个地址指向的指令,只要合理地放置这些地址,我们就可以执行我们想要执行的命令从而达到攻击的目的。
这样的一串以ret
结尾的指令,被称为gadget
。我们要攻击的程序中为我们设置了一个gadget_farm
,为我们提供了一系列这样可以执行的攻击指令,同时我们也只被允许使用程序中start_farm
与end_farm
函数标识之间的gadget
来构建我们的攻击字符串。
这种攻击方式被称为返回导向编程攻击。
Level 2
目的与之前的Level 2
相同,我们需要为rdi
赋上cookie
值,再跳转到touch2
函数执行,跳转到touch2
只需要将touch2
的入口地址放在最后一个gadget
之后,在它的ret
指令执行之后就会返回到touch2
中。
下面就要利用已有的gadget
为rdi
赋上我们想要的值。这里我们要将一个特定的值写入rdi
,但是我们只可以使用栈来存放这个数值,同时不知道栈的地址,这个时候我们可以想到使用pop
指令令这个值从栈中弹出到寄存器中。
查看gadget
中提供的我们可以执行指令。发现
1 | 00000000004019a7 <addval_219>: |
中最后的字节为58 90 c3
,这个三个字节分别编码了三条指令:
1 | popq %rax |
这个nop
在这里当然不影响,利用这个pop
指令我们就可以把栈中存放的内容弹出到rax
中。接下来我们需要的是
1 | movq %rax,%rdi |
这条指令,如果没有的话可以多传几次,正好我们发现了
1 | 00000000004019c3 <setval_426>: |
中最后的字节48 89 c7 90 c3
编码了这样的指令。
我们分别计算这些需要执行的gadget
的指令地址,写成攻击字符串:
1 | 00 00 00 00 00 00 00 00 |
Level 3
攻击目标与之前的Level 3
相同,需要将rdi
指向cookie
的字符串表示的首地址。
目标字符串毫无疑问还是存放在栈中的,但是我们如何在栈地址随机化的情况下去获取我们放在栈中的字符串的首地址呢?
查看gadget_farm
中提供的gadget
后,我们可以发现可以执行的命令中有
1 | movq %rsp,%rax |
这样一条,可以保存当前的rsp
值,但是我们面临一个问题,这条命令执行时rsp
的值为下一个地址,如果下一个地址中存放了目标字符串,那么命令就无法继续执行下去,也无法进入touch3
函数了。
除此之外,似乎没有别的gadget
可以帮助我们获取rsp
的地址了。
我在这个地方卡了好几个小时,最后在别人的提示下才发现gadget_farm
中有这样一个gadget
画风与其他的不太一样:
1 | 00000000004019d6 <add_xy>: |
这明明就是一个可以直接使用的函数!它的作用是将rdi
与rsi
中的值相加后存放在rax
中。
有了这个,我们就可以把rsp
的值加上一个数偏移若干后表示存放目标字符串的位置,就不会与需要执行的指令冲突了。
同时还要注意的是,这里有些gadget
藏得比较隐蔽,讲义中暗示我们有一些两字节编码的指令实际上没有任何影响,它们之前的指令同样也是可以使用的。
仔细找出所有可以执行的指令并整理之后我得出了这样一张图:
目标字符串存放的位置一定在touch3
地址之上(原因见前文)。
由于相加操作只能对rsi
与rdi
进行,经过观察可以发现栈地址是一个8字节值,所以无法通过下面这条movl
组成的路来传递,但是我们的偏移值完全可以。所以我们的思路就定下了,把rsp
的值存放在rdi
中,把偏移量的值通过popq
指令从栈中取出放在esi
中,再利用add_xy
函数将它们相加的结果存放到rax
再转移到rdi
中。这个偏移量是多少要等到我们的栈结构出来之后才可以确定。
根据上面这些信息,我们可以把栈结构示意出来:
标注灰色的地方是我们计算偏移量的部分(从rsp
读入时开始),可以计算出偏移量为4 x 8 = 32 = 0x20
,再依此计算各命令的地址、构建出我们的攻击字符串:
1 | 00 00 00 00 00 00 00 00 |
实验小结
Attack Lab
与之前的两个实验相比还是比较简单的,但是最后一个阶段确实因为自己的观察不够细致浪费了大量的时间。也告诉我们不要受思维定势的左右,一味地去寻找可以使用的gadget
而忽略了函数本身的作用。
这次实验加强了我对于函数调用栈,字节序,gdb
使用,汇编的理解。