
Análise detalhada de um desafio de CTF: engenharia reversa de um SPA em React, parsing de imagens SVG, criação de um solucionador automatizado com correspondência gulosa e obtenção de uma pontuação perfeita.
Resultado: 200/200 (100%) — Token: WKX-9245FA70-200 Data: 24 de março de 2026
Uma publicação no LinkedIn anunciou um desafio especial para o Wix KickstartX — um programa para desenvolvedores juniores. As regras:
wix-kickstartx-challenge-2026.base44.app/ e wixkickstart.comO site principal wixkickstart.com é um site hospedado na Wix (renderizado no servidor pelo mecanismo
Thunderbolt da Wix). Um curl padrão retorna apenas o shell JavaScript — sem conteúdo real.
Usamos Chromium headless para renderizá-lo:
chromium --headless --disable-gpu --no-sandbox --virtual-time-budget=10000 \
--dump-dom "https://wixkickstart.com" > /tmp/wix_rendered.html
Do DOM renderizado extraímos um iframe embutido:
Isso acabou sendo uma animação de partículas p5.js (enfeite da seção hero), não o desafio em si. O desafio real está no app Base44.
O desafio roda na Base44 — uma plataforma de apps low-code. O app é uma aplicação React de página única com toda a lógica em um único bundle JS:
https://wix-kickstartx-challenge-2026.base44.app/assets/index-oTG160r9.js
Tamanho: 410.809 bytes (React minificado + lógica do app + SDK Base44)
Ao buscar no bundle JS, mapeamos toda a API:
Entidades: GameSession, Participant
Padrão de URL da API:
POST /api/apps/{appId}/functions/{functionName}
App ID: 69aea07cbcb9a3dd1039a58d
const or = 1800; // Time limit: 1800 seconds (30 minutes)
const rm = 200; // Total items: 200 image-description pairs
Uma descoberta crítica — o app tem um modo de envio em massa por JSON. Do código minificado:
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
}
Texto de placeholder no modal:
{
"IMG-001": "DESC-042",
"IMG-002": "DESC-017",
...
}
Isso significa que não precisamos clicar 200 vezes na interface — podemos enviar um mapeamento JSON de todas as 200 correspondências programaticamente.
O SDK Base44 usa autenticação JWT:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
O payload do JWT contém:
{
"sub": "[email protected]",
"exp": 1782162460,
"iat": 1774386460
}
Cabeçalhos necessários para todas as chamadas de 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)
Estrutura da resposta:
{
"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
}
Cada imagem é um SVG 200x200 contendo:
Exemplo 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 descrição segue um padrão estrito:
{background} field[, overlaid with {overlay}]. {N} elements total:
{size} {opacity} {color} {shape} ({rotation}, at {position}) ·
{size} {opacity} {color} {shape} ({rotation}, at {position}) · ...
Exemplo:
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) · ...
Precisávamos mapear propriedades SVG para o vocabulário das descrições. Isso exigiu analisar todas as 200 imagens e todas as 200 descrições para encontrar correspondências exatas.
Ao contar ocorrências nos dois lados, estabelecemos um mapeamento 1:1:
Método: Contar as cores iniciais únicas do gradiente em todos os 200 SVGs, contar a primeira palavra única em todas as 200 descrições e fazer a correspondência pela contagem.
Os overlays SVG são implementados de forma diferente conforme o tipo:
Insight-chave: Os elementos de padrão de fundo usam opacity como um atributo XML
(por exemplo, opacity="0.06"), enquanto as formas em primeiro plano usam opacity dentro do atributo
style (por exemplo, style="opacity:0.7"). Essa distinção foi crucial para separar
os padrões de fundo das formas reais.
Todos os 200 SVGs usam exatamente 28 cores hexadecimais de preenchimento únicas. Todas as 200 descrições usam exatamente 28 nomes de cores únicos.
Baseado no raio máximo dos vértices da forma a partir da origem:
| Valor de opacidade no SVG | Nome na Descrição |
|---|---|
| 1.0 | solid |
| 0.85 | nearly solid |
| 0.7 | semi-transparent |
As formas são posicionadas em uma grade 7x7 nas coordenadas:
[14.3, 42.9, 71.4, 100.0, 128.6, 157.1, 185.7] tanto para X quanto para Y.
Elas mapeiam para nomes de colunas: far-left, left, inner-left, center, inner-right, right, far-right
E nomes de linhas: top, upper, upper-mid, center, lower-mid, lower, bottom
Para cada uma das 200 imagens SVG:
<stop> de <linearGradient id="bg"><line>, <circle fill="none" stroke>, pontos/formas de baixa opacidade<polygon>, <circle>, <path>, <rect> que:
width="200")fill="url(#vig)")opacity="0.0x" como atributo)style="opacity:..." (todas as formas em primeiro plano têm isso)Para cada uma das 200 descrições de texto:
(\d+) elements total(minuscule|modest|mid-sized|substantial|massive)
(solid|nearly solid|semi-transparent|faint)
(color name)
(shape type)
((rotation), at (position))
O algoritmo de correspondência usa uma abordagem de pontuação gulosa:
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
Em seguida, atribuir guloso os pares de maior pontuação primeiro:
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 Jogo
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/startGame
Body: {"email": "[email protected]"}
Response: 200 OK (2.2 MB — all game data)
2. Pontuar o Jogo
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. Gerar Token
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/generateToken
Body: {"sessionId": "69c2fd218e5b26f307c941c9"}
Response: 200 OK
{"token": "WKX-9245FA70-200", "score": 200}
| Passo | Ação |
|---|---|
| 21:07:45 UTC | Jogo iniciado (startGame chamado) |
| 21:07–21:15 | Desenvolvimento do script solucionador (análise + correspondência) |
| ~21:15 |
Tempo total de resolução: ~8 minutos de uma janela de 30 minutos.
O endpoint de envio em JSON foi o facilitador crítico — sem ele, precisaríamos de automação de navegador para clicar 400 vezes (selecionar imagem + selecionar descrição x 200).
O mapeamento baseado em contagem foi o avanço para cores, fundos e overlays.
Em vez de adivinhar a que hex #cd7f32 corresponde em inglês, contamos que ele aparece
65 vezes nos SVGs e "brass" aparece 65 vezes nas descrições — contagem única = correspondência
garantida.
Restrições rígidas eliminam candidatos rapidamente. Cada imagem tem uma combinação única de (tipo_de_fundo, tipo_de_overlay, contagem_de_formas). Com 12 fundos x 7 overlays x contagens de formas variadas, a maioria das imagens tem apenas algumas descrições possíveis, não 200.
SVG é dado estruturado. Ao contrário de imagens raster (PNG/JPG), SVGs são XML — cada forma, cor, posição e rotação está explicitamente codificada como texto. Nenhuma visão computacional é necessária.
A detecção de overlay era complicada. Os padrões de fundo usavam 5 técnicas SVG diferentes:
elementos <line>, <circle> apenas com stroke, <circle> com atributo de baixa opacidade,
<polygon> com atributo de baixa opacidade e a ausência de todos eles. A distinção-chave
era opacity como atributo XML (fundo) vs. dentro de style (formas em primeiro plano).
O vocabulário de cores não era óbvio. Nomes como "oxblood" (#800020), "brass"
(#cd7f32) e "deep sapphire" (#0f52ba) exigiram a abordagem de contagem — não dá
para adivinhar com segurança apenas pelos valores hexadecimais.
A classificação de formas a partir de paths SVG exigiu entender comandos de path SVG:
M (moveto), L (lineto), A (arc), (closepath)| Função | Finalidade |
|---|
startGame | Cria uma sessão de jogo, retorna 200 imagens + 200 descrições |
scoreGame | Aceita {matches: {}, sessionId: ""}, retorna {correctCount: N} |
generateToken | Recebe sessionId, retorna token de conclusão |
getLeaderboard | Retorna as maiores pontuações |
saveNickname | Salva o nome de exibição para o leaderboard |
| Cor Inicial do Gradiente SVG | Contagem | Palavra na Descrição | Contagem |
|---|
#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 |
| Padrão SVG | Método de Detecção | Contagem | Nome na Descrição | Contagem |
|---|
Elementos <line> horizontais (dy=0) | Verificar x1,y1,x2,y2 | 35 | striped overlay | 35 |
Elementos <line> verticais (dx=0) | Verificar x1,y1,x2,y2 | 34 | lattice pattern | 34 |
<circle> com opacity="0.06" (pontos) | Contar círculos de baixa opacidade | 30 | stippled layer | 30 |
| Nenhum elemento de overlay | Sem linhas, sem formas de fundo | 28 | (nenhum) | 28 |
Elementos <line> diagonais | Verificar a direção da inclinação | 27 | tilted cross-lines | 27 |
<polygon>/<path> pequenos com opacity="0.06" | Formas com atributo de baixa opacidade | 25 | arrow-band texture | 25 |
<circle> com fill="none" stroke="#888" | Círculos apenas com stroke | 21 | ringed pattern | 21 |
| Hex | Nome na Descrição | Hex | Nome na Descrição |
|---|
#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 Detecção | Nome na Descrição |
|---|
<polygon> com 3 vértices | Contar pares de pontos separados por espaço | pike |
<polygon> com 4 vértices | tilted square | |
<polygon> com 5 vértices | quint form | |
<polygon> com 6 vértices | bee cell | |
<polygon> com 10 vértices | asterisk | |
<rect> (não-fundo) | Tem largura/altura, não 200x200 | tilted square |
<circle> | Nome da tag | disc |
<path> com fill-rule="evenodd" | Dois caminhos de arco concêntricos | donut |
<path> com arco único + Z | Caminho de semicírculo | half-disc |
<path> com 6+ comandos L | Formato de cruz/mais | crosshair |
<path> com 3-5 comandos L | Formato semelhante a seta | pointer |
| Faixa de Raio | Nome na Descrição |
|---|
| 0–12 | minuscule |
| 13–16 | modest |
| 17–20 | mid-sized |
| 21–25 | substantial |
| 26+ | massive |
| 0.5 | faint |
| Valor de rotate() no SVG | Nome na Descrição |
|---|
| 0° | upright |
| 1–20° | slightly tilted |
| 21–55° | diagonal |
| 56–75° | steeply angled |
| 76–105° | sideways |
fill, mapeada via COLOR_MAPstyle="opacity:X"transform="... rotate(X)"transform="translate(X,Y) ..."| scoreGame enviado — 200/200 corretas |
| ~21:15 | generateToken chamado — WKX-9245FA70-200 |
| Componente | Tecnologia |
|---|
| Plataforma do desafio | Base44 (construtor de apps low-code) |
| Frontend | React SPA (bundle JS único, ~410KB) |
| Backend | Python/uvicorn atrás do Cloudflare |
| CDN/Proxy | Cloudflare (HTTP/3, compressão Brotli) |
| Autenticação | JWT (HS256), armazenado no localStorage |
| Formato de dados | SVG (base64 inline), API JSON |
| Tempo real | WebSocket (socket.io) para atualizações de sessão ao vivo |
| Solucionador | Python 3 (análise com regex, sem bibliotecas externas) |
Zfill-rule="evenodd"