
CTFチャレンジの詳細なwrite-up:React SPAのリバースエンジニアリング、SVG画像のパース、貪欲マッチングによる自動ソルバーの構築、そして完璧なスコア達成。
結果:200/200(100%) — トークン:WKX-9245FA70-200 日付:2026年3月24日
LinkedInの投稿で、Wix KickstartX(ジュニア開発者プログラム)向けの特別なチャレンジが発表されました。 ルール:
wix-kickstartx-challenge-2026.base44.app/ と wixkickstart.comメインサイトwixkickstart.comはWixがホストするサイトです(WixのThunderboltエンジンによるサーバーサイドレンダリング)。標準のcurlはJavaScriptのシェルのみを返し、実際のコンテンツは含まれません。レンダリングにはヘッドレスChromiumを使用しました:
chromium --headless --disable-gpu --no-sandbox --virtual-time-budget=10000 \
--dump-dom "https://wixkickstart.com" > /tmp/wix_rendered.html
レンダリングされたDOMから、埋め込まれたiframeを抽出しました:
これはp5.jsによるパーティクルアニメーション(ヒーローセクションの見た目を飾るもの)であり、チャレンジ本体ではありませんでした。実際のチャレンジはBase44アプリ上にあります。
チャレンジはBase44(ローコードアプリプラットフォーム)上で動作します。このアプリはシングルページのReactアプリケーションで、すべてのロジックが1つのJSバンドルにまとめられています:
https://wix-kickstartx-challenge-2026.base44.app/assets/index-oTG160r9.js
サイズ: 410,809バイト(minified React + アプリロジック + Base44 SDK)
JSバンドルをgrepすることで、API全体をマッピングしました:
エンティティ: GameSession、Participant
API URLパターン:
POST /api/apps/{appId}/functions/{functionName}
アプリID: 69aea07cbcb9a3dd1039a58d
const or = 1800; // Time limit: 1800 seconds (30 minutes)
const rm = 200; // Total items: 200 image-description pairs
重要な発見:このアプリにはJSON一括送信モードがあります。minifyされたソースより:
function Ik({onSubmit:r, onClose:n}) {
// ...
h = JSON.parse(s) // Parse JSON input
// Validation: must be object like { "IMG-001": "DESC-042", ... }
r(h) // Submit all matches at once
}
モーダル内のプレースホルダーテキスト:
{
"IMG-001": "DESC-042",
"IMG-002": "DESC-017",
...
}
つまり、UI上で200回クリックする必要はなく、200件すべてのマッチのJSONマッピングをプログラム的に送信できます。
Base44 SDKはJWT認証を使用します:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
JWTペイロードには以下が含まれます:
{
"sub": "[email protected]",
"exp": 1782162460,
"iat": 1774386460
}
すべてのAPI呼び出しに必要なヘッダー:
Content-Type: application/json
Authorization: Bearer {jwt_token}
X-App-Id: 69aea07cbcb9a3dd1039a58d
Base44-Functions-Version: prod
X-Origin-URL: https://wix-kickstartx-challenge-2026.base44.app/
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/startGame
Status: 200
Transferred: 62.87 kB compressed (2.20 MB decompressed)
Content-Encoding: br (Brotli)
レスポンス構造:
{
"status": "active",
"sessionId": "69c2fd218e5b26f307c941c9",
"startedAt": "2026-03-24T21:07:45.101Z",
"imageOrder": ["IMG-154", "IMG-102", ...], // 200 items (display order)
"descOrder": ["DESC-125", "DESC-109", ...], // 200 items (display order)
"imagesData": { "IMG-001": "data:image/svg+xml;base64,...", ... }, // 200 SVGs
"descriptionsData": { "DESC-105": "milky field, overlaid with...", ... } // 200 texts
}
各画像は200x200のSVGで、以下を含みます:
デコードされたSVGの例(IMG-001):
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
<defs>
<linearGradient id="bg" ...>
<stop offset="0%" stop-color="#f5f0eb"/>
<stop offset="100%" stop-color="#e8e0d5"/>
</linearGradient>
</defs>
<rect width="200" height="200" fill="url(#bg)"/>
<!-- Optional overlay lines/dots/rings here -->
<polygon points="..." fill="#E2725B" transform="translate(128.6,185.7) rotate(90)"
style="opacity:0.5;filter:drop-shadow(...)"/>
<!-- More shapes... -->
</svg>
各説明文は厳密なパターンに従います:
{background} field[, overlaid with {overlay}]. {N} elements total:
{size} {opacity} {color} {shape} ({rotation}, at {position}) ·
{size} {opacity} {color} {shape} ({rotation}, at {position}) · ...
例:
milky field, overlaid with tilted cross-lines. 10 elements total:
minuscule nearly solid dim gray pike (steeply angled, at center inner-left) ·
substantial solid azure pike (diagonal, at top center) · ...
SVGのプロパティを説明文の語彙に対応付ける必要がありました。正確な対応を見つけるには、200件すべての画像と200件すべての説明文を分析する必要がありました。
両側の出現回数を数えることで、1:1のマッピングを確立しました:
方法: 200件すべてのSVGでユニークなグラデーション開始色を数え、200件すべての説明文でユニークな最初の単語を数え、件数で照合します。
SVGのオーバーレイはタイプによって実装方法が異なります:
重要な洞察: 背景パターンの要素はopacityをXMLの属性として使用します(例:opacity="0.06")。一方、前景シェイプはopacityをstyle属性内で使用します(例:style="opacity:0.7")。この区別は、背景パターンと実際のシェイプを分離するうえで決定的に重要でした。
200件すべてのSVGは、正確に28種類のユニークなhexフィル色を使用しています。200件すべての説明文は、正確に28種類のユニークな色名を使用しています。
原点からのシェイプ頂点の最大半径に基づく:
| SVGのopacity値 | 説明名 |
|---|---|
| 1.0 | solid |
| 0.85 | nearly solid |
| 0.7 | semi-transparent |
| 0.5 |
シェイプは7x7グリッドの座標に配置されます:
[14.3, 42.9, 71.4, 100.0, 128.6, 157.1, 185.7](XとYの両方)。
これらは次のカラム名に対応します:far-left, left, inner-left, center, inner-right, right, far-right
行名は次のとおりです:top, upper, upper-mid, center, lower-mid, lower, bottom
200件のSVG画像のそれぞれについて:
<linearGradient id="bg">の最初の<stop>色を読み取る<line>、<circle fill="none" stroke>、低不透明度のドット/シェイプを確認する<polygon>、<circle>、<path>、<rect>要素を見つける:
width="200"をスキップ)fill="url(#vig)"をスキップ)opacity="0.0x"をスキップ)style="opacity:..."を持っている(すべての前景シェイプはこれを持つ)fill属性から取得し、COLOR_MAPでマッピング200件のテキスト説明文のそれぞれについて:
(\d+) elements total(minuscule|modest|mid-sized|substantial|massive)
(solid|nearly solid|semi-transparent|faint)
(color name)
(shape type)
((rotation), at (position))
マッチングアルゴリズムは貪欲なスコアリング手法を使用します:
def score_match(img, desc):
# Hard constraints — must match exactly
if img['num'] != desc['num']: return -10000 # Shape count
if img['bg'] != desc['bg']: return -10000 # Background type
if img['overlay'] != desc['overlay']: return -10000 # Overlay type
score = 100 # Base score for matching hard constraints
# Soft scoring — color overlap
for color in img_colors:
if color in desc_colors:
score += 10
# Soft scoring — type overlap
for type in img_types:
if type in desc_types:
score += 8
# Per-shape detail matching (Hungarian-style greedy)
for each image_shape:
find best matching desc_shape by:
+20 if color matches
+15 if type matches
+10 if size matches
+8 if opacity matches
+5 if rotation matches
score += best_match_score
return score
その後、スコアが最も高いペアから順に貪欲に割り当てます:
all_scores.sort(reverse=True)
for score, img_id, desc_id in all_scores:
if img_id not in matched and desc_id not in used:
matches[img_id] = desc_id
1. ゲーム開始
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/startGame
Body: {"email": "[email protected]"}
Response: 200 OK (2.2 MB — all game data)
2. スコア送信
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/scoreGame
Body: {
"matches": {"IMG-032": "DESC-182", "IMG-198": "DESC-134", ...},
"sessionId": "69c2fd218e5b26f307c941c9"
}
Response: 200 OK
{"correctCount": 200, "scoreSeal": "3d1ac8a7e7be4856"}
3. トークン生成
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/generateToken
Body: {"sessionId": "69c2fd218e5b26f307c941c9"}
Response: 200 OK
{"token": "WKX-9245FA70-200", "score": 200}
| ステップ | アクション |
|---|---|
| 21:07:45 UTC | ゲーム開始(startGame呼び出し) |
| 21:07–21:15 | ソルバースクリプトの開発(解析+マッチング) |
| ~21:15 | scoreGame送信 — 200/200正解 |
| ~21:15 | generateToken呼び出し — |
合計解答時間:30分間のうち約8分。
JSON送信エンドポイントが決定的な鍵でした。これがなければ、ブラウザ自動化で400回クリックする必要があったでしょう(画像の選択+説明文の選択×200)。
カウントによるマッピングが、色・背景・オーバーレイの突破口になりました。hex #cd7f32が英語で何に対応するかを推測する代わりに、SVG内に65回出現し、説明文内に"brass"が65回出現することを数えました。出現回数がユニーク = 確実に一致します。
ハード制約は候補を素早く排除します。 各画像は(background_type、overlay_type、shape_count)のユニークな組み合わせを持ちます。12種類の背景×7種類のオーバーレイ×さまざまなシェイプ数により、ほとんどの画像では200件ではなく、ほんの一握りの説明マッチ候補しかありません。
SVGは構造化データです。 ラスター画像(PNG/JPG)とは異なり、SVGはXMLであり、すべてのシェイプ、色、位置、回転がテキストとして明示的にエンコードされています。コンピュータビジョンは不要です。
オーバーレイ検出は厄介でした。 背景パターンは5つの異なるSVGテクニックを使用していました:<line>要素、ストロークのみの<circle>、低不透明度属性の<circle>、低不透明度属性の<polygon>、そしてこれらがすべて存在しないパターンです。重要な区別は、XML属性としてのopacity(背景)とstyle内のopacity(前景シェイプ)でした。
色の語彙は自明ではありませんでした。 "oxblood"(#800020)、"brass"(#cd7f32)、"deep sapphire"(#0f52ba)のような名前にはカウント手法が必要でした。16進数の値だけからこれらを確実に推測することはできません。
SVGパスからのシェイプ分類には、SVGパスコマンドの理解が必要でした:
M(moveto)、L(lineto)、A(arc)、Z(closepath)| 関数 | 目的 |
|---|
startGame | ゲームセッションを作成し、200枚の画像と200件の説明文を返す |
scoreGame | {matches: {}, sessionId: ""}を受け取り、{correctCount: N}を返す |
generateToken | sessionIdを受け取り、完了トークンを返す |
getLeaderboard | 上位スコアを返す |
saveNickname | リーダーボード用の表示名を保存する |
| SVGグラデーション開始色 | 件数 | 説明語 | 件数 |
|---|
#0a1628 | 28 | pitch | 28 |
#eef2f7 | 20 | frosted | 20 |
#0d0d0d | 19 | tenebrous | 19 |
#fef9f0 | 18 | pearlescent | 18 |
#1e0a2e | 18 | nocturnal | 18 |
#0a1a0a | 17 | midnight | 17 |
#1a0a0a | 15 | inky | 15 |
#f5f0eb | 14 | milky | 14 |
#f7f3ee | 14 | ethereal | 14 |
#f0f0f0 | 14 | radiant | 14 |
#f0f7f4 | 13 | glowing | 13 |
#1a1a2e | 10 | somber | 10 |
| SVGパターン | 検出方法 | 件数 | 説明名 | 件数 |
|---|
<line> elements, horizontal (dy=0) | x1,y1,x2,y2をチェック | 35 | striped overlay | 35 |
<line> elements, vertical (dx=0) | x1,y1,x2,y2をチェック | 34 | lattice pattern | 34 |
<circle> with opacity="0.06" (dots) | 低不透明度の円を数える | 30 | stippled layer | 30 |
| No overlay elements at all | 線も背景シェイプもない | 28 | (none) | 28 |
<line> elements, diagonal | 傾きの方向をチェック | 27 | tilted cross-lines | 27 |
Small <polygon>/<path> with opacity="0.06" | 低不透明度属性のシェイプ | 25 | arrow-band texture | 25 |
<circle> with fill="none" stroke="#888" | ストロークのみの円 | 21 | ringed pattern | 21 |
| Hex | 説明名 | Hex | 説明名 |
|---|
#708090 | blue-gray | #cd7f32 | brass | |
#f5f5f5 | near white | #dc143c | fiery red | |
#00bcd4 | electric cyan | #2196f3 | azure | |
#ff6b6b | salmon pink | #228b22 | rich green | |
#98ff98 | pale green | #b0b0b0 | platinum | |
#0f52ba | deep sapphire | #800020 | oxblood | |
#ff8c00 | deep orange | #4b0082 | dark purple | |
#e2725b | terra rosa | #ffbf00 | marigold | |
#b7410e | russet | #ff69b4 | candy pink | |
#ffd700 | bright gold | #c0c0c0 | tin | |
#6b8e23 | moss | #008080 | deep teal | |
#40e0d0 | pale teal | #0047ab | royal blue | |
#e34234 | burnt sienna | #a0522d | deep red | |
#4a4a4a | dim gray | #36454f | dark gray |
| SVG要素 | 検出ロジック | 説明名 |
|---|
<polygon> 3 vertices | スペース区切りの座標ペアを数える | pike |
<polygon> 4 vertices | tilted square | |
<polygon> 5 vertices | quint form | |
<polygon> 6 vertices | bee cell | |
<polygon> 10 vertices | asterisk | |
<rect> (non-background) | width/heightを持ち、200x200ではない | tilted square |
<circle> | タグ名 | disc |
<path> with fill-rule="evenodd" | 2つの同心円弧パス | donut |
<path> with single arc + Z | 半円のパス | half-disc |
<path> with 6+ L commands | 十字/プラス形状 | crosshair |
<path> with 3-5 L commands | 矢印状のシェイプ | pointer |
| 半径の範囲 | 説明名 |
|---|
| 0–12 | minuscule |
| 13–16 | modest |
| 17–20 | mid-sized |
| 21–25 | substantial |
| 26+ | massive |
faint| SVGのrotate()値 | 説明名 |
|---|
| 0° | upright |
| 1–20° | slightly tilted |
| 21–55° | diagonal |
| 56–75° | steeply angled |
| 76–105° | sideways |
style="opacity:X"から取得transform="... rotate(X)"から取得transform="translate(X,Y) ..."から取得| コンポーネント | 技術 |
|---|
| チャレンジプラットフォーム | Base44(ローコードアプリビルダー) |
| フロントエンド | React SPA(単一JSバンドル、約410KB) |
| バックエンド | Cloudflareの背後にあるPython/uvicorn |
| CDN/プロキシ | Cloudflare(HTTP/3、Brotli圧縮) |
| 認証 | JWT(HS256)、localStorageに保存 |
| データ形式 | SVG(base64インライン)、JSON API |
| リアルタイム | ライブセッション更新用のWebSocket(socket.io) |
| ソルバー | Python 3(正規表現パース、外部ライブラリなし) |
fill-rule="evenodd"を持つ2つの同心円弧パス