Offensive code analysis — Hands-on lab
Your turn. You build a deliberately vulnerable Flask repo, you read it, you put it through Semgrep, Bandit and Gitleaks, you deliver five or six findings, you fix, you re-scan. The walkthrough showed how to read output. Here, what counts is the deliverable.
Count on it: 2 to 3 hours.
Deliverable: a ~/analyse-code/flask-audit/ folder with the source, the raw outputs, a rapport.md of six findings, and a fix verified by a second scan.
The only code you audit is the one you just created in this folder, or a lab repo provided by the instructor. No GitHub repo from a stranger, no public exploit clone, no third-party application online. You do not push this lab to a public repository: it contains secret patterns, even fake ones, and dangerous routes.
Step 1 — Prepare the lab (15 min)
mkdir -p ~/analyse-code/flask-audit/{sorties,correctifs}
cd ~/analyse-code/flask-audit
Create four files. Copy them as-is: the tool identifiers in the self-assessment rubric rely on these lines.
config.py:
# Local lab — do not deploy, do not push.
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
SLACK_WEBHOOK = "https://hooks.slack.com/services/T00000000/B00000000/exemplecours"
DB_PASSWORD = "SuperSecret123!"
utils.py:
import hashlib
import pickle
def hash_mot_de_passe(mot: str) -> str:
return hashlib.md5(mot.encode("utf-8")).hexdigest()
def charger_session(blob: bytes):
return pickle.loads(blob)
app.py:
import os
import sqlite3
from flask import Flask, request, render_template_string
import config
from utils import hash_mot_de_passe, charger_session
app = Flask(__name__)
@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 %s" % host)
return "pong"
@app.route("/hello")
def hello():
name = request.args.get("name", "monde")
return render_template_string("<h1>Bonjour " + name + "</h1>")
@app.route("/session", methods=["POST"])
def session_charge():
return str(charger_session(request.data))
@app.route("/login", methods=["POST"])
def login():
password = request.form.get("password", "")
attendu = hash_mot_de_passe(config.DB_PASSWORD)
fourni = hash_mot_de_passe(password)
return "ok" if fourni == attendu else "ko"
requirements.txt:
Flask==3.0.3
You install Flask only if you want to run the app locally after the fix. For the SAST audit, the analyzers are enough.
In rapport.md, write the header right now. Without it, the rest has no frame.
# Static review — flask-audit
- Date:
- Scope: files `app.py`, `utils.py`, `config.py` (local lab)
- Tools: Bandit, Semgrep (`p/python`, `p/flask`, `p/secrets`), Gitleaks `--no-git`
- Execution decision: no payload sent, no `flask run` before findings are done
Step 2 — Manual static reading (20 min)
Before the tools. Open the three Python files and fill in, in sorties/lecture-manuelle.md, this table.
| File | Line | Primitive or signal | Family |
|---|---|---|---|
config.py | hardcoded secret | ||
utils.py | MD5 / pickle | ||
app.py | SQLi / command / XSS / flow |
Goal: six lines minimum, one per required family (SQLi, command injection, pickle, MD5, secret, XSS). If you do not have the six before Bandit, read again: they are all visible without a tool.
Also look for what the tools may miss: the Slack webhook is a hardcoded URL (a signal from the course), not only an "AWS secret."
rg -n "eval|exec|system|pickle|md5|AKIA|authorized_keys|render_template_string|execute" .
Put the output in sorties/rg.txt. It is your net, not your report.
Step 3 — Run the three analyzers (20 min)
cd ~/analyse-code/flask-audit
bandit -r . -f txt -o sorties/bandit.txt
bandit -r . -f json -o sorties/bandit.json
semgrep --config p/python --config p/flask --config p/secrets \
--json --output sorties/semgrep.json .
semgrep --config p/python --config p/flask --config p/secrets \
--text --output sorties/semgrep.txt .
gitleaks detect --source . --no-git -v --report-path sorties/gitleaks.json \
| tee sorties/gitleaks.txt
If Semgrep refuses a config, document the fallback in the report (--config auto or local rules) and continue. A missing tool is replaced by rg plus reading; it is not replaced by running the routes.
You must be able to show, for each tool, a non-empty file in sorties/. That is the first admissibility criterion.
Step 4 — Cross-check, do not copy (25 min)
Open the three outputs. Build sorties/matrice.md:
| Internal ID | Line | Bandit | Semgrep | Gitleaks | Manual reading | Verdict |
| --- | --- | --- | --- | --- | --- | --- |
| A-01 | | | | | | keep / noise / example |
Verdict rules:
- Keep: the line does what the tool claims (incoming data into
execute,os.system,pickle.loads, concatenated HTML, password hash in MD5, secret in the source). - Example:
AKIAIOSFODNN7EXAMPLEand the AWS doc secret. You keep it anyway as a pattern finding (the repo teaches storing keys inconfig.py), qualifying it "documentation key." - Noise: an alert on a constant with no incoming flow, or an exact duplicate. One finding per flaw, even if three tools speak.
You deliver five or six kept findings, not thirty screenshots. The six families of the topic must appear. If Bandit misses the XSS, Semgrep or your manual reading carries it.
login calls hash_mot_de_passe twice. That is one MD5 finding (the primitive in utils.py), not two. The /session route and charger_session are one pickle finding. The client fixes one place, not one alert per tool.
Step 5 — Write the six findings (40 min)
Each finding in rapport.md follows the same skeleton. Copy the template six times (A-01 to A-06). The inner code blocks stay at three backticks; the outer template is given here with four so that the page compiles — in your rapport.md (plain Markdown), three are enough everywhere.
## A-0X — `<family>`: `<one-line title>`
- **File / line**:
- **Tools**: (e.g. Bandit B608, Semgrep tainted-sql-string)
- **Prerequisite**: who can reach what, without running an exploit
- **Evidence** (excerpt):
```python
# paste ONLY the offending line, not the whole file
```
- **Impact**: one to three sentences, privilege and consequence
- **Remediation**: the action, not "secure the code"
- **Re-check**: command and criterion (the tool ID disappears, or the line no longer exists)
What each family must demonstrate
You send no HTTP payload. The evidence is the source.
| Suggested ID | Family | Minimal evidence | Impact to state | Expected remediation |
|---|---|---|---|---|
| A-01 | SQLi | cur.execute formatted with user_id from request.args | SQL query controlled by the client | Parameterized query ? / binding |
| A-02 | Command | os.system + host | System command controlled | subprocess as a list, no shell, validated host (allowlist) |
| A-03 | XSS | render_template_string + concatenation of name | Reflected HTML, possible script in the browser | Auto-escaped Jinja template, no concatenation |
| A-04 | Pickle | pickle.loads on request.data | Code execution on load | json + schema, or removal of the route |
| A-05 | MD5 | hashlib.md5 on a password | Crackable digest, not password storage | argon2 / bcrypt, never MD5 or SHA-1 for a secret |
| A-06 | Secret | keys in config.py | Leak as soon as the folder is shared | Environment variables, file outside the repo, sample keys removed |
Example written out for A-04 — the other five are yours, at the same level of precision.
## A-04 — Deserialization: `pickle.loads` on `POST /session`
- **File / line**: `utils.py` (`charger_session`) called by `app.py` (`session_charge`)
- **Tools**: Bandit B301, Semgrep `python.lang.security.deserialization.pickle.avoid-pickle`
- **Prerequisite**: reach `POST /session` (anonymous route in this lab)
- **Evidence**:
```python
def charger_session(blob: bytes):
return pickle.loads(blob)
```
- **Impact**: the request body is deserialized. A pickle object is not data: it is a program. The Flask process runs with the rights of the account that launches it.
- **Remediation**: remove `charger_session` and the route, or accept only a bounded JSON (`json.loads` + expected keys).
- **Re-check**: `rg -n pickle utils.py app.py` with no match; Bandit no longer cites B301.
Step 6 — One fix, then the re-scan (30 min)
Choose at least two findings among A-01, A-02, A-04 (the three that are code execution). Secrets (A-06) count too: extracting to the environment is a fix visible to Gitleaks.
Work in copies, do not overwrite without a net:
cp app.py correctifs/app.py.avant
cp utils.py correctifs/utils.py.avant
cp config.py correctifs/config.py.avant
Minimal fixes accepted (to adapt, not to paste blindly if your lines have shifted):
# A-01 — app.py
cur.execute("SELECT * FROM users WHERE id = ?", (user_id,))
# A-02 — app.py
import subprocess
subprocess.run(["ping", "-c", "1", "--", host], check=False)
# and, before the call: reject any host not in an allowlist of IPs/names
# A-03 — app.py
from flask import render_template
# template templates/hello.html: <h1>Bonjour {{ name }}</h1>
return render_template("hello.html", name=name)
# A-04 — utils.py + app.py
import json
def charger_session(blob: bytes):
return json.loads(blob.decode("utf-8"))
# A-05 — utils.py
from hashlib import sha256 # insufficient for a real password
# expected in the report: bcrypt or argon2, not another MD
# example direction:
# import bcrypt
# return bcrypt.hashpw(mot.encode(), bcrypt.gensalt()).decode()
# A-06 — config.py
import os
AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
DB_PASSWORD = os.environ["DB_PASSWORD"]
For A-03, the Jinja template escapes by default: a {{ name }} interpolation is no longer an HTML concatenation. Create templates/hello.html if you fix the XSS.
Rerun the same commands, to distinct files:
bandit -r . -f txt -o sorties/bandit-apres.txt
semgrep --config p/python --config p/flask --config p/secrets \
--text --output sorties/semgrep-apres.txt .
gitleaks detect --source . --no-git -v --report-path sorties/gitleaks-apres.json \
| tee sorties/gitleaks-apres.txt
In rapport.md, a Re-check section:
## Re-check
| ID | Before (tool ID or line) | After | Status |
| --- | --- | --- | --- |
| A-01 | B608 / execute % | absent from bandit-apres.txt | fixed / partial / failed |
| A-02 | | | |
| A-04 | | | |
| A-06 | AKIA in gitleaks.json | | |
"Partial" is honest: subprocess.run as a list closes the shell injection, but an unvalidated host is still a problem. You note it. A fix that silences Bandit by adding # nosec without changing the flow is a failure: mention it if you tried it, then remove it.
# nosec, nosemgrep and Gitleaks exclusions are accepted only for an argued false positive in the finding. The six families of this lab are not false positives.
Step 7 — Verifiable deliverable
The final tree, as a grader reopens it:
~/analyse-code/flask-audit/
app.py
utils.py
config.py
requirements.txt
templates/hello.html # if A-03 fixed
sorties/
lecture-manuelle.md
rg.txt
matrice.md
bandit.txt
bandit.json
semgrep.txt
semgrep.json
gitleaks.txt
gitleaks.json
bandit-apres.txt
semgrep-apres.txt
gitleaks-apres.txt
gitleaks-apres.json
correctifs/
app.py.avant
utils.py.avant
config.py.avant
rapport.md
rapport.md contains, in this order:
- The header from step 1 (scope, tools, decision not to run a payload).
- The cross-check matrix (or an explicit reference to
sorties/matrice.md). - Five or six findings on the step 5 template, all families present.
- The Re-check section with at least two lines at status
fixedorpartial, evidence insorties/*-apres.*. - One sentence on what you did not do: no
python app.pybefore findings are done, no public push, no payload.
Self-assessment rubric
- The scope is three Python files created for the lab (or a named instructor lab).
- A manual reading precedes the tools, with six families spotted.
-
sorties/bandit.txt,sorties/semgrep.txtandsorties/gitleaks.txtexist and are not empty. - The matrix cross-checks the tools: not one finding per alert, one finding per flaw.
- Six findings: SQLi, command, XSS, pickle, MD5, secret — each with source evidence, impact, remediation, re-scan criterion.
- The
AKIAIOSFODNN7EXAMPLEkeys are qualified as an example, not sold as a real AWS leak. - At least two fixes applied,
correctifs/*.avantcopies kept. - A second scan shows the disappearance (or partial status) of the concerned IDs.
- No payload was sent to a third-party machine. No public exploit was launched.
- The folder is not on a public GitHub.
Everything checked? The deliverable is admissible.
What usually blocks
- Semgrep fails offline. Use the already-downloaded configs, or Bandit +
rgwhile declaring it. Do not invent an output. - Gitleaks sees nothing. Check
--no-gitand thatconfig.pyis indeed in the current folder. Agitleaks protecton an empty repo does not scan untracked files. - Bandit is silent on the XSS. That is expected. The XSS is your manual / Semgrep finding, not a hole in your work.
- Temptation to
curlthe routes. The lab is a review. A DAST confirmation is off-topic and, on a poorly launched Flask, sometimes off127.0.0.1. - Report = pasted JSON. The grader reads
rapport.md. The JSON files are appendices.
What you take away from this lab
- A reproducible chain: reading, three tools, matrix, findings, fix, re-scan.
- The proof that a code finding stands without sending a payload.
- The reflex to qualify a sample secret rather than alarm over the AWS documentation key.
- A folder you can reopen in six months and re-verify.