
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:
| 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 |
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:
| 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 |
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:
| 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 |