
CTF 챌린지 상세 해설: 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 — 로우코드 앱 플랫폼에서 실행됩니다. 이 앱은 모든 로직이 하나의 JS 번들에 포함된 단일 페이지 React 애플리케이션입니다:
https://wix-kickstartx-challenge-2026.base44.app/assets/index-oTG160r9.js
크기: 410,809바이트(압축된 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 대량 제출 모드가 있습니다. 압축된 소스에서:
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"), 전경 도형은 style 속성 내부에서 opacity를 사용합니다(예: style="opacity:0.7"). 이 구분은 배경 패턴과 실제 도형을 분리하는 데 결정적이었습니다.
200개 모든 SVG는 정확히 28개의 고유한 hex 채우기 색상을 사용하며, 200개 모든 설명은 정확히 28개의 고유한 색상 이름을 사용합니다.
원점에서 도형 꼭짓점까지의 최대 반경 기준:
| SVG 불투명도 값 | 설명 이름 |
|---|---|
| 1.0 | solid |
| 0.85 | nearly solid |
| 0.7 | semi-transparent |
| 0.5 |
도형은 X와 Y 모두에 대해 다음 좌표의 7x7 그리드에 배치됩니다:
[14.3, 42.9, 71.4, 100.0, 128.6, 157.1, 185.7]
이 좌표는 열 이름에 매핑됩니다: 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번 클릭해야 했을 것입니다 (이미지 선택 + 설명 선택 x 200).
개수 기반 매핑이 색상, 배경, 오버레이에 대한 돌파구였습니다. hex #cd7f32가 영어로 무엇에 매핑되는지 추측하는 대신, SVG에서 65회 나타나고 설명에서 "brass"가 65회 나타나는 것을 세었습니다 — 고유한 개수 = 확실한 매칭.
하드 제약 조건이 후보를 빠르게 제거합니다. 각 이미지는 (background_type, overlay_type, shape_count)의 고유한 조합을 가집니다. 12가지 배경 x 7가지 오버레이 x 다양한 도형 개수로 대부분의 이미지는 200개가 아닌 소수의 가능한 설명 매칭만 갖습니다.
SVG는 구조화된 데이터입니다. 래스터 이미지(PNG/JPG)와 달리 SVG는 XML이므로 모든 도형, 색상, 위치, 회전이 텍스트로 명시적으로 인코딩됩니다. 컴퓨터 비전이 필요 없습니다.
오버레이 감지가 까다로웠습니다. 배경 패턴은 5가지 서로 다른 SVG 기법을 사용했습니다: <line> 요소, stroke만 있는 <circle>, 낮은 불투명도 속성을 가진 <circle>, 낮은 불투명도 속성을 가진 <polygon>, 그리고 이들 모두가 없는 경우. 핵심 구분은 XML 속성으로서의 opacity(배경)와 style 내부의 opacity(전경 도형)였습니다.
색상 어휘가 명확하지 않았습니다. "oxblood"(#800020), "brass"(#cd7f32), "deep sapphire"(#0f52ba) 같은 이름은 개수 기반 접근 방식이 필요했습니다 — hex 값만으로는 신뢰성 있게 추측할 수 없습니다.
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> 요소, 수평 (dy=0) | x1,y1,x2,y2 확인 | 35 | striped overlay | 35 |
<line> 요소, 수직 (dx=0) | x1,y1,x2,y2 확인 | 34 | lattice pattern | 34 |
opacity="0.06"인 <circle> (점) | 낮은 불투명도 원 개수 세기 | 30 | stippled layer | 30 |
| 오버레이 요소가 전혀 없음 | 선 없음, 배경 도형 없음 | 28 | (없음) | 28 |
<line> 요소, 대각선 | 기울기 방향 확인 | 27 | tilted cross-lines | 27 |
opacity="0.06"인 작은 <polygon>/<path> | 낮은 불투명도 속성 도형 | 25 | arrow-band texture | 25 |
fill="none" stroke="#888"인 <circle> | stroke만 있는 원 | 21 | ringed pattern | 21 |
| Hex | 설명 이름 | Hex | 설명 이름 |
|---|
#708090 | 청회색 | #cd7f32 | 놋쇠색 | |
#f5f5f5 | 거의 흰색 | #dc143c | 불타는 빨강 | |
#00bcd4 | 전기 시안 | #2196f3 | 하늘색 | |
#ff6b6b | 연어 핑크 | #228b22 | 짙은 초록 | |
#98ff98 | 옅은 초록 | #b0b0b0 | 플래티넘 | |
#0f52ba | 진한 사파이어 | #800020 | 옥스블러드 | |
#ff8c00 | 진한 주황 | #4b0082 | 진한 보라 | |
#e2725b | 테라 로사 | #ffbf00 | 메리골드 | |
#b7410e | 적갈색 | #ff69b4 | 캔디 핑크 | |
#ffd700 | 밝은 금색 | #c0c0c0 | 주석색 | |
#6b8e23 | 이끼색 | #008080 | 진한 틸 | |
#40e0d0 | 옅은 틸 | #0047ab | 로열 블루 | |
#e34234 | 번트 시에나 | #a0522d | 진한 빨강 | |
#4a4a4a | 어두운 회색 | #36454f | 짙은 회색 |
| SVG 요소 | 탐지 로직 | 설명 이름 |
|---|
<polygon> 꼭짓점 3개 | 공백으로 구분된 점 쌍 개수 세기 | pike |
<polygon> 꼭짓점 4개 | tilted square | |
<polygon> 꼭짓점 5개 | quint form | |
<polygon> 꼭짓점 6개 | bee cell | |
<polygon> 꼭짓점 10개 | asterisk | |
<rect> (배경 아님) | width/height가 있고 200x200이 아님 | tilted square |
<circle> | 태그 이름 | disc |
fill-rule="evenodd"인 <path> | 두 개의 동심원 호 경로 | donut |
단일 호 + Z를 가진 <path> | 반원 경로 | half-disc |
L 명령 6개 이상인 <path> | 십자/플러스 모양 | crosshair |
L 명령 3-5개인 <path> | 화살표 모양 | 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"를 가진 두 개의 동심원 호 경로