FILE结构
深入学习一下 IO 攻击,本篇涉及的基本原理都只是从 CTF.wiki上总结的。然后找了几个比较典型的 IO 攻击例题。但是感觉对这块还得加强练习,不同的 libc 版本,攻击的方式也就有所不同。后续再总结对于 不同版本的 libc 的 IO 攻击。
FILE介绍
FILE 在 Linux 系统的标准IO库中是用于描述文件的结构,称为文件流。 FILE 结构在程序执行 fopen 等函数时会进行创建,并分配在堆中。通常使用一个指向 FILE 结构的指针来接受该返回值。
FILE结构如下:
1 | struct _IO_FILE { |
进程中的 FILE 结构会通过 chain域彼此链接形成一个链表,链表头部用全局变量 _IO_list_all 表示。通过该值可以遍历所有FILE结构体。
注意:也就是我们可以自己伪造一个 FILE 结构,然后通过修改 _IO_list_all 的链表头部的值来 将控制流劫持到 我们的 fake FILE结构中。
在标准 I/O 库中,每个程序启动时有三个文件流是自动打开的:stdin、stdout、stderr。在标准状态下,_IO_list_all 指向了一个有这些文件流构成的链表,但是这三个文件流位于 libc.so 的数据段,而 fopen 创建的文件流是分配在堆内存上的。
在 Libc.so 中可以找到 stdin\stdout\stderr 等符号,这些符号是指向 FILE结构的指针,真正的结构的符号是:
1 | _IO_2_1_stderr_ |
注意: 修改stdin的 FILE 结构中的 _IO_buf_base 和 _IO_buf_base 可以 实现从 _IO_buf_base 处 任意写。
在 _IO_FILE 结构外包裹这另一种结构 _IO_FILE_Plus,其中包含了一个重要的指针 vtable 指向了一系列的函数指针。
在 libc2.23 版本下,32位的 vtable 偏移为 0x94,64位偏移为 0xd8
1 | struct _IO_FILE_plus |
vtable 是 IO_jump_t 类型的指针,IO_jump_t 中保存了一些函数指针,而标准 IO 函数中会调用这些函数指针:
1 | void * funcs[] = { |
fread
fread 是标准 IO 库函数,作用是从 文件流中读数据,函数原型如下:
1 | size_t fread ( void *buffer, size_t size, size_t count, FILE *stream) ; |
- buffer 存放读取数据的缓冲区
- size: 指定每个记录的长度
- count: 指定记录的个数
- stream:目标文件流
- 返回值:返回读取到数据缓冲区的记录个数
fread 的真正的功能实现在子函数 _IO_sgetn 中:
1 | _IO_size_t |
在 _IO_sgetn 函数中会调用 _IO_XSGETN,而 _IO_XSGETN 是 _IO_FILE_plus.vtable 中的函数指针,在调用这个函数时会首先取出 vtable 中的指针然后再进行调用:
1 | _IO_size_t |
在默认情况下函数指针是指向 _IO_file_xsgetn 函数的:
1 | if (fp->_IO_buf_base |
fwrite
fwrite 是标准 IO 库函数,作用是向文件流写入数据,函数原型如下:
1 | size_t fwrite(const void* buffer, size_t size, size_t count, FILE* stream); |
- buffer: 是一个指针,对 fwrite 来说,是要写入数据的地址;
- size: 要写入内容的单字节数;
- count: 要进行写入 size 字节的数据项的个数;
- stream: 目标文件指针;
- 返回值:实际写入的数据项个数 count。
fwrite 的函数名为_IO_fwrite。在 _IO_fwrite 中主要是调用 _IO_XSPUTN 来实现写入的功能。
_IO_XSPUTN 位于 _IO_FILE_plus 的 vtable中,调用该函数需要首先取出 vtable 中的指针,再跳过去进行调用。
1 | written = _IO_sputn (fp, (const char *) buf, request); |
在 _IO_XSPTUN 对应的默认函数 _IO_new_file_xsputn 中会调用同样位于 vtable 中的 _IO_OVERFLOW
1 | /* Next flush the (full) buffer. */ |
_IO_OVERFLOW 对应的函数是 _IO_new_file_overflow
1 | if (ch == EOF) |
在 _IO_new_file_overflow 内部会调用系统接口 write 函数。
fopen
fopen 在标准 IO 库中用于打开文件,原型如下:
1 | FILE *fopen(char *filename, *type); |
- filename: 目标文件的路径
- type: 打开方式的类型
- 返回值: 返回一个文件指针
在 fopen 内部会创建 FILE 结构并进行一些初始化操作,
首先在 fopen对应的函数 __fopen_interlnam 内部会调用 malloc 函数,分配FILE 结构的空间,因此可以获知 FILE 结构是存储在堆上的
1 | *new_f = (struct locked_FILE *) malloc (sizeof (struct locked_FILE)); |
之后会为创建的 FILE 初始化 vtable,并调用 _IO_file_init 进一步初始化操作。
1 | _IO_JUMPS (&new_f->fp) = &_IO_file_jumps; |
在 _IO_file_init 函数的初始化操作中,会调用 _IO_link_in 把新分配的 FILE 链入 _IO_list_all 为起始的 FILE 链表中。
1 | void |
之后 fopen_internal 函数会调用 _IO_file_fopen 函数打开目标文件,IO_file_fopen 会根据用户传入的打开模式进行打开操作,总之最后会调用到系统接口 open 函数:
1 | if (_IO_file_fopen ((_IO_FILE *) new_f, filename, mode, is32) != NULL) |
- 使用 malloc 分配 FILE 结构
- 设置 FILE 结构的 vtable
- 初始化分配的 FILE 结构
- 将初始化的 FILE 结构链入 FILE 结构链表中
- 调用系统调用打开文件
fclose
fclose 是标准 IO 库中用于关闭已打开文件的函数,其作用与 fopen 相反。
1 | int flcose(FILE *stream) |
功能: 关闭一个文件流,使用 fclose 就可以把缓冲区内最后剩余的数据输出到磁盘文件中,并释放文件指针和有关的缓冲区
fclose首先会调用 _IO_unlink_it 将指定的 FILE 从 _chain 链表中拖链
1 | if (fp->_IO_file_flags & _IO_IS_FILEBUF) |
之后会调用 _IO_file_close_it 函数,_IO_file_close_it 会调用系统接口 close 关闭文件
1 | if (fp->_IO_file_flags & _IO_IS_FILEBUF) |
最后调用 vtable 中的 _IO_FINISH,其对应的是 _IO_file_finish 函数,其中会调用 free 函数释放之前分配的 FILE 结构
1 | _IO_FINISH (fp); |
printf/puts
printf 和 puts 是常用的输出函数,在 printf 的参数是以 ‘\n’ 结束的纯字符串时,printf 会被优化为 puts 函数并去除换行符。
puts 在源码中实现的函数是 _IO_puts,这个函数的操作与 fwrite 的流程大致相同,函数内部同样会调用 vtable 中的 _IO_sputn,结构会执行 _IO_new_file_xsputn,最后会调用到系统接口 write 函数。
printf 的调用回溯栈如下,同样通过 _IO_file_xsputn 实现:
1 | vfprintf+11 |
伪造 vtable 劫持程序流程
根据 Linux 中文件流的特性(FILE),可知Linux 中的一些常见的 IO 操作函数都需要经过 FILE 结构进行处理。特别是 _IO_FILE_plus 结构中存在 vtable,一些函数会取出 vtable 中的指针进行调用。
因此 伪造 vtable 劫持程序流程的中心思想就是针对 _IO_FILE_plus 的 vtable 动手脚,通过把 vtable 指向我们控制的内存,并在其中布置函数指针来实现。
因此 vtable 劫持分为两种,一种是直接改写 vtable 中的函数指针,通过任意地址写就可以实现。另一种是覆盖 vtable 的指针指向我们控制的内存,然后在其中布置函数指针。
2018 HCTF the_end
程序存在可以任意位置写 5 字节,除了 canary 保护全开。
思路:
- 利用的是在程序调用 exit 后,会遍历
_IO_list_all
,调用_IO_2_1_stdout
下的vatable
中_setbuf
- 可以先修改两个字节在当前
_vtable
附近伪造一个fake_vtable
,然后使用 3 个字节修改 fake_vtable 中_setbuf
的内容为one_gadget
其实,我感觉可以直接修改 当前的 _IO_file_jumps 下的 stebuf 的指针为 One_gadget地址。可以直接 getshell。
这里不能直接修改 _IO_2_1_stdout_
结构体的 vtable
指针,因为这是虚表指针,执行的是 指向的地址里的虚表函数。
不能直接修改 直接虚表里的 _setbuf
为 one_gadget
,是因为在 2.23之后该 虚表不能够修改。
1 | from pwn import * |
FSOP
FSOP 是 File Stream Oriented Programming 的缩写,从 FILE 介绍得知进程内所有的 _IO_FILE 结构会使用 _chain 域相互连接行程一个链表,这个链表的头部由 _IO_list_all 维护。
FSOP 的核心思想是劫持 _IO_list_all 的值来伪造链表和其中的 _IO_FILE 项,但是单纯的伪造只是构造了数据还需要某种方法进行触发。 FSOP选择的触发方法是调用 _IO_flush_all_lockp,这个函数会刷新 _IO_list_all 链表中所有项的文件流。相当于对每个 FILE 调用 fflush,也对应着会调用 _IO_FILE_plus.vtable 中的 _IO_overflow。
1 | int |
而 _IO_flush_all_lockp 不需要攻击者手动调用,在一些情况下这个函数会被系统调用。
- 当libc 执行 abort 流程时
- 当执行 exit 函数时
- 当执行流从 main 函数返回时
FSOP攻击条件
需要获知 Libc.so基址,因为 _IO_list_all 是作为全局变量储存在 libc.so 中的,不泄露 Libc 基址就不能改写 _IO_list_all
之后需要用任意地址写把 _IO_list_all 的内容改为指向我们可控内存的指针。
之后的问题是在可控内存中布置什么数据,需要布置一个理想函数的 vtable 指针。但是为了能够让我们构造的 fake_FILE 能够正常工作,还需要布置一些其他数据。这里依据是我们前面给出的:
1 | if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)) |
即:
- fp->_mode <= 0
- fp->_IO_write_ptr > fp->_IO_write_base
glibc 2.24下的 IO_FILE的利用
在 2.24 版本的 glibc 中,全新加入了针对 _IO_FILE_plus 的vtable 劫持的检测措施。glibc 会在调用虚函数之前首先检查 vtable 地址的合法性,首先会验证 vtable 是否位于 _IO_vtable 段中,如果满足条件就正常执行,否则会调用 _IO_vtable_check 做进一步检查。
1 | /* Check if unknown vtable pointers are permitted; otherwise, |
计算 section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
,紧接着会判断 vtable - start_libc_IO_vtables 的 offset ,如果这个 offset 大于 section_length , 即大于 __stop___libc_IO_vtables - __start___libc_IO_vtables
那么就会调用 _IO_vtable_check()
这个函数
1 | void attribute_hidden |
如果 vtable 是非法得,则会引发 abort
这里得检查使得以往使用 vtable 进行利用的技术很难实现。
新的利用技术
fileno 与缓冲区的相关利用
在 vtable 难以被利用之后,利用的关注点从 vtable 转移到 _IO_FILE 结构内部的域中。前面介绍过 _IO_FILE 在使用标准 IO 库时会进行创建并负责维护一些相关信息。其中有一些域是表示调用诸如 fwrite、fread 等函数时写入地址或读地址的。如果可以控制这些数据就可以实现任意地址写或任意地址读。
1 | struct _IO_FILE { |
因为进程中包含了系统默认的三个文件流 stdin\stdout\stderr,因此这种方式可以不需要进程中存在文件操作,通过 scanf\printf 一样可以进行利用。
在 _IO_FILE 中 _IO_buf_base 表示操作的起始地址,__IO_buf_end 表示结束地址,通过控制这两个数据可以实现控制读写的操作。
示例
1 |
|
未执行 scanf
前,_IO_2_1_stdin_
结构体中各缓冲区如下,缓冲区都为0
执行 scanf
之后,_IO_2_1_stdin_
结构体中各缓冲区如下,可以看到 read_base
和 write_base
和 buf_base
都是我们输入字符串的开始地址,而 read_end
则是我们输入字符串的结束后一个地址,read_ptr
是我们输入的字符串末尾地址。 也就是我们的输入与 read_ptr
、read_base
、read_end
三个指针有关。而 read
和 write
相关的函数其缓冲区 都是与 buf
缓冲区有关的。我们也可以从下图知道 buf_end
是我们的 输入输出缓冲区的大小。
第二次输入时,_IO_2_1_stdin_
结构体区如下,可以发现 read_ptr
和 read_end
已经更新,也就是说我们每次的输入都是调用了 _IO_2_1_stdin
这个结构体,并且先存储到了我们的 输入输出缓冲区中。
也就是说,如果我们想要修改 我们输入的字符的位置,只需要修改 buf_base
和 buf_end
指针即可。我们即可实现任意地址输入。
_IO_str_jumps -> overflow
libc 中不仅仅只有 _IO_file_jumps
这么一个 vtable,还有一个叫 __IO_str_jumps
的,这个 vtable
不再 check
范围内。
1 | const struct _IO_jump_t _IO_str_jumps libio_vtable = |
如果能设置文件指针的 vtable
为 _IO_str_jumps
就能调用不一样的文件操作函数。这里以 _IO_str_overflow
为例子:
1 | int |
利用以下代码来劫持程序流程:
1 | new_buf |
几个条件 bypass:
- fp-> _flags & _IO_NO_WRITES 为假
- (pos = fp-> _IO_write_ptr - fp-> _IO_write_base) >= ((fp-> _IO_buf_end - fp-> _IO_buf_base) + flush_only(1))
- fp-> _flags & _IO_USER_BUF(0x01) 为假
- 2*(fp-> _IO_buf_end - fp-> _IO_buf_base) +100 不能为负数
- new_size = 2 * (fp-> IO_buf_end - fp-> _IO_buf_base) + 100;应当指向 /bin/sh 字符串对应的地址
- fp + 0xe0 指向 system 地址
构造
1 | _flags = 0 |
示例
1 |
|
_IO_str_jumps->finish
原理与上面的 _IO_str_jumps->overflow 类似
1 | void |
条件
- _IO_buf_base 不为空
- _flags & _IO_USER_BUF(0x01) 为假
构造如下:
1 | _flags = (binsh_in_libc + 0x10) & ~1 |
示例:
1 |
|
2016-hitcon house of orange
程序分析
在upgrade中存在堆溢出,此外程序所有的保护都开了,需要leak libc地址和 heap地址,这里如何实现地址泄露也是一个难点。
1.利用堆溢出修改 top chunk的size 较小;
2.申请一个超过top chunk的 fake size 的chunk,将现有 top chunk放入到 unsorted bin中;
3.如果我们接下来申请一个large chunk,由于是用malloc 申请的,所以 该large chunk 块里 的 fd 和 bk 指针会存储 main_arena 地址(即libc 地址),并在 fd 和 bk 指针后面会存储当前的堆块地址。
4.然后就要通过 FSOP 劫持 程序流程,在最后堆错误,打印错误函数时,getshell。
1 | $1 = { |
漏洞利用
泄露地址之后,主要关注 地址有:
_IO_list_all 地址,这个地址是 文件 _IO_FILE 开始遍历的地址,修改这里 可以试文件链到我们的 fake IO_FILE_STRUCT
fake_IO_struct 中的几个结构:file->mode < 0; file->write_ptr > file->write_base ;file->vtable 指向我们的 fake_IO_FILE_jumps
伪造 fake_IO_FILE_jumps 中的 第四个 _IO_OVERFLOW 为 system地址。并在 fake_IO_struct 的头部放上 /bin/sh\x00
漏洞触发,最后 只要malloc 错误就行,比如 unsortedbin 的指针此时不正确,再申请堆块 就会 报错。
然后想要劫持 IO_FILE 到我们伪造的 fake_FILE,需要我们将 unsortedbin chunk的 bk 指针设为 IO_list_all-0x10,然后当我们分配 unsortedbin 时,就会将 我们当前的 Unosrtedbin 的地址写入 IO_list_all中。这样程序malloc 错误遍历 IO_FILE 时就会首先遍历 我们的 fake_FILE。
- 首先将 unsortedbin 的 bk指针改为 _IO_list_all - 0x10 的地址
随后分配unsortedbin chunk时,由于 unsortedbin attack _IO_list_all 的 地址会被改为 main_arena+88的地址;相当于如果 遍历 _IO_FILE 时 从 _IO_list_all 开始会直接遍历到 main_arena+88 开始的地址。
- 由于当从unsortedbin 中找到的块不符合申请的大小时,会将当前unsortedbin chunk放入对应大小的 smallbin 中,而 0x60 的大小将会放到 small_bin0x60处,也就相当于会将 当前的 unsortedbin chunk 的地址存储在 smallbin 0x60 处,如上图所示。而系统将 main_arena+88 开始处的地址当作了 一个 _IO_FILE 处理,所以会将其 smalbin0x60 处的值在 _IO_FILE 中也存在一个作用,即是 _IO_FILE 中 chains 的位置,系统会根据它 继续向下遍历找到下一个 _IO_FILE 结构,如下图所示,此时从 main_arena+88 开始的 _IO_FILE 的 chain 是 unsortedbin chunk 地址。
- 那么系统将会继续向下遍历 unsortedbin chunk 这个地址的 _IO_FILE struct,而在 unsortedbin chunk中,满足 _IO_OVERFLOW 的执行条件
1 | fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base |
由于将摘下来的unsorted bin chunk放到smallbin[0x60]中
最后,由于unsortedbin attack破坏了unsorted bin的链表结构。此时,victim=_IO_list_all-0x10,victim->size=0,满足__builtin_expect (victim->size <= 2 * SIZE_SZ, 0),所以在大循环中系统调用malloc_printerr去打印错误信息。
所以,此时会去执行 vatble 指向的 _IO_jump_t 虚表里的 _io_overflow 函数,而该函数被我们修改为了 gadget 地址,即可 getshell。
EXP
1 |
|
WCTF 2017 wannaheap
未找到 二进制文件,后续补一下。
Tokoy Western CTF 2017 Parrot
程序分析
这题看着代码结构挺简单的,但是简单题却挺难的。
代码漏洞在 处理输入最后一位为0时,如果buf =0,而size 我们输入一个 较长的地址,那么就可以直接对 size+1 的地址写为0。
这道题将所有的保护都开了,我们首先需要leak libc 地址,然后需要通过 io 攻击改写 free_hook的方法来 getshell。
漏洞利用
leak 地址
这道题地址 泄露需要说一下,不同于上一题是申请了一个 large bin chunk来泄露 libc 和堆地址。这题是通过先申请 几个fast bin chunk,然后再申请一个大的 small bin,这样再 free 掉 small bin 时就会 将 fast bin 进行堆合并,并放到 unsortedbin 中。并且由于与 top chunk 相邻,所以最终会被放入 top chunk中。但是 该unsortedbin 的 fd 和bk 指针仍然保存了 main_arena+88 的地址,我们再申请一个 small chunk,该地址不会被清空,我们就可以将其输出。
getsgell
这道题的关键在于 修改 stdin 结构的 IO_buf_base 和 _IO_buf_end,修改 _IO_buf_base 的最后一字节 为 00,这样就可以将 stdin 输入指针向上移。我们能够覆盖 _IO_stdin , 然后将 _IO_buf_base 修改为 free_hook 为 one_gadget 地址,实现getshell。
将 _IO_buf_base
的最低位设为 \x00
。
通过scanf
函数读入数据,将 _IO_buf_base``和_IO_buf_end
覆盖了。但是由于 scanf
只会读取一个int
类型的数据,所以输入缓冲区 _IO_read_ptr
和 _IO_read_end
之间的输入缓冲区会有我们之前输入的数据,所以我们需要先把这个输入缓冲区清空,也就是 _IO_read_ptr
和 _IO_read_end
的地址相等才能够重新读取我们输入的数据。而程序中有 getchar
函数,可以直接读取一个字符,我们循环 0x57
来消耗输入缓冲区。
当 _IO_read_ptr
和 _IO_read_end
的地址相等时,scanf
函数会重新读取用户输入的数据到 _IO_buf_base
指向的地址,如下图所示,成功覆盖了 __free_hook
为One_gadget
地址。
EXP
1 | #!/usr/bin/env python |
Hacklu 2018 heap hell 2
程序分析
程序首先根据输入的地址,mmap 了一块堆内存,如果没有输入地址的话,会使用默认地址。
程序漏洞在 读取用户输入时,根据用户输入的 offset 来找到在上面mmap 出来的内存的开始写入位置,根据输入的size 来判断读取的输入长度。
这里如果,我们能够设置 初始化地址为address 0x10000,然后设置 size 为 0x20000,因为 address+0x10000-size
为0,但是此时为unsigned, 就会为一个极大值。 所以我们就可以通过设置 offset,来实现对任意地址写入。
漏洞利用
本题也开启了所有保护,所以首先需要先泄露地址,然后再去 getshell。
泄露地址
泄露地址,其实原理还是利用上面的 free unsortedbin chunk的特性,会在当前 unsortedbin chunk的 fd 和 bk处写下当前的 chunk 地址,而如果是首次的 unsortedbin 则会在 fd 或者 bk 处写下 main_arena 地址。然后我们直接读取该 地址的 fd 或者 bk指针即可获得 libc 地址。
getshell
这道题由于是 glibc-2.28 的环境,所以上面两种修改 vtable 的方式就不行了,因为增加了 check vtable。我们就需要使用 新的 _IO_str_jumps 来攻击。
EXP
1 | from pwn import * |
2017 HCTF babyprintf
程序分析
程序很简单是一个格式化字符串漏洞,我们可以实现泄露地址,和修改任意地址。
利用分析
- 泄露 libc地址
直接使用格式化字符串漏洞,泄露 libc地址。
- getshell
getshell的方法,使用类似 House of orange
的方法,不过这里注意 不能在直接使用修改 _IO_file_jumps
里为 system
地址,而是采用 _IO_str_jumps
里的 overflow
方法
修改top chunk size
先修改 top chunk的size,为0xd01
,然后申请 0x1000
的大块,将 top chunk
放入 unsortedbin
中。
伪造 _IO_FILE
然后通过堆溢出 修改 top chunk
的 0x300
之后的 chunk
的size
为 0x61
,bk
为 io_list_all-0x10
。并从 0x300
开始布置 fake _IO_FILE
,满足以下要求:
1 | _IO_write_ptr>_IO_write_base |
并在 fake _IO_FILE
后 0xf0
的位置处伪造 system_addr
触发漏洞
随后,我们申请一个堆块 malloc(1),此时由于 会将 我们伪造的 0x61
的 top chunk
放入 small bin
中,然后向 bk
也就是 _IO_list_all
分配堆块,但是该地址的 size 不会通过检查,此时触发崩溃。
程序执行 _IO_flush_all_lockp
,从 main_arena+88
开始遍历 FILE
结构体,遍历到 我们伪造的 fake _IO_FILE
时发现 参数满足 _IO_OVERFLOW
执行条件,会通过 vtable
指针指向 的虚表 也就是 _IO_str_jumps-8
的第4个函数执行,此时被修改为 _IO_str_finish
函数。最终会执行 fp->_s._free_buffer
处的函数,也就是 fake _IO_FILE
后面紧邻的 system
函数,参数是 _IO_buf_base
,也就是 bin_sh_addr
。
Tips
为了很快速的伪造 FILE 结构体,针对常用的 FILE中会修改的数据和偏移,使用如下函数会很快完成:
1 | def pack_file(_flags = 0, |
在 pack_file
后面跟上 vtable
和 fp->_s._free_buffer
的值即可。
EXP
1 | from pwn import * |
ASIS2018 fifty-dollars
程序分析
程序漏洞很简单,delete
函数中存在一个 UAF
和 double_free
漏洞。
利用分析
- 泄露 heap地址
由于有输出函数,所以很好泄露heap地址
- 泄露 Libc地址
由于程序规定了只能申请 0x50
大小的堆块,所以需要通过 double_free
来 修改一个 chunk
的 size
,然后再释放它到 unsortedbin
中去泄露 libc
地址。
- getshell
这道题由于一次只能输入 0x50
大小的数据,需要多次构造 fake_FILE
数据。而且这道题 构造了三次 FILE链。
list_all -> main_arena+88 -> main_arene+184 -> fake_FILE_chunk
getshell
方式与上面一致,通过 _IO_str_jumps
走 _IO_str_finish
EXP
1 | from pwn import * |
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2020/08/30/IO-FILE-Related/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!