Zum Hauptinhalt springen

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.

Strict boundaries

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.

FileLinePrimitive or signalFamily
config.pyhardcoded secret
utils.pyMD5 / pickle
app.pySQLi / 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: AKIAIOSFODNN7EXAMPLE and the AWS doc secret. You keep it anyway as a pattern finding (the repo teaches storing keys in config.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.

One finding, one flaw

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 IDFamilyMinimal evidenceImpact to stateExpected remediation
A-01SQLicur.execute formatted with user_id from request.argsSQL query controlled by the clientParameterized query ? / binding
A-02Commandos.system + hostSystem command controlledsubprocess as a list, no shell, validated host (allowlist)
A-03XSSrender_template_string + concatenation of nameReflected HTML, possible script in the browserAuto-escaped Jinja template, no concatenation
A-04Picklepickle.loads on request.dataCode execution on loadjson + schema, or removal of the route
A-05MD5hashlib.md5 on a passwordCrackable digest, not password storageargon2 / bcrypt, never MD5 or SHA-1 for a secret
A-06Secretkeys in config.pyLeak as soon as the folder is sharedEnvironment 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.

Do not "fix" by muzzling the tool

# 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:

  1. The header from step 1 (scope, tools, decision not to run a payload).
  2. The cross-check matrix (or an explicit reference to sorties/matrice.md).
  3. Five or six findings on the step 5 template, all families present.
  4. The Re-check section with at least two lines at status fixed or partial, evidence in sorties/*-apres.*.
  5. One sentence on what you did not do: no python app.py before 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.txt and sorties/gitleaks.txt exist 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 AKIAIOSFODNN7EXAMPLE keys are qualified as an example, not sold as a real AWS leak.
  • At least two fixes applied, correctifs/*.avant copies 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 + rg while declaring it. Do not invent an output.
  • Gitleaks sees nothing. Check --no-git and that config.py is indeed in the current folder. A gitleaks protect on 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 curl the routes. The lab is a review. A DAST confirmation is off-topic and, on a poorly launched Flask, sometimes off 127.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.