
Write-up detallado de un desafío CTF: ingeniería inversa de una SPA de React, parseo de imágenes SVG, creación de un solucionador automático con emparejamiento voraz (greedy) y logro de una puntuación perfecta.
Resultado: 200/200 (100%) — Token: WKX-9245FA70-200 Fecha: 24 de marzo de 2026
Una publicación de LinkedIn anunció un desafío especial para Wix KickstartX, un programa para desarrolladores júnior. Las reglas:
wix-kickstartx-challenge-2026.base44.app/ y wixkickstart.comEl sitio principal wixkickstart.com es un sitio alojado en Wix (renderizado del lado del servidor por el
motor Thunderbolt de Wix). Un curl estándar solo devuelve el shell de JavaScript, sin contenido real.
Usamos Chromium sin interfaz gráfica para renderizarlo:
chromium --headless --disable-gpu --no-sandbox --virtual-time-budget=10000 \
--dump-dom "https://wixkickstart.com" > /tmp/wix_rendered.html
Del DOM renderizado extrajimos un iframe incrustado:
Resultó ser una animación de partículas p5.js (decoración de la sección hero), no el desafío en sí. El desafío real está en la app de Base44.
El desafío se ejecuta en Base44, una plataforma de aplicaciones low-code. La app es una aplicación React de una sola página con toda la lógica en un único bundle de JS:
https://wix-kickstartx-challenge-2026.base44.app/assets/index-oTG160r9.js
Tamaño: 410 809 bytes (React minificado + lógica de la app + SDK de Base44)
Haciendo grep al bundle de JS, mapeamos toda la API:
Entidades: GameSession, Participant
Patrón de URL de la API:
POST /api/apps/{appId}/functions/{functionName}
ID de la app: 69aea07cbcb9a3dd1039a58d
const or = 1800; // Límite de tiempo: 1800 segundos (30 minutos)
const rm = 200; // Total de elementos: 200 pares imagen-descripción
Un hallazgo crítico: la app tiene un modo de envío masivo JSON. Del código fuente minificado:
function Ik({onSubmit:r, onClose:n}) {
// ...
h = JSON.parse(s) // Parsear entrada JSON
// Validación: debe ser un objeto como { "IMG-001": "DESC-042", ... }
r(h) // Enviar todas las coincidencias de una vez
}
Texto de marcador de posición en el modal:
{
"IMG-001": "DESC-042",
"IMG-002": "DESC-017",
...
}
Esto significa que no necesitamos hacer clic 200 veces en la interfaz: podemos enviar un mapeo JSON de las 200 coincidencias mediante programación.
El SDK de Base44 usa autenticación JWT:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
El payload del JWT contiene:
{
"sub": "[email protected]",
"exp": 1782162460,
"iat": 1774386460
}
Cabeceras requeridas para todas las llamadas a la 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)
Estructura de la respuesta:
{
"status": "active",
"sessionId": "69c2fd218e5b26f307c941c9",
"startedAt": "2026-03-24T21:07:45.101Z",
"imageOrder": ["IMG-154", "IMG-102", ...], // 200 elementos (orden de visualización)
"descOrder": ["DESC-125", "DESC-109", ...], // 200 elementos (orden de visualización)
"imagesData": { "IMG-001": "data:image/svg+xml;base64,...", ... }, // 200 SVGs
"descriptionsData": { "DESC-105": "milky field, overlaid with...", ... } // 200 textos
}
Cada imagen es un SVG de 200x200 que contiene:
Ejemplo de SVG decodificado (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>
Cada descripción sigue un patrón estricto:
{background} field[, overlaid with {overlay}]. {N} elements total:
{size} {opacity} {color} {shape} ({rotation}, at {position}) ·
{size} {opacity} {color} {shape} ({rotation}, at {position}) · ...
Ejemplo:
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) · ...
Necesitábamos mapear las propiedades SVG al vocabulario de las descripciones. Esto requirió analizar las 200 imágenes y las 200 descripciones para encontrar las correspondencias exactas.
Contando las ocurrencias en ambos lados, establecimos un mapeo 1:1:
Método: contar los colores únicos de inicio de gradiente en los 200 SVG, contar la primera palabra única en las 200 descripciones y emparejar por conteo.
Las superposiciones SVG se implementan de forma diferente según el tipo:
Idea clave: los elementos de patrón de fondo usan opacity como atributo XML
(por ejemplo, opacity="0.06"), mientras que las formas del primer plano usan opacity dentro del atributo
style (por ejemplo, style="opacity:0.7"). Esta distinción fue fundamental para separar
los patrones de fondo de las formas reales.
Los 200 SVG usan exactamente 28 colores de relleno hex únicos. Las 200 descripciones usan exactamente 28 nombres de color únicos.
Basado en el radio máximo de los vértices de la forma desde el origen:
| Valor de opacidad SVG | Nombre en la descripción |
|---|---|
| 1.0 | solid |
| 0.85 | nearly solid |
| 0.7 | semi-transparent |
Las formas se colocan en una cuadrícula de 7x7 en las coordenadas:
[14.3, 42.9, 71.4, 100.0, 128.6, 157.1, 185.7] tanto para X como para Y.
Estas se corresponden con los nombres de columna: far-left, left, inner-left, center, inner-right, right, far-right
Y nombres de fila: top, upper, upper-mid, center, lower-mid, lower, bottom
Para cada una de las 200 imágenes SVG:
<stop> de <linearGradient id="bg"><line>, <circle fill="none" stroke>, puntos/formas de baja opacidad<polygon>, <circle>, <path>, <rect> que:
width="200")fill="url(#vig)")opacity="0.0x" como atributo)style="opacity:..." (todas las formas del primer plano lo tienen)Para cada una de las 200 descripciones de texto:
(\d+) elements total(minuscule|modest|mid-sized|substantial|massive)
(solid|nearly solid|semi-transparent|faint)
(color name)
(shape type)
((rotation), at (position))
El algoritmo de emparejamiento usa un enfoque de puntuación voraz:
def score_match(img, desc):
# Restricciones duras: deben coincidir exactamente
if img['num'] != desc['num']: return -10000 # Conteo de formas
if img['bg'] != desc['bg']: return -10000 # Tipo de fondo
if img['overlay'] != desc['overlay']: return -10000 # Tipo de superposición
score = 100 # Puntuación base por cumplir las restricciones duras
# Puntuación suave: solapamiento de colores
for color in img_colors:
if color in desc_colors:
score += 10
# Puntuación suave: solapamiento de tipos
for type in img_types:
if type in desc_types:
score += 8
# Emparejamiento de detalles por forma (voraz estilo húngaro)
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
Luego asignar vorazmente los pares de mayor puntuación primero:
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. Iniciar juego
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/startGame
Body: {"email": "[email protected]"}
Response: 200 OK (2.2 MB — todos los datos del juego)
2. Puntuar juego
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. Generar token
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/generateToken
Body: {"sessionId": "69c2fd218e5b26f307c941c9"}
Response: 200 OK
{"token": "WKX-9245FA70-200", "score": 200}
| Paso | Acción |
|---|---|
| 21:07:45 UTC | Juego iniciado (se llamó a startGame) |
| 21:07–21:15 | Desarrollo del script solucionador (parseo + emparejamiento) |
| ~21:15 |
Tiempo total de resolución: ~8 minutos de una ventana de 30 minutos.
El endpoint de envío JSON fue el habilitador crítico: sin él, habríamos necesitado automatización de navegador para hacer clic 400 veces (seleccionar imagen + seleccionar descripción x 200).
El mapeo basado en conteos fue el avance para colores, fondos y superposiciones.
En lugar de adivinar a qué corresponde #cd7f32 en inglés, contamos que aparece
65 veces en los SVG y "brass" aparece 65 veces en las descripciones: un conteo único = coincidencia garantizada.
Las restricciones duras eliminan candidatos rápidamente. Cada imagen tiene una combinación única de (tipo de fondo, tipo de superposición, conteo de formas). Con 12 fondos x 7 superposiciones x conteos de formas variables, la mayoría de las imágenes solo tienen un puñado de descripciones posibles, no 200.
El SVG son datos estructurados. A diferencia de las imágenes rasterizadas (PNG/JPG), los SVG son XML: cada forma, color, posición y rotación está explícitamente codificado como texto. No se necesita visión por computadora.
La detección de superposiciones fue complicada. Los patrones de fondo usaban 5 técnicas SVG diferentes:
elementos <line>, <circle> solo con trazo, <circle> con atributo de baja opacidad,
<polygon> con atributo de baja opacidad y ausencia de todo lo anterior. La distinción clave
fue opacity como atributo XML (fondo) frente a dentro de style (formas del primer plano).
El vocabulario de colores no era obvio. Nombres como "oxblood" (#800020), "brass"
(#cd7f32) y "deep sapphire" (#0f52ba) requirieron el enfoque de conteo: no se pueden
adivinar de forma fiable solo a partir de los valores hex.
La clasificación de formas a partir de rutas SVG requirió comprender los comandos de ruta SVG:
M (moveto), L (lineto), A (arc), (closepath)| Función | Propósito |
|---|
startGame | Crea una sesión de juego, devuelve 200 imágenes + 200 descripciones |
scoreGame | Acepta {matches: {}, sessionId: ""}, devuelve {correctCount: N} |
generateToken | Toma sessionId, devuelve el token de finalización |
getLeaderboard | Devuelve los puntajes más altos |
saveNickname | Guarda el nombre para mostrar en la tabla de clasificación |
| Color inicial del gradiente SVG | Conteo | Palabra de descripción | Conteo |
|---|
#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 |
| Patrón SVG | Método de detección | Conteo | Nombre en la descripción | Conteo |
|---|
Elementos <line> horizontales (dy=0) | Comprobar x1,y1,x2,y2 | 35 | striped overlay | 35 |
Elementos <line> verticales (dx=0) | Comprobar x1,y1,x2,y2 | 34 | lattice pattern | 34 |
<circle> con opacity="0.06" (puntos) | Contar círculos de baja opacidad | 30 | stippled layer | 30 |
| Sin elementos de superposición | Sin líneas, sin formas de fondo | 28 | (ninguno) | 28 |
Elementos <line> diagonales | Comprobar la dirección de la pendiente | 27 | tilted cross-lines | 27 |
<polygon>/<path> pequeños con opacity="0.06" | Formas con atributo de baja opacidad | 25 | arrow-band texture | 25 |
<circle> con fill="none" stroke="#888" | Solo círculos con trazo | 21 | ringed pattern | 21 |
| Hex | Nombre en la descripción | Hex | Nombre en la descripción |
|---|
#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 |
| Elemento SVG | Lógica de detección | Nombre en la descripción |
|---|
<polygon> con 3 vértices | Contar pares de puntos separados por espacios | pike |
<polygon> con 4 vértices | tilted square | |
<polygon> con 5 vértices | quint form | |
<polygon> con 6 vértices | bee cell | |
<polygon> con 10 vértices | asterisk | |
<rect> (no fondo) | Tiene width/height, no 200x200 | tilted square |
<circle> | Nombre de la etiqueta | disc |
<path> con fill-rule="evenodd" | Dos rutas de arco concéntricas | donut |
<path> con un solo arco + Z | Ruta de semicírculo | half-disc |
<path> con 6+ comandos L | Forma de cruz/más | crosshair |
<path> con 3-5 comandos L | Forma de flecha | pointer |
| Rango de radio | Nombre en la descripción |
|---|
| 0–12 | minuscule |
| 13–16 | modest |
| 17–20 | mid-sized |
| 21–25 | substantial |
| 26+ | massive |
| 0.5 | faint |
| Valor de rotate() SVG | Nombre en la descripción |
|---|
| 0° | upright |
| 1–20° | slightly tilted |
| 21–55° | diagonal |
| 56–75° | steeply angled |
| 76–105° | sideways |
fill, mapeado mediante COLOR_MAPstyle="opacity:X"transform="... rotate(X)"transform="translate(X,Y) ..."| scoreGame enviado — 200/200 correctos |
| ~21:15 | generateToken llamado — WKX-9245FA70-200 |
| Componente | Tecnología |
|---|
| Plataforma del desafío | Base44 (constructor de apps low-code) |
| Frontend | React SPA (un único bundle JS, ~410 KB) |
| Backend | Python/uvicorn detrás de Cloudflare |
| CDN/Proxy | Cloudflare (HTTP/3, compresión Brotli) |
| Autenticación | JWT (HS256), almacenado en localStorage |
| Formato de datos | SVG (base64 en línea), API JSON |
| Tiempo real | WebSocket (socket.io) para actualizaciones de sesión en vivo |
| Solucionador | Python 3 (parseo con regex, sin librerías externas) |
Zfill-rule="evenodd"