Echoes of Hackers · Forge · Octobre Rose

⚒️ MAESTRO · LAB_ONLY

php2shell - Lab pédagogique de détection

Salut Apache👋 Ce lab te montre comment un ver se comporte (amorçage → balayage → propagation → collecte → exfiltration) et comment le détecter, autour des deux vraies failles PHP d'octobre 2026 : CVE-2026-109618 (OOM) et CVE-2026-109619 (use-after-free / état global). Tout est à copier depuis cette page : pas de zip, pas de pack à côté.

⚠️ Ce n'est PAS un exploit - c'est un simulateur de comportement

Ce lab ne contient aucune charge d'exploitation réelle : aucun PoC des CVE, aucun déclenchement d'OOM ou de use-after-free, aucun code exécuté sur une cible. Le « ver-jouet » simule les étapes d'un ver en envoyant un simple en-tête marqueur X-Lab-Php2shell-Sim. Les sites leurres acceptent volontairement ce marqueur pour jouer le rôle de cible et produire des traces. Objectif : apprendre à détecter le comportement, pas à l'exploiter.

Garde-fous intégrés :

  • LAB_ONLY : chaque composant refuse de démarrer si LAB_ONLY=1 n'est pas présent.
  • Sous-réseau 172.20.0.0/24 : le ver-jouet refuse toute cible hors de ce /24 de lab.
  • Anti-Internet : réseau Docker internal: true (pas de route sortante) et contrôle applicatif qui arrête le programme si Internet est joignable.
  • Consentement des cibles : seuls les leurres qui répondent X-Lab-Decoy-State: simulated-compromise sont comptés.
  • Exfiltration simulée : le « butin » (un canari local) est écrit dans un fichier local, jamais envoyé sur le réseau.

🎗️ Note Octobre Rose

On forge des défenses, pas des armes. Dépister tôt, détecter tôt : même esprit côté SI. Ce lab reste strictement confiné - on apprend, on patche, on protège.

Théorie - deux failles mémoire (octobre 2026)

Ces deux CVE ne sont pas l'ancienne histoire du CGI PHP avec -d auto_prepend_file=php://input (injection d'argument CGI). Ce sont des failles de gestion mémoire au cœur de PHP, qui touchent presque chaque requête : la gestion de la mémoire et la gestion des erreurs. C'est ce qui les rend graves et difficiles à corriger sans casser la compatibilité ou les performances.

CVE-2026-109618 - RCE via corruption OOM

CritiqueCVSS 9.2 GHSA-w8hw-w9fx-j79w

Mécanisme : PHP utilise longjmp() pour interrompre le traitement d'une requête lors d'une erreur out-of-memory. Si l'allocation échoue pendant l'initialisation, un état de requête partiellement initialisé est déjà atteignable. La routine d'arrêt (shutdown/cleanup) peut alors traiter cet état invalide comme valide, d'où une corruption mémoire pouvant mener à l'exécution de code.

Quand ça frappe : au pire moment de l'init mémoire. Sous Apache mod_php, une requête non authentifiée peut déclencher la faille avant même que le code applicatif ne s'exécute (RCE démontrée en configuration par défaut). PHP-FPM est aussi concerné via des chemins applicatifs.

; CVE-2026-109618 - chemin OOM (conceptuel, PAS un PoC) requete arrive -> allocation etat interne de requete alloc echoue -> condition OUT-OF-MEMORY longjmp saute l'init -> etat de requete PARTIELLEMENT initialise mais DEJA referencable shutdown nettoyage -> lit cet etat invalide comme s'il etait valide ; => corruption memoire potentielle (sous mod_php: avant le code appli)

CVE-2026-109619 - RCE via état global obsolète (UAF)

CritiqueGHSA-6wmf-4rv5-p3rp

Mécanisme : une erreur fatale au mauvais endroit peut laisser un état global interne pointant vers de la mémoire appartenant à une requête déjà libérée. Une requête ultérieure traitée par le même worker réutilise cet état obsolète avant que l'initialisation normale ne le réinitialise : c'est un use-after-free entre requêtes.

Quand ça frappe : à la requête suivante sur un worker persistant (mod_php, FPM). Use-after-free et crash de worker confirmés ; RCE démontrée contre une application réelle sous Apache mod_php.

; CVE-2026-109619 - etat global obsolete (trace conceptuelle annotee, PAS un PoC) requete_N : erreur fatale au mauvais moment cleanup : la memoire de l'etat de requete est LIBEREE (free) etat_global : conserve encore un POINTEUR vers cette memoire liberee requete_N+1 : meme worker -> USE-AFTER-FREE avant la reinitialisation normale ; => memoire reutilisee/controlable => RCE demontree (mod_php)

Le point commun : les deux failles vivent dans des parties que presque toutes les requêtes traversent - allocation mémoire et gestion des erreurs. L'attaquant n'a pas besoin d'authentification ; il lui suffit d'un accès réseau au service web et de requêtes HTTP spécialement forgées. Ce lab ne forge aucune de ces requêtes : il n'en imite que la phase comportementale observable (propagation, collecte, exfil) via un en-tête marqueur inoffensif.

Versions corrigées & mitigations provisoires (CERT-FR)

Correctifs publiés le 21 octobre 2026. La seule protection complète est la mise à jour : les mitigations ci-dessous ne font que réduire la surface d'attaque.

Branche PHPVersions vulnérablesVersion corrigée
PHP 8.5.x< 8.5.128.5.12
PHP 8.4.x< 8.4.278.4.27
PHP 8.3.x< 8.3.368.3.36
PHP 8.2.x< 8.2.358.2.35
PHP 8.1 et antérieurfin de viebackports source minimaux (7.4/8.0/8.1) ou vendeur LTS

Risque le plus élevé : Apache mod_php. Également concernés : PHP-FPM, php-cgi, FrankenPHP, LiteSpeed.

Contournements CVE-2026-109618 (OOM)

  • Fixer memory_limit=-1 au niveau administrateur (ex. php_admin_value dans le pool FPM ou la conf Apache) pour qu'aucune appli ne puisse le redéfinir.
  • post_max_size bas : 8M sans upload, 32M si uploads nécessaires ; garder upload_max_filesize < post_max_size.
  • Limiter au niveau serveur/proxy : taille du corps décodé, nombre de variables, de parties multipart, d'en-têtes, profondeur d'imbrication (y compris sans Content-Length exploitable).
  • Utiliser des limites conteneur/cgroup comme garde-fou de disponibilité (tuer seulement le worker touché). Ne pas utiliser RLIMIT_AS/RLIMIT_DATA (ils provoquent justement l'échec d'allocation vulnérable).

Contournements CVE-2026-109619 (UAF)

  • session.upload_progress.enabled=0 puis redémarrer chaque worker (via php.ini/pool FPM/conf serveur appliquée avant le parsing - pas ini_set() ni .user.ini).
  • Sur PHP 8.5 : fatal_error_backtraces=0 puis redémarrer les workers.
  • Supprimer les configurations incorrectes/obsolètes appliquées à chaque requête qui génèrent des diagnostics avant l'initialisation.
  • Corriger les erreurs fatales et les dépassements de délai accessibles aux attaquants.
Sources officielles

Architecture du lab

flowchart TB subgraph LAB["Reseau forge_net 172.20.0.0/24 - internal:true (PAS d'Internet)"] ORCH["orchestrateur 172.20.0.5<br/>superviseur defensif + ver-jouet"] BANK["banque-leurre 172.20.0.11<br/>Forge Banque (consent)"] GOV["gouv-leurre 172.20.0.12<br/>Forge.gouv (consent)"] HP["honeypot 172.20.0.20<br/>pot de miel passif"] SUR["suricata-ids<br/>observe le trafic"] end ORCH -- "header X-Lab-Php2shell-Sim" --> BANK ORCH -- "header X-Lab-Php2shell-Sim" --> GOV ORCH -. "scan /24" .-> HP SUR -. "alertes sid:2600xxx" .-> ORCH BANK -- "logs SIM-COMPROMISSION" --> ORCH GOV -- "logs SIM-COMPROMISSION" --> ORCH INET(("Internet")) x-. "BLOQUE (internal:true + garde-fou)" .-x LAB
                FORGE_NET  172.20.0.0/24   (internal:true -> AUCUN Internet)
   +-----------------------------------------------------------------------+
   |                                                                       |
   |   [orchestrateur .5] --header X-Lab-Php2shell-Sim--> [banque .11]      |
   |        |  \                                          [gouv   .12]      |
   |        |   \--scan /24-------------------------------> [honeypot .20]  |
   |        |                                                              |
   |   [suricata-ids] .....alertes sid:2600xxx.....> (journaux /logs)      |
   |        ^                                                              |
   |   banque/gouv --logs SIM-COMPROMISSION--> orchestrateur               |
   |                                                                       |
   +-----------------------------------------------------------------------+
            X   Internet  <--BLOQUE (reseau internal + garde-fou)

Si le diagramme ne s'affiche pas (CDN bloqué), le schéma ASCII de secours apparaît automatiquement.

Prérequis

  • Ta machine Kali avec Docker + docker compose v2 (docker compose version).
  • Tout se passe en local, réseau isolé. Ne lance rien en dehors du lab.
  • On ne lance pas Docker dans cette page : les commandes sont fournies à copier, tu décides quand exécuter.

Déploiement - étapes numérotées (dans l'ordre)

Fais les étapes dans l'ordre, de 1 à 12. Chaque chose à faire est un bloc avec un bouton Copier.

Étape 1 Créer l'arborescence du lab

Tout le lab tient dans un seul dossier de ton Bureau. Copie et exécute :

terminal - arborescence
# Cree l'arborescence du lab dans ton Bureau
mkdir -p ~/Bureau/php2shell-lab/{banque,gouv,honeypot,orchestrateur,canaris,suricata}
mkdir -p ~/Bureau/php2shell-lab/logs/{banque,gouv,honeypot,suricata}
cd ~/Bureau/php2shell-lab

Étape 2 Écrire docker-compose.yml

À la racine ~/Bureau/php2shell-lab/. Note le réseau internal: true (garde-fou anti-Internet) et le sous-réseau 172.20.0.0/24.

~/Bureau/php2shell-lab/docker-compose.yml
# php2shell-lab - LAB_ONLY - reseau 100% isole (aucun acces Internet)
# Images PHP deliberement PATCHEES : ce lab n'exploite AUCUNE CVE reelle.
services:
  banque-leurre:
    build: ./banque
    container_name: banque-leurre
    environment: [ "LAB_ONLY=1" ]
    env_file: ./canaris/banque.env
    volumes: [ "./logs/banque:/var/log" ]
    networks:
      forge_net:
        ipv4_address: 172.20.0.11
    restart: "no"

  gouv-leurre:
    build: ./gouv
    container_name: gouv-leurre
    environment: [ "LAB_ONLY=1" ]
    env_file: ./canaris/gouv.env
    volumes: [ "./logs/gouv:/var/log" ]
    networks:
      forge_net:
        ipv4_address: 172.20.0.12
    restart: "no"

  honeypot:
    build: ./honeypot
    container_name: honeypot
    environment: [ "LAB_ONLY=1" ]
    volumes: [ "./logs/honeypot:/logs" ]
    networks:
      forge_net:
        ipv4_address: 172.20.0.20
    restart: "no"

  suricata:
    image: jasonish/suricata:latest
    container_name: suricata-ids
    network_mode: "service:banque-leurre"   # observe le meme namespace reseau que la banque
    cap_add: [ NET_ADMIN, NET_RAW, SYS_NICE ]
    volumes:
      - ./suricata/lab.rules:/etc/suricata/rules/lab.rules:ro
      - ./logs/suricata:/var/log/suricata
    command: [ "-i", "eth0", "-S", "/etc/suricata/rules/lab.rules" ]
    restart: "no"

  orchestrateur:
    build: ./orchestrateur
    container_name: orchestrateur
    environment: [ "LAB_ONLY=1" ]
    volumes: [ "./logs:/logs" ]
    networks:
      forge_net:
        ipv4_address: 172.20.0.5
    restart: "no"

networks:
  forge_net:
    name: forge_net
    driver: bridge
    internal: true          # GARDE-FOU anti-Internet : le /24 ne route PAS vers l'exterieur
    ipam:
      config:
        - subnet: 172.20.0.0/24

Étape 3 Site leurre « banque » (consent)

Dans banque/. Le Dockerfile part d'une image PHP patchée : aucune vraie faille servie. index.php accepte volontairement le marqueur et journalise.

banque/Dockerfile
# banque/Dockerfile - site leurre "Forge Banque"
# Base PHP PATCHEE (>= 8.2.35) : le lab ne repose sur AUCUNE vulnerabilite reelle.
FROM php:8.2.35-apache
LABEL lab="php2shell" role="banque-leurre"
RUN mkdir -p /var/log && touch /var/log/leurre.log
COPY index.php /var/www/html/index.php
EXPOSE 80
banque/index.php
<?php
// banque-leurre - SITE LEURRE DE LABORATOIRE ("Forge Banque").
// AUCUNE vulnerabilite reelle. Ce site ACCEPTE VOLONTAIREMENT le marqueur de
// simulation afin de jouer le role d'une cible "compromise" et de produire des
// traces pour l'entrainement a la detection. Il n'execute RIEN de ce que le
// "ver-jouet" envoie : il se contente de JOURNALISER l'evenement.
declare(strict_types=1);

$marqueur = $_SERVER['HTTP_X_LAB_PHP2SHELL_SIM'] ?? '';
$journal  = '/var/log/leurre.log';

if ($marqueur !== '') {
    // Consentement explicite du leurre : on NOTE, on n'EXECUTE pas.
    $ligne = sprintf("[%s] SIM-COMPROMISSION src=%s marqueur=%s\n",
        date('c'), $_SERVER['REMOTE_ADDR'] ?? '?', substr($marqueur, 0, 64));
    @file_put_contents($journal, $ligne, FILE_APPEND);
    header('Content-Type: application/json; charset=utf-8');
    header('X-Lab-Decoy-State: simulated-compromise');
    http_response_code(200);
    echo json_encode([
        'lab'    => 'php2shell',
        'role'   => 'banque-leurre',
        'etat'   => 'compromission SIMULEE enregistree (aucun code execute)',
        'canari' => getenv('CANARI_TOKEN') ?: 'absent',
    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    return;
}
?>
<!doctype html>
<html lang="fr"><head><meta charset="utf-8"><title>Forge Banque - Espace client</title></head>
<body style="font-family:system-ui,sans-serif;background:#0b0f14;color:#eee;padding:2rem">
  <h1>🏦 Forge Banque</h1>
  <p><strong>LEURRE DE LABORATOIRE</strong> - aucune donnee reelle, aucun vrai service.</p>
  <form onsubmit="return false">
    <p><input placeholder="Identifiant"></p>
    <p><input type="password" placeholder="Mot de passe"></p>
    <button>Connexion</button>
  </form>
</body></html>

Étape 4 Site leurre « gouv » (consent)

Dans gouv/. Même principe que la banque.

gouv/Dockerfile
# gouv/Dockerfile - site leurre "Forge.gouv"
# Base PHP PATCHEE (>= 8.2.35) : aucune vulnerabilite reelle n'est servie.
FROM php:8.2.35-apache
LABEL lab="php2shell" role="gouv-leurre"
RUN mkdir -p /var/log && touch /var/log/leurre.log
COPY index.php /var/www/html/index.php
EXPOSE 80
gouv/index.php
<?php
// gouv-leurre - SITE LEURRE DE LABORATOIRE ("Forge.gouv - teleservices").
// Meme principe que la banque : il CONSENT a jouer la cible et JOURNALISE le
// marqueur de simulation. Aucune execution, aucune vulnerabilite reelle.
declare(strict_types=1);

$marqueur = $_SERVER['HTTP_X_LAB_PHP2SHELL_SIM'] ?? '';
$journal  = '/var/log/leurre.log';

if ($marqueur !== '') {
    $ligne = sprintf("[%s] SIM-COMPROMISSION src=%s marqueur=%s\n",
        date('c'), $_SERVER['REMOTE_ADDR'] ?? '?', substr($marqueur, 0, 64));
    @file_put_contents($journal, $ligne, FILE_APPEND);
    header('Content-Type: application/json; charset=utf-8');
    header('X-Lab-Decoy-State: simulated-compromise');
    http_response_code(200);
    echo json_encode([
        'lab'    => 'php2shell',
        'role'   => 'gouv-leurre',
        'etat'   => 'compromission SIMULEE enregistree (aucun code execute)',
        'canari' => getenv('CANARI_TOKEN') ?: 'absent',
    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    return;
}
?>
<!doctype html>
<html lang="fr"><head><meta charset="utf-8"><title>Forge.gouv - Teleservices</title></head>
<body style="font-family:system-ui,sans-serif;background:#10131a;color:#eee;padding:2rem">
  <h1>🏛️ Forge.gouv</h1>
  <p><strong>LEURRE DE LABORATOIRE</strong> - portail fictif, aucune donnee d'usager.</p>
  <p>Demarches : etat civil (demo) · impots (demo) · carte grise (demo)</p>
</body></html>

Étape 5 Fichiers .env canaris (appâts)

Dans canaris/. Ces secrets sont faux et pistés : s'ils réapparaissent ailleurs, c'est une collecte détectée.

canaris/banque.env
# canaris/banque.env - .env CANARI (appats de laboratoire).
# Ces "secrets" sont FAUX et pistes. S'ils apparaissent dans un log, une requete
# ou un fichier d'exfiltration, c'est la PREUVE qu'une collecte a eu lieu.
LAB_ONLY=1
CANARI_TOKEN=CANARI-BANQUE-7f3a9c2e-NE-PAS-UTILISER
DB_HOST=172.20.0.11
DB_USER=forge_bank_app
DB_PASSWORD=canari-faux-mdp-9931
API_KEY=sk_live_FORGE_CANARI_0000_FAKE
canaris/gouv.env
# canaris/gouv.env - .env CANARI (appats de laboratoire).
# Secrets FAUX et pistes. Toute apparition ailleurs = collecte detectee.
LAB_ONLY=1
CANARI_TOKEN=CANARI-GOUV-1b8d4f60-NE-PAS-UTILISER
DB_HOST=172.20.0.12
DB_USER=teleservice_ro
DB_PASSWORD=canari-faux-mdp-4472
JWT_SECRET=CANARI_GOUV_JWT_FAKE_DO_NOT_USE

Étape 6 Honeypot passif

Dans honeypot/. Il n'offre aucun service réel : toute connexion est un signal.

honeypot/Dockerfile
# honeypot/Dockerfile - pot de miel passif
FROM python:3.12-slim
LABEL lab="php2shell" role="honeypot"
COPY honeypot.py /honeypot.py
EXPOSE 8080
CMD ["python", "/honeypot.py"]
honeypot/honeypot.py
#!/usr/bin/env python3
"""honeypot.py - pot de miel PASSIF de laboratoire (LAB_ONLY).

Il n'offre AUCUN service reel : il ecoute, journalise QUI le contacte, repond un
204 vide, puis ferme. En fonctionnement normal, PERSONNE ne devrait lui parler ;
donc toute connexion est un signal fort (hote suspect / mouvement lateral)."""
import os, socket, datetime, sys

if os.getenv("LAB_ONLY") != "1":
    sys.exit("Refus : LAB_ONLY != 1 (honeypot reserve au laboratoire).")

HOTE, PORT = "0.0.0.0", 8080
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((HOTE, PORT))
srv.listen(16)
print(f"[honeypot] ecoute {HOTE}:{PORT} - LAB_ONLY", flush=True)

while True:
    conn, addr = srv.accept()
    try:
        data = conn.recv(2048)
        ts = datetime.datetime.now().isoformat()
        ligne = f"[{ts}] CONTACT depuis {addr[0]}:{addr[1]} ({len(data)} octets)\n"
        with open("/logs/honeypot.log", "a") as f:
            f.write(ligne)
        print(ligne.strip(), flush=True)
        conn.sendall(b"HTTP/1.1 204 No Content\r\nX-Lab-Honeypot: 1\r\n\r\n")
    finally:
        conn.close()

Étape 7 Règles Suricata légères

Dans suricata/. Quatre règles pour détecter les phases du ver (marqueur, balayage, fuite de canari, contact honeypot).

suricata/lab.rules
# suricata/lab.rules - regles Suricata LEGERES de laboratoire (php2shell).
# Objectif PEDAGOGIQUE : apprendre a DETECTER le comportement du ver, jamais a
# l'exploiter. Les SID 2600xxx sont locaux au lab.

# 1) Marqueur du ver-jouet dans un en-tete HTTP
alert http any any -> $HOME_NET any (msg:"LAB php2shell - marqueur X-Lab-Php2shell-Sim vu"; flow:to_server,established; http.header; content:"X-Lab-Php2shell-Sim"; nocase; classtype:policy-violation; sid:2600001; rev:1;)

# 2) Balayage horizontal : une source parle a beaucoup d'hotes du /24
alert tcp 172.20.0.0/24 any -> 172.20.0.0/24 80 (msg:"LAB php2shell - balayage horizontal probable (scan lateral)"; flow:to_server; threshold:type both, track by_src, count 10, seconds 15; classtype:attempted-recon; sid:2600002; rev:1;)

# 3) Fuite de canari : un jeton appat part dans une requete (exfil simulee)
alert http any any -> any any (msg:"LAB php2shell - jeton canari dans une requete (exfil simulee)"; http.request_body; content:"CANARI-"; nocase; classtype:data-loss; sid:2600003; rev:1;)

# 4) Contact du honeypot : ne devrait jamais arriver en temps normal
alert tcp any any -> 172.20.0.20 8080 (msg:"LAB php2shell - contact du honeypot (hote suspect)"; flow:to_server; classtype:bad-unknown; sid:2600004; rev:1;)

Étape 8 Orchestrateur défensif (blue team)

Dans orchestrateur/. Il vérifie les garde-fous puis corrèle les journaux. Il n'attaque rien.

orchestrateur/Dockerfile
# orchestrateur/Dockerfile - superviseur DEFENSIF (blue team)
FROM python:3.12-slim
LABEL lab="php2shell" role="orchestrateur-defensif"
COPY orchestrateur.py /orchestrateur.py
CMD ["python", "/orchestrateur.py"]
orchestrateur/orchestrateur.py
#!/usr/bin/env python3
"""orchestrateur.py - superviseur DEFENSIF du lab php2shell (blue team, LAB_ONLY).

Role : (1) verifier les garde-fous d'isolation, (2) surveiller les journaux des
leurres, du honeypot et de Suricata, (3) produire un tableau de bord des
detections. Il n'attaque RIEN et ne porte aucune charge offensive : c'est l'outil
cote defense qui apprend a RECONNAITRE les phases du ver dans les traces."""
import os, sys, time, glob, json, socket, ipaddress

SOUS_RESEAU   = ipaddress.ip_network("172.20.0.0/24")
DOSSIER_LOGS  = "/logs"
MOTS_CLES     = ("SIM-COMPROMISSION", "CANARI-", "CONTACT")

def garde_fous():
    """Refuse de tourner hors du lab, et verifie que le reseau est bien isole."""
    if os.getenv("LAB_ONLY") != "1":
        sys.exit("ARRET : LAB_ONLY != 1 (le lab doit rester confine).")
    try:
        socket.setdefaulttimeout(2)
        socket.create_connection(("1.1.1.1", 53))
        sys.exit("ARRET : Internet accessible - le reseau n'est PAS isole !")
    except OSError:
        print("[garde-fou] Internet injoignable : reseau correctement isole OK", flush=True)

def surveiller():
    """Lit les journaux montes dans /logs et remonte chaque detection une fois."""
    vus = set()
    print("[orchestrateur] surveillance des journaux... (Ctrl+C pour arreter)", flush=True)
    while True:
        for chemin in glob.glob(f"{DOSSIER_LOGS}/**/*.log", recursive=True):
            try:
                with open(chemin, encoding="utf-8", errors="replace") as f:
                    for i, ligne in enumerate(f):
                        cle = (chemin, i)
                        if cle in vus:
                            continue
                        vus.add(cle)
                        if any(m in ligne for m in MOTS_CLES):
                            print(json.dumps(
                                {"source": os.path.basename(chemin),
                                 "detection": ligne.strip()},
                                ensure_ascii=False), flush=True)
            except FileNotFoundError:
                pass
        time.sleep(3)

if __name__ == "__main__":
    garde_fous()
    surveiller()

Étape 9 Le ver-jouet BÉNIN

À la racine ~/Bureau/php2shell-lab/ver_jouet.py (il sera copié dans l'orchestrateur à l'étape 12). Aucune charge d'exploit : il envoie seulement le marqueur. Sa décomposition fonction par fonction est plus bas.

~/Bureau/php2shell-lab/ver_jouet.py
#!/usr/bin/env python3
"""ver_jouet.py - SIMULATEUR de comportement de ver, 100% BENIN (LAB_ONLY).

   /!\ CE N'EST PAS UN EXPLOIT /!\
Il n'y a AUCUNE charge RCE / OOM / UAF, aucun PoC de CVE, aucun code servi a la
cible. Le "ver" se contente d'ENVOYER un en-tete marqueur (X-Lab-Php2shell-Sim)
a des sites LEURRES qui ACCEPTENT VOLONTAIREMENT de jouer la cible. But : montrer
et apprendre a DETECTER les phases d'un ver (amorcage, balayage, propagation,
collecte, exfiltration) SANS jamais compromettre quoi que ce soit.

Garde-fous : LAB_ONLY obligatoire ; cibles bornees a 172.20.0.0/24 ;
refus total si Internet est joignable."""
import os, sys, json, time, socket, ipaddress
from urllib import request, error

MARQUEUR      = "X-Lab-Php2shell-Sim"
SIGNATURE     = "ver-jouet/benin-1.0 (simulation-pedagogique)"
SOUS_RESEAU   = ipaddress.ip_network("172.20.0.0/24")
CANARI_LOCAL  = "/tmp/canari_local.txt"
JOURNAL_EXFIL = "/tmp/exfil_simulee.jsonl"

# ============================== boot ==============================
def boot():
    """AMORCAGE. S'execute en PREMIER, une seule fois, au lancement.
    Dans un vrai ver : s'installe, verifie qu'il n'est pas deja la, prepare son
    etat. Ici : il VERIFIE LES GARDE-FOUS. Sans LAB_ONLY=1, et si Internet est
    joignable, il REFUSE de demarrer. C'est le seul "demarrage" du cycle."""
    if os.getenv("LAB_ONLY") != "1":
        sys.exit("ARRET : LAB_ONLY != 1 - simulateur reserve au laboratoire.")
    try:
        socket.setdefaulttimeout(2)
        socket.create_connection(("1.1.1.1", 53))
        sys.exit("ARRET : Internet joignable - le ver-jouet refuse de sortir du lab.")
    except OSError:
        pass
    print(f"[boot] garde-fous OK - {SIGNATURE}", flush=True)
    return {"demarre": time.time(), "infectes": []}

# ============================== scan ==============================
def scan():
    """BALAYAGE. Demarre JUSTE APRES boot. Enumere les hotes du /24 de lab et
    teste le port 80. C'est la phase de reconnaissance : il CARTOGRAPHIE les
    cibles vivantes mais n'envoie encore AUCUN marqueur. (Suricata reperera le
    pic de connexions : regle sid:2600002.)"""
    cibles = []
    for ip in SOUS_RESEAU.hosts():
        ip = str(ip)
        try:
            with socket.create_connection((ip, 80), timeout=0.3):
                cibles.append(ip)
                print(f"[scan] hote web vivant : {ip}", flush=True)
        except OSError:
            continue
    return cibles

# =========================== infect_stub ===========================
def infect_stub(ip, etat):
    """PROPAGATION (STUB BENIN). C'est ICI qu'un VRAI ver "se propage" : il
    enverrait une charge pour executer du code sur la cible. Ce simulateur
    N'ENVOIE AUCUNE CHARGE : seulement l'en-tete marqueur. Le leurre, qui a
    CONSENTI, repond X-Lab-Decoy-State: simulated-compromise. Rien n'est execute
    sur la cible ; on note juste "propagation simulee". (Regle sid:2600001.)"""
    if ipaddress.ip_address(ip) not in SOUS_RESEAU:
        print(f"[infect] REFUS {ip} : hors du sous-reseau de lab", flush=True)
        return
    req = request.Request(f"http://{ip}/",
                          headers={MARQUEUR: "sim", "User-Agent": SIGNATURE})
    try:
        with request.urlopen(req, timeout=2) as r:
            if r.headers.get("X-Lab-Decoy-State") == "simulated-compromise":
                etat["infectes"].append(ip)
                print(f"[infect] {ip} : compromission SIMULEE acceptee par le leurre", flush=True)
            else:
                print(f"[infect] {ip} : cible non consentante - ignoree", flush=True)
    except error.URLError as e:
        print(f"[infect] {ip} : injoignable ({e})", flush=True)

# ============================= harvest =============================
def harvest():
    """COLLECTE. Apres la "propagation". Un vrai ver fouille la machine pour des
    secrets (mots de passe, cles, .env). Le simulateur lit UNIQUEMENT un fichier
    canari LOCAL depose expres pour le lab - jamais de vrais fichiers systeme."""
    if os.path.exists(CANARI_LOCAL):
        with open(CANARI_LOCAL) as f:
            data = f.read().strip()
        print(f"[harvest] canari local collecte ({len(data)} octets)", flush=True)
        return {"canari_local": data}
    print("[harvest] aucun canari local - rien a collecter", flush=True)
    return {}

# =========================== exfil_stub ===========================
def exfil_stub(butin, etat):
    """EXFILTRATION (STUB BENIN). DERNIERE phase. Un vrai ver enverrait le butin
    vers un serveur externe (C2). Ici, l'anti-Internet l'INTERDIT : on ECRIT le
    butin dans un fichier LOCAL pour que la defense (Suricata sid:2600003 /
    orchestrateur) le repere. AUCUNE donnee ne quitte la machine."""
    enregistrement = {
        "horodatage": time.time(),
        "infectes_simules": etat["infectes"],
        "butin": butin,
        "note": "EXFIL SIMULEE - ecriture locale uniquement, aucun envoi reseau",
    }
    with open(JOURNAL_EXFIL, "a") as f:
        f.write(json.dumps(enregistrement, ensure_ascii=False) + "\n")
    print(f"[exfil] butin ecrit localement dans {JOURNAL_EXFIL} (aucun envoi)", flush=True)

# ============================== main ==============================
def main():
    etat = boot()                     # 1. amorcage + garde-fous
    for ip in scan():                 # 2. balayage du /24
        infect_stub(ip, etat)         # 3. propagation SIMULEE (header seul)
    butin = harvest()                 # 4. collecte du canari local
    exfil_stub(butin, etat)           # 5. exfil SIMULEE (fichier local)
    print(f"[fin] cycle termine - {len(etat['infectes'])} leurre(s) marque(s) en simulation", flush=True)

if __name__ == "__main__":
    main()

Étape 10 Construire & démarrer le lab

On ne lance pas Docker pour toi. Copie ces commandes et exécute-les quand tu veux, depuis ~/Bureau/php2shell-lab/.
terminal - build & run
# Depuis ~/Bureau/php2shell-lab
# 1) Construire les images (ne tire que les bases officielles patchees)
docker compose build

# 2) Demarrer le lab ISOLE (reseau interne, aucun acces Internet)
docker compose up -d

# 3) Verifier que tout tourne
docker compose ps

Étape 11 Lancer la simulation

Dépose un canari local, copie le ver-jouet dans l'orchestrateur et lance-le (confiné au /24).

terminal - simulation
# 1) Deposer un canari LOCAL dans l'orchestrateur (la "cible" de collecte)
echo "CANARI-LOCAL-$(date +%s)-NE-PAS-UTILISER" \
  | docker compose exec -T orchestrateur tee /tmp/canari_local.txt

# 2) Copier le ver-jouet dans l'orchestrateur, puis le lancer (confine au /24)
docker compose cp ver_jouet.py orchestrateur:/ver_jouet.py
docker compose exec -e LAB_ONLY=1 orchestrateur python /ver_jouet.py

Étape 12 Observer la détection, puis nettoyer

Regarde les traces côté défense, puis arrête tout.

terminal - observer
# Tableau de bord des detections cote defense
docker compose logs orchestrateur | tail -n 40          # correlation des traces
docker compose exec banque-leurre cat /var/log/leurre.log   # SIM-COMPROMISSION
docker compose exec gouv-leurre   cat /var/log/leurre.log
tail -n 40 logs/suricata/fast.log                       # alertes Suricata sid:2600xxx
cat logs/honeypot/honeypot.log 2>/dev/null || echo "honeypot : aucun contact (normal)"
terminal - nettoyage
# Tout arreter et effacer reseaux/volumes du lab
docker compose down -v

Décomposition du ver-jouet, fonction par fonction

Le cycle complet est dans main() : boot() → scan() → infect_stub() → harvest() → exfil_stub(). Voici ce que fait chaque aspect, quand il démarre et quand « ça se propage ».

1

boot() - amorçage

Quand : en tout premier, une seule fois au lancement.

Propagation : aucune.

Prépare l'état du ver et vérifie les garde-fous : si LAB_ONLY=1 est absent ou si Internet est joignable, il s'arrête. C'est l'équivalent bénin de la phase d'installation/persistance d'un vrai ver, mais réduit à un contrôle de sécurité.

2

scan() - balayage / reconnaissance

Quand : juste après boot().

Propagation : pas encore - il cartographie seulement.

Énumère les hôtes de 172.20.0.0/24 et teste le port 80 pour lister les cibles vivantes. Ce pic de connexions est détectable (Suricata sid:2600002). Aucun marqueur n'est encore envoyé.

3

infect_stub() - propagation (stub bénin)

Quand : pour chaque cible trouvée par scan().

Propagation : oui, ici - mais simulée.

C'est le cœur « propagation ». Un vrai ver enverrait une charge d'exploitation ; ici on envoie seulement l'en-tête X-Lab-Php2shell-Sim. Le leurre consent et répond X-Lab-Decoy-State: simulated-compromise : on compte alors une « infection simulée ». Rien n'est exécuté sur la cible, et toute cible hors du /24 est refusée (Suricata sid:2600001).

4

harvest() - collecte

Quand : après la phase de propagation.

Propagation : non (local).

Un vrai ver fouillerait la machine pour des secrets. Ici il lit uniquement un fichier canari local (/tmp/canari_local.txt) déposé exprès - jamais de vrais fichiers système.

5

exfil_stub() - exfiltration (stub bénin)

Quand : en dernier, une fois la collecte faite.

Propagation : non - écriture locale uniquement.

Un vrai ver enverrait le butin vers un C2. Ici l'anti-Internet l'interdit : le butin est écrit dans un fichier local (/tmp/exfil_simulee.jsonl) pour que la défense le repère (Suricata sid:2600003 sur le motif CANARI-). Aucune donnée ne quitte la machine.

Où « se propage »-t-il ? Uniquement dans infect_stub(), et seulement de façon simulée : un en-tête, pas une charge. La « propagation » réelle d'un ver (exécuter du code sur la cible via une faille comme les CVE ci-dessus) est volontairement absente. On garde la forme du comportement pour l'entraîner à la détection, jamais le fond offensif.

Détection - ce qu'il faut repérer (IOC de lab)

Phase du verIndice observableOù le voirRègle
BalayagePic de connexions TCP/80 d'une même source vers plusieurs hôtes du /24Suricata fast.logsid:2600002
Propagation (simulée)En-tête X-Lab-Php2shell-Sim dans des requêtes HTTPSuricata + leurre.log (SIM-COMPROMISSION)sid:2600001
CollecteLecture du canari local ; jetons CANARI-*Journaux orchestrateur-
Exfiltration (simulée)Motif CANARI- dans un corps de requête / fichier exfil_simulee.jsonlSuricata http.request_bodysid:2600003
Mouvement latéralToute connexion au honeypot (anormale par nature)honeypot.logsid:2600004

Exercice : relance ver_jouet.py, puis entraîne-toi à retrouver chaque phase dans les journaux. L'orchestrateur te sert de tableau de bord corrélé.