
Ausführlicher Write-up einer CTF-Challenge: Reverse Engineering einer React-SPA, Parsen von SVG-Bildern, Erstellen eines automatisierten Solvers mit Greedy-Matching und Erzielen einer perfekten Punktzahl.
Ergebnis: 200/200 (100 %) — Token: WKX-9245FA70-200 Datum: 24. März 2026
Ein LinkedIn-Post kündigte eine spezielle Challenge für Wix KickstartX an — ein Junior-Entwicklerprogramm. Die Regeln:
wix-kickstartx-challenge-2026.base44.app/ und wixkickstart.comDie Hauptseite wixkickstart.com ist eine von Wix gehostete Seite (serverseitig gerendert von Wix' Thunderbolt-Engine). Ein Standard-curl-Aufruf gibt nur die JavaScript-Hülle zurück — keine tatsächlichen Inhalte. Wir verwendeten headless Chromium, um sie zu rendern:
chromium --headless --disable-gpu --no-sandbox --virtual-time-budget=10000 \
--dump-dom "https://wixkickstart.com" > /tmp/wix_rendered.html
Aus dem gerenderten DOM extrahierten wir einen eingebetteten iframe:
Das entpuppte sich als p5.js-Partikelanimation (Augenschmaus im Hero-Bereich), nicht als die Challenge selbst. Die eigentliche Challenge befindet sich in der Base44-App.
Die Challenge läuft auf Base44 — einer Low-Code-App-Plattform. Die App ist eine Single-Page-React-Anwendung, deren gesamte Logik in einem einzigen JS-Bundle steckt:
https://wix-kickstartx-challenge-2026.base44.app/assets/index-oTG160r9.js
Größe: 410.809 Bytes (minifiziertes React + App-Logik + Base44-SDK)
Indem wir das JS-Bundle per grep durchsuchten, bildeten wir die gesamte API ab:
Entitäten: GameSession, Participant
API-URL-Muster:
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
Eine entscheidende Entdeckung — die App verfügt über einen JSON-Stapelübermittlungsmodus. Aus dem minifizierten Quellcode:
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
}
Platzhaltertext im Modal:
{
"IMG-001": "DESC-042",
"IMG-002": "DESC-017",
...
}
Das bedeutet, dass wir nicht 200 Mal in der UI klicken müssen — wir können ein JSON-Mapping aller 200 Zuordnungen programmatisch übermitteln.
Das Base44-SDK nutzt JWT-Authentifizierung:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Das JWT-Payload enthält:
{
"sub": "[email protected]",
"exp": 1782162460,
"iat": 1774386460
}
Erforderliche Header für alle API-Aufrufe:
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)
Antwortstruktur:
{
"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
}
Jedes Bild ist ein 200x200-SVG mit:
Beispiel für ein dekodiertes SVG (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>
Jede Beschreibung folgt einem strengen Muster:
{background} field[, overlaid with {overlay}]. {N} elements total:
{size} {opacity} {color} {shape} ({rotation}, at {position}) ·
{size} {opacity} {color} {shape} ({rotation}, at {position}) · ...
Beispiel:
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) · ...
Wir mussten SVG-Eigenschaften auf das Beschreibungsvokabular abbilden. Dafür analysierten wir alle 200 Bilder und alle 200 Beschreibungen, um exakte Entsprechungen zu finden.
Durch Zählen der Vorkommen auf beiden Seiten stellten wir eine 1:1-Zuordnung her:
Methode: Eindeutige Verlaufs-Startfarben in allen 200 SVGs zählen, eindeutige erste Wörter in allen 200 Beschreibungen zählen, anhand der Anzahl zuordnen.
SVG-Überlagerungen werden je nach Typ unterschiedlich implementiert:
Wichtige Erkenntnis: Hintergrundmusterelemente verwenden opacity als XML-Attribut (z. B. opacity="0.06"), während Vordergrundformen opacity innerhalb des style-Attributs verwenden (z. B. style="opacity:0.7"). Diese Unterscheidung war entscheidend, um Hintergrundmuster von den eigentlichen Formen zu trennen.
Alle 200 SVGs verwenden genau 28 eindeutige Hex-Füllfarben. Alle 200 Beschreibungen verwenden genau 28 eindeutige Farbnamen.
Basierend auf dem maximalen Radius der Formeckpunkte vom Ursprung:
| SVG-Opacity-Wert | Beschreibungsname |
|---|---|
| 1.0 | solid |
| 0.85 | nearly solid |
| 0.7 | semi-transparent |
Formen werden auf einem 7x7-Raster an den Koordinaten platziert:
[14.3, 42.9, 71.4, 100.0, 128.6, 157.1, 185.7] for both X and Y.
Diese bilden die Spaltennamen ab: far-left, left, inner-left, center, inner-right, right, far-right
Und die Zeilennamen: top, upper, upper-mid, center, lower-mid, lower, bottom
Für jedes der 200 SVG-Bilder:
<stop>-Farbe aus <linearGradient id="bg"> auslesen<line>, <circle fill="none" stroke>, Punkten/Formen mit niedriger Deckkraft prüfen<polygon>, <circle>, <path>, <rect>-Elemente finden, die:
width="200" überspringen)fill="url(#vig)" überspringen)opacity="0.0x" als Attribut überspringen)style="opacity:..." haben (alle Vordergrundformen haben das)Für jede der 200 Textbeschreibungen:
(\d+) elements total(minuscule|modest|mid-sized|substantial|massive)
(solid|nearly solid|semi-transparent|faint)
(color name)
(shape type)
((rotation), at (position))
Der Abgleichsalgorithmus verwendet einen Greedy-Scoring-Ansatz:
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
Danach werden die Paare mit der höchsten Punktzahl zuerst gierig zugewiesen:
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. Spiel starten
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/startGame
Body: {"email": "[email protected]"}
Response: 200 OK (2.2 MB — all game data)
2. Spiel bewerten
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. Token generieren
POST /api/apps/69aea07cbcb9a3dd1039a58d/functions/generateToken
Body: {"sessionId": "69c2fd218e5b26f307c941c9"}
Response: 200 OK
{"token": "WKX-9245FA70-200", "score": 200}
| Schritt | Aktion |
|---|---|
| 21:07:45 UTC | Spiel gestartet (startGame aufgerufen) |
| 21:07–21:15 | Entwicklung des Solver-Skripts (Parsing + Matching) |
| ~21:15 | scoreGame übermittelt — |
Gesamtlösungszeit: ~8 Minuten eines 30-Minuten-Fensters.
Der JSON-Übermittlungs-Endpoint war der entscheidende Wegbereiter — ohne ihn hätten wir Browser-Automatisierung gebraucht, um 400 Mal zu klicken (Bild auswählen + Beschreibung auswählen x 200).
Zählbasiertes Zuordnen war der Durchbruch bei Farben, Hintergründen und Überlagerungen. Statt zu raten, welchem englischen Begriff das Hex #cd7f32 entspricht, zählten wir, dass es 65 Mal in den SVGs vorkommt und „brass" 65 Mal in den Beschreibungen — eindeutige Anzahl = garantierte Übereinstimmung.
Harte Randbedingungen eliminieren Kandidaten schnell. Jedes Bild hat eine eindeutige Kombination aus (background_type, overlay_type, shape_count). Bei 12 Hintergründen x 7 Überlagerungen x unterschiedlichen Formanzahlen haben die meisten Bilder nur eine Handvoll möglicher Beschreibungsübereinstimmungen, nicht 200.
SVG-Dateien sind strukturierte Daten. Anders als Rasterbilder (PNG/JPG) sind SVGs XML — jede Form, Farbe, Position und Rotation ist explizit als Text kodiert. Keine Computer Vision nötig.
Die Überlagerungserkennung war knifflig. Hintergrundmuster nutzten 5 verschiedene SVG-Techniken: <line>-Elemente, <circle> nur mit Stroke, <circle> mit niedrigem Opacity-Attribut, <polygon> mit niedrigem Opacity-Attribut sowie die Abwesenheit all dieser Elemente. Die entscheidende Unterscheidung war opacity als XML-Attribut (Hintergrund) gegenüber innerhalb von style (Vordergrundformen).
Das Farbvokabular war nicht offensichtlich. Namen wie „oxblood" (#800020), „brass" (#cd7f32) und „deep sapphire" (#0f52ba) erforderten den Zählansatz — aus reinen Hex-Werten kann man diese nicht zuverlässig erraten.
Die Formklassifizierung aus SVG-Pfaden erforderte Verständnis der SVG-Pfadbefehle:
M (moveto), L (lineto), A (arc), (closepath)| Funktion | Zweck |
|---|
startGame | Erstellt eine Spielsitzung, gibt 200 Bilder + 200 Beschreibungen zurück |
scoreGame | Akzeptiert {matches: {}, sessionId: ""}, gibt {correctCount: N} zurück |
generateToken | Nimmt sessionId entgegen, gibt Abschluss-Token zurück |
getLeaderboard | Gibt die besten Punktzahlen zurück |
saveNickname | Speichert den Anzeigenamen für die Bestenliste |
| SVG-Verlauf-Startfarbe | Anzahl | Beschreibungswort | Anzahl |
|---|
#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 |
| SVG-Muster | Erkennungsmethode | Anzahl | Beschreibungsname | Anzahl |
|---|
<line>-Elemente, horizontal (dy=0) | x1,y1,x2,y2 prüfen | 35 | striped overlay | 35 |
<line>-Elemente, vertikal (dx=0) | x1,y1,x2,y2 prüfen | 34 | lattice pattern | 34 |
<circle> mit opacity="0.06" (Punkte) | Kreise mit niedriger Deckkraft zählen | 30 | stippled layer | 30 |
| Überhaupt keine Überlagerungselemente | Keine Linien, keine Hintergrundformen | 28 | (keine) | 28 |
<line>-Elemente, diagonal | Steigungsrichtung prüfen | 27 | tilted cross-lines | 27 |
Kleine <polygon>/<path>-Elemente mit opacity="0.06" | Formen mit niedrigem Opacity-Attribut | 25 | arrow-band texture | 25 |
<circle> mit fill="none" stroke="#888" | Kreise nur mit Stroke | 21 | ringed pattern | 21 |
| Hex | Beschreibungsname | Hex | Beschreibungsname |
|---|
#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 |
| SVG-Element | Erkennungslogik | Beschreibungsname |
|---|
<polygon> 3 Ecken | Leerzeichengetrennte Punktpaare zählen | pike |
<polygon> 4 Ecken | tilted square | |
<polygon> 5 Ecken | quint form | |
<polygon> 6 Ecken | bee cell | |
<polygon> 10 Ecken | asterisk | |
<rect> (kein Hintergrund) | Hat width/height, nicht 200x200 | tilted square |
<circle> | Tag-Name | disc |
<path> mit fill-rule="evenodd" | Zwei konzentrische Bogenpfade | donut |
<path> mit einem einzelnen Bogen + Z | Halbkreis-Pfad | half-disc |
<path> mit 6+ L-Befehlen | Kreuz/Plus-Form | crosshair |
<path> mit 3–5 L-Befehlen | Pfeilartige Form | pointer |
| Radiusbereich | Beschreibungsname |
|---|
| 0–12 | minuscule |
| 13–16 | modest |
| 17–20 | mid-sized |
| 21–25 | substantial |
| 26+ | massive |
| 0.5 |
faint |
| SVG-rotate()-Wert | Beschreibungsname |
|---|
| 0° | upright |
| 1–20° | slightly tilted |
| 21–55° | diagonal |
| 56–75° | steeply angled |
| 76–105° | sideways |
fill-Attribut, über COLOR_MAP zugeordnetstyle="opacity:X"transform="... rotate(X)"transform="translate(X,Y) ..."| ~21:15 | generateToken aufgerufen — WKX-9245FA70-200 |
| Komponente | Technologie |
|---|
| Challenge-Plattform | Base44 (Low-Code-App-Builder) |
| Frontend | React-SPA (ein einzelnes JS-Bundle, ~410 KB) |
| Backend | Python/uvicorn hinter Cloudflare |
| CDN/Proxy | Cloudflare (HTTP/3, Brotli-Kompression) |
| Auth | JWT (HS256), in localStorage gespeichert |
| Datenformat | SVG (Base64 inline), JSON-API |
| Echtzeit | WebSocket (socket.io) für Live-Sitzungsupdates |
| Solver | Python 3 (Regex-Parsing, keine externen Bibliotheken) |
Zfill-rule="evenodd"