Pentest d'un tracker privé : Nuxt.js, Cloudflare et 3 vulnérabilités trouvées

Un tracker privé BitTorrent, c'est une application web comme les autres : authentification, API REST, WebSockets pour le chat en temps réel, CDN devant. La différence avec un SaaS standard ? Les membres partagent des données sensibles — ratio de téléchargement, historique, parfois l'adresse IP publique dans les fichiers .torrent. Une fuite a des conséquences réelles.

Méthodologie en 5 phases

Un pentest web structuré suit toujours le même rythme. En raccourci :

Phase Objectif Outils
1. Reconnaissance Cartographier la surface d'attaque sans toucher la cible crt.sh, Shodan, Whois, Certificate Transparency
2. Scan Identifier ports ouverts, services, versions nmap (avec précaution derrière Cloudflare)
3. Énumération Découvrir endpoints, paramètres, fonctionnalités Browser DevTools, analyse JS, ffuf
4. Exploitation Confirmer et exploiter les vulnérabilités trouvées Burp Suite, scripts Python, Playwright
5. Rapport Documenter findings, impact, remédiation Markdown + CVSS scoring

La phase 1 (recon) est la plus importante et la plus sous-estimée. Plus on comprend la cible avant d'envoyer une seule requête, plus les phases suivantes sont efficaces.

Recon — fingerprinting de la stack sans toucher le serveur

Certificate Transparency (crt.sh) : les certificats TLS sont publics par design depuis 2013. En cherchant le domaine cible sur crt.sh, on trouve tous les sous-domaines qui ont jamais eu un certificat : api.trackrx.example, static.trackrx.example, admin.trackrx.example (inexistant en production, mais intéressant à noter).

Fingerprinting Cloudflare : les IPs résolues sont des IPs anycast Cloudflare. Le vrai serveur est masqué. Shodan et Censys ne donnent rien d'utile sur l'IP d'origine. Les headers HTTP confirment Cloudflare : CF-Ray, cf-cache-status, server: cloudflare.

Stack via les chunks Nuxt : sans même être authentifié, le code source de la page d'accueil révèle tout. Les chunks JavaScript chargés depuis /_nuxt/ confirment Nuxt.js SSR. En parcourant un chunk d'entrée :

// Dans /_nuxt/entry.XXXX.js — extrait déobfusqué
import { defineNuxtPlugin } from '#app'
// version Nuxt exposée dans window.__NUXT_DATA__

Et dans le HTML source, le payload SSR injecté par Nuxt :

<script type="application/json" id="__NUXT_DATA__">
[null,"3.15.4","user@example.com","Pseudo123",...]
</script>

La version exacte de Nuxt, et l'email de l'utilisateur connecté exposé dans le HTML. On y reviendra (VUL-03).

Scan & Énumération — ports fermés, endpoints ouverts

Scan nmap limité (Cloudflare filtre agressivement) : seuls les ports 80 et 443 répondent sur les IPs Cloudflare. Pas de surface d'attaque réseau directe.

En revanche, un fichier .torrent téléchargé depuis le tracker révèle le hostname interne dans le tracker announce URL : http://tracker:7070/announce. Confirmation : Docker Compose, le service s'appelle tracker, port 7070 non exposé publiquement. Les ports 3306 (MySQL), 5432 (PostgreSQL), 6379 (Redis) sont fermés. Pas d'accès direct à la BDD.

Discovery des endpoints API : les chunks Nuxt contiennent les appels useFetch() et $fetch() en clair. En parcourant les sources :

# Extraire les endpoints depuis les chunks JS
curl -s https://trackrx.example/_nuxt/app.XXXX.js | \
  grep -oE '"/api/[a-z0-9/_-]+"' | sort -u

Résultat : une trentaine d'endpoints documentés sans même avoir de compte. /api/torrents, /api/users/:id, /api/messages/channels/:id/messages, /api/auth/login, /api/auth/logout, etc.

VUL-01 — CORS misconfiguration (Moyen)

CORS (Cross-Origin Resource Sharing) est le mécanisme qui contrôle quels sites web externes peuvent faire des requêtes vers une API depuis un navigateur. Quand une requête cross-origin arrive, le serveur répond avec des headers qui disent "j'accepte les requêtes depuis ces origines".

Sur les endpoints authentifiés de TrackrX :

HTTP/2 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS

Ces deux headers ensemble : ACAO: * (toutes les origines) + ACAC: true (envoyer les cookies/credentials) sont une contradiction selon la spec CORS.

Le mythe vs la réalité : beaucoup de développeurs (et d'outils de scan) signalent cette combinaison comme une vulnérabilité critique permettant à n'importe quel site malveillant de faire des requêtes authentifiées à votre API. En pratique, les navigateurs refusent cette combinaison. Chrome, Firefox, Safari : si ACAO: *, les credentials ne sont pas envoyés, même si ACAC: true. La spec est formelle là-dessus. Le risque réel est faible — mais le header est incorrect et doit être corrigé, car un futur changement de comportement navigateur ou un client non-navigateur pourrait l'exploiter.

Remédiation : whitelist d'origines explicite côté serveur.

// ❌ Avant
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');

// ✅ Après
const ALLOWED_ORIGINS = ['https://trackrx.example', 'https://www.trackrx.example'];
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin'); // important pour les caches
}

VUL-02 — Historique de chat sans limite de profondeur (Faible)

Le système de messagerie expose un endpoint de pagination classique :

GET /api/messages/channels/1/messages?limit=100&before=MSG_ID

Le paramètre before permet de paginer vers le passé. Sans limite de profondeur, on peut remonter indéfiniment. Script d'extraction récursif :

def fetch_all_history(session, base_url, channel_id):
    all_messages = []
    oldest_id = None
    while True:
        params = {"limit": 100}
        if oldest_id:
            params["before"] = oldest_id
        r = session.get(
            f"{base_url}/api/messages/channels/{channel_id}/messages",
            params=params
        )
        data = r.json()
        if not data:
            break
        all_messages.extend(data)
        oldest_id = min(m["id"] for m in data)
        print(f"Total récupéré : {len(all_messages)}")
    return all_messages

Résultat : 60 000+ messages extraits en environ 30 minutes avec un délai raisonnable entre les requêtes (pour ne pas déclencher le rate limiting Cloudflare). L'impact n'est pas une compromission directe — les messages sont accessibles aux membres connectés de toute façon. Mais extraire l'intégralité de l'historique permet un profiling précis des membres : habitudes horaires, centres d'intérêt, liens entre pseudos et comportements. Vecteur de social engineering.

Remédiation : limiter l'historique accessible à 30 jours dans la requête (clause WHERE created_at > NOW() - INTERVAL '30 days'), et/ou ajouter un rate limiting spécifique sur cet endpoint.

VUL-03 — Données sensibles dans __NUXT_DATA__ (Faible)

Nuxt.js en mode SSR (Server-Side Rendering) génère le HTML côté serveur et l'envoie au client déjà rendu. Pour éviter que le JavaScript côté client re-fetch tout depuis l'API, Nuxt sérialise l'état initial de l'application dans une balise <script type="application/json" id="__NUXT_DATA__"> directement dans le HTML.

Le problème : cet état inclut toutes les données chargées côté serveur au moment du rendu — y compris l'email de l'utilisateur connecté :

<!-- Dans le HTML de la page de profil -->
<script type="application/json" id="__NUXT_DATA__">
[null,"3.15.4","user@example.com","Pseudo123","2024-01-15T10:23:00Z",...]
</script>

En soi, l'utilisateur connecté voit son propre email — ce n'est pas une fuite vers un tiers. L'impact réel dépend du contexte : ordinateur partagé, injection XSS qui lirait ce payload, ou screenshot de DevTools. La sévérité reste faible, mais c'est un signal d'alerte sur la surface de données exposées via SSR.

Remédiation : auditer systématiquement ce qui est transmis au client via les useAsyncData() et useFetch() côté serveur. Ne sérialiser que les données strictement nécessaires au rendu initial. Les données utilisateur sensibles (email, tokens) peuvent être chargées côté client après hydratation, ou exclues explicitement du payload SSR.

WebSockets derrière Cloudflare — la technique Playwright

TrackrX utilise Socket.IO pour le chat en temps réel. Tester les WebSockets en pentest, c'est d'habitude simple : wscat, un script Python avec websockets, ou Burp Suite avec l'extension WebSocket. Sauf que Cloudflare bloque ces clients.

Cloudflare utilise notamment la signature TLS (JA3/JA3S fingerprint) pour identifier les clients. Un script Python qui fait une connexion TLS produit une signature différente d'un vrai navigateur Chrome. Résultat : 403 ou connexion refusée avant même d'atteindre le serveur d'origine.

La solution : utiliser un vrai Chromium piloté par Playwright. Le navigateur produit la même signature TLS qu'un utilisateur réel. Cloudflare le laisse passer.

from playwright.async_api import async_playwright
import asyncio
import json

async def capture_ws_frames(url: str, session_cookie: str):
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context()

        # Injecter le cookie de session pour être authentifié
        await context.add_cookies([{
            "name": "__Host-session",
            "value": session_cookie,
            "domain": "trackrx.example",
            "path": "/",
        }])

        page = await context.new_page()
        frames = []

        # Intercepter les frames WebSocket entrants
        page.on("websocket", lambda ws: ws.on(
            "framereceived",
            lambda f: frames.append(f.payload)
        ))

        await page.goto(url)
        await asyncio.sleep(5)  # laisser le temps au socket de recevoir des données
        await browser.close()
        return frames


def parse_socketio_frame(frame: str) -> dict | None:
    """Parser les frames Socket.IO (protocole EIO4).
    Les frames de données commencent par '42' : '4' = message EIO, '2' = event Socket.IO.
    """
    if not frame.startswith("42"):
        return None  # ping (2), pong (3), connect (40), etc.
    try:
        data = json.loads(frame[2:])
        return {
            "event": data[0],
            "payload": data[1] if len(data) > 1 else None
        }
    except (json.JSONDecodeError, IndexError):
        return None


async def main():
    frames = await capture_ws_frames(
        "https://trackrx.example/chat",
        session_cookie="YOUR_SESSION_COOKIE"
    )
    parsed = [parse_socketio_frame(f) for f in frames if isinstance(f, str)]
    for event in filter(None, parsed):
        print(f"[{event['event']}] {event['payload']}")

asyncio.run(main())

Ce qui a été observé via cette technique : les events Socket.IO (message:new, user:typing, channel:update), les structures de payload, et la confirmation que les données sont bien filtrées par canal — pas de fuite cross-channel observable côté client.

Ce que Cloudflare ne fait pas

Cloudflare WAF est excellent pour filtrer les attaques connues et volumétriques : SQLi, XSS réfléchis, scans automatisés, DDoS L3/L4/L7. Mais certaines classes de vulnérabilités passent systématiquement, par design.

Les IDOR (Insecure Direct Object Reference) : accéder aux données d'un autre utilisateur via son ID. Cloudflare ne peut pas distinguer une requête légitime d'une requête IDOR — les deux sont des requêtes HTTP normales sur un endpoint connu. Exemple de test :

def test_idor(session, base_url, other_user_id):
    """Tester l'accès non autorisé aux ressources d'un autre utilisateur."""
    endpoints = [
        f"/api/users/{other_user_id}/downloads",
        f"/api/users/{other_user_id}/invites",
        f"/api/users/{other_user_id}/messages",
    ]
    for ep in endpoints:
        r = session.get(f"{base_url}{ep}", timeout=10)
        status = "❌ IDOR" if r.status_code == 200 else "✅ OK"
        print(f"{r.status_code} {status} {ep}")

Sur TrackrX, tous les endpoints testés retournent 403 pour les ressources d'un autre utilisateur. Pas d'IDOR. Mais c'est une vérification côté serveur, pas Cloudflare qui protège.

La business logic : une limite de ratio qui peut être contournée, une fonctionnalité d'invitation qui peut être abusée — Cloudflare ne comprend pas les règles métier de votre application. Un WAF est une couche de défense, pas un substitut à une autorisation correcte côté serveur.

Les requêtes authentifiées : une fois authentifié avec un compte valide, toutes les requêtes ont un cookie de session légitime. Pour Cloudflare, elles sont indistinguables du trafic normal. L'extraction des 60 000 messages (VUL-02) n'a pas été bloquée.

Bilan positif — ce qui est bien fait

Un bon pentest, ce n'est pas que chercher ce qui ne va pas. TrackrX a plusieurs points solides :

  • Headers sécurité corrects : CSP avec nonces (pas de unsafe-inline), HSTS avec max-age=31536000; includeSubDomains, X-Frame-Options: DENY, X-Content-Type-Options: nosniff.
  • Cookie __Host-session : le préfixe __Host- est une protection méconnue mais efficace. Un cookie avec ce préfixe est automatiquement Secure, sans Domain, et avec Path=/ — le navigateur refuse de le définir si ces conditions ne sont pas remplies. Ça empêche certaines attaques de fixation de session via sous-domaine compromis.
  • CSRF double-submit robuste : token CSRF (__csrf) envoyé à la fois en cookie et en header, valeur générée côté serveur, invalidée après usage. Pas de bypass évident.
  • Pas d'injection SQL observable : ORM paramétré, aucune erreur de BDD exposée dans les réponses, messages d'erreur génériques.
  • Brute force bloqué : Cloudflare rate limite les tentatives sur /api/auth/login après quelques requêtes.
  • Ports BDD fermés : 3306, 5432, 6379 non accessibles publiquement.
  • Pas de panels admin exposés : aucun /admin, :8080, :9090 accessibles publiquement.

Conclusion

Sur une application bien développée avec Cloudflare en frontal, les vulnérabilités triviales (SQLi, XSS réfléchi, RCE) sont rares. Ce qu'on trouve, c'est de la configuration incorrecte (CORS), des fonctionnalités légitimes mal bornées (historique sans limite), et des fuites de données passives (NUXT_DATA). Rien de spectaculaire — mais des choses qui méritent d'être corrigées.

Le ratio signal/bruit d'un pentest sur ce type de stack est faible si on s'arrête aux scans automatisés. La vraie valeur est dans la compréhension applicative : lire les chunks JS, comprendre l'auth, tester les flux métier. Cloudflare ne voit pas la différence entre un membre qui parcourt son historique et un script qui l'extrait en entier — c'est au serveur de le voir.

La technique Playwright pour les WebSockets derrière Cloudflare est réutilisable dans n'importe quel pentest web moderne : de plus en plus d'applications utilisent des WebSockets pour le temps réel, et de plus en plus sont derrière un WAF qui filtre les outils de pentest classiques.

Commentaires (0)