0CTF 2018 Web partial Writeup

0x00

0ctf题目真的很强,萌新表示只能赛后复现writeup了。做完了misc两个签到题就跪了。正文还没有整理完毕,目前Ezdoor和Login的writeup思路已经写在了正文中,部分扩展和引用内容均已标注。

EzDoor

看起来很友好的一题,直接给了源码。

<?php

error_reporting(0);

$dir = 'sandbox/' . sha1($_SERVER['REMOTE_ADDR']) . '/';
if(!file_exists($dir)){  
  mkdir($dir);
}
if(!file_exists($dir . "index.php")){  
  touch($dir . "index.php");
}

function clear($dir)  
{
  if(!is_dir($dir)){
    unlink($dir);
    return;
  }
  foreach (scandir($dir) as $file) {
    if (in_array($file, [".", ".."])) {
      continue;
    }
    unlink($dir . $file);
  }
  rmdir($dir);
}

switch ($_GET["action"] ?? "") {  
  case 'pwd':
    echo $dir;
    break;
  case 'phpinfo':
    echo file_get_contents("phpinfo.txt");
    break;
  case 'reset':
    clear($dir);
    break;
  case 'time':
    echo time();
    break;
  case 'upload':
    if (!isset($_GET["name"]) || !isset($_FILES['file'])) {
      break;
    }

    if ($_FILES['file']['size'] > 100000) {
      clear($dir);
      break;
    }

    $name = $dir . $_GET["name"];
    if (preg_match("/[^a-zA-Z0-9.\/]/", $name) ||
      stristr(pathinfo($name)["extension"], "h")) {
      break;
    }
    move_uploaded_file($_FILES['file']['tmp_name'], $name);
    $size = 0;
    foreach (scandir($dir) as $file) {
      if (in_array($file, [".", ".."])) {
        continue;
      }
      $size += filesize($dir . $file);
    }
    if ($size > 100000) {
      clear($dir);
    }
    break;
  case 'shell':
    ini_set("open_basedir", "/var/www/html/$dir:/var/www/html/flag");
    include $dir . "index.php";
    break;
  default:
    highlight_file(__FILE__);
    break;
}

对每个ip建立沙箱并初始化一个index.php,并且沙箱内页面无法直接访问。依次测试了各参数:phpinfo给出了部分信息,php7.0.28,opcache,session upload process,等等;上传功能对扩展名进行了判断,尝试使用最新的解析漏洞绕过失败;根据shell函数,标准思路因该是覆盖沙箱内index.php逻辑,从而getshell。
之后没了思路,上传一直没有效果,后来师傅说是opcache,找到了php7.0 opcache getshell,根据原文在给出的phpinfo中查看了几项要求,发现打开了时间戳校检,也给了如何绕过,利用time参数可以获取当前时间戳,利用clean可初始化index.php时间戳,基本的过程都有了参考。
基本条件已经满足,本地搭建同版本同目录测试环境生成bin文件上传覆盖,选择使用fpm的php花掉了很多时间去把环境运行起来,心态炸了。
覆盖时注意将服务器获取的时间戳写入本地生成的bin文件中。

scandir(/var/www/html/flag)返回

Array  
(
    [0] => .
    [1] => ..
    [2] => 93f4c28c0cf0b07dfd7012dca2cb868cc0228cad
)

读文件,拿到一个opcache文件,010打开后发现格式有问题,比较后添加一个00。
使用opcache_disassembler.py还原,可得opcode,还原出大概逻辑如下:

def encode(data):  
    return output.encode('hex')
def encrypt(flag,strings):  
    mt_srand(1337)
    output = ""
    for i in range(len(flag)):
        output += chr(ord(flag[i])^ord(strings[i])^mt_rand(0,255))
    return encode(output)
flag = raw_input('input_your_flag_here')  
if encrypt(flag,this_is_a_very_secret_key)==="85b954fc8380a466276e4a48249ddd4a199fc34e5b061464e4295fc5020c88bfd8545519ab":  
    print 'Congratulation! You got it!'
else:  
    'Wrong Answer'

解密即可。
cdxy给出了vld精准指令还原的过程。

Loginme

nodejs和mongodb结合,一度以为是mongodb盲注(x)
贴一下代码和注释

var express = require('express')  
var app = express()

var bodyParser = require('body-parser')  
app.use(bodyParser.urlencoded({}));

var path    = require("path");  
var moment = require('moment');  
var MongoClient = require('mongodb').MongoClient;  
var url = "mongodb://localhost:27017/";

MongoClient.connect(url, function(err, db) { # 连接mongodb  
    if (err) throw err;
    dbo = db.db("test_db");
    var collection_name = "users";
    var password_column = "password_"+Math.random().toString(36).slice(2) # 初始化字段名
    var password = "XXXXXXXXXXXXXXXXXXXXXX";
    // flag is flag{password}
    var myobj = { "username": "admin", "last_access": moment().format('YYYY-MM-DD HH:mm:ss Z')};
    myobj[password_column] = password;
    dbo.collection(collection_name).remove({}); # 清空
    dbo.collection(collection_name).update(
        { name: myobj.name },
        myobj,
        { upsert: true } # 强制更新
    );

    app.get('/', function (req, res) {
        res.sendFile(path.join(__dirname,'index.html'));
    })
    app.post('/check', function (req, res) {
        var check_function = 'if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.'+password_column+'){\nreturn 1;\n}else{\nreturn 0;}';

        for(var k in req.body){ # 对post参数进行检查
            var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1}); # black list 
            if(!valid) res.send('Nope');
            check_function = check_function.replace(
                new RegExp('#'+k+'#','gm')
                ,JSON.stringify(req.body[k])) # 正则替换,字符串转换强行增加一对""
        }
        var query = {"$where" : check_function};
        var newvalue = {$set : {last_access: moment().format('YYYY-MM-DD HH:mm:ss Z')}}
        dbo.collection(collection_name).updateOne(query,newvalue,function (e,r){
            if(e) throw e;
            res.send('ok'); # 查询成功(无结果)返回ok
            // ... implementing, plz dont release this.
        });
    })
    app.listen(8081)
});

拿到题目后把源码看懂用了不少时间,逻辑分析清楚后把点放在了mongodb的盲注上,然而黑名单的限制导致延时函数无法注入,卡了很久之后队友告诉我是利用正则贪婪,然而最后也没有搞明白,赛后搜wp时也没看懂(菜),还好有一位师傅写的很具体,在这里我引用了他的wp,也结合了自己的一点分析。
源码中还有一点没分析到,当查询异常时回返回异常,这里也可以利用。

分析我们可控的位置,即req.body字典,更清晰来说是字典的keys和values(之前一直卡在keys限于username和password上了,一直没看懂师傅们的解题思路)。然后body插入进了正则和替换后的字符串中,我们考虑如何改变正则来控制查询条件。
首先自带的语句中包含了#界定符,我们可以直接使用|忽略掉。然后考虑如何构造。
即重点在... && hex_md5(#password#) == this.password_column)如何更改。
如下三种思路:

... && #password# == this.password_column.substr( ... )){ ... // 无法替换出 ( ),放弃
... && #password# <= this.password_column){ ... // <= 左右会出现引号,无法处理,放弃
... && #password# <= this["password_column"]){ ... // <= 左右会出现引号,但可以用作 "password" 和 "password_column" 中的引号,可以尝试
// if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.password_column){return 1;}else{return 0;}

// 一、替换用户名
username      -> "admin"  
// 结果: if(this.username == "admin" && "admin" == "admin" && hex_md5(#password#) == this.password_column){return 1;}else{return 0;}

// 二、替换密码判断部分(用到了正则中的断言)
(?=\){)      ->   "] +"    # 这里做了更改,原文为{},可能是手误
== this.      ->   "<=this["
hex.*?rd.*?"  ->   "猜测的密码"  
"(?=\))       ->   ""
// 结果: if(this.username == "admin" && "admin" == "admin" && "猜测的密码" <=this["password_column"] +""){return 1;}else{return 0;}

// 三、将 return 0 换为会报错的 return a
0;             -> "asuna"  
asuna          -> "+a+"  
// 结果: if(this.username == "admin" && "admin" == "admin" && "猜测的密码" <=this["password_column"] +""){return 1;}else{return ""+a+""}

之后就是写脚本跑了
话说题目突然给了三个相同的环境...

refer:
https://coxxs.me/676

Easy User Manage System

Welcome to the Easy UMS. http://202.120.7.196:2333/
Since you don't like the waf, port is changed to 2333.
You need bind your phone with exactly 8.8.8.8 to get flag.

题目环境很简单,注册一个账户,填写用户名 密码 和 phone(使用ip即可),登录后服务器向ip发送一个包含token的head请求,将token填写在二次验证的页面,即可登录。

Listening on [0.0.0.0] (port 80)  
Connection from 202.120.7.196 34302 received!  
HEAD /?5d67ebdbc589ced5321bb4f76b734e33 HTTP/1.1  
Host: xx  
Accept: */*  


登陆后如下:
跟进去

这里便是更改界面了
很明显不能够改为8.8.8.8,根据hint提示,改为8.8.8.8即可得到flag,我们尝试更改ip,发现在提交后有数秒的等待,之后再要求验证新的ip。
有师傅尝试了注入等方法,没有效果,但是有数(3)秒的等待很可能就是出题人在某个条件前故意加了sleep,很显然是判断有没有提交过token,没有就需要提交服务器发送的token。
那么我们想到了条件竞争,在三秒的延时中,完成另一个请求提交token的过程,改变另一个请求提交的ip。
在这里给出在阅读wp的过程中见到的测试过程。
1. 登陆后准备两个页面(同一个session),A的ip填写自己的,B填写8.8.8.8,提交A的请求,拿到token,暂时不提交token;这时,提交B的请求,同时(三秒内)在A界面提交A的token。但是发现A界面提交后仍存在延时,之后A,B界面均需要填写token,可能是服务器对单个session的请求严格按照时间顺序执行了,A原有的token已经被B请求的token覆盖,所以A需要重新验证。
2. 准备两个session的页面(同一个account),A的ip填写自己的,B填写8.8.8.8,提交A的请求,拿到token,暂时不提交token;这时,提交B的请求,同时(三秒内)在A界面提交A的token。这时,A页面更改成功,拿到flag。

refer:
https://coxxs.me/676
https://whit3hat.com/2018/04/05/0ps-ctf-easy-management-web/

h4x0rs.club 2

backend_www got backup at /var/www/html.tar.gz
Hint: Get open-redirect first, lead admin to the w0rld!
https://h4x0rs.club/game/

https://github.com/l4wio/CTF-challenges-by-me/tree/master/0ctf_quals-2018/h4x0rs.club

Blog

http://202.120.7.197:8090/
wp:
https://blog.cal1.cn/post/0CTF%202018%20Quals%20Bl0g%20writeup

xmsec

Lancet成员,很菜却在努力。