06 · Common Web Vulnerabilities — OWASP Top 10 Overview¶
The OWASP Top 10 is the industry-standard list of the most critical web application security risks, maintained by the Open Web Application Security Project and updated periodically from real-world breach data. This module covers the concepts, then has you build and run a small, deliberately vulnerable local Flask app so you can see two of the most common vulnerabilities — SQL injection and XSS — actually happen, and actually get fixed, on your own machine.
Scope: your own machine, offline, nothing else
Everything in this module runs on 127.0.0.1 (localhost) only. You will
never point these techniques at a website, server, or app you don't own.
Testing for these vulnerabilities against systems you don't have explicit
written authorization to test is illegal in most jurisdictions — this
module exists so you understand the mechanism defensively, the same way
Security+ and CEH teach it.
1. The OWASP Top 10 (2021 edition), briefly¶
| # | Category | In short |
|---|---|---|
| A01 | Broken Access Control | An authenticated user can act beyond their intended permissions (Module 5) |
| A02 | Cryptographic Failures | Sensitive data exposed due to weak/missing encryption (Module 4) |
| A03 | Injection | Untrusted input is interpreted as code/commands (SQL injection, this module) |
| A04 | Insecure Design | The flaw is architectural, not a coding bug — no amount of patching fixes a design that never considered abuse |
| A05 | Security Misconfiguration | Default credentials, verbose error messages, unnecessary features left enabled |
| A06 | Vulnerable and Outdated Components | Using a library/framework with a known, unpatched vulnerability |
| A07 | Identification and Authentication Failures | Weak login/session mechanisms (Module 5) |
| A08 | Software and Data Integrity Failures | Trusting unsigned code/updates/CI pipelines |
| A09 | Security Logging and Monitoring Failures | Attacks go undetected because nobody's watching (Module 9) |
| A10 | Server-Side Request Forgery (SSRF) | Tricking a server into making requests on the attacker's behalf |
This module builds a hands-on demo of A03 (Injection), specifically SQL injection, and cross-site scripting (XSS) — technically its own category in earlier OWASP editions and still one of the most common bugs found in the wild, now folded conceptually under injection-style input-handling failures.
2. The vulnerable app¶
Create a file called vulnerable_app.py:
"""
DELIBERATELY VULNERABLE demo app -- for local, offline, educational use only.
Do not deploy this. Do not expose it to a network. Localhost only.
"""
from flask import Flask, request
import sqlite3
app = Flask(__name__)
DB = "/tmp/vulnapp_demo.db"
def init_db():
conn = sqlite3.connect(DB)
conn.execute("DROP TABLE IF EXISTS users")
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)")
conn.execute("INSERT INTO users (username, password) VALUES ('admin', 's3cr3t-admin-pw')")
conn.execute("INSERT INTO users (username, password) VALUES ('alice', 'alicepw123')")
conn.commit()
conn.close()
@app.route("/vulnerable_login", methods=["GET"])
def vulnerable_login():
username = request.args.get("username", "")
password = request.args.get("password", "")
conn = sqlite3.connect(DB)
# VULNERABLE: raw string interpolation into SQL -- classic SQL injection
query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
cur = conn.execute(query)
row = cur.fetchone()
conn.close()
if row:
return f"Login OK, welcome {row[1]} (query was: {query})"
return f"Login failed (query was: {query})"
@app.route("/safe_login", methods=["GET"])
def safe_login():
username = request.args.get("username", "")
password = request.args.get("password", "")
conn = sqlite3.connect(DB)
# SAFE: parameterized query -- user input never becomes part of the SQL text
cur = conn.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password))
row = cur.fetchone()
conn.close()
if row:
return f"Login OK, welcome {row[1]}"
return "Login failed"
@app.route("/vulnerable_greet")
def vulnerable_greet():
name = request.args.get("name", "guest")
# VULNERABLE: user input echoed directly into HTML -- reflected XSS
return f"<html><body><h1>Hello, {name}!</h1></body></html>"
@app.route("/safe_greet")
def safe_greet():
from markupsafe import escape
name = request.args.get("name", "guest")
# SAFE: escape user input before embedding in HTML
return f"<html><body><h1>Hello, {escape(name)}!</h1></body></html>"
if __name__ == "__main__":
init_db()
app.run(host="127.0.0.1", port=5055)
Install Flask and run it:
* Serving Flask app 'vulnerable_app'
* Debug mode: off
* Running on http://127.0.0.1:5055
Press CTRL+C to quit
3. SQL injection, demonstrated¶
The vulnerable endpoint builds a SQL query by directly inserting the request parameters into a string. A normal login attempt looks fine:
Now supply a username designed to alter the query's structure rather than
just its data — a single quote to close the string early, -- to comment out
the rest of the query (including the password check):
Login OK, welcome admin (query was: SELECT * FROM users WHERE username = 'admin'--' AND password = 'x')
No password was needed. The -- turned everything after it into a SQL
comment, so the query the database actually executed was effectively
SELECT * FROM users WHERE username = 'admin' — the injected input changed
what code ran, not just what data was searched for. This is the entire
concept of injection in one example: input that was supposed to be data
got interpreted as syntax.
Now hit the safe version with the identical payload:
The parameterized query (? placeholders with values passed separately)
tells the database driver, unambiguously, "this entire string is a literal
value, never interpret it as SQL syntax" — regardless of what characters it
contains. This is the actual fix, not "block the -- characters" or any
other input-filtering approach, which are fragile and routinely bypassed.
Parameterized queries (or an ORM that generates them for you) are the correct
defense against SQL injection, full stop.
4. Cross-site scripting (XSS), demonstrated¶
The vulnerable greet endpoint echoes a name parameter directly into an HTML
response with no escaping:
A real browser loading this URL would execute that <script> tag — this
is reflected XSS: an attacker crafts a URL containing malicious script,
tricks a victim into clicking it (phishing, Module 7), and the victim's own
browser runs the attacker's JavaScript in the context of the trusted site,
with access to that site's cookies and session.
The safe version escapes user input before embedding it:
< became < and > became > — the browser now renders the literal
text <script>alert(1)</script> as a harmless string instead of executing it
as markup. This is what every templating engine's "auto-escaping" mode
(Jinja2, which Flask uses by default when rendering templates properly,
React's JSX, etc.) does automatically — the vulnerability above only exists
because this demo builds HTML with raw f-strings instead of a template.
5. Why the fix generalizes¶
Both vulnerabilities above share one root cause: untrusted input was mixed with the code/markup that interprets it, without a boundary between the two. The fix in both cases is the same pattern — keep data and syntax strictly separated:
| Vulnerability | Wrong (mixes data and syntax) | Right (keeps them separate) |
|---|---|---|
| SQL injection | String-building a query with f-strings/concatenation | Parameterized queries / prepared statements |
| XSS | Building HTML with raw string interpolation | Auto-escaping template engines, output encoding |
| Command injection (not demoed here) | os.system(f"ping {user_input}") |
subprocess.run(["ping", user_input]) — no shell interpretation |
This is the generalizable lesson Level 2 Module 2 and Level 2 Module 7 build on: nearly every injection-class vulnerability is a variant of "we let attacker-controlled text influence how a downstream interpreter parses something."
How It Actually Works: why SQL injection and XSS happen at the parser level, and why parameterization actually fixes it¶
Both SQL injection and XSS exist for the same underlying reason: a program concatenates untrusted data into a string that is then handed to a different interpreter (a SQL engine, an HTML/JS renderer), and that interpreter has no way to tell "this part is code the developer wrote" from "this part is data a user typed" — because by the time it receives the string, both look like identical characters.
If input is x' OR '1'='1, the SQL parser tokenizes the final string
exactly as written — it has no memory of where the developer's string
literal was "supposed" to end, because that boundary was lost the moment
concatenation happened. The parser faithfully executes
WHERE name = 'x' OR '1'='1', a syntactically valid, semantically different
query. Parameterized queries (prepared statements) fix this by sending
the query template and the data to the database as two separate messages
over the wire: the database compiles the SQL structure first — literally
building a parse tree with placeholder slots — before the parameter values
ever arrive. Once compiled, those slots are typed data locations, not code
positions, so a value like x' OR '1'='1 is bound as a single literal
string value to compare against, with the quote character treated as three
harmless bytes rather than syntax. The vulnerability class doesn't get
"detected and blocked" — it becomes structurally impossible, because the
data channel and the code channel are never merged again.
XSS follows the identical shape one layer up the stack: untrusted input gets
concatenated into HTML that a browser's parser then tokenizes. If input
contains <script>, and it lands in a context the parser reads as element
content, the browser's HTML tokenizer opens a new script-parsing state right
there — it cannot distinguish "text the developer wrote" from "text a user
submitted" any more than the SQL parser could. Output encoding (escaping
< to <, " to ", etc., context-appropriately) is the browser
equivalent of parameterization: it doesn't hide the malicious string, it
transforms it into a sequence of characters that the parser's grammar can
only ever interpret as literal text, never as a tag or attribute boundary.
This is also why context matters enormously — encoding that's correct
inside an HTML attribute is wrong inside a <script> block or a URL, because
each of those has a different parser/grammar with different special
characters, which is exactly why "just escape everything the same way"
sanitizers keep failing in production.
Key terms¶
| Term | Meaning |
|---|---|
| Injection | Untrusted input interpreted as code/commands rather than pure data |
| SQL injection (SQLi) | Injection targeting a SQL query |
| XSS (Cross-Site Scripting) | Injection of script that a victim's browser executes |
| Parameterized query | A query where data is passed separately from SQL syntax, preventing SQLi |
| Output encoding / escaping | Converting special characters so they render as text, not markup |
| Reflected XSS | Malicious script delivered via a crafted URL/request, executed once |
Exercise¶
Using the app you just built and ran:
-
Reproduce both attacks exactly as shown above and capture the actual output on your machine — confirm the SQLi bypass logs in as
adminwithout the password, and confirm the XSS payload appears unescaped in/vulnerable_greet. -
Try a second SQLi payload. Instead of
admin'--, try' OR '1'='1. Predict what the resulting query will look like before running it, then run it against/vulnerable_login?username=' OR '1'='1&password=xand check your prediction against thequery was:text in the response. -
Confirm the fix holds by running both of your payloads from steps 1–2 against
/safe_loginand confirming both fail. -
Extend the app. Add a third endpoint,
/vulnerable_search, that takes aqparameter and returnsf"<p>Results for: {q}</p>"— deliberately vulnerable to XSS like/vulnerable_greet. Then write/safe_search, its escaped counterpart, and prove both behave as expected with the same<script>payload used in section 4. -
Written answer. In two or three sentences, explain why "just block quotes and semicolons" is not a real fix for SQL injection — think about what other characters or encodings might achieve the same syntactic break (hint: consider
OR,UNION, or numeric-only injection with no quotes at all, e.g. against a query built as... WHERE id = {user_input}).