Skip to content

How-to : Test de pénétration en conteneur live

Ce guide documente comment exécuter un test de pénétration adversarial en conteneur live contre une application NENE2 — de la configuration jusqu'aux 30 phases d'attaque — et consigne les résultats canoniques de la session de test v1.5.329 (2026-05-31, 150+ cas).

Le test adopte un état d'esprit de cracker : on suppose que l'attaquant a un accès complet au code source (boîte blanche), a lu toute la documentation publique, et essaiera toutes les classes d'attaque connues avant d'abandonner.


Prérequis

  • Docker Compose disponible (docker compose version)
  • curl, nc (netcat), openssl, python3 installés sur l'hôte
  • Un conteneur NENE2 en cours d'exécution avec des credentials de test

1. Configuration du conteneur

Démarrez une cible de test isolée. Utilisez un port dédié (jamais le port de production) et injectez des credentials de test :

bash
# PHP built-in server target — fastest to spin up, tests raw NENE2 behaviour
NENE2_MACHINE_API_KEY=pentest-key docker compose run -d --rm \
  -e NENE2_LOCAL_JWT_SECRET=pentest-jwt-secret-32chars-min!! \
  -e APP_ENV=local \
  -e APP_DEBUG=false \
  -p 8299:80 \
  app php -S 0.0.0.0:80 -t public_html/

# Apache target — tests full stack including Apache config hardening
NENE2_MACHINE_API_KEY=pentest-key docker compose up -d app
# Available on :8200 (see port registry in CLAUDE.md §8)

Contrôle de fumée de base :

bash
curl -si http://localhost:8299/
# Expected: 200 OK, security headers present, no Server/X-Powered-By

Énumérez la surface d'attaque depuis OpenAPI :

bash
curl -s http://localhost:8299/openapi.php | grep -E "^  /"
# → /, /health, /machine/health, /examples/protected,
#   /examples/notes, /examples/notes/{id}, /examples/tags, /examples/tags/{id}

Générez des credentials de test à l'intérieur du conteneur :

bash
CID=$(docker ps --filter "publish=8299" --format "{{.ID}}")
VALID_JWT=$(docker exec $CID php -r "
  require 'vendor/autoload.php';
  \$v = new Nene2\Auth\LocalBearerTokenVerifier('pentest-jwt-secret-32chars-min!!');
  echo \$v->issue(['sub'=>'tester','exp'=>time()+86400]);
")

2. Phases d'attaque

Phase 1 — Confusion d'algorithme JWT

IDAttaqueAttenduv1.5.329
J-01alg:none (signature vide)401✅ BLOCKED
J-02alg:NONE (majuscules)401✅ BLOCKED
J-03alg:None (casse mixte)401✅ BLOCKED
J-04alg:hs256 (minuscules)401✅ BLOCKED
J-05alg:RS256 (confusion de clé)401✅ BLOCKED
J-06Pas de champ alg401✅ BLOCKED
J-07kid: ../../etc/passwd200 (sig valide)✅ SAFE — champs d'en-tête supplémentaires ignorés
J-08jku: http://evil.com200 (sig valide)✅ SAFE — pas de fetch JWK
bash
# J-01: alg:none
H=$(echo -n '{"typ":"JWT","alg":"none"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
P=$(echo -n '{"sub":"admin","exp":9999999999}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
curl -si -H "Authorization: Bearer $H.$P." http://localhost:8299/examples/protected
# → 401  detail: "Token algorithm must be HS256."

Phase 1b — Manipulation de payload JWT

IDAttaqueAttenduv1.5.329
J-09exp: 0 (epoch 1970)401 expiré✅ BLOCKED
J-10exp: null401 doit être numérique✅ BLOCKED
J-11exp: "never"401 doit être numérique✅ BLOCKED
J-12exp: 9999999999.9 (float)401 doit être numérique✅ BLOCKED
J-13Le payload est un tableau JSON401 doit être numérique✅ BLOCKED
J-14Double espace dans la valeur Bearer401✅ BLOCKED
J-15Pas de schéma Bearer401✅ BLOCKED
J-16Token à 4 segments (point supplémentaire)401 format invalide✅ BLOCKED
J-17En-tête + payload seulement (pas de sig)401✅ BLOCKED

Invariant clé : exp doit être un entier présent — l'absence ou un mauvais type est rejeté (corrigé en v1.5.329).

Phase 2 — Injection SQL

Tous les repositories utilisent des requêtes paramétrées avec placeholder ?. Aucune interpolation de chaîne brute.

IDAttaqueAttenduv1.5.329
S-01' OR 1=1-- classique dans le titre201 (stocké comme littéral)✅ SAFE
S-02UNION SELECT 1,2,3--201 (stocké comme littéral)✅ SAFE
S-03Booléen aveugle AND 1=1--201 (stocké comme littéral)✅ SAFE
S-04Basé sur le temps AND SLEEP(2)--201 en <50ms✅ SAFE — SLEEP non exécuté
S-05SQLi en paramètre de chemin /notes/1' OR '1'='1200 (cast int → 1)✅ SAFE
S-06Octet nul \0' OR '1'='1201 (littéral)✅ SAFE
S-07Second ordre : stocker le payload, puis lire200 (relecture littérale)✅ SAFE
S-08SLEEP(5) dans un champ du corps201 en <50ms✅ SAFE
S-10limit=UNION SELECT... dans la query string422 (validation)✅ SAFE
bash
# Verify parameterized queries: SLEEP is not executed
time curl -si -X POST -H "Content-Type: application/json" \
  -d '{"title":"x'\'' AND SLEEP(3)--","body":"x"}' \
  http://localhost:8299/examples/notes
# → 201 in < 100ms  (SLEEP never ran)

Phase 3 — Path traversal / LFI / wrappers PHP

IDAttaqueAttenduv1.5.329
P-01../../etc/passwd404✅ BLOCKED
P-02Variantes URL-encodées %2e%2e%2f (5 formes)404✅ BLOCKED
P-03Double-encodé %252e%252e404✅ BLOCKED
P-04UTF-8 overlong %c0%ae404✅ BLOCKED
P-05php://input / php://filter / data://404✅ BLOCKED
P-06LFI via le paramètre {id}404✅ BLOCKED
P-07Octet nul 1%00.html200 (cast int → 1)✅ SAFE — enregistrement DB pour id=1 retourné
P-08.htaccess sur Apache403✅ BLOCKED
P-08b.htaccess sur le serveur PHP intégré200⚠️ EXPOSED (voir VULN-01)
P-09.git/HEAD404✅ BLOCKED
P-10Fichiers de sauvegarde (.bak, .swp, ~, etc.)404✅ BLOCKED

Phase 4 — Attaques de protocole HTTP

IDAttaqueAttenduv1.5.329
H-01Request smuggling CL.TEaucune réponse (le serveur PHP intégré bloque)
H-02Smuggling TE.CL405 (méthode racine non concordante)
H-03Transfer-Encoding obfusqué TE.TEaucune réponse
H-04Downgrade HTTP/1.0200 (corps correct)
H-05Abus de proxy URI absolue404
H-06Pliage d'en-tête HTTP500 (bug du serveur PHP intégré)⚠️ VULN-02
H-07Pipelining HTTPréponses entrelacées✅ SAFE
H-08100 en-têtes personnalisés simultanés200✅ SAFE
H-10Upgrade WebSocket200 (upgrade ignoré)✅ SAFE
H-12Version HTTP invalide (HTTP/9.9)200 (le serveur PHP intégré accepte)✅ SAFE

Phase 5 — Mass assignment / IDOR / logique métier

IDAttaqueAttenduv1.5.329
B-01Mass assignment (id, __proto__ dans le corps)201 (champs supplémentaires ignorés)✅ SAFE
B-02IDOR : DELETE la note d'un autre utilisateur204ℹ️ Attendu (les exemples n'ont pas de propriété)
B-04ID négatif / zéro404✅ SAFE
B-05ID en débordement d'entier404✅ SAFE
B-06DELETE puis re-accès au même ID404✅ SAFE
B-07Course de DELETE concurrentstous 404 (idempotent)✅ SAFE
B-08Corps à la limite de 1Mo413✅ BLOCKED

Phase 6 — Contournement de clé API

IDAttaqueAttenduv1.5.329
A-01Pas de clé401✅ BLOCKED
A-02Clé dans la query string (?key=, ?api_key=)401✅ BLOCKED
A-03Clé dans le corps de la requête401✅ BLOCKED
A-04Variations de casse du nom d'en-tête200 (PSR-7 normalise)✅ SAFE
A-05Espaces en tête/fin dans la valeur200 (PSR-7 trim)✅ SAFE
A-06Double slash //machine/health401 sans clé, 200 avec✅ SAFE
A-07X-Original-URL / X-Rewrite-URL200 (en-tête ignoré)✅ SAFE
A-08Contournement preflight OPTIONS405✅ BLOCKED
A-09Méthode HEAD401✅ BLOCKED
A-10Brute force de mots de passe courants401 partout✅ BLOCKED
A-11Chemin URL-encodé (%6Dachine)404✅ BLOCKED
bash
# Timing attack: hash_equals used → constant-time comparison
time (for i in $(seq 1 10); do
  curl -so /dev/null -H "X-NENE2-API-Key: a" http://localhost:8299/machine/health
done)
time (for i in $(seq 1 10); do
  curl -so /dev/null -H "X-NENE2-API-Key: pentest-key" http://localhost:8299/machine/health
done)
# → timing difference < 5ms over 10 requests: SAFE

Phase 7 — Injection / XSS / SSTI / exécution de code

IDAttaqueAttenduv1.5.329
I-01XSS <script>alert(1)</script> stocké201, retourné comme chaîne JSON✅ SAFE — l'encodage JSON neutralise
I-02SSTI 49 / ${7*7}201, stocké littéralement✅ SAFE — pas de moteur de template
I-03PHP <?php system("id"); ?>201, stocké comme littéral✅ SAFE
I-04Log4Shell ${jndi:ldap://...}200 (en-tête ignoré)✅ SAFE — PHP, pas Java
I-05JSON imbriqué sur 1000 niveaux400 (limite de parse PHP)✅ BLOCKED
I-06Caractères de contrôle Unicode BiDi201 (stocké)✅ SAFE — risque d'affichage seulement
I-07Clés JSON dupliquéesla dernière valeur l'emporte (comportement PHP)ℹ️ INFO-01

Note XSS stocké : les payloads XSS sont stockés et retournés verbatim dans les réponses JSON. Comme l'API est uniquement JSON (Content-Type: application/json + X-Content-Type-Options: nosniff), les navigateurs n'exécuteront pas le script. Le risque ne se matérialise que si une autre application rend ces données dans un contexte HTML sans échappement.

Phase 8 — Désérialisation / injection d'objet PHP

IDAttaqueAttenduv1.5.329
D-01Wrapper phar:// dans un paramètre de chemin404✅ BLOCKED
D-02Payload serialize PHP O:8:"stdClass":...400 (corps invalide)✅ BLOCKED
D-03Formulaire URL-encodé avec payload serialize400 (mauvais Content-Type)✅ BLOCKED

Phase 9 — Injection d'en-tête / response splitting

IDAttaqueAttenduv1.5.329
R-01Injection d'en-tête Location via l'id de note créée/examples/notes/<int>✅ SAFE — int uniquement
R-02CRLF dans WWW-Authenticate via erreur JWTmessage fixe assaini✅ SAFE
R-03Content-Type sniffingX-Content-Type-Options: nosniff✅ SAFE
R-04ClickjackingX-Frame-Options: SAMEORIGIN✅ SAFE

Phase 10 — Contournement CORS / SOP

IDAttaqueAttenduv1.5.329
C-01Origin: null (iframe sandboxé)Vary: Origin, pas d'en-tête ACAO✅ SAFE
C-02CRLF dans l'en-tête Originassaini par la couche curl/http✅ SAFE
C-03Cache poisoning de l'en-tête VaryVary: Origin présent✅ SAFE
C-04Preflight avec méthode injectéeméthode ignorée par PHP✅ SAFE
C-05Access-Control-Allow-Origin: *en-tête absent (allowlist vide)✅ SAFE

Phases 11-20 — Encodage / protocole / timing

IDAttaqueRésultat
E-01Emoji / Unicode haut dans le JSON✅ 201 (stocké correctement)
E-02Override BiDi RTL (risque de spoofing)✅ 201 (affichage seulement)
E-05SQLi de pagination via paramètres de requête✅ 422 (validé comme entier)
H-06bEn-tête Authorization plié⚠️ 500 (bug du serveur PHP intégré)
20X-Request-Id de 129 caractères rejeté✅ Le serveur génère un nouvel ID aléatoire
21Injection de log via X-Request-Id %0a✅ Rejeté (caractères invalides)
22Apache ServerTokens/ServerSignatureServer: Apache uniquement
23Escalade de privilège JWT sub=admin✅ Claims non utilisés pour l'authz
26Replay JWT (expiré il y a 2s)✅ 401 Token has expired.
27Divulgation de stack trace 500✅ Message générique uniquement
28XSS dans instance des Problem Details✅ URL-encodé (sûr)
29SSRF via l'endpoint health check✅ Aucune URL acceptée
15Oracle de timing sur la clé APIhash_equals — diff < 5ms

3. Constats

VULN-01 — .htaccess lisible depuis le serveur PHP intégré ⚠️ MEDIUM

Déclencheur : curl http://localhost:8299/.htaccess
Réponse : 200 + contenu complet du fichier (règles de réécriture Apache)
Cause racine : le serveur intégré de PHP (php -S) n'applique pas les restrictions d'accès .htaccess — il traite .htaccess comme un fichier statique.
Impact : révèle les règles de réécriture d'URL. Le contenu n'est pas secret (pas de mots de passe/tokens), mais confirme le pattern de réécriture vers index.php.
Atténuation : utilisez le conteneur Apache (docker compose up -d app) au lieu de php -S pour les tests sensibles à la sécurité. Apache retourne correctement 403.

bash
# Apache (correct): 403 Forbidden
curl -si http://localhost:8200/.htaccess | head -1

# PHP built-in server (exposed): 200 OK
curl -si http://localhost:8299/.htaccess | head -1

VULN-02 — Le pliage d'en-tête HTTP plante le serveur PHP intégré ⚠️ LOW

Déclencheur :

GET / HTTP/1.1\r\nHost: localhost\r\nX-NENE2-API-Key:\r\n <key>\r\n\r\n

Réponse : HTTP/1.0 500 Internal Server Error (corps vide)
Cause racine : le serveur HTTP intégré de PHP ne prend pas en charge le pliage d'en-tête RFC 7230 (déprécié mais toujours valide en HTTP/1.1). Le code du framework NENE2 n'est pas impliqué.
Impact : développement uniquement (serveur PHP intégré). Apache gère correctement les en-têtes pliés.

INFO-01 — Clés JSON dupliquées : la dernière valeur l'emporte

{"title":"first","title":"INJECTED"}title = "INJECTED"
Comportement standard de json_decode en PHP. La validation s'applique à la valeur finale (la dernière), donc il n'y a pas de voie de contournement de validation. Noté pour information.


4. Invariants de sécurité vérifiés

Ces garanties ont tenu sur l'ensemble des 150+ cas de test :

InvariantVérification
Toutes les requêtes SQL paramétréesSLEEP non exécuté ; payloads d'injection stockés comme littéraux
JWT doit être HS256 + sig valide + exp entierLes 17 variantes d'attaque JWT bloquées
Clé API vérifiée avec hash_equalsDifférence de timing < 5ms sur 10 itérations
Débordement de Content-Length géré413 avec en-têtes corrects, aucune fuite de warning PHP
En-têtes de sécurité sur chaque réponseCSP / XCTO / XFO / Referrer-Policy / Permissions-Policy confirmés
Server: / X-Powered-By: supprimésAucun en-tête présent dans les réponses Apache
Jamais de stack trace dans le corps 500Uniquement le générique "The server encountered an unexpected condition."
Path traversal bloquéLes 15 variantes d'encodage retournent 404
Fichiers .env / .git / sauvegardeTous 404 dans le document root
CORS par défaut : aucune origine autoriséeAccess-Control-Allow-Origin absent pour les origines arbitraires

5. Exécuter la suite de tests

Répétition minimale viable des contrôles clés (< 5 minutes) :

bash
TARGET=http://localhost:8299
APIKEY=pentest-key
SECRET=pentest-jwt-secret-32chars-min!!
CID=$(docker ps --filter "publish=8299" --format "{{.ID}}")

# 1. JWT alg:none
H=$(echo -n '{"typ":"JWT","alg":"none"}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
P=$(echo -n '{"sub":"admin","exp":9999999999}' | base64 -w0 | tr '+/' '-_' | tr -d '=')
curl -si -H "Authorization: Bearer $H.$P." $TARGET/examples/protected | grep "HTTP/"
# expected: 401

# 2. SQL injection time-based
time curl -so /dev/null -X POST -H "Content-Type: application/json" \
  -d '{"title":"x'\'' AND SLEEP(3)--","body":"x"}' $TARGET/examples/notes
# expected: < 500ms total

# 3. Path traversal
curl -si "$TARGET/%2e%2e/%2e%2e/etc/passwd" | grep "HTTP/"
# expected: 404

# 4. Content-Length overflow
curl -si -X POST -H "Content-Length: 9999999999999" $TARGET/ | head -3
# expected: 413 Request Entity Too Large (not 200 + PHP warning)

# 5. API key timing
time (for i in $(seq 1 10); do
  curl -so /dev/null -H "X-NENE2-API-Key: a" $TARGET/machine/health
done)
# expected: similar timing to correct key (hash_equals)

# 6. .htaccess exposure (Apache only)
curl -si http://localhost:8200/.htaccess | grep "HTTP/"
# expected: 403

# 7. JWT exp required
NEXP=$(docker exec $CID php -r "
  require 'vendor/autoload.php';
  \$v = new Nene2\Auth\LocalBearerTokenVerifier('$SECRET');
  echo \$v->issue(['sub'=>'user1']);
")
curl -si -H "Authorization: Bearer $NEXP" $TARGET/examples/protected | grep "detail"
# expected: "Token must contain a numeric exp claim."

Guides associés

Publié sous licence MIT.