
Compte-rendu détaillé d'un challenge CTF : rétro-ingénierie d'une SPA React, analyse d'images SVG, construction d'un solveur automatisé avec appariement glouton, et obtention d'un score parfait.
Résultat : 200/200 (100 %) — Jeton : WKX-9245FA70-200 Date : 24 mars 2026
Une publication LinkedIn annonçait un challenge spécial pour Wix KickstartX — un programme pour développeurs juniors. Les règles :
wix-kickstartx-challenge-2026.base44.app/ et wixkickstart.comLe site principal wixkickstart.com est un site hébergé par Wix (rendu côté serveur par le moteur Thunderbolt de Wix). Un curl standard ne renvoie que la coquille JavaScript — aucun contenu réel. Nous avons utilisé Chromium headless pour le rendre :
chromium --headless --disable-gpu --no-sandbox --virtual-time-budget=10000 \
--dump-dom "https://wixkickstart.com" > /tmp/wix_rendered.html
À partir du DOM rendu, nous avons extrait un iframe intégré :
Il s'est avéré que c'était une animation de particules p5.js (un artifice visuel de la section hero), et non le challenge lui-même. Le véritable challenge se trouve dans l'application Base44.
Le challenge s'exécute sur Base44 — une plateforme d'applications low-code. L'application est une application React à page unique avec toute la logique dans un seul bundle JS :
https://wix-kickstartx-challenge-2026.base44.app/assets/index-oTG160r9.js
Taille : 410 809 octets (React minifié + logique applicative + SDK Base44)
En utilisant grep sur le bundle JS, nous avons cartographié l'intégralité de l'API :
Entités : GameSession, Participant
Modèle d'URL d'API :
POST /api/apps/{appId}/functions/{functionName}
ID d'application : 69aea07cbcb9a3dd1039a58d
const or = 1800; // Time limit: 1800 seconds (30 minutes)
const rm = 200; // Total items: 200 image-description pairs
Une découverte critique — l'application dispose d'un mode de soumission groupée JSON. À partir du source minifié :
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
}
Texte d'espace réservé dans la modale :
{
"IMG-001": "DESC-042",
"IMG-002": "DESC-017",
...
}
Cela signifie que nous n'avons pas besoin de cliquer 200 fois dans l'interface — nous pouvons soumettre programmatiquement un mapping JSON des 200 correspondances.
Le SDK Base44 utilise l'authentification JWT :
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Le payload JWT contient :
{
"sub": "[email protected]",
"exp": 1782162460,
"iat": 1774386460
}
En-têtes requis pour tous les appels 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)
Structure de la réponse :
{
"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
}
Chaque image est un SVG de 200x200 contenant :
Exemple de SVG décodé (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>
Chaque description suit un modèle strict :
{background} field[, overlaid with {overlay}]. {N} elements total:
{size} {opacity} {color} {shape} ({rotation}, at {position}) ·
{size} {opacity} {color} {shape} ({rotation}, at {position}) · ...
Exemple :
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) · ...
Nous devions faire correspondre les propriétés SVG au vocabulaire des descriptions. Cela nécessitait d'analyser les 200 images et les 200 descriptions pour trouver des correspondances exactes.
En comptant les occurrences des deux côtés, nous avons établi un mapping 1:1 :
Méthode : compter les couleurs de début de dégradé uniques dans les 200 SVG, compter les premiers mots uniques dans les 200 descriptions, faire correspondre par nombre.
Les superpositions SVG sont implémentées différemment selon le type :
Point clé : les éléments de motif d'arrière-plan utilisent opacity comme attribut XML (par exemple, opacity="0.06"), tandis que les formes de premier plan utilisent opacity dans l'attribut style (par exemple, style="opacity:0.7"). Cette distinction a été cruciale pour séparer les motifs d'arrière-plan des formes réelles.
Les 200 SVG utilisent exactement 28 couleurs de remplissage hexadécimales uniques. Les 200 descriptions utilisent exactement 28 noms de couleurs uniques.
Basée sur le rayon maximal des sommets de la forme par rapport à l'origine :
| Valeur d'opacité SVG | Nom de description |
|---|---|
| 1.0 | solid |
| 0.85 | nearly solid |
| 0.7 | semi-transparent |
Les formes sont placées sur une grille 7x7 aux coordonnées :
[14.3, 42.9, 71.4, 100.0, 128.6, 157.1, 185.7] pour X et Y.
Celles-ci correspondent aux noms de colonnes : far-left, left, inner-left, center, inner-right, right, far-right
Et aux noms de lignes : top, upper, upper-mid, center, lower-mid, lower, bottom
Pour chacune des 200 images SVG :
<stop> dans <linearGradient id="bg"><line>, <circle fill="none" stroke>, les points/formes à faible opacité<polygon>, <circle>, <path>, <rect> qui :
width="200")fill="url(#vig)")opacity="0.0x" comme attribut)style="opacity:..." (toutes les formes de premier plan en ont un)Pour chacune des 200 descriptions textuelles :
(\d+) elements total(minuscule|modest|mid-sized|substantial|massive)
(solid|nearly solid|semi-transparent|faint)
(color name)
(shape type)
((rotation), at (position))
L'algorithme de correspondance utilise une approche de score gloutonne :
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
Ensuite, on attribue de manière gloutonne les paires ayant le score le plus élevé en premier :
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. Démarrer le jeu
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/startGame
Body: {"email": "[email protected]"}
Response: 200 OK (2.2 MB — all game data)
2. Noter le jeu
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. Générer le jeton
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/generateToken
Body: {"sessionId": "69c2fd218e5b26f307c941c9"}
Response: 200 OK
{"token": "WKX-9245FA70-200", "score": 200}
| Étape | Action |
|---|---|
| 21:07:45 UTC | Jeu démarré (startGame appelé) |
| 21:07–21:15 | Développement du script solveur (analyse + correspondance) |
| ~21:15 | scoreGame soumis — |
Temps total de résolution : ~8 minutes sur une fenêtre de 30 minutes.
Le point de terminaison de soumission JSON a été le facteur clé — sans lui, il aurait fallu automatiser le navigateur pour cliquer 400 fois (sélectionner l'image + sélectionner la description x 200).
Le mapping par comptage a été la percée pour les couleurs, les arrière-plans et les superpositions. Au lieu de deviner à quelle couleur anglaise correspond le hex #cd7f32, nous avons compté qu'il apparaît 65 fois dans les SVG et que « brass » apparaît 65 fois dans les descriptions — un nombre unique = correspondance garantie.
Les contraintes strictes éliminent rapidement les candidats. Chaque image a une combinaison unique de (background_type, overlay_type, shape_count). Avec 12 arrière-plans x 7 superpositions x des nombres de formes variables, la plupart des images n'ont qu'une poignée de correspondances de description possibles, pas 200.
Le SVG est une donnée structurée. Contrairement aux images matricielles (PNG/JPG), les SVG sont du XML — chaque forme, couleur, position et rotation est explicitement encodée sous forme de texte. Aucune vision par ordinateur nécessaire.
La détection des superpositions était délicate. Les motifs d'arrière-plan utilisaient 5 techniques SVG différentes : des éléments <line>, des <circle> avec contour uniquement, des <circle> avec attribut de faible opacité, des <polygon> avec attribut de faible opacité, et l'absence de tout cela. La distinction clé était opacity comme attribut XML (arrière-plan) par rapport à opacity dans style (formes de premier plan).
Le vocabulaire des couleurs n'était pas évident. Des noms comme « oxblood » (#800020), « brass » (#cd7f32) et « deep sapphire » (#0f52ba) nécessitaient l'approche par comptage — on ne peut pas les deviner de manière fiable à partir des seules valeurs hexadécimales.
La classification des formes à partir des chemins SVG nécessitait de comprendre les commandes de chemin SVG :
M (moveto), (lineto), (arc), (closepath)| Fonction | Rôle |
|---|
startGame | Crée une session de jeu, renvoie 200 images + 200 descriptions |
scoreGame | Accepte {matches: {}, sessionId: ""}, renvoie {correctCount: N} |
generateToken | Prend sessionId, renvoie le jeton de complétion |
getLeaderboard | Renvoie les meilleurs scores |
saveNickname | Enregistre le nom affiché pour le classement |
| Couleur de début de dégradé SVG | Nombre | Mot de description | Nombre |
|---|
#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 |
| Motif SVG | Méthode de détection | Nombre | Nom de description | Nombre |
|---|
Éléments <line> horizontaux (dy=0) | Vérifier x1,y1,x2,y2 | 35 | striped overlay | 35 |
Éléments <line> verticaux (dx=0) | Vérifier x1,y1,x2,y2 | 34 | lattice pattern | 34 |
<circle> avec opacity="0.06" (points) | Compter les cercles à faible opacité | 30 | stippled layer | 30 |
| Aucun élément de superposition | Aucune ligne, aucune forme d'arrière-plan | 28 | (aucun) | 28 |
Éléments <line> diagonaux | Vérifier la direction de la pente | 27 | tilted cross-lines | 27 |
Petits <polygon>/<path> avec opacity="0.06" | Formes avec attribut de faible opacité | 25 | arrow-band texture | 25 |
<circle> avec fill="none" stroke="#888" | Cercles avec contour uniquement | 21 | ringed pattern | 21 |
| Hex | Nom de description | Hex | Nom de description |
|---|
#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 |
| Élément SVG | Logique de détection | Nom de description |
|---|
<polygon> à 3 sommets | Compter les paires de points séparées par des espaces | pike |
<polygon> à 4 sommets | tilted square | |
<polygon> à 5 sommets | quint form | |
<polygon> à 6 sommets | bee cell | |
<polygon> à 10 sommets | asterisk | |
<rect> (hors arrière-plan) | Possède width/height, pas 200x200 | tilted square |
<circle> | Nom de la balise | disc |
<path> avec fill-rule="evenodd" | Deux chemins d'arc concentriques | donut |
<path> avec arc unique + Z | Chemin en demi-cercle | half-disc |
<path> avec 6 commandes L ou plus | Forme de croix/plus | crosshair |
<path> avec 3 à 5 commandes L | Forme en pointe de flèche | pointer |
| Plage de rayon | Nom de description |
|---|
| 0–12 | minuscule |
| 13–16 | modest |
| 17–20 | mid-sized |
| 21–25 | substantial |
| 26+ | massive |
| 0.5 |
faint |
| Valeur de rotate() SVG | Nom de description |
|---|
| 0° | upright |
| 1–20° | slightly tilted |
| 21–55° | diagonal |
| 56–75° | steeply angled |
| 76–105° | sideways |
fill, mappée via COLOR_MAPstyle="opacity:X"transform="... rotate(X)"transform="translate(X,Y) ..."| ~21:15 | generateToken appelé — WKX-9245FA70-200 |
| Composant | Technologie |
|---|
| Plateforme du challenge | Base44 (constructeur d'applications low-code) |
| Frontend | SPA React (bundle JS unique, ~410 Ko) |
| Backend | Python/uvicorn derrière Cloudflare |
| CDN/Proxy | Cloudflare (HTTP/3, compression Brotli) |
| Authentification | JWT (HS256), stocké dans localStorage |
| Format de données | SVG (base64 inline), API JSON |
| Temps réel | WebSocket (socket.io) pour les mises à jour de session en direct |
| Solveur | Python 3 (analyse regex, aucune bibliothèque externe) |
LAZfill-rule="evenodd"