
Write-up dettagliato di una challenge CTF: reverse engineering di una SPA React, parsing di immagini SVG, creazione di un solver automatizzato con greedy matching e raggiungimento di un punteggio perfetto.
Risultato: 200/200 (100%) — Token: WKX-9245FA70-200 Data: 24 marzo 2026
Un post su LinkedIn annunciava una sfida speciale per Wix KickstartX — un programma per sviluppatori junior. Le regole:
wix-kickstartx-challenge-2026.base44.app/ e wixkickstart.comIl sito principale wixkickstart.com è un sito ospitato su Wix (renderizzato lato server dal motore
Thunderbolt di Wix). Una curl standard restituisce solo lo shell JavaScript — nessun contenuto reale.
Abbiamo usato Chromium headless per renderizzarlo:
chromium --headless --disable-gpu --no-sandbox --virtual-time-budget=10000 \
--dump-dom "https://wixkickstart.com" > /tmp/wix_rendered.html
Dal DOM renderizzato abbiamo estratto un iframe incorporato:
Questa si è rivelata essere un'animazione di particelle p5.js (semplice effetto visivo della hero section), non la sfida stessa. La vera sfida vive nell'app Base44.
La sfida gira su Base44 — una piattaforma app low-code. L'app è una applicazione React a pagina singola con tutta la logica in un unico bundle JS:
https://wix-kickstartx-challenge-2026.base44.app/assets/index-oTG160r9.js
Dimensione: 410.809 byte (React minificato + logica app + SDK Base44)
Cercando nel bundle JS, abbiamo mappato l'intera API:
Entità: GameSession, Participant
Pattern URL API:
POST /api/apps/{appId}/functions/{functionName}
App ID: 69aea07cbcb9a3dd1039a58d
const or = 1800; // Limite di tempo: 1800 secondi (30 minuti)
const rm = 200; // Elementi totali: 200 coppie immagine-descrizione
Un risultato fondamentale — l'app ha una modalità di invio JSON in blocco. Dal sorgente minificato:
function Ik({onSubmit:r, onClose:n}) {
// ...
h = JSON.parse(s) // Analizza l'input JSON
// Validazione: deve essere un oggetto come { "IMG-001": "DESC-042", ... }
r(h) // Invia tutte le corrispondenze in una volta
}
Testo segnaposto nella modale:
{
"IMG-001": "DESC-042",
"IMG-002": "DESC-017",
...
}
Questo significa che non dobbiamo cliccare 200 volte nell'interfaccia — possiamo inviare una mappatura JSON di tutte le 200 corrispondenze in modo programmatico.
L'SDK Base44 usa autenticazione JWT:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Il payload JWT contiene:
{
"sub": "[email protected]",
"exp": 1782162460,
"iat": 1774386460
}
Header richiesti per tutte le chiamate 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 compresso (2.20 MB decompresso)
Content-Encoding: br (Brotli)
Struttura della risposta:
{
"status": "active",
"sessionId": "69c2fd218e5b26f307c941c9",
"startedAt": "2026-03-24T21:07:45.101Z",
"imageOrder": ["IMG-154", "IMG-102", ...], // 200 elementi (ordine di visualizzazione)
"descOrder": ["DESC-125", "DESC-109", ...], // 200 elementi (ordine di visualizzazione)
"imagesData": { "IMG-001": "data:image/svg+xml;base64,...", ... }, // 200 SVG
"descriptionsData": { "DESC-105": "milky field, overlaid with...", ... } // 200 testi
}
Ogni immagine è un SVG 200x200 contenente:
Esempio di SVG decodificato (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)"/>
<!-- Linee/punti/anelli di overlay opzionali qui -->
<polygon points="..." fill="#E2725B" transform="translate(128.6,185.7) rotate(90)"
style="opacity:0.5;filter:drop-shadow(...)"/>
<!-- Altre forme... -->
</svg>
Ogni descrizione segue uno schema rigoroso:
{campo} field[, overlaid with {overlay}]. {N} elementi totali:
{dimensione} {opacità} {colore} {forma} ({rotazione}, at {posizione}) ·
{dimensione} {opacità} {colore} {forma} ({rotazione}, at {posizione}) · ...
Esempio:
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) · ...
Dovevamo mappare le proprietà SVG al vocabolario delle descrizioni. Questo ha richiesto l'analisi di tutte le 200 immagini e tutte le 200 descrizioni per trovare le corrispondenze esatte.
Contando le occorrenze su entrambi i lati, abbiamo stabilito una corrispondenza 1:1:
Metodo: Contare i colori di inizio gradiente unici in tutti i 200 SVG, contare la prima parola unica in tutte le 200 descrizioni, abbinare in base al conteggio.
Gli overlay SVG sono implementati in modo diverso a seconda del tipo:
Intuizione chiave: Gli elementi del pattern di sfondo usano opacity come attributo XML
(es. opacity="0.06"), mentre le forme in primo piano usano opacity dentro l'attributo style
(es. style="opacity:0.7"). Questa distinzione è stata fondamentale per separare i pattern di sfondo
dalle forme reali.
Tutti i 200 SVG usano esattamente 28 colori hex di riempimento unici. Tutte le 200 descrizioni usano esattamente 28 nomi di colore unici.
Basata sul raggio massimo dei vertici della forma dall'origine:
| Valore opacità SVG | Nome Descrizione |
|---|---|
| 1.0 | solid |
| 0.85 | nearly solid |
| 0.7 | semi-transparent |
Le forme sono posizionate su una griglia 7x7 alle coordinate:
[14.3, 42.9, 71.4, 100.0, 128.6, 157.1, 185.7] sia per X che per Y.
Queste corrispondono ai nomi delle colonne: far-left, left, inner-left, center, inner-right, right, far-right
E ai nomi delle righe: top, upper, upper-mid, center, lower-mid, lower, bottom
Per ciascuna delle 200 immagini SVG:
<stop> da <linearGradient id="bg"><line>, <circle fill="none" stroke>, punti/forme a bassa opacità<polygon>, <circle>, <path>, <rect> che:
width="200")fill="url(#vig)")opacity="0.0x" come attributo)style="opacity:..." (tutte le forme in primo piano ce l'hanno)Per ciascuno dei 200 testi descrittivi:
(\d+) elements total(minuscule|modest|mid-sized|substantial|massive)
(solid|nearly solid|semi-transparent|faint)
(nome colore)
(tipo di forma)
((rotazione), at (posizione))
L'algoritmo di abbinamento usa un approccio greedy basato su punteggi:
def score_match(img, desc):
# Vincoli rigidi — devono corrispondere esattamente
if img['num'] != desc['num']: return -10000 # Conteggio forme
if img['bg'] != desc['bg']: return -10000 # Tipo di sfondo
if img['overlay'] != desc['overlay']: return -10000 # Tipo di overlay
score = 100 # Punteggio base per aver soddisfatto i vincoli rigidi
# Punteggio soft — sovrapposizione colori
for color in img_colors:
if color in desc_colors:
score += 10
# Punteggio soft — sovrapposizione tipi
for type in img_types:
if type in desc_types:
score += 8
# Abbinamento dettagli per singola forma (greedy stile ungherese)
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
Poi assegnare greedy prima le coppie con punteggio più alto:
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. Avvia Gioco
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/startGame
Body: {"email": "[email protected]"}
Response: 200 OK (2.2 MB — tutti i dati di gioco)
2. Valuta Gioco
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. Genera Token
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/generateToken
Body: {"sessionId": "69c2fd218e5b26f307c941c9"}
Response: 200 OK
{"token": "WKX-9245FA70-200", "score": 200}
| Passo | Azione |
|---|---|
| 21:07:45 UTC | Gioco avviato (chiamata startGame) |
| 21:07–21:15 | Sviluppo dello script solver (parsing + abbinamento) |
| ~21:15 | scoreGame inviato — |
Tempo totale di risoluzione: ~8 minuti di una finestra di 30 minuti.
L'endpoint di invio JSON è stato il fattore critico — senza di esso, avremmo dovuto usare automazione del browser per cliccare 400 volte (seleziona immagine + seleziona descrizione x 200).
La mappatura basata sul conteggio è stata la svolta per colori, sfondi e overlay.
Invece di indovinare a cosa corrisponda l'hex #cd7f32 in inglese, abbiamo contato che appare
65 volte negli SVG e "brass" appare 65 volte nelle descrizioni — conteggio unico = abbinamento garantito.
I vincoli rigidi eliminano rapidamente i candidati. Ogni immagine ha una combinazione unica di (tipo_sfondo, tipo_overlay, numero_forme). Con 12 sfondi x 7 overlay x conteggi di forme variabili, la maggior parte delle immagini ha solo una manciata di possibili corrispondenze di descrizione, non 200.
SVG è dati strutturati. A differenza delle immagini raster (PNG/JPG), gli SVG sono XML — ogni forma, colore, posizione e rotazione è esplicitamente codificato come testo. Nessuna visione artificiale necessaria.
Il rilevamento degli overlay era complicato. I pattern di sfondo usavano 5 diverse tecniche SVG:
elementi <line>, <circle> solo con stroke, <circle> con attributo a bassa opacità,
<polygon> con attributo a bassa opacità e assenza di tutti questi. La distinzione chiave
era opacity come attributo XML (sfondo) vs. dentro style (forme in primo piano).
Il vocabolario dei colori non era ovvio. Nomi come "oxblood" (#800020), "brass"
(#cd7f32) e "deep sapphire" (#0f52ba) richiedevano l'approccio del conteggio — non si può
indovinare in modo affidabile solo dai valori hex.
La classificazione delle forme dai percorsi SVG richiedeva la comprensione dei comandi dei path SVG:
M (moveto), L (lineto), A (arc), (closepath)| Funzione | Scopo |
|---|
startGame | Crea una sessione di gioco, restituisce 200 immagini + 200 descrizioni |
scoreGame | Accetta {matches: {}, sessionId: ""}, restituisce {correctCount: N} |
generateToken | Prende sessionId, restituisce il token di completamento |
getLeaderboard | Restituisce i punteggi migliori |
saveNickname | Salva il nome visualizzato per la classifica |
| Colore Inizio Gradiente SVG | Conteggio | Parola Descrizione | Conteggio |
|---|
#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 |
| Pattern SVG | Metodo di Rilevamento | Conteggio | Nome Descrizione | Conteggio |
|---|
Elementi <line> orizzontali (dy=0) | Controlla x1,y1,x2,y2 | 35 | striped overlay | 35 |
Elementi <line> verticali (dx=0) | Controlla x1,y1,x2,y2 | 34 | lattice pattern | 34 |
<circle> con opacity="0.06" (punti) | Conta i cerchi a bassa opacità | 30 | stippled layer | 30 |
| Nessun elemento di overlay | Nessuna linea, nessuna forma di sfondo | 28 | (nessuno) | 28 |
Elementi <line> diagonali | Controlla la direzione della pendenza | 27 | tilted cross-lines | 27 |
Piccoli <polygon>/<path> con opacity="0.06" | Forme con attributo a bassa opacità | 25 | arrow-band texture | 25 |
<circle> con fill="none" stroke="#888" | Cerchi solo con stroke | 21 | ringed pattern | 21 |
| Hex | Nome Descrizione | Hex | Nome Descrizione |
|---|
#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 | Logica di Rilevamento | Nome Descrizione |
|---|
<polygon> con 3 vertici | Conta le coppie di punti separati da spazio | pike |
<polygon> con 4 vertici | tilted square | |
<polygon> con 5 vertici | quint form | |
<polygon> con 6 vertici | bee cell | |
<polygon> con 10 vertici | asterisk | |
<rect> (non di sfondo) | Ha width/height, non 200x200 | tilted square |
<circle> | Nome del tag | disc |
<path> con fill-rule="evenodd" | Due percorsi ad arco concentrici | donut |
<path> con singolo arco + Z | Percorso a semicerchio | half-disc |
<path> con 6+ comandi L | Forma a croce/più | crosshair |
<path> con 3-5 comandi L | Forma simile a una freccia | pointer |
| Intervallo di Raggio | Nome Descrizione |
|---|
| 0–12 | minuscule |
| 13–16 | modest |
| 17–20 | mid-sized |
| 21–25 | substantial |
| 26+ | massive |
| 0.5 |
faint |
| Valore rotate() SVG | Nome Descrizione |
|---|
| 0° | upright |
| 1–20° | slightly tilted |
| 21–55° | diagonal |
| 56–75° | steeply angled |
| 76–105° | sideways |
fill, mappato tramite COLOR_MAPstyle="opacity:X"transform="... rotate(X)"transform="translate(X,Y) ..."| ~21:15 | generateToken chiamato — WKX-9245FA70-200 |
| Componente | Tecnologia |
|---|
| Piattaforma della sfida | Base44 (low-code app builder) |
| Frontend | React SPA (unico bundle JS, ~410KB) |
| Backend | Python/uvicorn dietro Cloudflare |
| CDN/Proxy | Cloudflare (HTTP/3, compressione Brotli) |
| Autenticazione | JWT (HS256), salvato in localStorage |
| Formato dati | SVG (base64 inline), API JSON |
| Real-time | WebSocket (socket.io) per aggiornamenti sessione in tempo reale |
| Solver | Python 3 (parsing con regex, nessuna libreria esterna) |
Zfill-rule="evenodd"