深入了解GOT,PLT和动态链接
之前几篇介绍exploit的文章, 有提到return-to-plt
的技术. 当时只简单介绍了
GOT和PLT表的基本作用和他们之间的关系, 所以今天就来详细分析下其具体的工作过程.
本文所用的依然是Linux x86 64位环境, 不过分析的ELF文件是32位的(-m32).
大局观
首先, 我们要知道, GOT和PLT只是一种重定向
的实现方式. 所以为了理解他们的作用,
就要先知道什么是重定向, 以及我们为什么需要重定向.
重定向(relocations)
, 简单来说就是二进制文件中留下的"坑", 预留给外部变量或函数.
这里的变量和函数统称为符号(symbols)
. 在编译期我们通常只知道外部符号的类型
(变量类型和函数原型), 而不需要知道具体的值(变量值和函数实现). 而这些预留的"坑",
会在用到之前(链接期间或者运行期间)填上. 在链接期间填上主要通过工具链中的连接器,
比如GNU链接器ld
; 在运行期间填上则通过动态连接器, 或者说解释器(interpreter)来实现.
比如:
$ file /bin/ls/bin/ls: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked,interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=3c233e12c466a83aa9b2094b07dbfaa5bd10eccd, stripped
可以看到/bin/ls
的解释器是/lib64/ld-linux-x86-64.so.2
.
在本文中, 用下面两个简单的c文件来进行说明, 首先是symbol.c, 定义了一个函数变量:
// symbol.cint my_var = 42;int my_func(int a, int b) { return a + b;}
编译为动态链接库:
gcc -g -m32 -masm=intel -shared -fPIC symbol.c -o libsymbol.so
另一个文件是main.c, 调用该动态链接库:
// main.cint var = 10;extern int my_var;extern int my_func(int, int);int main() { int a, b; a = var; b = my_var; return my_func(a, b);}
分别编译两个版本, 位置相关的main
和位置无关的main_pi
, 具体会稍后解释.
# 位置相关gcc -g -m32 -masm=intel -L. -lsymbol -no-pie -fno-pic main.c libsymbol.so -o main# 位置无关gcc -g -m32 -masm=intel -L. -lsymbol main.c libsymbol.so -o main_pi
符号表
函数和变量作为符号被存在可执行文件中, 不同类型的符号又聚合在一起, 称为符号表
.
有两种类型的符号表, 一种是常规的(.symtab和.strtab), 另一种是动态的(.dynsym和.dynstr),
他们都在对应的section中, 以main为例:
$ readelf -S ./main [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [ 5] .dynsym DYNSYM 080481ec 0001ec 0000b0 10 A 6 1 4 [ 6] .dynstr STRTAB 0804829c 00029c 000085 00 A 0 0 1... [33] .symtab SYMTAB 00000000 00120c 000490 10 34 52 4 [34] .strtab STRTAB 00000000 00169c 0001e1 00 0 0 1
常规的符号表通常只在调试时用到. 我们平时用的strip
命令删除的就是该符号表;
而动态符号表则是程序执行时候真正会查找的目标.
位置无关代码
刚刚编译动态链接库时指定了-fPIC, 编译main_pi
时(默认)指定了-pie, 其实都是为了
生成位置无关的代码, 那么什么是位置无关? 为什么要位置无关?
我们执行一个可执行文件的时候, 其实是先将磁盘上的该文件读取到内存中, 然后再执行.
而每个进程都有自己的虚拟内存空间, 以32位程序为例, 就有2^32=4GB的寻址空间, 从0x00000000
到0xffffffff. 这里暂时不深入介绍, 只需要知道虚拟内存最终会通过页表映射到物理内存中.
当然, 如果你感兴趣, 强烈推荐你去看下Gustavo Duarte的这篇文章.
按照链接器的约定, 32位程序会加载到0x08048000
这个地址中(为什么?),
所以我们写程序时, 可以以这个地址为基础, 对变量进行绝对地址寻址. 以main为例:
$ readelf -S ./main | grep "\.data" [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [25] .data PROGBITS 0804a014 001014 00000c 00 WA 0 0 4
.data部分在可执行文件中的偏移量为0x1014, 那么加载到虚拟内存中的地址应该是
0x8048000+0x1014=0x804a14, 正好和显示的结果一样. 再看看main函数的汇编代码:
$ objdump -d ./main | grep "<main>" -A 15080484db <main>: 80484db:8d 4c 24 04 lea ecx,[esp+0x4] 80484df:83 e4 f0 and esp,0xfffffff0 80484e2:ff 71 fc push DWORD PTR [ecx-0x4] 80484e5:55 push ebp 80484e6:89 e5 mov ebp,esp 80484e8:51 push ecx 80484e9:83 ec 14 sub esp,0x14 80484ec:a1 1c a0 04 08 mov eax,ds:0x804a01c 80484f1:89 45 f4 mov DWORD PTR [ebp-0xc],eax 80484f4:a1 20 a0 04 08 mov eax,ds:0x804a020 80484f9:89 45 f0 mov DWORD PTR [ebp-0x10],eax 80484fc:83 ec 08 sub esp,0x8 80484ff:ff 75 f0 push DWORD PTR [ebp-0x10] 8048502:ff 75 f4 push DWORD PTR [ebp-0xc] 8048505:e8 a6 fe ff ff call 80483b0 <my_func@plt>
注意80484ec这行, 可以看到获取变量直接用的绝对地址0x804a01c(正好在.data范围内).
用gdb(在启动程序之前)可看到该地址正是var
变量的地址, 且初始值为10:
$ gdb ./main(gdb) x/xw 0x804a01c0x804a01c <var>:0x0000000a
按绝对地址寻址, 对可执行文件来说不是什么大问题, 因为一个进程只有一个主函数.
可对于动态链接库而言就比较麻烦, 如果每个.so文件都要求加载到某个绝对地址,
那简直是个噩梦, 因为你无法保证不和别人的.so加载地址冲突. 所以就有了位置无关代码的概念.
以位置无关的方式编译的main_pi
, 来看看其相关信息:
$ readelf -S ./main_pi | grep "\.data" [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [25] .data PROGBITS 00002014 001014 00000c 00 WA 0 0 4
偏移量还是固定的, 但Addr部分不再是绝对地址. 也就是说程序可以加载到虚拟内存的任意位置.
听起来很神奇? 其实实现很简单, 继续看看main()的汇编:
$ objdump -d main_pi | grep "<main>" -A 2000000660 <main>: 660:8d 4c 24 04 lea ecx,[esp+0x4] 664:83 e4 f0 and esp,0xfffffff0 667:ff 71 fc push DWORD PTR [ecx-0x4] 66a:55 push ebp 66b:89 e5 mov ebp,esp 66d:53 push ebx 66e:51 push ecx 66f:83 ec 10 sub esp,0x10 672:e8 36 00 00 00 call 6ad <__x86.get_pc_thunk.ax> 677:05 89 19 00 00 add eax,0x1989 67c:8b 90 1c 00 00 00 mov edx,DWORD PTR [eax+0x1c] 682:89 55 f4 mov DWORD PTR [ebp-0xc],edx 685:8b 90 f0 ff ff ff mov edx,DWORD PTR [eax-0x10] 68b:8b 12 mov edx,DWORD PTR [edx] 68d:89 55 f0 mov DWORD PTR [ebp-0x10],edx 690:83 ec 08 sub esp,0x8 693:ff 75 f0 push DWORD PTR [ebp-0x10] 696:ff 75 f4 push DWORD PTR [ebp-0xc] 699:89 c3 mov ebx,eax 69b:e8 20 fe ff ff call 4c0 <my_func@plt>
注意67c~682处, 和之前的区别是这次通过eax寄存器来对变量进行寻址, 不过有个__x86.get_pc_thunk.ax
函数,
其作用很简单, 在之前的IOLI-crackme0x06-0x09 writeup中有简单介绍过:
objdump -d main_pi | grep "__x86.get_pc_thunk.ax" -A 2000006ad <__x86.get_pc_thunk.ax>: 6ad:8b 04 24 mov eax,DWORD PTR [esp] 6b0:c3 ret
作用就是把esp(即返回地址)的值保存在eax(PIC寄存器)中, 在接下来寻址用.
有人可能好奇, 为什么这么麻烦, 直接用eip寄存器不就行了?
其实64位下就是这样操作的! 不过32位下不支持直接访问PC寄存器,
所以就多了一层间接的函数调用.
扯远了, 经过672和677两条指令后, eax的值将等于相对当前PC指针的固定位移.
只看静态代码的话, 可知eax=0x677+0x1989=0x2000, 而这个地址是...
$ readelf -S ./main_pi | grep 2000 -C 1 [23] .got PROGBITS 00001fe4 000fe4 00001c 04 WA 0 0 4 [24] .got.plt PROGBITS 00002000 001000 000014 04 WA 0 0 4 [25] .data PROGBITS 00002014 001014 00000c 00 WA 0 0 4
.got.plt的起始地址! 这个section我们接下来会说到. 现在先看汇编的67c处,
通过eax+0x1c=0x201c获取了变量的值, 这个地址已经进入到了.data之中:
$ gdb ./main_pi (gdb) x/xw 0x2000+0x1c0x201c <var>:0x0000000a
所以, 位置无关代码实际上就是通过运行时PC指针的值来找到代码所引用的
其他符号的位置, 不管二进制文件被加载到哪个位置, 都可以正确执行.
缺点
位置无关代码的缺点是, 在执行时要保留一个寄存器作为PIC寄存器,
有可能会导致寄存器不够用; 还有一个缺点是运行时要经过计算来获得
符号的地址, 从某种方面来说也对运行速度有点小影响.
优点
位置无关代码的优点就跟他名字一样, 可以保证加载到任意地址都能
正常执行, 这也是每个动态链接库都需要支持的.
动态链接
刚刚我们说位置无关代码的时候有看到, PIC寄存器为.got.plt的地址, 然后按偏移量
来获取变量. 上面只看了eax+0x1c即从.data段获取的内容(var
), 还有一个参数是通过
eax-0x10即.got段之中获取的my_var
. 后者是在symbol.c中定义的, 所以其内容在编译期
未知. 如果是静态链接, 则可以在链接时解析符号的值. 我们这里主要考虑动态链接的情况.
一些定义
上面说了很多.got, .plt啥的, 那么这些section到底是做什么用的呢. 其实这些都是
链接器(或解释器, 下面统称为链接器)在执行重定向时会用到的部分, 先来看他们的定义.
.got
这是我们常说的GOT, 即Global Offset Table, 全局偏移表. 这是链接器在执行链接时
实际上要填充的部分, 保存了所有外部符号的地址信息.
不过值得注意的是, 在i386架构下, 除了每个函数占用一个GOT表项外,GOT表项还保留了
3个公共表项, 每项32位(4字节), 保存在前三个位置, 分别是:
其中, link_map
数据结构的定义如下:
struct link_map{ /* Shared library's load address. */ ElfW(Addr) l_addr; /* Pointer to library's name in the string table. */ char *l_name; /* Dynamic section of the shared object. Includes dynamic linking info etc. Not interesting to us. */ ElfW(Dyn) *l_ld; /* Pointer to previous and next link_map node. */ struct link_map *l_next, *l_prev; };
.plt
这也是我们常说的PLT, 即Procedure Linkage Table, 进程链接表. 这个表里包含了一些代码,
用来(1)调用链接器来解析某个外部函数的地址, 并填充到.got.plt中, 然后跳转到该函数; 或者
(2)直接在.got.plt中查找并跳转到对应外部函数(如果已经填充过).
.got.plt
.got.plt相当于.plt的GOT全局偏移表, 其内容有两种情况, 1)如果在之前查找过该符号,
内容为外部函数的具体地址. 2)如果没查找过, 则内容为跳转回.plt的代码, 并执行查找.
至于为什么要这么绕, 后面会说明具体原因.
.plt.got
说实话, 这部分我还不知道有什么具体作用, 可能是为了对称吧. 逃)
对于我们将要研究的main程序, 这些段的地址如下:
$ readelf -S main | egrep '.plt|.got' [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [12] .plt PROGBITS 080483a0 0003a0 000030 04 AX 0 0 16 [13] .plt.got PROGBITS 080483d0 0003d0 000008 00 AX 0 0 8 [23] .got PROGBITS 08049ffc 000ffc 000004 04 WA 0 0 4 [24] .got.plt PROGBITS 0804a000 001000 000014 04 WA 0 0 4
变量
有了上面的定义, 先看变量的解析过程, 以main为例(位置相关的),
查看需要重定向的符号:
$ readelf --relocs ./mainRelocation section '.rel.dyn' at offset 0x358 contains 2 entries: Offset Info Type Sym.Value Sym. Name08049ffc 00000206 R_386_GLOB_DAT 00000000 __gmon_start__0804a020 00000605 R_386_COPY 0804a020 my_varRelocation section '.rel.plt' at offset 0x368 contains 2 entries: Offset Info Type Sym.Value Sym. Name0804a00c 00000107 R_386_JUMP_SLOT 00000000 my_func0804a010 00000307 R_386_JUMP_SLOT 00000000 __libc_start_main@GLIBC_2.0
my_var
的地址为0804a020, 注意这里实际上是.bss段, 如下:
$ readelf -S main | grep 0804a020 -B 2 [24] .got.plt PROGBITS 0804a000 001000 000014 04 WA 0 0 4 [25] .data PROGBITS 0804a014 001014 00000c 00 WA 0 0 4 [26] .bss NOBITS 0804a020 001020 000008 00 WA 0 0 4
因为main.c里只是声明变量而且没初始化, 在链接前并不知道是否在外部定义.
同时, 该变量的值一开始是不知道的, 我们可以通过gdb来验证:
(gdb) x/dw 0x0804a0200x804a020 <my_var>:0
显示值为0, 但实际上在symbol.c中定义了其值为42, 启动前我们先在这里下个观察点,
看看究竟是什么时候加载进去的:
(gdb) set environment LD_LIBRARY_PATH=.(gdb) watch -l *0x804a020Hardware watchpoint 1: -location *0x804a020(gdb) runStarting program: /home/pan/project/cFile/shared_library/plt/main Hardware watchpoint 1: -location *0x804a020Old value = 0New value = 420xf7ff2e08 in ?? () from /lib/ld-linux.so.2(gdb) x/xd 0x804a0200x804a020 <my_var>:42
所以, 确实是链接器/lib/ld-linux.so.2
负责填充了该变量的内容.
而且是在程序运行之前就完成了符号解析.
函数
接下来看看外部函数符号. 外部函数的内容(指令)也是像变量一样在
程序运行之前完成填充的吗? 其实这理论上是可以的, 事实上稍有不同.
静态分析
我们先从汇编看看main是如何调用my_func()
函数的:
(gdb) disassemble mainDump of assembler code for function main: 0x080484db <+0>:lea ecx,[esp+0x4] 0x080484df <+4>:and esp,0xfffffff0 0x080484e2 <+7>:push DWORD PTR [ecx-0x4] 0x080484e5 <+10>:push ebp 0x080484e6 <+11>:mov ebp,esp 0x080484e8 <+13>:push ecx 0x080484e9 <+14>:sub esp,0x14 0x080484ec <+17>:mov eax,ds:0x804a01c 0x080484f1 <+22>:mov DWORD PTR [ebp-0xc],eax 0x080484f4 <+25>:mov eax,ds:0x804a020 0x080484f9 <+30>:mov DWORD PTR [ebp-0x10],eax 0x080484fc <+33>:sub esp,0x8 0x080484ff <+36>:push DWORD PTR [ebp-0x10] 0x08048502 <+39>:push DWORD PTR [ebp-0xc] 0x08048505 <+42>:call 0x80483b0 <my_func@plt>
调用的地址是0x80483b0, 在.plt段中, 之前说了PLT的定义, 现在具体看看里面的内容:
(gdb) disassemble 0x80483b0Dump of assembler code for function my_func@plt: 0x080483b0 <+0>:jmp DWORD PTR ds:0x804a00c 0x080483b6 <+6>:push 0x0 0x080483bb <+11>:jmp 0x80483a0End of assembler dump.
首先是跳转到*0x804a00c
, 该地址在.got.plt之中, 之前说了, .got.plt相当于
.plt的GOT, 而GOT本身相当于一个数组, 看看该"数组"的内容:
(gdb) x/4xw 0x804a00c0x804a00c:0x080483b60x080483c60x000000000x00000000
所以, 0x080483b0这里的跳转, 相当于跳转到0x080483b6, 即下一条指令!
这个多余的跳转先打个问号, 把流程走完再说. 接着, 跳转到了0x80483a0,
这个地址, 是.plt的起始地址, 这里的指令如下:
(gdb) x/2i 0x080483a0 0x80483a0:push DWORD PTR ds:0x804a004 0x80483a6:jmp DWORD PTR ds:0x804a008
跳转到了0x804a008, 在前面我们知道0x804a000是.got.plt的地址,
而在上一节的定义中, 也知道了.got表前三项的作用, 0x804a008
正好是第三项got2, 即_dl_runtime_resolve
函数的地址. 0x804a004
则是调用该函数的参数, 且值为got1, 即本ELF的link_map
的地址.
如下, 在进程未启动前, got1和got2都为0, 在启动时由链接器装填:
(gdb) x/4xw 0x804a0000x804a000:0x08049f0c0x000000000x000000000x080483b6
因此, 实际上(第一次)调用my_func@plt
就相当于调用了
_dl_runtime_resolve((link_map *)m, 0)
! 其中link_map
提供了运行时的必要信息,
而0则是my_func
函数的偏移(在my_func@plt
中push 0x0).
该函数定义在glibc/sysdeps/i386/dl-trampoline.S中, 关键代码如下:
_dl_runtime_resolve: cfi_adjust_cfa_offset (8) pushl %eax # Preserve registers otherwise clobbered. cfi_adjust_cfa_offset (4) pushl %ecx cfi_adjust_cfa_offset (4) pushl %edx cfi_adjust_cfa_offset (4) movl 16(%esp), %edx # Copy args pushed by PLT in register. Note movl 12(%esp), %eax # that `fixup' takes its parameters in regs. call _dl_fixup # Call resolver. popl %edx # Get register content back. cfi_adjust_cfa_offset (-4) movl (%esp), %ecx movl %eax, (%esp) # Store the function address. movl 4(%esp), %eax ret $12 # Jump to function address.
从注释里也可以看出来, 该函数实际上做了两件事:
- 1)解析出
my_func
的地址并将值填入.got.plt中. - 2)跳转执行真正的
my_func
函数.
动态分析
上面虽然用了gdb, 但程序并未运行, 只是分析静态的汇编代码, 为了验证上面的说法,
我们需要进行动态分析. 接着上面的分析, 我们这次在调用_dl_runtime_resolve
前打上断点. 还记得之前在my_func@plt
中一次多余的跳转吗? 当时打了个问号,
现在就来解答这个疑问. 在0x804a00c处打上观察点并运行:
(gdb) b *0x80483a6(gdb) watch -l *0x804a00c(gdb) runBreakpoint 1, 0x080483a6 in ?? ()(gdb) x/xw 0x804a00c0x804a00c:0x080483b6(gdb) continueHardware watchpoint 1: -location *0x804a00cOld value = 0x80483b6New value = 0xf7fcf4f00xf7fe8113 in ?? () from /lib/ld-linux.so.2(gdb) disassemble 0xf7fcf4f0Dump of assembler code for function my_func:...
可以看到, 在_dl_runtime_resolve
之前, 0x804a00c地址的值为0x080483b6,
即下一条指令. 而运行之后, 该地址的值变为0xf7fcf4f0, 正是my_func
的加载地址!
也就是说, my_func
函数的地址是在第一次调用时, 才通过连接器动态解析并加载到
.got.plt中的. 而这个过程, 也称之为延时加载
或者惰性加载
.
延时加载
延时加载的好处是, 只有当外部函数被调用了才会去进行动态加载, 降低程序的启动时间.
而第一次加载之后, 对于后续的调用就可以直接跳转而不需要再去加载.
这样一方面减少了进程的启动开销, 另一方面也不会造成太多额外的运行时开销,
所以延时加载在当今也是广泛应用的一个思想. 对于位置无关的代码,
延时加载的过程也是类似的, 并没有太大区别. 读者可以自己去追踪一下.
相关攻击
上节的分析忽略了一个重要的地方, 那就是各个段的权限, 再重温一下各个section:
$ readelf -S mainSection Headers: [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [12] .plt PROGBITS 080483a0 0003a0 000030 04 AX 0 0 16 [13] .plt.got PROGBITS 080483d0 0003d0 000008 00 AX 0 0 8 [14] .text PROGBITS 080483e0 0003e0 0001a2 00 AX 0 0 16 [23] .got PROGBITS 08049ffc 000ffc 000004 04 WA 0 0 4 [24] .got.plt PROGBITS 0804a000 001000 000014 04 WA 0 0 4 [25] .data PROGBITS 0804a014 001014 00000c 00 WA 0 0 4 [26] .bss NOBITS 0804a020 001020 000008 00 WA 0 0 4Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
为了使得结果更清晰, 我删除了一些无关的输出. 从上表的Flg行可以看到, 前三个段
都有可执行权限(X), 却没有写(W)权限; 而后几个都有写权限, 却不可执行.
现代的操作系统一般都支持NX特性, 所以这样的结果是很常见的.
同时, 这也是为什么要将PLT和GOT分开的原因. 链接器运行时填充的区域, 必须是可写的,
但可写的区域一般不可执行, 对外部变量没有影响, 但对于外部函数来说就需要
引入一个可执行的区域作为引导, 这就是PLT的作用.
ret2libc
我在栈溢出攻击和缓解中有提到, ret2libc的使用场景是当栈不可执行时,
直接跳转到libc.so某个函数的地址, 比如system()
, 来获得shell.
不过前提是要知道libc.so在运行时的加载地址. 如果没启用ASLR, 这个地址是固定的.
启用ASLR之后就会有个随机的偏移, 如下:
根据ASLR随机化的等级, 会在栈和内核空间之间, 栈和动态库(mmap)之间, 堆和.bss之间
都分别加上随机的偏移. 所以此时libc.so的地址是未知的, ret2libc攻击也就得到缓解了.
ret2plt
但是, 虽然ASLR随机化了上面的几个地址, 在位置相关代码的情况下, PLT的地址还是确定的!
所以如果没有启用位置无关代码的话, 即使启用了ASLR, 我们还是可以通过PLT来跳转到libc
中的函数执行, 这种攻击方法就叫ret2plt.
除此之外, 因为.got.plt是有写入权限的, 攻击者还可以通过代码中的内存破坏漏洞对
.got.plt段进行覆盖, 从而间接控制代码的执行流程.
攻击缓解
ret2plt这么屌, 就没人管管吗? 当然有! 一个最简单的办法就是启用位置无关代码,
不过就算可执行程序的代码是位置无关的, 链接器还是有可能将其加载到老地方.
一个更正确的缓解措施是RELRO
即relocations read-only
.
RELRO是链接器的一个选项, 可以通过man ld
来查看. 主要作用就是令重定向只读.
有两个RELRO的等级, 部分RELRO和完全RELRO.
部分RELRO(由ld -z relro
启用):
- 将.got段映射为只读(但.got.plt还是可以写)
- 重新排列各个段来减少全局变量溢出导致覆盖代码段的可能性.
完全RELRO(由ld -z relro -z now
启用)
- 执行部分RELRO的所有操作.
- 让链接器在链接期间(执行程序之前)解析所有的符号, 然后去除.got的写权限.
- 将.got.plt合并到.got段中, 所以.got.plt将不复存在.
因此可以看到, 只有完全RELRO才能防止攻击者覆盖.got.plt, 因为在链接期间
就对程序符号进行了解析. 当然同时也放弃了延时绑定所带来的好处.
总结
为了灵活利用虚拟内存空间, 所以编译器可以产生位置无关的代码.
可执行文件可以是位置无关的, 也可以是位置相关的, 动态链接库
绝大多数都是位置无关的. GOT表可写不可执行, PLT可执行不可写,
他们相互作用来实现函数符号的延时绑定. ASLR并不随机化PLT部分,
所以对ret2plt攻击没有直接影响. 为防止恶意修改got, 链接器提供了RELRO
选项, 去除got的写权限, 但也牺牲了延时绑定带来的好处.
参考文章
RELRO - A (not so well known) Memory Corruption Mitigation Technique
欢迎交流, 文章转载请注明出处, 谢谢!