
Подробный разбор CTF-задания: реверс-инжиниринг React SPA, парсинг SVG-изображений, создание автоматического солвера с жадным сопоставлением и достижение идеального результата.
Результат: 200/200 (100%) — Токен: WKX-9245FA70-200 Дата: 24 марта 2026 года
В посте LinkedIn был анонсирован специальный челлендж для Wix KickstartX — программы для начинающих разработчиков. Правила:
wix-kickstartx-challenge-2026.base44.app/ и wixkickstart.comОсновной сайт wixkickstart.com размещён на Wix (серверный рендеринг движком Thunderbolt от Wix). Обычный curl возвращает только JavaScript-оболочку — без фактического содержимого. Чтобы отрендерить его, мы использовали headless Chromium:
chromium --headless --disable-gpu --no-sandbox --virtual-time-budget=10000 \
--dump-dom "https://wixkickstart.com" > /tmp/wix_rendered.html
Из отрендеренного DOM мы извлекли встроенный iframe:
Это оказалось p5.js-анимацией частиц (декоративный элемент hero-секции), а не сам челлендж. Настоящий челлендж находится в приложении Base44.
Челлендж запущен на Base44 — low-code платформе для приложений. Приложение представляет собой одностраничное React-приложение, вся логика которого находится в одном JS-бандле:
https://wix-kickstartx-challenge-2026.base44.app/assets/index-oTG160r9.js
Размер: 410 809 байт (минифицированный React + логика приложения + SDK Base44)
С помощью grep по JS-бандлу мы составили карту всего API:
Сущности: GameSession, Participant
Шаблон URL API:
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",
...
}
Это значит, что нам не нужно кликать 200 раз в интерфейсе — мы можем программно отправить JSON-сопоставление всех 200 пар.
SDK Base44 использует 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
}
Каждое изображение — это SVG размером 200x200, содержащий:
Пример декодированного 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 описаний, чтобы найти точные соответствия.
Подсчитав вхождения с обеих сторон, мы установили взаимно-однозначное соответствие:
Метод: Посчитать уникальные начальные цвета градиентов во всех 200 SVG, посчитать уникальные первые слова во всех 200 описаниях и сопоставить по количеству.
Оверлеи SVG реализованы по-разному в зависимости от типа:
Ключевая идея: элементы фоновых узоров используют opacity как XML-атрибут (например, opacity="0.06"), тогда как фигуры переднего плана используют opacity внутри атрибута style (например, style="opacity:0.7"). Это различие было критически важным для отделения фоновых узоров от настоящих фигур.
Во всех 200 SVG используется ровно 28 уникальных шестнадцатеричных цветов заливки. Во всех 200 описаниях используется ровно 28 уникальных названий цветов.
Основано на максимальном радиусе вершин фигуры от центра:
| Значение opacity в SVG | Название в описании |
|---|---|
| 1.0 | solid |
| 0.85 | nearly solid |
| 0.7 | semi-transparent |
Фигуры размещаются на сетке 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-изображений:
<stop> из <linearGradient id="bg"><line>, <circle fill="none" stroke>, точек/фигур с низкой прозрачностью<polygon>, <circle>, <path>, <rect>, которые:
width="200")fill="url(#vig)")opacity="0.0x")style="opacity:..." (есть у всех фигур переднего плана)Для каждого из 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 — |
Общее время решения: ~8 минут из 30-минутного окна.
Эндпоинт JSON-отправки стал критически важным фактором — без него нам понадобилась бы автоматизация браузера для 400 кликов (выбор изображения + выбор описания x 200).
Сопоставление на основе подсчёта стало прорывом для цветов, фонов и оверлеев.
Вместо того чтобы угадывать, какому английскому слову соответствует hex-код #cd7f32, мы подсчитали, что он встречается
65 раз в SVG, а "brass" — 65 раз в описаниях — уникальное количество = гарантированное
совпадение.
Жёсткие ограничения быстро отсекают кандидатов. Каждое изображение имеет уникальную комбинацию (background_type, overlay_type, shape_count). При 12 фонах x 7 оверлеях x различных количествах фигур большинство изображений имеют лишь несколько возможных совпадений с описаниями, а не 200.
SVG — это структурированные данные. В отличие от растровых изображений (PNG/JPG), SVG являются XML — каждая фигура, цвет, позиция и поворот явно закодированы текстом. Компьютерное зрение не нужно.
Определение оверлея было коварным. Фоновые узоры использовали 5 разных техник SVG:
элементы <line>, <circle> только со stroke, <circle> с атрибутом низкой прозрачности,
<polygon> с атрибутом низкой прозрачности и отсутствие всего этого. Ключевое различие
— opacity как XML-атрибут (фон) против opacity внутри style (фигуры переднего плана).
Цветовой словарь был неочевидным. Названия вроде "oxblood" (#800020), "brass"
(#cd7f32) и "deep sapphire" (#0f52ba) потребовали подхода с подсчётом — по одним
hex-значениям их невозможно надёжно угадать.
Классификация фигур по SVG-путям потребовала понимания команд SVG-путей:
M (moveto), L (lineto), (arc), (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 |
<circle> с opacity="0.06" (точки) | Подсчёт окружностей с низкой прозрачностью | 30 | stippled layer | 30 |
| Нет элементов оверлея вообще | Нет линий, нет фоновых фигур | 28 | (none) | 28 |
Элементы <line>, диагональные | Проверка направления наклона | 27 | tilted cross-lines | 27 |
Небольшие <polygon>/<path> с opacity="0.06" | Фигуры с атрибутом низкой прозрачности | 25 | arrow-band texture | 25 |
<circle> с fill="none" stroke="#888" | Окружности только со stroke | 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 вершины | Считать пары координат, разделённые пробелами | pike |
<polygon> 4 вершины | tilted square | |
<polygon> 5 вершин | quint form | |
<polygon> 6 вершин | bee cell | |
<polygon> 10 вершин | asterisk | |
<rect> (не фон) | Имеет width/height, не 200x200 | tilted square |
<circle> | Имя тега | disc |
<path> с fill-rule="evenodd" | Два концентрических дуговых пути | donut |
<path> с одной дугой + Z | Путь в виде полукруга | half-disc |
<path> с 6+ командами L | Форма креста/плюса | crosshair |
<path> с 3-5 командами L | Стрелообразная форма | pointer |
| Диапазон радиуса | Название в описании |
|---|
| 0–12 | minuscule |
| 13–16 | modest |
| 17–20 | mid-sized |
| 21–25 | substantial |
| 26+ | massive |
| 0.5 |
faint |
| Значение rotate() в SVG | Название в описании |
|---|
| 0° | upright |
| 1–20° | slightly tilted |
| 21–55° | diagonal |
| 56–75° | steeply angled |
| 76–105° | sideways |
fill, сопоставляется через COLOR_MAPstyle="opacity:X"transform="... rotate(X)"transform="translate(X,Y) ..."| ~21:15 | Вызван generateToken — WKX-9245FA70-200 |
| Компонент | Технология |
|---|
| Платформа челленджа | Base44 (low-code конструктор приложений) |
| Фронтенд | React SPA (один JS-бандл, ~410 КБ) |
| Бэкенд | Python/uvicorn за Cloudflare |
| CDN/Прокси | Cloudflare (HTTP/3, сжатие Brotli) |
| Аутентификация | JWT (HS256), хранится в localStorage |
| Формат данных | SVG (base64 inline), JSON API |
| Реалтайм | WebSocket (socket.io) для обновлений сессии в реальном времени |
| Решатель | Python 3 (парсинг регулярными выражениями, без внешних библиотек) |
AZfill-rule="evenodd"