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:

  1. A login form backed by a SQLite users table.
  2. 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.

Acme Intranet login page on the vulnerable app

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.

curl SQLi payload logging in as admin with no valid 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:

UNION-based SQLi leaking the admin password through the role field

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:

curl command injection: whoami output appended to the ping result

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.

patched login page

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:

patched app rejecting both exploit payloads while legitimate use still works

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

Lab: download and run it

Download the lab (zip)

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:

  1. Unzip the archive and open a terminal in sqli-to-command-injection/.
  2. Start the vulnerable app:
    ./run.sh before        # or: .\run.ps1 before

    This creates a local virtual environment, installs Flask, and serves the app on http://127.0.0.1:5000.

  3. Run the SQLi and command-injection payloads from this article against

    it (they’re also listed in the lab’s README.md).

  4. Stop the server (Ctrl+C), then start the patched app:
    ./run.sh after          # or: .\run.ps1 after
  5. Re-run the same payloads and confirm they now fail, while a legitimate

    login and ping still work.

  6. To reset the lab, delete the generated before/lab.db / after/lab.db

    files 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.

References